1use std::env;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result, bail};
5use clap::ArgAction;
6use clap_complete::engine::ArgValueCompleter;
7
8use crate::cli::PushMode;
9use crate::commands::Run;
10use crate::completions;
11use crate::providers::{ReviewProvider, ReviewState, detect_review_provider};
12use crate::settings;
13use crate::style;
14use crate::{git, stack};
15
16#[derive(Debug, clap::Args)]
18pub struct Submit {
19 #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
21 branch: Option<String>,
22 #[arg(long, short = 'n', action = ArgAction::SetTrue)]
24 dry_run: bool,
25 #[arg(long, conflicts_with = "branch")]
27 stack: bool,
28 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "stack")]
30 no_stack: bool,
31 #[arg(
34 long,
35 action = ArgAction::SetTrue,
36 conflicts_with_all = ["branch", "stack", "no_stack"],
37 )]
38 downstack: bool,
39 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
41 push: bool,
42 #[arg(long, action = ArgAction::SetTrue)]
44 no_push: bool,
45 #[arg(long, short = 'd')]
48 desc: Option<String>,
49 #[arg(
53 long = "desc-file",
54 value_name = "PATH",
55 value_hint = clap::ValueHint::FilePath,
56 conflicts_with = "desc",
57 )]
58 desc_file: Option<PathBuf>,
59 #[arg(long, value_name = "CSV", value_delimiter = ',')]
64 reviewers: Vec<String>,
65 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_draft")]
67 draft: bool,
68 #[arg(long, action = ArgAction::SetTrue)]
70 no_draft: bool,
71 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "draft")]
73 ready: bool,
74 #[arg(long, action = ArgAction::SetTrue)]
77 rebuild_overview: bool,
78}
79
80impl Run for Submit {
81 fn run(self) -> Result<()> {
82 let submit_stack = if self.stack {
85 true
86 } else if self.no_stack || self.branch.is_some() {
87 false
88 } else {
89 settings::bool_setting(settings::SUBMIT_STACK_KEY)?
90 };
91
92 let draft = if self.draft {
95 true
96 } else if self.no_draft {
97 false
98 } else {
99 settings::bool_setting(settings::SUBMIT_DRAFT_KEY)?
100 };
101
102 let desc = match self.desc_file {
105 Some(path) => {
106 let path = expand_tilde(path);
107 let raw = std::fs::read_to_string(&path).with_context(|| {
108 format!("failed to read description file {}", path.display())
109 })?;
110 Some(raw.trim().to_owned())
111 }
112 None => self.desc,
113 };
114
115 submit(SubmitOptions {
116 branch: self.branch,
117 submit_stack,
118 downstack: self.downstack,
119 dry_run: self.dry_run,
120 push_mode: PushMode::from_flags(self.push, self.no_push),
121 desc,
122 reviewers: normalize_reviewers(&self.reviewers),
123 draft,
124 ready: self.ready,
125 rebuild_overview: self.rebuild_overview,
126 })
127 }
128}
129
130fn normalize_reviewers(raw: &[String]) -> Vec<String> {
137 let mut reviewers: Vec<String> = Vec::new();
138 for entry in raw {
139 let trimmed = entry.trim();
140 let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed).trim();
141 let name = if stripped.eq_ignore_ascii_case("copilot") {
142 "@copilot"
143 } else {
144 stripped
145 };
146 if name.is_empty() || reviewers.iter().any(|seen| seen == name) {
147 continue;
148 }
149 reviewers.push(name.to_owned());
150 }
151 reviewers
152}
153
154pub struct SubmitOptions {
157 pub branch: Option<String>,
158 pub submit_stack: bool,
159 pub downstack: bool,
160 pub dry_run: bool,
161 pub push_mode: crate::cli::PushMode,
162 pub desc: Option<String>,
163 pub reviewers: Vec<String>,
164 pub draft: bool,
165 pub ready: bool,
166 pub rebuild_overview: bool,
167}
168
169fn expand_tilde(path: PathBuf) -> PathBuf {
174 let home = env::var_os("HOME")
175 .or_else(|| env::var_os("USERPROFILE"))
176 .map(PathBuf::from);
177 expand_tilde_with(path, home)
178}
179
180fn expand_tilde_with(path: PathBuf, home: Option<PathBuf>) -> PathBuf {
181 let Some(rest) = path.to_str().and_then(|text| text.strip_prefix('~')) else {
182 return path;
183 };
184 let mut chars = rest.chars();
187 let tail = match chars.next() {
188 None => "",
189 Some(separator) if std::path::is_separator(separator) => chars.as_str(),
190 Some(_) => return path,
191 };
192 let Some(home) = home else {
193 return path;
194 };
195 if tail.is_empty() {
196 home
197 } else {
198 home.join(tail)
199 }
200}
201
202pub fn submit(options: SubmitOptions) -> Result<()> {
203 let SubmitOptions {
204 branch,
205 submit_stack,
206 downstack,
207 dry_run,
208 push_mode,
209 desc,
210 reviewers,
211 draft,
212 ready,
213 rebuild_overview,
214 } = options;
215
216 let branch = branch.map_or_else(git::current_branch, Ok)?;
217 let desc_branch = branch.clone();
219
220 let branches = if downstack {
221 stack::path_from_root(&branch)?
224 } else if submit_stack {
225 stack::stack_line(&branch)?
229 } else {
230 vec![branch.clone()]
231 };
232
233 if submit_stack || downstack {
237 let trunk = stack::trunk_branch(&git::local_branches()?);
238 if Some(&branch) == trunk.as_ref() {
239 if stack::children_of(&branch)?.is_empty() {
240 bail!("no stacked branches to submit");
241 }
242 bail!("you are on the trunk ({branch}); check out a stacked branch first");
243 }
244 }
245
246 let branch_parents = branch_parents(&branches)?;
247
248 let push = settings::push_enabled(push_mode, settings::PUSH_ON_SUBMIT_KEY)?;
252 if push {
253 let remote = settings::remote()?;
254 if dry_run {
255 anstream::println!(
256 "would push {} to {remote}",
257 style::branch(&branches.join(" "))
258 );
259 } else {
260 git::push_set_upstream_force_with_lease(&remote, &branches)?;
261 anstream::println!("pushed {} to {remote}", style::branch(&branches.join(" ")));
262 stack::publish_metadata(&remote);
265 }
266 }
267
268 let (provider, review_provider) = detect_review_provider()?;
269 let mut summary = SubmitSummary::default();
270
271 let mut created = Vec::new();
272 for (branch, parent) in &branch_parents {
273 let action = submit_branch(review_provider.as_ref(), branch, parent, dry_run, draft)?;
274 if action == SubmitAction::Created {
275 created.push(branch.clone());
276 }
277 summary.record(action);
278 }
279
280 let desc_target = desc.as_ref().map(|_| desc_branch.as_str());
286 crate::notes::seed_template_notes(
287 review_provider.as_ref(),
288 provider.kind,
289 &created,
290 desc_target,
291 dry_run,
292 )?;
293
294 if ready {
297 for branch in &branches {
298 let Some(review) = review_provider.review_for_branch(branch)? else {
299 continue;
300 };
301 if review.branch != *branch || !review.draft {
302 continue;
303 }
304 if dry_run {
305 anstream::println!("would mark {} ready", review.id);
306 continue;
307 }
308 let output = review_provider.mark_ready(&review)?;
309 anstream::println!("marked {} ready", review.id);
310 if !output.is_empty() {
311 println!("{output}");
312 }
313 }
314 }
315
316 let renamed: Vec<(String, String)> = if submit_stack || downstack {
323 branch_parents
324 .iter()
325 .filter_map(|(branch, _)| {
326 stack::renamed_from(branch)
327 .ok()
328 .flatten()
329 .map(|old| (branch.clone(), old))
330 })
331 .collect()
332 } else {
333 Vec::new()
334 };
335 let mut reconciled: Vec<&str> = Vec::new();
339 for (branch, old) in &renamed {
340 if close_superseded_review(review_provider.as_ref(), old, dry_run)? {
341 reconciled.push(branch);
342 }
343 }
344
345 if let Some(desc) = desc {
349 crate::notes::update_description_note(
350 review_provider.as_ref(),
351 &desc_branch,
352 &desc,
353 dry_run,
354 )?;
355 }
356 crate::notes::update_closes_notes(review_provider.as_ref(), &branches, dry_run)?;
357 if submit_stack || downstack {
358 crate::notes::update_stack_notes(
359 review_provider.as_ref(),
360 &branch_parents,
361 dry_run,
362 rebuild_overview,
363 )?;
364 }
365 apply_reviewers(review_provider.as_ref(), &branches, &reviewers, dry_run)?;
366
367 if !dry_run {
370 for branch in &reconciled {
371 stack::clear_renamed_from(branch)?;
372 }
373 }
374
375 anstream::println!(
376 "{}",
377 style::success(&format!(
378 "submit complete: {} created, {} updated, {} skipped",
379 summary.created, summary.updated, summary.skipped
380 ))
381 );
382 Ok(())
383}
384
385fn close_superseded_review(
393 review_provider: &dyn ReviewProvider,
394 old: &str,
395 dry_run: bool,
396) -> Result<bool> {
397 let Some(review) = review_provider.review_for_branch(old)? else {
398 return Ok(true);
399 };
400 if review.branch != *old {
401 return Ok(true);
402 }
403
404 if dry_run {
405 anstream::println!("would close superseded review {} for {old}", review.id);
406 return Ok(true);
407 }
408 if !crate::prompt::confirm_default_yes(&format!(
409 "close the replaced review {} for {old} and delete its branch? [Y/n] ",
410 review.id
411 ))? {
412 anstream::println!("kept review {} for {old}", review.id);
413 return Ok(false);
414 }
415
416 review_provider.close_review(&review, true)?;
417 anstream::println!("closed superseded review {} for {old}", review.id);
418 Ok(true)
419}
420
421fn apply_reviewers(
426 review_provider: &dyn ReviewProvider,
427 branches: &[String],
428 reviewers: &[String],
429 dry_run: bool,
430) -> Result<()> {
431 if reviewers.is_empty() {
432 return Ok(());
433 }
434 let list = reviewers.join(", ");
435 for branch in branches {
436 let Some(review) = review_provider.review_for_branch(branch)? else {
437 if dry_run {
440 anstream::println!("would request reviews from {list} for {branch}");
441 } else {
442 anstream::println!("skipped reviewers: no review found for {branch}");
443 }
444 continue;
445 };
446 if review.branch != *branch || review.state == ReviewState::Merged {
447 continue;
448 }
449 if dry_run {
450 anstream::println!("would request reviews from {list} in {}", review.id);
451 continue;
452 }
453 let output = review_provider.request_reviewers(&review, reviewers)?;
454 anstream::println!("requested reviews from {list} in {}", review.id);
455 if !output.is_empty() {
456 println!("{output}");
457 }
458 }
459 Ok(())
460}
461
462fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
463 let mut branch_parents = Vec::new();
464 for branch in branches {
465 let Some(parent) = stack::parent_of(branch)? else {
466 bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
467 };
468 branch_parents.push((branch.to_owned(), parent));
469 }
470 Ok(branch_parents)
471}
472
473fn submit_branch(
474 review_provider: &dyn ReviewProvider,
475 branch: &str,
476 parent: &str,
477 dry_run: bool,
478 draft: bool,
479) -> Result<SubmitAction> {
480 if let Some(review) = review_provider.review_for_branch(branch)? {
481 if review.base == parent {
482 if dry_run {
483 anstream::println!(
484 "would skip {} -> {} ({})",
485 review.branch,
486 review.base,
487 review.id
488 );
489 } else {
490 anstream::println!(
491 "{}",
492 style::dim(&format!(
493 "{} already targets {} ({})",
494 review.branch, review.base, review.id
495 ))
496 );
497 }
498 return Ok(SubmitAction::Skipped);
499 }
500
501 let output = if dry_run {
502 String::new()
503 } else {
504 review_provider.update_review_base(&review, parent)?
505 };
506 anstream::println!(
507 "{} {} -> {} {}",
508 if dry_run { "would update" } else { "updated" },
509 style::branch(&review.branch),
510 style::branch(parent),
511 style::dim(&format!("({})", review.id))
512 );
513 if !output.is_empty() {
514 println!("{output}");
515 }
516 } else {
517 let output = if dry_run {
518 String::new()
519 } else {
520 review_provider.create_review(branch, parent, draft)?
521 };
522 anstream::println!(
523 "{} {} -> {}",
524 if dry_run { "would create" } else { "created" },
525 style::branch(branch),
526 style::branch(parent)
527 );
528 if !output.is_empty() {
529 println!("{output}");
530 }
531 return Ok(SubmitAction::Created);
532 }
533
534 Ok(SubmitAction::Updated)
535}
536
537#[derive(Debug, Default)]
538struct SubmitSummary {
539 created: usize,
540 updated: usize,
541 skipped: usize,
542}
543
544impl SubmitSummary {
545 fn record(&mut self, action: SubmitAction) {
546 match action {
547 SubmitAction::Created => self.created += 1,
548 SubmitAction::Updated => self.updated += 1,
549 SubmitAction::Skipped => self.skipped += 1,
550 }
551 }
552}
553
554#[derive(Debug, Clone, Copy, Eq, PartialEq)]
555enum SubmitAction {
556 Created,
557 Updated,
558 Skipped,
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564
565 fn home() -> Option<PathBuf> {
566 Some(PathBuf::from("/home/dev"))
567 }
568
569 #[test]
570 fn expand_tilde_resolves_a_bare_tilde_and_subpaths() {
571 assert_eq!(
572 expand_tilde_with(PathBuf::from("~"), home()),
573 PathBuf::from("/home/dev")
574 );
575 assert_eq!(
576 expand_tilde_with(PathBuf::from("~/notes/pr.md"), home()),
577 PathBuf::from("/home/dev/notes/pr.md")
578 );
579 }
580
581 #[test]
582 fn expand_tilde_leaves_other_paths_untouched() {
583 for raw in ["/etc/pr.md", "notes/pr.md", "~alice/pr.md", "docs/~x.md"] {
586 assert_eq!(
587 expand_tilde_with(PathBuf::from(raw), home()),
588 PathBuf::from(raw)
589 );
590 }
591 }
592
593 #[test]
594 fn expand_tilde_passes_through_when_home_is_unset() {
595 assert_eq!(
596 expand_tilde_with(PathBuf::from("~/pr.md"), None),
597 PathBuf::from("~/pr.md")
598 );
599 }
600
601 fn reviewers(raw: &[&str]) -> Vec<String> {
602 normalize_reviewers(
603 &raw.iter()
604 .map(|entry| (*entry).to_owned())
605 .collect::<Vec<_>>(),
606 )
607 }
608
609 #[test]
610 fn normalize_reviewers_strips_at_and_trims() {
611 assert_eq!(reviewers(&["@foo", "@bar"]), vec!["foo", "bar"]);
613 assert_eq!(reviewers(&["foo", "bar"]), vec!["foo", "bar"]);
614 assert_eq!(reviewers(&[" @foo ", " bar"]), vec!["foo", "bar"]);
615 }
616
617 #[test]
618 fn normalize_reviewers_keeps_team_paths_but_drops_the_at() {
619 assert_eq!(
621 reviewers(&["@my-org/backend", "acme/team"]),
622 vec!["my-org/backend", "acme/team"]
623 );
624 }
625
626 #[test]
627 fn normalize_reviewers_drops_blanks_and_dedupes_in_order() {
628 assert_eq!(
629 reviewers(&["foo", "", " ", "@foo", "bar", "@bar"]),
630 vec!["foo", "bar"]
631 );
632 }
633
634 #[test]
635 fn normalize_reviewers_preserves_the_copilot_at_prefix() {
636 assert_eq!(reviewers(&["@copilot"]), vec!["@copilot"]);
639 assert_eq!(reviewers(&["copilot"]), vec!["@copilot"]);
640 assert_eq!(reviewers(&["@Copilot", "copilot"]), vec!["@copilot"]);
641 }
642
643 #[cfg(windows)]
644 #[test]
645 fn expand_tilde_accepts_a_backslash_on_windows() {
646 assert_eq!(
647 expand_tilde_with(PathBuf::from(r"~\notes\pr.md"), home()),
648 PathBuf::from("/home/dev").join(r"notes\pr.md")
649 );
650 }
651}