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 Plan {
22 #[arg(long)]
24 ruleset: Option<String>,
25
26 #[arg(long)]
28 copilot_instructions: bool,
29 },
30
31 Apply {
33 #[arg(long)]
35 ruleset: Option<String>,
36
37 #[arg(long)]
39 copilot_instructions: bool,
40
41 #[arg(long)]
43 yes: bool,
44 },
45
46 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
94fn 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 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 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 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}