1use anyhow::{Context, 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::detection::project_type::ProjectType;
9use crate::detection::versions;
10use crate::engine::audit_log::AuditLog;
11use crate::github::Client;
12use crate::github::commits::CommitFile;
13
14#[derive(Args)]
15pub struct CommitCommand {
16 #[command(subcommand)]
17 action: CommitAction,
18}
19
20#[derive(clap::Subcommand)]
21enum CommitAction {
22 Plan {
24 #[arg(long)]
26 template: String,
27 },
28
29 Apply {
31 #[arg(long)]
33 template: String,
34
35 #[arg(long)]
37 yes: bool,
38 },
39}
40
41struct TemplateResult {
43 repo_name: String,
44 target_path: String,
45 rendered: String,
46 already_exists: bool,
47 existing_matches: bool,
48}
49
50impl CommitCommand {
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 CommitAction::Plan { template } => plan(client, manifest, system, repo, template).await,
60 CommitAction::Apply { template, yes } => {
61 apply(client, manifest, system, repo, template, *yes).await
62 }
63 }
64 }
65}
66
67fn resolve_template_info(template: &str) -> Result<(&str, &str)> {
68 match template {
69 "dependabot" => Ok((".github/dependabot.yml", "dependabot")),
70 "codeql" => Ok((".github/workflows/codeql.yml", "codeql")),
71 "dependency-submission" => Ok((
72 ".github/workflows/dependency-submission.yml",
73 "dependency-submission",
74 )),
75 _ => anyhow::bail!(
76 "Unknown template: {template}. Available: dependabot, codeql, dependency-submission"
77 ),
78 }
79}
80
81async fn detect_and_render(
82 client: &Client,
83 repo_name: &str,
84 default_branch: &str,
85 template_category: &str,
86 target_path: &str,
87 manifest: &Manifest,
88) -> Result<TemplateResult> {
89 let project_type = detect_project_type(client, repo_name).await?;
91
92 let tera_template_name = match (&project_type, template_category) {
93 (ProjectType::Gradle, "dependabot") => "dependabot/gradle.yml.tera",
94 (ProjectType::Npm, "dependabot") => "dependabot/npm.yml.tera",
95 (ProjectType::Gradle, "codeql") => "codeql/gradle.yml.tera",
96 (ProjectType::Npm, "codeql") => "codeql/npm.yml.tera",
97 (ProjectType::Gradle, "dependency-submission") => "dependency-submission/gradle.yml.tera",
98 (pt, cat) => {
99 anyhow::bail!("No template for {cat} + {pt} in repo {repo_name}");
100 }
101 };
102
103 let mut tera_context = tera::Context::new();
105 tera_context.insert("default_branch", default_branch);
106
107 match project_type {
108 ProjectType::Gradle => {
109 let java_ver = detect_java_version(client, repo_name).await?;
110 tera_context.insert("java_version", &java_ver.to_string());
111
112 if let Some(reg) = manifest.templates.registries.get("gradle-artifactory") {
113 tera_context.insert("registry_url", ®.url);
114 if let Some(ref provider) = reg.jfrog_oidc_provider {
115 tera_context.insert("jfrog_oidc_provider", provider);
116 }
117 }
118 }
119 ProjectType::Npm => {
120 let node_ver = detect_node_version(client, repo_name).await?;
121 tera_context.insert("node_version", &node_ver);
122 }
123 _ => {}
124 }
125
126 let tera = load_templates_with_custom_dir(
127 manifest
128 .templates
129 .custom_dir
130 .as_ref()
131 .map(std::path::Path::new),
132 )?;
133 let rendered = tera
134 .render(tera_template_name, &tera_context)
135 .with_context(|| format!("Failed to render template {tera_template_name}"))?;
136
137 let existing = client.get_file(repo_name, target_path, None).await?;
139 let (already_exists, existing_matches) = if let Some(ref content) = existing {
140 let decoded = Client::decode_content(content).unwrap_or_default();
141 (true, decoded.trim() == rendered.trim())
142 } else {
143 (false, false)
144 };
145
146 Ok(TemplateResult {
147 repo_name: repo_name.to_owned(),
148 target_path: target_path.to_owned(),
149 rendered,
150 already_exists,
151 existing_matches,
152 })
153}
154
155async fn detect_project_type(client: &Client, repo: &str) -> Result<ProjectType> {
156 if client
158 .get_file(repo, "build.gradle.kts", None)
159 .await?
160 .is_some()
161 {
162 return Ok(ProjectType::Gradle);
163 }
164 if client.get_file(repo, "build.gradle", None).await?.is_some() {
165 return Ok(ProjectType::Gradle);
166 }
167 if client.get_file(repo, "package.json", None).await?.is_some() {
168 return Ok(ProjectType::Npm);
169 }
170 if client.get_file(repo, "Cargo.toml", None).await?.is_some() {
171 return Ok(ProjectType::Cargo);
172 }
173 Ok(ProjectType::Unknown)
174}
175
176async fn detect_java_version(client: &Client, repo: &str) -> Result<u8> {
177 for file in &["build.gradle.kts", "build.gradle"] {
179 if let Some(content) = client.get_file(repo, file, None).await? {
180 let text = Client::decode_content(&content)?;
181 if let Some(ver) = versions::extract_java_version(&text) {
182 tracing::info!("{repo}: detected Java {ver} from {file}");
183 return Ok(ver);
184 }
185 }
186 }
187
188 tracing::warn!("{repo}: could not detect Java version, defaulting to 21");
189 Ok(21)
190}
191
192async fn detect_node_version(client: &Client, repo: &str) -> Result<String> {
193 if let Some(content) = client.get_file(repo, "package.json", None).await? {
194 let text = Client::decode_content(&content)?;
195 if let Some(ver) = versions::extract_node_version(&text) {
196 let major: String = ver.chars().filter(|c| c.is_ascii_digit()).collect();
198 if !major.is_empty() {
199 tracing::info!("{repo}: detected Node {major} from package.json");
200 return Ok(major);
201 }
202 }
203 }
204
205 tracing::warn!("{repo}: could not detect Node version, defaulting to 20");
206 Ok("20".to_owned())
207}
208
209async fn resolve_repos_with_branches(
210 client: &Client,
211 manifest: &Manifest,
212 system: Option<&str>,
213 repo: Option<&str>,
214) -> Result<Vec<(String, String)>> {
215 if let Some(repo_name) = repo {
216 let r = client.get_repo(repo_name).await?;
217 return Ok(vec![(r.name, r.default_branch)]);
218 }
219
220 let sys = system.ok_or_else(|| anyhow::anyhow!("Either --system or --repo is required"))?;
221 let excludes = manifest.exclude_patterns_for_system(sys);
222 let explicit = manifest.explicit_repos_for_system(sys);
223 let repos = client
224 .list_repos_for_system(sys, &excludes, &explicit)
225 .await?;
226 Ok(repos
227 .into_iter()
228 .map(|r| (r.name, r.default_branch))
229 .collect())
230}
231
232async fn plan(
233 client: &Client,
234 manifest: &Manifest,
235 system: Option<&str>,
236 repo: Option<&str>,
237 template: &str,
238) -> Result<()> {
239 let (target_path, template_category) = resolve_template_info(template)?;
240 let repos = resolve_repos_with_branches(client, manifest, system, repo).await?;
241
242 println!();
243 println!(
244 " {} Commit plan: {} → {}",
245 style("📋").bold(),
246 style(template).cyan().bold(),
247 style(target_path).dim()
248 );
249 println!(
250 " {} Scanning {} repositories...",
251 style("🔍").bold(),
252 repos.len()
253 );
254 println!();
255
256 let mut to_create = 0;
257 let mut to_update = 0;
258 let mut up_to_date = 0;
259 let mut skipped = 0;
260
261 for (repo_name, default_branch) in &repos {
262 match detect_and_render(
263 client,
264 repo_name,
265 default_branch,
266 template_category,
267 target_path,
268 manifest,
269 )
270 .await
271 {
272 Ok(result) => {
273 if result.existing_matches {
274 println!(
275 " {} {}",
276 style("✓").green(),
277 style(&result.repo_name).dim()
278 );
279 up_to_date += 1;
280 } else if result.already_exists {
281 println!(
282 " {} {} (update {})",
283 style("⚡").yellow(),
284 style(&result.repo_name).bold(),
285 target_path
286 );
287 to_update += 1;
288 } else {
289 println!(
290 " {} {} (create {})",
291 style("⚡").yellow(),
292 style(&result.repo_name).bold(),
293 target_path
294 );
295 to_create += 1;
296 }
297 }
298 Err(e) => {
299 println!(
300 " {} {}: {}",
301 style("⏭").dim(),
302 style(&repo_name).dim(),
303 style(e).dim()
304 );
305 skipped += 1;
306 }
307 }
308 }
309
310 println!();
311 println!(
312 " Summary: {} to create, {} to update, {} up to date, {} skipped",
313 style(to_create).yellow().bold(),
314 style(to_update).yellow().bold(),
315 style(up_to_date).green(),
316 style(skipped).dim()
317 );
318
319 if to_create + to_update > 0 {
320 println!(
321 "\n Run {} to apply.",
322 style(format!("ward commit apply --template {template}"))
323 .cyan()
324 .bold()
325 );
326 }
327
328 Ok(())
329}
330
331async fn apply(
332 client: &Client,
333 manifest: &Manifest,
334 system: Option<&str>,
335 repo: Option<&str>,
336 template: &str,
337 yes: bool,
338) -> Result<()> {
339 let (target_path, template_category) = resolve_template_info(template)?;
340 let repos = resolve_repos_with_branches(client, manifest, system, repo).await?;
341 let branch_name = &manifest.templates.branch;
342
343 println!();
344 println!(
345 " {} Preparing commits: {} → {}",
346 style("📋").bold(),
347 style(template).cyan().bold(),
348 style(target_path).dim()
349 );
350
351 let mut pending: Vec<TemplateResult> = Vec::new();
353 for (repo_name, default_branch) in &repos {
354 match detect_and_render(
355 client,
356 repo_name,
357 default_branch,
358 template_category,
359 target_path,
360 manifest,
361 )
362 .await
363 {
364 Ok(result) if !result.existing_matches => {
365 pending.push(result);
366 }
367 Ok(_) => {
368 tracing::debug!("{repo_name}: already up to date, skipping");
369 }
370 Err(e) => {
371 tracing::warn!("{repo_name}: skipped ({e})");
372 }
373 }
374 }
375
376 if pending.is_empty() {
377 println!(
378 "\n {} All repositories already up to date.",
379 style("✅").green()
380 );
381 return Ok(());
382 }
383
384 println!(
385 "\n {} repos need changes. Branch: {}",
386 style(pending.len()).yellow().bold(),
387 style(branch_name).cyan()
388 );
389
390 for r in &pending {
391 let action = if r.already_exists { "update" } else { "create" };
392 println!(
393 " {} {} → {action} {}",
394 style("⚡").yellow(),
395 r.repo_name,
396 r.target_path
397 );
398 }
399
400 if !yes {
401 println!();
402 let proceed = Confirm::new()
403 .with_prompt(format!(
404 " Commit to {} repos and create PRs?",
405 pending.len()
406 ))
407 .default(false)
408 .interact()?;
409
410 if !proceed {
411 println!(" Aborted.");
412 return Ok(());
413 }
414 }
415
416 let audit_log = AuditLog::new()?;
417 let mut succeeded = 0usize;
418 let mut failed: Vec<(String, String)> = Vec::new();
419
420 for result in &pending {
421 println!(" {} {} ...", style("▶").magenta(), result.repo_name);
422
423 let default_branch = repos
424 .iter()
425 .find(|(n, _)| *n == result.repo_name)
426 .map(|(_, b)| b.as_str())
427 .unwrap_or("main");
428
429 match commit_and_pr(&CommitPrParams {
430 client,
431 repo: &result.repo_name,
432 default_branch,
433 branch_name,
434 target_path: &result.target_path,
435 content: &result.rendered,
436 template,
437 reviewers: &manifest.templates.reviewers,
438 commit_prefix: &manifest.templates.commit_message_prefix,
439 })
440 .await
441 {
442 Ok(pr_url) => {
443 println!(" {} PR: {}", style("✅").green(), style(&pr_url).cyan());
444 audit_log.log(
445 &result.repo_name,
446 &format!("commit_template_{template}"),
447 "success",
448 result.already_exists,
449 true,
450 )?;
451 succeeded += 1;
452 }
453 Err(e) => {
454 println!(" {} {}", style("❌").red(), e);
455 failed.push((result.repo_name.clone(), e.to_string()));
456 }
457 }
458 }
459
460 println!();
461 if failed.is_empty() {
462 println!(
463 " {} All {} repos committed and PRs created.",
464 style("✅").green(),
465 succeeded
466 );
467 } else {
468 println!(
469 " {} {} succeeded, {} failed:",
470 style("⚠️").yellow(),
471 succeeded,
472 failed.len()
473 );
474 for (repo, err) in &failed {
475 println!(" {} {}: {}", style("❌").red(), repo, err);
476 }
477 }
478
479 println!(
480 "\n {} Audit log: {}",
481 style("📋").bold(),
482 audit_log.path().display()
483 );
484
485 Ok(())
486}
487
488struct CommitPrParams<'a> {
489 client: &'a Client,
490 repo: &'a str,
491 default_branch: &'a str,
492 branch_name: &'a str,
493 target_path: &'a str,
494 content: &'a str,
495 template: &'a str,
496 reviewers: &'a [String],
497 commit_prefix: &'a str,
498}
499
500async fn commit_and_pr(params: &CommitPrParams<'_>) -> Result<String> {
501 let CommitPrParams {
502 client,
503 repo,
504 default_branch,
505 branch_name,
506 target_path,
507 content,
508 template,
509 reviewers,
510 commit_prefix,
511 } = params;
512
513 client
515 .create_branch(repo, branch_name, default_branch)
516 .await?;
517
518 let message = format!("{commit_prefix}add {template} configuration");
520 let files = vec![CommitFile {
521 path: target_path.to_string(),
522 content: content.to_string(),
523 }];
524
525 client
526 .create_commit(repo, branch_name, &message, &files)
527 .await?;
528
529 let pr_title = format!("{commit_prefix}add {template} configuration");
531 let pr_body = format!(
532 "## Ward: automated template commit\n\n\
533 Template: `{template}`\n\
534 File: `{target_path}`\n\n\
535 This PR was created by [ward](https://github.com/OriginalMHV/ward).\n\n\
536 ---\n\
537 *Review the file contents, then merge.*"
538 );
539
540 let pr = client
541 .create_pull_request(
542 repo,
543 &pr_title,
544 &pr_body,
545 branch_name,
546 default_branch,
547 reviewers,
548 )
549 .await?;
550
551 Ok(pr.html_url)
552}