33//! interested people.
44
55use crate :: {
6- config:: { MentionsConfig , MentionsPathConfig } ,
6+ config:: { MentionsConfig , MentionsEntryConfig , MentionsEntryType } ,
77 db:: issue_data:: IssueData ,
88 github:: { IssuesAction , IssuesEvent } ,
99 handlers:: Context ,
1010} ;
1111use anyhow:: Context as _;
12+ use itertools:: Itertools ;
1213use serde:: { Deserialize , Serialize } ;
13- use std:: fmt:: Write ;
1414use std:: path:: Path ;
15+ use std:: { fmt:: Write , path:: PathBuf } ;
1516use tracing as log;
1617
1718const MENTIONS_KEY : & str = "mentions" ;
1819
1920pub ( super ) struct MentionsInput {
20- paths : Vec < String > ,
21+ to_mention : Vec < ( String , Vec < PathBuf > ) > ,
2122}
2223
2324#[ derive( Debug , Default , Deserialize , Serialize , Clone , PartialEq ) ]
2425struct MentionState {
25- paths : Vec < String > ,
26+ #[ serde( alias = "paths" ) ]
27+ entries : Vec < String > ,
2628}
2729
2830pub ( super ) async fn parse_input (
@@ -61,23 +63,42 @@ pub(super) async fn parse_input(
6163 {
6264 let file_paths: Vec < _ > = files. iter ( ) . map ( |fd| Path :: new ( & fd. filename ) ) . collect ( ) ;
6365 let to_mention: Vec < _ > = config
64- . paths
66+ . entries
6567 . iter ( )
66- . filter ( |( path, MentionsPathConfig { cc, .. } ) | {
67- let path = Path :: new ( path) ;
68- // Only mention matching paths.
69- let touches_relevant_files = file_paths. iter ( ) . any ( |p| p. starts_with ( path) ) ;
68+ . filter_map ( |( entry, MentionsEntryConfig { cc, type_, .. } ) | {
69+ let relevant_file_paths: Vec < PathBuf > = match type_ {
70+ MentionsEntryType :: Filename => {
71+ let path = Path :: new ( entry) ;
72+ // Only mention matching paths.
73+ file_paths
74+ . iter ( )
75+ . filter ( |p| p. starts_with ( path) )
76+ . map ( |p| PathBuf :: from ( p) )
77+ . collect ( )
78+ }
79+ MentionsEntryType :: Content => {
80+ // Only mentions byte-for-byte matching content inside the patch.
81+ files
82+ . iter ( )
83+ . filter ( |f| patch_contains ( & f. patch , & * * entry) )
84+ . map ( |f| PathBuf :: from ( & f. filename ) )
85+ . collect ( )
86+ }
87+ } ;
7088 // Don't mention if only the author is in the list.
7189 let pings_non_author = match & cc[ ..] {
7290 [ only_cc] => only_cc. trim_start_matches ( '@' ) != & event. issue . user . login ,
7391 _ => true ,
7492 } ;
75- touches_relevant_files && pings_non_author
93+ if !relevant_file_paths. is_empty ( ) && pings_non_author {
94+ Some ( ( entry. to_string ( ) , relevant_file_paths) )
95+ } else {
96+ None
97+ }
7698 } )
77- . map ( |( key, _mention) | key. to_string ( ) )
7899 . collect ( ) ;
79100 if !to_mention. is_empty ( ) {
80- return Ok ( Some ( MentionsInput { paths : to_mention } ) ) ;
101+ return Ok ( Some ( MentionsInput { to_mention } ) ) ;
81102 }
82103 }
83104 Ok ( None )
@@ -94,23 +115,36 @@ pub(super) async fn handle_input(
94115 IssueData :: load ( & mut client, & event. issue , MENTIONS_KEY ) . await ?;
95116 // Build the message to post to the issue.
96117 let mut result = String :: new ( ) ;
97- for to_mention in & input. paths {
98- if state. data . paths . iter ( ) . any ( |p| p == to_mention ) {
118+ for ( entry , relevant_file_paths ) in input. to_mention {
119+ if state. data . entries . iter ( ) . any ( |e| e == & entry ) {
99120 // Avoid duplicate mentions.
100121 continue ;
101122 }
102- let MentionsPathConfig { message, cc } = & config. paths [ to_mention ] ;
123+ let MentionsEntryConfig { message, cc, type_ } = & config. entries [ & entry ] ;
103124 if !result. is_empty ( ) {
104125 result. push_str ( "\n \n " ) ;
105126 }
106127 match message {
107128 Some ( m) => result. push_str ( m) ,
108- None => write ! ( result, "Some changes occurred in {to_mention}" ) . unwrap ( ) ,
129+ None => match type_ {
130+ MentionsEntryType :: Filename => {
131+ write ! ( result, "Some changes occurred in {entry}" ) . unwrap ( )
132+ }
133+ MentionsEntryType :: Content => write ! (
134+ result,
135+ "Some changes regarding `{entry}` occurred in {}" ,
136+ relevant_file_paths
137+ . iter( )
138+ . map( |f| f. to_string_lossy( ) )
139+ . join( ", " )
140+ )
141+ . unwrap ( ) ,
142+ } ,
109143 }
110144 if !cc. is_empty ( ) {
111145 write ! ( result, "\n \n cc {}" , cc. join( ", " ) ) . unwrap ( ) ;
112146 }
113- state. data . paths . push ( to_mention . to_string ( ) ) ;
147+ state. data . entries . push ( entry ) ;
114148 }
115149 if !result. is_empty ( ) {
116150 event
@@ -122,3 +156,64 @@ pub(super) async fn handle_input(
122156 }
123157 Ok ( ( ) )
124158}
159+
160+ fn patch_contains ( patch : & str , needle : & str ) -> bool {
161+ for line in patch. lines ( ) {
162+ if ( !line. starts_with ( "+++" ) && line. starts_with ( '+' ) )
163+ || ( !line. starts_with ( "---" ) && line. starts_with ( '-' ) )
164+ {
165+ if line. contains ( needle) {
166+ return true ;
167+ }
168+ }
169+ }
170+
171+ false
172+ }
173+
174+ #[ cfg( test) ]
175+ mod tests {
176+ use super :: * ;
177+
178+ #[ test]
179+ fn finds_added_line ( ) {
180+ let patch = "\
181+ --- a/file.txt
182+ +++ b/file.txt
183+ +hello world
184+ context line
185+ " ;
186+ assert ! ( patch_contains( patch, "hello" ) ) ;
187+ }
188+
189+ #[ test]
190+ fn finds_removed_line ( ) {
191+ let patch = "\
192+ --- a/file.txt
193+ +++ b/file.txt
194+ -old value
195+ +new value
196+ " ;
197+ assert ! ( patch_contains( patch, "old value" ) ) ;
198+ }
199+
200+ #[ test]
201+ fn ignores_diff_headers ( ) {
202+ let patch = "\
203+ --- a/file.txt
204+ +++ b/file.txt
205+ context line
206+ " ;
207+ assert ! ( !patch_contains( patch, "file.txt" ) ) ; // should *not* match header
208+ }
209+
210+ #[ test]
211+ fn needle_not_present ( ) {
212+ let patch = "\
213+ --- a/file.txt
214+ +++ b/file.txt
215+ +added line
216+ " ;
217+ assert ! ( !patch_contains( patch, "missing" ) ) ;
218+ }
219+ }
0 commit comments