3
3
//! interested people.
4
4
5
5
use crate :: {
6
- config:: { MentionsConfig , MentionsPathConfig } ,
6
+ config:: { MentionsConfig , MentionsEntryConfig , MentionsEntryType } ,
7
7
db:: issue_data:: IssueData ,
8
8
github:: { IssuesAction , IssuesEvent } ,
9
9
handlers:: Context ,
10
10
} ;
11
11
use anyhow:: Context as _;
12
+ use itertools:: Itertools ;
12
13
use serde:: { Deserialize , Serialize } ;
13
- use std:: fmt:: Write ;
14
14
use std:: path:: Path ;
15
+ use std:: { fmt:: Write , path:: PathBuf } ;
15
16
use tracing as log;
16
17
17
18
const MENTIONS_KEY : & str = "mentions" ;
18
19
19
20
pub ( super ) struct MentionsInput {
20
- paths : Vec < String > ,
21
+ to_mention : Vec < ( String , Vec < PathBuf > ) > ,
21
22
}
22
23
23
24
#[ derive( Debug , Default , Deserialize , Serialize , Clone , PartialEq ) ]
24
25
struct MentionState {
25
- paths : Vec < String > ,
26
+ #[ serde( alias = "paths" ) ]
27
+ entries : Vec < String > ,
26
28
}
27
29
28
30
pub ( super ) async fn parse_input (
@@ -61,23 +63,42 @@ pub(super) async fn parse_input(
61
63
{
62
64
let file_paths: Vec < _ > = files. iter ( ) . map ( |fd| Path :: new ( & fd. filename ) ) . collect ( ) ;
63
65
let to_mention: Vec < _ > = config
64
- . paths
66
+ . entries
65
67
. 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
+ } ;
70
88
// Don't mention if only the author is in the list.
71
89
let pings_non_author = match & cc[ ..] {
72
90
[ only_cc] => only_cc. trim_start_matches ( '@' ) != & event. issue . user . login ,
73
91
_ => true ,
74
92
} ;
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
+ }
76
98
} )
77
- . map ( |( key, _mention) | key. to_string ( ) )
78
99
. collect ( ) ;
79
100
if !to_mention. is_empty ( ) {
80
- return Ok ( Some ( MentionsInput { paths : to_mention } ) ) ;
101
+ return Ok ( Some ( MentionsInput { to_mention } ) ) ;
81
102
}
82
103
}
83
104
Ok ( None )
@@ -94,23 +115,36 @@ pub(super) async fn handle_input(
94
115
IssueData :: load ( & mut client, & event. issue , MENTIONS_KEY ) . await ?;
95
116
// Build the message to post to the issue.
96
117
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 ) {
99
120
// Avoid duplicate mentions.
100
121
continue ;
101
122
}
102
- let MentionsPathConfig { message, cc } = & config. paths [ to_mention ] ;
123
+ let MentionsEntryConfig { message, cc, type_ } = & config. entries [ & entry ] ;
103
124
if !result. is_empty ( ) {
104
125
result. push_str ( "\n \n " ) ;
105
126
}
106
127
match message {
107
128
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
+ } ,
109
143
}
110
144
if !cc. is_empty ( ) {
111
145
write ! ( result, "\n \n cc {}" , cc. join( ", " ) ) . unwrap ( ) ;
112
146
}
113
- state. data . paths . push ( to_mention . to_string ( ) ) ;
147
+ state. data . entries . push ( entry ) ;
114
148
}
115
149
if !result. is_empty ( ) {
116
150
event
@@ -122,3 +156,64 @@ pub(super) async fn handle_input(
122
156
}
123
157
Ok ( ( ) )
124
158
}
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