11use std:: {
2- collections:: BTreeSet ,
2+ collections:: { BTreeMap , BTreeSet } ,
33 fmt:: Display ,
44 path:: { Path , PathBuf } ,
55 process:: Command ,
@@ -152,15 +152,26 @@ pub fn generate_issues(
152152#[ derive( Debug , PartialEq , Eq , PartialOrd , Ord ) ]
153153pub struct GithubIssue {
154154 pub title : String ,
155- pub assignees : Vec < String > ,
155+ pub assignees : BTreeSet < String > ,
156156 pub body : String ,
157157 pub labels : Vec < String > ,
158158}
159159
160160#[ derive( Debug , PartialEq , Eq , PartialOrd , Ord ) ]
161161enum GithubAction {
162- CreateLabel { label : GhLabel } ,
163- CreateIssue { issue : GithubIssue } ,
162+ CreateLabel {
163+ label : GhLabel ,
164+ } ,
165+ CreateIssue {
166+ issue : GithubIssue ,
167+ } ,
168+
169+ // We intentionally do not sync the issue *text*, because it may have been edited.
170+ SyncIssue {
171+ number : u64 ,
172+ remove_owners : BTreeSet < String > ,
173+ add_owners : BTreeSet < String > ,
174+ } ,
164175}
165176
166177#[ derive( Debug , Serialize , Deserialize , PartialEq , Eq , PartialOrd , Ord ) ]
@@ -171,7 +182,21 @@ struct GhLabel {
171182
172183#[ derive( Debug , Serialize , Deserialize , PartialEq , Eq , PartialOrd , Ord ) ]
173184struct ExistingGithubIssue {
185+ number : u64 ,
186+ assignees : BTreeSet < String > ,
187+ }
188+
189+ #[ derive( Debug , Serialize , Deserialize , PartialEq , Eq , PartialOrd , Ord ) ]
190+ struct ExistingGithubIssueJson {
174191 title : String ,
192+ number : u64 ,
193+ assignees : Vec < ExistingGithubAssigneeJson > ,
194+ }
195+
196+ #[ derive( Debug , Serialize , Deserialize , PartialEq , Eq , PartialOrd , Ord ) ]
197+ struct ExistingGithubAssigneeJson {
198+ login : String ,
199+ name : String ,
175200}
176201
177202fn list_labels ( repository : & str ) -> anyhow:: Result < Vec < GhLabel > > {
@@ -192,7 +217,7 @@ fn list_labels(repository: &str) -> anyhow::Result<Vec<GhLabel>> {
192217fn list_issue_titles_in_milestone (
193218 repository : & str ,
194219 timeframe : & str ,
195- ) -> anyhow:: Result < BTreeSet < String > > {
220+ ) -> anyhow:: Result < BTreeMap < String , ExistingGithubIssue > > {
196221 let output = Command :: new ( "gh" )
197222 . arg ( "-R" )
198223 . arg ( repository)
@@ -201,12 +226,27 @@ fn list_issue_titles_in_milestone(
201226 . arg ( "-m" )
202227 . arg ( timeframe)
203228 . arg ( "--json" )
204- . arg ( "title" )
229+ . arg ( "title,assignees,number " )
205230 . output ( ) ?;
206231
207- let existing_issues: Vec < ExistingGithubIssue > = serde_json:: from_slice ( & output. stdout ) ?;
232+ let existing_issues: Vec < ExistingGithubIssueJson > = serde_json:: from_slice ( & output. stdout ) ?;
208233
209- Ok ( existing_issues. into_iter ( ) . map ( |e_i| e_i. title ) . collect ( ) )
234+ Ok ( existing_issues
235+ . into_iter ( )
236+ . map ( |e_i| {
237+ (
238+ e_i. title ,
239+ ExistingGithubIssue {
240+ number : e_i. number ,
241+ assignees : e_i
242+ . assignees
243+ . iter ( )
244+ . map ( |a| format ! ( "@{}" , a. login) )
245+ . collect ( ) ,
246+ } ,
247+ )
248+ } )
249+ . collect ( ) )
210250}
211251/// Initializes the required `T-<team>` labels on the repository.
212252/// Warns if the labels are found with wrong color.
@@ -256,26 +296,50 @@ fn initialize_issues(
256296 goal_documents : & [ GoalDocument ] ,
257297) -> anyhow:: Result < BTreeSet < GithubAction > > {
258298 // the set of issues we want to exist
259- let mut desired_issues: BTreeSet < GithubIssue > = goal_documents
299+ let desired_issues: BTreeSet < GithubIssue > = goal_documents
260300 . iter ( )
261301 . map ( |goal_document| issue ( timeframe, goal_document) )
262302 . collect :: < anyhow:: Result < _ > > ( ) ?;
263303
264- // remove any existings that already exist
304+ // Compare desired issues against existing issues
265305 let existing_issues = list_issue_titles_in_milestone ( repository, timeframe) ?;
266- desired_issues. retain ( |i| !existing_issues. contains ( & i. title ) ) ;
306+ let mut actions = BTreeSet :: new ( ) ;
307+ for desired_issue in desired_issues {
308+ match existing_issues. get ( & desired_issue. title ) {
309+ Some ( existing_issue) => {
310+ if existing_issue. assignees != desired_issue. assignees {
311+ actions. insert ( GithubAction :: SyncIssue {
312+ number : existing_issue. number ,
313+ remove_owners : existing_issue
314+ . assignees
315+ . difference ( & desired_issue. assignees )
316+ . cloned ( )
317+ . collect ( ) ,
318+ add_owners : desired_issue
319+ . assignees
320+ . difference ( & existing_issue. assignees )
321+ . cloned ( )
322+ . collect ( ) ,
323+ } ) ;
324+ }
325+ }
267326
268- Ok ( desired_issues
269- . into_iter ( )
270- . map ( |issue| GithubAction :: CreateIssue { issue } )
271- . collect ( ) )
327+ None => {
328+ actions. insert ( GithubAction :: CreateIssue {
329+ issue : desired_issue,
330+ } ) ;
331+ }
332+ }
333+ }
334+
335+ Ok ( actions)
272336}
273337
274338fn issue ( timeframe : & str , document : & GoalDocument ) -> anyhow:: Result < GithubIssue > {
275- let mut assignees = vec ! [ ] ;
339+ let mut assignees = BTreeSet :: default ( ) ;
276340 for username in document. metadata . owner_usernames ( ) {
277341 if get_person_data ( username) ?. is_some ( ) {
278- assignees. push ( username[ 1 ..] . to_string ( ) ) ;
342+ assignees. insert ( username[ 1 ..] . to_string ( ) ) ;
279343 }
280344 }
281345
@@ -388,6 +452,9 @@ impl Display for GithubAction {
388452 GithubAction :: CreateIssue { issue } => {
389453 write ! ( f, "create issue \" {}\" " , issue. title)
390454 }
455+ GithubAction :: SyncIssue { number, .. } => {
456+ write ! ( f, "sync issue #{}" , number)
457+ }
391458 }
392459 }
393460}
@@ -441,7 +508,7 @@ impl GithubAction {
441508 . arg ( "-l" )
442509 . arg ( labels. join ( "," ) )
443510 . arg ( "-a" )
444- . arg ( assignees . join ( "," ) )
511+ . arg ( comma ( & assignees ) )
445512 . arg ( "-m" )
446513 . arg ( & timeframe)
447514 . output ( ) ?;
@@ -456,6 +523,43 @@ impl GithubAction {
456523 Ok ( ( ) )
457524 }
458525 }
526+ GithubAction :: SyncIssue {
527+ number,
528+ remove_owners,
529+ add_owners,
530+ } => {
531+ let mut command = Command :: new ( "gh" ) ;
532+ command
533+ . arg ( "-R" )
534+ . arg ( & repository)
535+ . arg ( "issue" )
536+ . arg ( "edit" )
537+ . arg ( number. to_string ( ) ) ;
538+
539+ if !remove_owners. is_empty ( ) {
540+ command. arg ( "--remove-assignee" ) . arg ( comma ( & remove_owners) ) ;
541+ }
542+
543+ if !add_owners. is_empty ( ) {
544+ command. arg ( "--add-assignee" ) . arg ( comma ( & add_owners) ) ;
545+ }
546+
547+ let output = command. output ( ) ?;
548+ if !output. status . success ( ) {
549+ Err ( anyhow:: anyhow!(
550+ "failed to sync issue `{}`: {}" ,
551+ number,
552+ String :: from_utf8_lossy( & output. stderr)
553+ ) )
554+ } else {
555+ Ok ( ( ) )
556+ }
557+ }
459558 }
460559 }
461560}
561+
562+ /// Returns a comma-separated list of the strings in `s` (no spaces).
563+ fn comma ( s : & BTreeSet < String > ) -> String {
564+ s. iter ( ) . map ( |s| & s[ ..] ) . collect :: < Vec < _ > > ( ) . join ( "," )
565+ }
0 commit comments