Skip to main content

ward/cli/
teams.rs

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 teams and their repo access
20    List,
21
22    /// Preview team access changes (dry-run)
23    Plan,
24
25    /// Apply team access to repositories
26    Apply {
27        /// Skip confirmation prompt
28        #[arg(long, short)]
29        yes: bool,
30    },
31
32    /// Audit current team access across repos
33    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, &current));
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, &current);
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, &current);
597        assert!(!diff.has_changes());
598    }
599}