1use anyhow::Result;
2use clap::Args;
3use console::style;
4use dialoguer::Confirm;
5
6use crate::config::Manifest;
7use crate::config::manifest::TeamAccess;
8use crate::github::Client;
9use crate::github::teams::Team;
10
11#[derive(Args)]
12pub struct TeamsCommand {
13 #[command(subcommand)]
14 action: TeamsAction,
15}
16
17#[derive(clap::Subcommand)]
18enum TeamsAction {
19 List,
21
22 Plan,
24
25 Apply {
27 #[arg(long, short)]
29 yes: bool,
30 },
31
32 Audit,
34}
35
36impl TeamsCommand {
37 pub async fn run(
38 &self,
39 client: &Client,
40 manifest: &Manifest,
41 system: Option<&str>,
42 repo: Option<&str>,
43 ) -> Result<()> {
44 match &self.action {
45 TeamsAction::List => list(client, manifest, system, repo).await,
46 TeamsAction::Plan => plan(client, manifest, system, repo).await,
47 TeamsAction::Apply { yes } => apply(client, manifest, system, repo, *yes).await,
48 TeamsAction::Audit => audit(client, manifest, system, repo).await,
49 }
50 }
51}
52
53async fn resolve_repos(
54 client: &Client,
55 manifest: &Manifest,
56 system: Option<&str>,
57 repo: Option<&str>,
58) -> Result<Vec<String>> {
59 if let Some(repo_name) = repo {
60 return Ok(vec![repo_name.to_owned()]);
61 }
62
63 let sys = system.ok_or_else(|| {
64 anyhow::anyhow!("Either --system or --repo is required for teams commands")
65 })?;
66
67 let excludes = manifest.exclude_patterns_for_system(sys);
68 let explicit = manifest.explicit_repos_for_system(sys);
69 let repos = client
70 .list_repos_for_system(sys, &excludes, &explicit)
71 .await?;
72 Ok(repos.into_iter().map(|r| r.name).collect())
73}
74
75fn teams_for_system<'a>(manifest: &'a Manifest, system_id: &str) -> &'a [TeamAccess] {
76 manifest
77 .system(system_id)
78 .map(|s| s.teams.as_slice())
79 .unwrap_or(&[])
80}
81
82struct TeamDiff {
83 repo: String,
84 to_add: Vec<TeamAccess>,
85 to_update: Vec<TeamAccess>,
86 to_remove: Vec<String>,
87}
88
89impl TeamDiff {
90 fn has_changes(&self) -> bool {
91 !self.to_add.is_empty() || !self.to_update.is_empty() || !self.to_remove.is_empty()
92 }
93
94 fn change_count(&self) -> usize {
95 self.to_add.len() + self.to_update.len() + self.to_remove.len()
96 }
97}
98
99fn diff_teams(repo: &str, desired: &[TeamAccess], current: &[Team]) -> TeamDiff {
100 let mut to_add = Vec::new();
101 let mut to_update = Vec::new();
102 let mut to_remove = Vec::new();
103
104 for d in desired {
105 match current.iter().find(|c| c.slug == d.slug) {
106 None => to_add.push(d.clone()),
107 Some(c) if c.permission != d.permission => to_update.push(d.clone()),
108 _ => {}
109 }
110 }
111
112 for c in current {
113 if !desired.iter().any(|d| d.slug == c.slug) {
114 to_remove.push(c.slug.clone());
115 }
116 }
117
118 TeamDiff {
119 repo: repo.to_string(),
120 to_add,
121 to_update,
122 to_remove,
123 }
124}
125
126async fn list(
127 client: &Client,
128 manifest: &Manifest,
129 system: Option<&str>,
130 repo: Option<&str>,
131) -> Result<()> {
132 let repos = resolve_repos(client, manifest, system, repo).await?;
133
134 println!();
135 println!(
136 " {} Listing teams for {} repositories...",
137 style("[..]").dim(),
138 repos.len()
139 );
140
141 println!();
142 println!(
143 " {} {}",
144 style(format!("{:<40}", "Repository")).bold().underlined(),
145 style("Teams").bold().underlined(),
146 );
147 println!(" {}", style("\u{2500}".repeat(70)).dim());
148
149 for repo_name in &repos {
150 let teams = client.list_repo_teams(repo_name).await?;
151
152 let summary = if teams.is_empty() {
153 style("(none)").dim().to_string()
154 } else {
155 teams
156 .iter()
157 .map(|t| format!("{} ({})", t.slug, t.permission))
158 .collect::<Vec<_>>()
159 .join(", ")
160 };
161
162 println!(" {:<40} {}", repo_name, summary);
163 }
164
165 Ok(())
166}
167
168async fn build_diffs(
169 client: &Client,
170 manifest: &Manifest,
171 system: Option<&str>,
172 repo: Option<&str>,
173) -> Result<Vec<TeamDiff>> {
174 let sys_id = system.ok_or_else(|| {
175 anyhow::anyhow!(
176 "--system is required for teams plan/apply (teams are configured per system)"
177 )
178 })?;
179
180 let desired = teams_for_system(manifest, sys_id);
181 if desired.is_empty() {
182 anyhow::bail!("No teams configured for system '{}' in ward.toml", sys_id);
183 }
184
185 let repos = resolve_repos(client, manifest, system, repo).await?;
186
187 println!();
188 println!(
189 " {} Scanning teams for {} repositories...",
190 style("[..]").dim(),
191 repos.len()
192 );
193
194 let mut diffs = Vec::new();
195
196 for repo_name in &repos {
197 let current = client.list_repo_teams(repo_name).await?;
198 diffs.push(diff_teams(repo_name, desired, ¤t));
199 }
200
201 Ok(diffs)
202}
203
204async fn plan(
205 client: &Client,
206 manifest: &Manifest,
207 system: Option<&str>,
208 repo: Option<&str>,
209) -> Result<()> {
210 let diffs = build_diffs(client, manifest, system, repo).await?;
211
212 print_diff_table(&diffs);
213
214 let needs_changes = diffs.iter().filter(|d| d.has_changes()).count();
215 if needs_changes > 0 {
216 println!(
217 "\n Run {} to apply these changes.",
218 style("ward teams apply").cyan().bold()
219 );
220 }
221
222 Ok(())
223}
224
225async fn apply(
226 client: &Client,
227 manifest: &Manifest,
228 system: Option<&str>,
229 repo: Option<&str>,
230 yes: bool,
231) -> Result<()> {
232 let diffs = build_diffs(client, manifest, system, repo).await?;
233
234 let needs_changes = diffs.iter().filter(|d| d.has_changes()).count();
235 if needs_changes == 0 {
236 println!(
237 "\n {} All team access is up to date.",
238 style("[ok]").green()
239 );
240 return Ok(());
241 }
242
243 print_diff_table(&diffs);
244
245 if !yes {
246 let proceed = Confirm::new()
247 .with_prompt(format!(
248 " Apply team changes to {needs_changes} repositories?"
249 ))
250 .default(false)
251 .interact()?;
252
253 if !proceed {
254 println!(" Aborted.");
255 return Ok(());
256 }
257 }
258
259 println!();
260 println!(" {} Applying team changes...", style("[..]").dim());
261
262 let mut succeeded = 0usize;
263 let mut failed: Vec<(String, String)> = Vec::new();
264
265 for diff in diffs.iter().filter(|d| d.has_changes()) {
266 for team in &diff.to_add {
267 match client
268 .add_team_to_repo(&diff.repo, &team.slug, &team.permission)
269 .await
270 {
271 Ok(()) => {
272 println!(
273 " {} {}: added {} ({})",
274 style("[ok]").green(),
275 diff.repo,
276 team.slug,
277 team.permission
278 );
279 succeeded += 1;
280 }
281 Err(e) => {
282 println!(
283 " {} {}: failed to add {}: {}",
284 style("[!!]").red(),
285 diff.repo,
286 team.slug,
287 e
288 );
289 failed.push((diff.repo.clone(), e.to_string()));
290 }
291 }
292 }
293
294 for team in &diff.to_update {
295 match client
296 .add_team_to_repo(&diff.repo, &team.slug, &team.permission)
297 .await
298 {
299 Ok(()) => {
300 println!(
301 " {} {}: updated {} -> {}",
302 style("[ok]").green(),
303 diff.repo,
304 team.slug,
305 team.permission
306 );
307 succeeded += 1;
308 }
309 Err(e) => {
310 println!(
311 " {} {}: failed to update {}: {}",
312 style("[!!]").red(),
313 diff.repo,
314 team.slug,
315 e
316 );
317 failed.push((diff.repo.clone(), e.to_string()));
318 }
319 }
320 }
321
322 for slug in &diff.to_remove {
323 match client.remove_team_from_repo(&diff.repo, slug).await {
324 Ok(()) => {
325 println!(
326 " {} {}: removed {}",
327 style("[ok]").green(),
328 diff.repo,
329 slug
330 );
331 succeeded += 1;
332 }
333 Err(e) => {
334 println!(
335 " {} {}: failed to remove {}: {}",
336 style("[!!]").red(),
337 diff.repo,
338 slug,
339 e
340 );
341 failed.push((diff.repo.clone(), e.to_string()));
342 }
343 }
344 }
345 }
346
347 println!();
348 if failed.is_empty() {
349 println!(
350 " {} {} changes applied successfully.",
351 style("[ok]").green(),
352 succeeded
353 );
354 } else {
355 println!(
356 " {} {} succeeded, {} failed:",
357 style("[!!]").yellow(),
358 succeeded,
359 failed.len()
360 );
361 for (repo, err) in &failed {
362 println!(" {} {}: {}", style("[!!]").red(), repo, err);
363 }
364 }
365
366 Ok(())
367}
368
369async fn audit(
370 client: &Client,
371 manifest: &Manifest,
372 system: Option<&str>,
373 repo: Option<&str>,
374) -> Result<()> {
375 let sys_id = system.unwrap_or("default");
376 let desired = teams_for_system(manifest, sys_id);
377 let repos = resolve_repos(client, manifest, system, repo).await?;
378
379 println!();
380 println!(
381 " {} Auditing team access for {} repositories...",
382 style("[..]").dim(),
383 repos.len()
384 );
385
386 println!();
387 println!(
388 " {} {}",
389 style(format!("{:<40}", "Repository")).bold().underlined(),
390 style("Teams").bold().underlined(),
391 );
392 println!(" {}", style("\u{2500}".repeat(70)).dim());
393
394 let mut total_ok = 0;
395 let mut total_issues = 0;
396
397 for repo_name in &repos {
398 let teams = client.list_repo_teams(repo_name).await?;
399
400 let all_desired_present = desired.iter().all(|d| {
401 teams
402 .iter()
403 .any(|t| t.slug == d.slug && t.permission == d.permission)
404 });
405
406 if all_desired_present && !desired.is_empty() {
407 total_ok += 1;
408 } else if !desired.is_empty() {
409 total_issues += 1;
410 }
411
412 let indicator = if desired.is_empty() || all_desired_present {
413 style("[ok]").green().to_string()
414 } else {
415 style("[!!]").red().to_string()
416 };
417
418 let summary = if teams.is_empty() {
419 "(none)".to_string()
420 } else {
421 teams
422 .iter()
423 .map(|t| format!("{} ({})", t.slug, t.permission))
424 .collect::<Vec<_>>()
425 .join(", ")
426 };
427
428 println!(" {} {:<38} {}", indicator, repo_name, summary);
429 }
430
431 println!();
432 println!(
433 " Summary: {} compliant, {} need attention",
434 style(total_ok).green().bold(),
435 if total_issues > 0 {
436 style(total_issues).red().bold()
437 } else {
438 style(total_issues).green().bold()
439 }
440 );
441
442 Ok(())
443}
444
445fn print_diff_table(diffs: &[TeamDiff]) {
446 println!();
447 println!(" {}", style("Teams Plan").bold().cyan());
448 println!(" {}", style("\u{2500}".repeat(60)).dim());
449
450 for diff in diffs {
451 if diff.has_changes() {
452 println!(
453 " {} {} ({} changes)",
454 style("[!!]").yellow(),
455 style(&diff.repo).bold(),
456 diff.change_count()
457 );
458 for team in &diff.to_add {
459 println!(
460 " {} add: {} ({})",
461 style("+").green(),
462 team.slug,
463 team.permission
464 );
465 }
466 for team in &diff.to_update {
467 println!(
468 " {} update: {} -> {}",
469 style("~").yellow(),
470 team.slug,
471 team.permission
472 );
473 }
474 for slug in &diff.to_remove {
475 println!(" {} remove: {}", style("-").red(), slug);
476 }
477 } else {
478 println!(" {} {}", style("[ok]").green(), style(&diff.repo).dim());
479 }
480 }
481
482 let needs_changes = diffs.iter().filter(|d| d.has_changes()).count();
483 let up_to_date = diffs.len() - needs_changes;
484
485 println!();
486 println!(
487 " Summary: {} need changes, {} up to date",
488 if needs_changes > 0 {
489 style(needs_changes).yellow().bold()
490 } else {
491 style(needs_changes).green().bold()
492 },
493 style(up_to_date).green()
494 );
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 #[test]
502 fn test_team_config_parsing() {
503 let toml_str = r#"
504 [org]
505 name = "org"
506 [[systems]]
507 id = "be"
508 name = "Backend"
509 teams = [
510 { slug = "developers", permission = "push" },
511 { slug = "devops", permission = "admin" },
512 ]
513 "#;
514 let m: crate::config::Manifest = toml::from_str(toml_str).unwrap();
515 let teams = teams_for_system(&m, "be");
516 assert_eq!(teams.len(), 2);
517 assert_eq!(teams[0].slug, "developers");
518 assert_eq!(teams[0].permission, "push");
519 assert_eq!(teams[1].slug, "devops");
520 assert_eq!(teams[1].permission, "admin");
521 }
522
523 #[test]
524 fn test_team_access_diff() {
525 let desired = vec![
526 TeamAccess {
527 slug: "developers".to_string(),
528 permission: "push".to_string(),
529 },
530 TeamAccess {
531 slug: "devops".to_string(),
532 permission: "admin".to_string(),
533 },
534 ];
535
536 let current = vec![
537 Team {
538 id: 1,
539 name: "Developers".to_string(),
540 slug: "developers".to_string(),
541 description: None,
542 permission: "pull".to_string(),
543 privacy: "closed".to_string(),
544 },
545 Team {
546 id: 2,
547 name: "Old Team".to_string(),
548 slug: "old-team".to_string(),
549 description: None,
550 permission: "push".to_string(),
551 privacy: "closed".to_string(),
552 },
553 ];
554
555 let diff = diff_teams("my-repo", &desired, ¤t);
556 assert_eq!(diff.repo, "my-repo");
557 assert_eq!(diff.to_add.len(), 1);
558 assert_eq!(diff.to_add[0].slug, "devops");
559 assert_eq!(diff.to_update.len(), 1);
560 assert_eq!(diff.to_update[0].slug, "developers");
561 assert_eq!(diff.to_update[0].permission, "push");
562 assert_eq!(diff.to_remove.len(), 1);
563 assert_eq!(diff.to_remove[0], "old-team");
564 }
565
566 #[test]
567 fn test_team_config_empty_default() {
568 let toml_str = r#"
569 [org]
570 name = "org"
571 [[systems]]
572 id = "be"
573 name = "Backend"
574 "#;
575 let m: crate::config::Manifest = toml::from_str(toml_str).unwrap();
576 let teams = teams_for_system(&m, "be");
577 assert!(teams.is_empty());
578 }
579
580 #[test]
581 fn test_team_diff_no_changes() {
582 let desired = vec![TeamAccess {
583 slug: "devs".to_string(),
584 permission: "push".to_string(),
585 }];
586
587 let current = vec![Team {
588 id: 1,
589 name: "Devs".to_string(),
590 slug: "devs".to_string(),
591 description: None,
592 permission: "push".to_string(),
593 privacy: "closed".to_string(),
594 }];
595
596 let diff = diff_teams("repo", &desired, ¤t);
597 assert!(!diff.has_changes());
598 }
599}