Skip to main content

ward/cli/
settings.rs

1use anyhow::Result;
2use clap::Args;
3use console::style;
4use dialoguer::Confirm;
5
6use crate::config::Manifest;
7use crate::config::templates::load_templates_with_custom_dir;
8use crate::engine::audit_log::AuditLog;
9use crate::github::Client;
10use crate::github::commits::CommitFile;
11
12#[derive(Args)]
13pub struct SettingsCommand {
14    #[command(subcommand)]
15    action: SettingsAction,
16}
17
18#[derive(clap::Subcommand)]
19enum SettingsAction {
20    /// Show what settings/rulesets would change
21    Plan {
22        /// Ruleset to apply (copilot-review)
23        #[arg(long)]
24        ruleset: Option<String>,
25
26        /// Deploy copilot review instructions
27        #[arg(long)]
28        copilot_instructions: bool,
29    },
30
31    /// Apply settings and rulesets
32    Apply {
33        /// Ruleset to apply (copilot-review)
34        #[arg(long)]
35        ruleset: Option<String>,
36
37        /// Deploy copilot review instructions
38        #[arg(long)]
39        copilot_instructions: bool,
40
41        /// Skip confirmation prompt
42        #[arg(long)]
43        yes: bool,
44    },
45
46    /// Audit current settings state
47    Audit,
48}
49
50impl SettingsCommand {
51    pub async fn run(
52        &self,
53        client: &Client,
54        manifest: &Manifest,
55        system: Option<&str>,
56        repo: Option<&str>,
57    ) -> Result<()> {
58        match &self.action {
59            SettingsAction::Plan {
60                ruleset,
61                copilot_instructions,
62            } => {
63                plan(
64                    client,
65                    manifest,
66                    system,
67                    repo,
68                    ruleset.as_deref(),
69                    *copilot_instructions,
70                )
71                .await
72            }
73            SettingsAction::Apply {
74                ruleset,
75                copilot_instructions,
76                yes,
77            } => {
78                apply(
79                    client,
80                    manifest,
81                    system,
82                    repo,
83                    ruleset.as_deref(),
84                    *copilot_instructions,
85                    *yes,
86                )
87                .await
88            }
89            SettingsAction::Audit => audit(client, manifest, system, repo).await,
90        }
91    }
92}
93
94/// Detect if a repo is an operations/GitOps repo (vs application repo).
95fn is_ops_repo(repo_name: &str) -> bool {
96    repo_name.ends_with("-operation")
97        || repo_name.ends_with("-operations")
98        || repo_name.ends_with("-ops")
99        || repo_name.ends_with("-gitops")
100}
101
102struct RepoRulesetState {
103    repo: String,
104    has_copilot_review: bool,
105    has_instructions: bool,
106    is_ops: bool,
107}
108
109async fn scan_repo(client: &Client, repo: &str) -> Result<RepoRulesetState> {
110    let rulesets = client.list_rulesets(repo).await?;
111    let has_copilot_review = rulesets.iter().any(|r| r.name == "Copilot Code Review");
112
113    let has_instructions = client
114        .get_file(repo, ".github/copilot-instructions.md", None)
115        .await?
116        .is_some();
117
118    Ok(RepoRulesetState {
119        repo: repo.to_owned(),
120        has_copilot_review,
121        has_instructions,
122        is_ops: is_ops_repo(repo),
123    })
124}
125
126async fn resolve_repos(
127    client: &Client,
128    manifest: &Manifest,
129    system: Option<&str>,
130    repo: Option<&str>,
131) -> Result<Vec<String>> {
132    if let Some(repo_name) = repo {
133        return Ok(vec![repo_name.to_owned()]);
134    }
135    let sys = system.ok_or_else(|| anyhow::anyhow!("Either --system or --repo is required"))?;
136    let excludes = manifest.exclude_patterns_for_system(sys);
137    let explicit = manifest.explicit_repos_for_system(sys);
138    let repos = client
139        .list_repos_for_system(sys, &excludes, &explicit)
140        .await?;
141    Ok(repos.into_iter().map(|r| r.name).collect())
142}
143
144async fn plan(
145    client: &Client,
146    manifest: &Manifest,
147    system: Option<&str>,
148    repo: Option<&str>,
149    ruleset: Option<&str>,
150    copilot_instructions: bool,
151) -> Result<()> {
152    let repos = resolve_repos(client, manifest, system, repo).await?;
153    let do_ruleset = ruleset.is_some() || (!copilot_instructions);
154    let do_instructions = copilot_instructions || ruleset.is_none();
155
156    println!();
157    println!(
158        "  {} Settings plan: scanning {} repos...",
159        style("🔍").bold(),
160        repos.len()
161    );
162    println!();
163
164    let mut ruleset_needed = 0;
165    let mut instructions_needed = 0;
166    let mut up_to_date = 0;
167
168    for repo_name in &repos {
169        let state = scan_repo(client, repo_name).await?;
170        let mut changes = Vec::new();
171
172        if do_ruleset && !state.has_copilot_review {
173            changes.push("create Copilot Code Review ruleset");
174            ruleset_needed += 1;
175        }
176
177        if do_instructions && !state.has_instructions {
178            changes.push(if state.is_ops {
179                "deploy copilot-instructions.md (ops)"
180            } else {
181                "deploy copilot-instructions.md (app)"
182            });
183            instructions_needed += 1;
184        }
185
186        if changes.is_empty() {
187            println!("  {} {}", style("✓").green(), style(repo_name).dim());
188            up_to_date += 1;
189        } else {
190            println!("  {} {}", style("⚡").yellow(), style(repo_name).bold());
191            for change in &changes {
192                println!("     {change}");
193            }
194        }
195    }
196
197    println!();
198    println!(
199        "  Summary: {} need ruleset, {} need instructions, {} up to date",
200        style(ruleset_needed).yellow().bold(),
201        style(instructions_needed).yellow().bold(),
202        style(up_to_date).green()
203    );
204
205    if ruleset_needed + instructions_needed > 0 {
206        println!(
207            "\n  Run {} to apply.",
208            style("ward settings apply").cyan().bold()
209        );
210    }
211
212    Ok(())
213}
214
215async fn apply(
216    client: &Client,
217    manifest: &Manifest,
218    system: Option<&str>,
219    repo: Option<&str>,
220    ruleset: Option<&str>,
221    copilot_instructions: bool,
222    yes: bool,
223) -> Result<()> {
224    let repos = resolve_repos(client, manifest, system, repo).await?;
225    let do_ruleset = ruleset.is_some() || (!copilot_instructions);
226    let do_instructions = copilot_instructions || ruleset.is_none();
227    let branch_name = &manifest.templates.branch;
228
229    println!();
230    println!("  {} Scanning {} repos...", style("🔍").bold(), repos.len());
231
232    // Scan all repos
233    let mut work: Vec<(RepoRulesetState, String)> = Vec::new();
234    for repo_name in &repos {
235        let state = scan_repo(client, repo_name).await?;
236        let r = client.get_repo(repo_name).await?;
237        let needs_work = (do_ruleset && !state.has_copilot_review)
238            || (do_instructions && !state.has_instructions);
239        if needs_work {
240            work.push((state, r.default_branch));
241        }
242    }
243
244    if work.is_empty() {
245        println!("\n  {} All repos up to date.", style("✅").green());
246        return Ok(());
247    }
248
249    println!(
250        "\n  {} repos need changes:",
251        style(work.len()).yellow().bold()
252    );
253    for (state, _) in &work {
254        let mut actions = Vec::new();
255        if do_ruleset && !state.has_copilot_review {
256            actions.push("ruleset");
257        }
258        if do_instructions && !state.has_instructions {
259            actions.push(if state.is_ops {
260                "instructions (ops)"
261            } else {
262                "instructions (app)"
263            });
264        }
265        println!(
266            "  {} {} - {}",
267            style("⚡").yellow(),
268            state.repo,
269            actions.join(", ")
270        );
271    }
272
273    if !yes {
274        println!();
275        let proceed = Confirm::new()
276            .with_prompt(format!("  Apply to {} repos?", work.len()))
277            .default(false)
278            .interact()?;
279        if !proceed {
280            println!("  Aborted.");
281            return Ok(());
282        }
283    }
284
285    let audit_log = AuditLog::new()?;
286    let tera = load_templates_with_custom_dir(
287        manifest
288            .templates
289            .custom_dir
290            .as_ref()
291            .map(std::path::Path::new),
292    )?;
293    let mut succeeded = 0usize;
294    let mut failed: Vec<(String, String)> = Vec::new();
295
296    for (state, default_branch) in &work {
297        println!("  {} {} ...", style("▶").magenta(), state.repo);
298
299        // Create ruleset
300        if do_ruleset && !state.has_copilot_review {
301            match client.create_copilot_review_ruleset(&state.repo).await {
302                Ok(()) => {
303                    println!("    {} Copilot review ruleset created", style("✅").green());
304                    audit_log.log(
305                        &state.repo,
306                        "create_copilot_review_ruleset",
307                        "success",
308                        false,
309                        true,
310                    )?;
311                }
312                Err(e) => {
313                    println!("    {} Ruleset: {e}", style("❌").red());
314                    failed.push((state.repo.clone(), format!("ruleset: {e}")));
315                    continue;
316                }
317            }
318        }
319
320        // Deploy instructions
321        if do_instructions && !state.has_instructions {
322            let template_name = if state.is_ops {
323                "copilot-review/instructions-ops.md.tera"
324            } else {
325                "copilot-review/instructions-app.md.tera"
326            };
327
328            let ctx = tera::Context::new();
329            match tera.render(template_name, &ctx) {
330                Ok(rendered) => {
331                    match deploy_instructions(
332                        client,
333                        &state.repo,
334                        default_branch,
335                        branch_name,
336                        &rendered,
337                        &manifest.templates.reviewers,
338                        &manifest.templates.commit_message_prefix,
339                    )
340                    .await
341                    {
342                        Ok(pr_url) => {
343                            println!(
344                                "    {} Instructions PR: {}",
345                                style("✅").green(),
346                                style(&pr_url).cyan()
347                            );
348                            audit_log.log(
349                                &state.repo,
350                                "deploy_copilot_instructions",
351                                "success",
352                                false,
353                                true,
354                            )?;
355                        }
356                        Err(e) => {
357                            println!("    {} Instructions: {e}", style("❌").red());
358                            failed.push((state.repo.clone(), format!("instructions: {e}")));
359                            continue;
360                        }
361                    }
362                }
363                Err(e) => {
364                    println!("    {} Template render: {e}", style("❌").red());
365                    failed.push((state.repo.clone(), format!("template: {e}")));
366                    continue;
367                }
368            }
369        }
370
371        succeeded += 1;
372    }
373
374    println!();
375    if failed.is_empty() {
376        println!("  {} All {} repos updated.", style("✅").green(), succeeded);
377    } else {
378        println!(
379            "  {} {} succeeded, {} failed:",
380            style("⚠️").yellow(),
381            succeeded,
382            failed.len()
383        );
384        for (repo, err) in &failed {
385            println!("    {} {}: {}", style("❌").red(), repo, err);
386        }
387    }
388
389    println!(
390        "\n  {} Audit log: {}",
391        style("📋").bold(),
392        audit_log.path().display()
393    );
394
395    Ok(())
396}
397
398async fn deploy_instructions(
399    client: &Client,
400    repo: &str,
401    default_branch: &str,
402    branch_name: &str,
403    content: &str,
404    reviewers: &[String],
405    commit_prefix: &str,
406) -> Result<String> {
407    client
408        .create_branch(repo, branch_name, default_branch)
409        .await?;
410
411    let files = vec![CommitFile {
412        path: ".github/copilot-instructions.md".to_owned(),
413        content: content.to_owned(),
414    }];
415
416    client
417        .create_commit(
418            repo,
419            branch_name,
420            &format!("{commit_prefix}add Copilot review instructions"),
421            &files,
422        )
423        .await?;
424
425    let pr = client
426        .create_pull_request(
427            repo,
428            &format!("{commit_prefix}add Copilot review instructions"),
429            "## Ward: Copilot review instructions\n\n\
430             Deploys `.github/copilot-instructions.md` for automatic Copilot code review.\n\n\
431             ---\n\
432             *Review the instructions, then merge.*",
433            branch_name,
434            default_branch,
435            reviewers,
436        )
437        .await?;
438
439    Ok(pr.html_url)
440}
441
442async fn audit(
443    client: &Client,
444    manifest: &Manifest,
445    system: Option<&str>,
446    repo: Option<&str>,
447) -> Result<()> {
448    let repos = resolve_repos(client, manifest, system, repo).await?;
449
450    println!();
451    println!(
452        "  {} Settings audit: {} repos",
453        style("🔍").bold(),
454        repos.len()
455    );
456    println!();
457    println!(
458        "  {:40} {:10} {:14} {}",
459        style("Repository").bold().underlined(),
460        style("Type").bold().underlined(),
461        style("Review Rule").bold().underlined(),
462        style("Instructions").bold().underlined(),
463    );
464
465    let mut all_ok = 0;
466    let mut issues = 0;
467
468    for repo_name in &repos {
469        let state = scan_repo(client, repo_name).await?;
470
471        let ruleset_icon = if state.has_copilot_review {
472            format!("{}", style("✅").green())
473        } else {
474            format!("{}", style("❌").red())
475        };
476        let instr_icon = if state.has_instructions {
477            format!("{}", style("✅").green())
478        } else {
479            format!("{}", style("❌").red())
480        };
481        let repo_type = if state.is_ops { "ops" } else { "app" };
482
483        let ok = state.has_copilot_review && state.has_instructions;
484        if ok {
485            all_ok += 1;
486        } else {
487            issues += 1;
488        }
489
490        println!(
491            "  {:40} {:10} {:14} {}",
492            repo_name, repo_type, ruleset_icon, instr_icon
493        );
494    }
495
496    println!();
497    println!(
498        "  Summary: {} fully configured, {} need attention",
499        style(all_ok).green().bold(),
500        if issues > 0 {
501            style(issues).red().bold()
502        } else {
503            style(issues).green().bold()
504        }
505    );
506
507    Ok(())
508}
509
510#[cfg(test)]
511mod tests {
512    use super::*;
513
514    #[test]
515    fn detect_ops_repo_by_operations_suffix() {
516        assert!(is_ops_repo("backend-user-service-operations"));
517    }
518
519    #[test]
520    fn detect_ops_repo_by_operation_singular() {
521        assert!(is_ops_repo("backend-user-service-operation"));
522    }
523
524    #[test]
525    fn detect_ops_repo_by_ops_suffix() {
526        assert!(is_ops_repo("frontend-app-ops"));
527    }
528
529    #[test]
530    fn detect_ops_repo_by_gitops_suffix() {
531        assert!(is_ops_repo("platform-gitops"));
532    }
533
534    #[test]
535    fn detect_ops_repo_with_operation_in_middle() {
536        assert!(!is_ops_repo("my-operation-manager"));
537    }
538
539    #[test]
540    fn detect_ops_repo_by_operation_suffix() {
541        assert!(is_ops_repo("my-service-operation"));
542    }
543
544    #[test]
545    fn regular_repo_not_ops() {
546        assert!(!is_ops_repo("backend-user-service"));
547    }
548
549    #[test]
550    fn regular_repo_with_similar_name() {
551        assert!(!is_ops_repo("backend-optimizer"));
552    }
553}