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 = 't', value_name = "TEXT")]
48 title: Option<String>,
49 #[arg(long, short = 'd')]
52 desc: Option<String>,
53 #[arg(
57 long = "desc-file",
58 value_name = "PATH",
59 value_hint = clap::ValueHint::FilePath,
60 conflicts_with = "desc",
61 )]
62 desc_file: Option<PathBuf>,
63 #[arg(long, value_name = "CSV", value_delimiter = ',')]
68 reviewers: Vec<String>,
69 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_draft")]
71 draft: bool,
72 #[arg(long, action = ArgAction::SetTrue)]
74 no_draft: bool,
75 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "draft")]
77 ready: bool,
78 #[arg(long, action = ArgAction::SetTrue)]
81 rebuild_overview: bool,
82}
83
84impl Run for Submit {
85 fn run(self) -> Result<()> {
86 let submit_stack = if self.stack {
89 true
90 } else if self.no_stack || self.branch.is_some() {
91 false
92 } else {
93 settings::bool_setting(settings::SUBMIT_STACK_KEY)?
94 };
95
96 let draft = if self.draft {
99 true
100 } else if self.no_draft {
101 false
102 } else {
103 settings::bool_setting(settings::SUBMIT_DRAFT_KEY)?
104 };
105
106 let desc = match self.desc_file {
109 Some(path) => {
110 let path = expand_tilde(path);
111 let raw = std::fs::read_to_string(&path).with_context(|| {
112 format!("failed to read description file {}", path.display())
113 })?;
114 Some(raw.trim().to_owned())
115 }
116 None => self.desc,
117 };
118
119 let title = match self.title {
122 Some(title) if title.trim().is_empty() => bail!("--title cannot be empty"),
123 Some(title) => Some(title.trim().to_owned()),
124 None => None,
125 };
126
127 submit(SubmitOptions {
128 branch: self.branch,
129 submit_stack,
130 downstack: self.downstack,
131 dry_run: self.dry_run,
132 push_mode: PushMode::from_flags(self.push, self.no_push),
133 title,
134 desc,
135 reviewers: normalize_reviewers(&self.reviewers),
136 draft,
137 ready: self.ready,
138 rebuild_overview: self.rebuild_overview,
139 })
140 }
141}
142
143fn normalize_reviewers(raw: &[String]) -> Vec<String> {
150 let mut reviewers: Vec<String> = Vec::new();
151 for entry in raw {
152 let trimmed = entry.trim();
153 let stripped = trimmed.strip_prefix('@').unwrap_or(trimmed).trim();
154 let name = if stripped.eq_ignore_ascii_case("copilot") {
155 "@copilot"
156 } else {
157 stripped
158 };
159 if name.is_empty() || reviewers.iter().any(|seen| seen == name) {
160 continue;
161 }
162 reviewers.push(name.to_owned());
163 }
164 reviewers
165}
166
167pub struct SubmitOptions {
170 pub branch: Option<String>,
171 pub submit_stack: bool,
172 pub downstack: bool,
173 pub dry_run: bool,
174 pub push_mode: crate::cli::PushMode,
175 pub title: Option<String>,
176 pub desc: Option<String>,
177 pub reviewers: Vec<String>,
178 pub draft: bool,
179 pub ready: bool,
180 pub rebuild_overview: bool,
181}
182
183fn expand_tilde(path: PathBuf) -> PathBuf {
188 let home = env::var_os("HOME")
189 .or_else(|| env::var_os("USERPROFILE"))
190 .map(PathBuf::from);
191 expand_tilde_with(path, home)
192}
193
194fn expand_tilde_with(path: PathBuf, home: Option<PathBuf>) -> PathBuf {
195 let Some(rest) = path.to_str().and_then(|text| text.strip_prefix('~')) else {
196 return path;
197 };
198 let mut chars = rest.chars();
201 let tail = match chars.next() {
202 None => "",
203 Some(separator) if std::path::is_separator(separator) => chars.as_str(),
204 Some(_) => return path,
205 };
206 let Some(home) = home else {
207 return path;
208 };
209 if tail.is_empty() {
210 home
211 } else {
212 home.join(tail)
213 }
214}
215
216pub fn submit(options: SubmitOptions) -> Result<()> {
217 let SubmitOptions {
218 branch,
219 submit_stack,
220 downstack,
221 dry_run,
222 push_mode,
223 title,
224 desc,
225 reviewers,
226 draft,
227 ready,
228 rebuild_overview,
229 } = options;
230
231 let branch = branch.map_or_else(git::current_branch, Ok)?;
232 let target_branch = branch.clone();
234
235 let branches = if downstack {
236 stack::path_from_root(&branch)?
239 } else if submit_stack {
240 stack::stack_line(&branch)?
244 } else {
245 vec![branch.clone()]
246 };
247
248 if submit_stack || downstack {
252 let trunk = stack::trunk_branch(&git::local_branches()?);
253 if Some(&branch) == trunk.as_ref() {
254 if stack::children_of(&branch)?.is_empty() {
255 bail!("no stacked branches to submit");
256 }
257 bail!("you are on the trunk ({branch}); check out a stacked branch first");
258 }
259 }
260
261 let branch_parents = branch_parents(&branches)?;
262
263 let push = settings::push_enabled(push_mode, settings::PUSH_ON_SUBMIT_KEY)?;
267 if push {
268 let remote = settings::remote()?;
269 if dry_run {
270 anstream::println!(
271 "would push {} to {remote}",
272 style::branch(&branches.join(" "))
273 );
274 } else {
275 git::push_set_upstream_force_with_lease(&remote, &branches)?;
276 anstream::println!("pushed {} to {remote}", style::branch(&branches.join(" ")));
277 stack::publish_metadata(&remote);
280 }
281 }
282
283 let (provider, review_provider) = detect_review_provider()?;
284 let mut summary = SubmitSummary::default();
285
286 let mut created = Vec::new();
287 for (branch, parent) in &branch_parents {
288 let branch_title = title.as_deref().filter(|_| *branch == target_branch);
291 let action = submit_branch(
292 review_provider.as_ref(),
293 branch,
294 parent,
295 dry_run,
296 draft,
297 branch_title,
298 )?;
299 if action == SubmitAction::Created {
300 created.push(branch.clone());
301 }
302 summary.record(action);
303 }
304
305 let desc_target = desc.as_ref().map(|_| target_branch.as_str());
311 crate::notes::seed_template_notes(
312 review_provider.as_ref(),
313 provider.kind,
314 &created,
315 desc_target,
316 dry_run,
317 )?;
318
319 if ready {
322 for branch in &branches {
323 let Some(review) = review_provider.review_for_branch(branch)? else {
324 continue;
325 };
326 if review.branch != *branch || !review.draft {
327 continue;
328 }
329 if dry_run {
330 anstream::println!("would mark {} ready", review.id);
331 continue;
332 }
333 let output = review_provider.mark_ready(&review)?;
334 anstream::println!("marked {} ready", review.id);
335 if !output.is_empty() {
336 println!("{output}");
337 }
338 }
339 }
340
341 let renamed: Vec<(String, String)> = if submit_stack || downstack {
348 branch_parents
349 .iter()
350 .filter_map(|(branch, _)| {
351 stack::renamed_from(branch)
352 .ok()
353 .flatten()
354 .map(|old| (branch.clone(), old))
355 })
356 .collect()
357 } else {
358 Vec::new()
359 };
360 let mut reconciled: Vec<&str> = Vec::new();
364 for (branch, old) in &renamed {
365 if close_superseded_review(review_provider.as_ref(), old, dry_run)? {
366 reconciled.push(branch);
367 }
368 }
369
370 if let Some(title) = &title {
374 if !created.contains(&target_branch) {
377 apply_title(review_provider.as_ref(), &target_branch, title, dry_run)?;
378 }
379 }
380 if let Some(desc) = desc {
381 crate::notes::update_description_note(
382 review_provider.as_ref(),
383 &target_branch,
384 &desc,
385 dry_run,
386 )?;
387 }
388 crate::notes::update_closes_notes(review_provider.as_ref(), &branches, dry_run)?;
389 if submit_stack || downstack {
390 crate::notes::update_stack_notes(
391 review_provider.as_ref(),
392 &branch_parents,
393 dry_run,
394 rebuild_overview,
395 )?;
396 }
397 apply_reviewers(review_provider.as_ref(), &branches, &reviewers, dry_run)?;
398
399 if !dry_run {
402 for branch in &reconciled {
403 stack::clear_renamed_from(branch)?;
404 }
405 }
406
407 anstream::println!(
408 "{}",
409 style::success(&format!(
410 "submit complete: {} created, {} updated, {} skipped",
411 summary.created, summary.updated, summary.skipped
412 ))
413 );
414 Ok(())
415}
416
417fn close_superseded_review(
425 review_provider: &dyn ReviewProvider,
426 old: &str,
427 dry_run: bool,
428) -> Result<bool> {
429 let Some(review) = review_provider.review_for_branch(old)? else {
430 return Ok(true);
431 };
432 if review.branch != *old {
433 return Ok(true);
434 }
435
436 if dry_run {
437 anstream::println!("would close superseded review {} for {old}", review.id);
438 return Ok(true);
439 }
440 if !crate::prompt::confirm_default_yes(&format!(
441 "close the replaced review {} for {old} and delete its branch? [Y/n] ",
442 review.id
443 ))? {
444 anstream::println!("kept review {} for {old}", review.id);
445 return Ok(false);
446 }
447
448 review_provider.close_review(&review, true)?;
449 anstream::println!("closed superseded review {} for {old}", review.id);
450 Ok(true)
451}
452
453fn apply_title(
457 review_provider: &dyn ReviewProvider,
458 branch: &str,
459 title: &str,
460 dry_run: bool,
461) -> Result<()> {
462 let Some(review) = review_provider.review_for_branch(branch)? else {
463 if dry_run {
464 anstream::println!("would set the title on the review for {branch}");
465 } else {
466 anstream::println!("skipped title: no review found for {branch}");
467 }
468 return Ok(());
469 };
470 if review.branch != branch {
471 anstream::println!(
472 "skipped title: review {} belongs to {}",
473 review.id,
474 review.branch
475 );
476 return Ok(());
477 }
478 if dry_run {
479 anstream::println!("would set the title in {}", review.id);
480 return Ok(());
481 }
482
483 let output = review_provider.update_review_title(&review, title)?;
484 anstream::println!("set title in {}", review.id);
485 if !output.is_empty() {
486 println!("{output}");
487 }
488 Ok(())
489}
490
491fn apply_reviewers(
496 review_provider: &dyn ReviewProvider,
497 branches: &[String],
498 reviewers: &[String],
499 dry_run: bool,
500) -> Result<()> {
501 if reviewers.is_empty() {
502 return Ok(());
503 }
504 let list = reviewers.join(", ");
505 for branch in branches {
506 let Some(review) = review_provider.review_for_branch(branch)? else {
507 if dry_run {
510 anstream::println!("would request reviews from {list} for {branch}");
511 } else {
512 anstream::println!("skipped reviewers: no review found for {branch}");
513 }
514 continue;
515 };
516 if review.branch != *branch || review.state == ReviewState::Merged {
517 continue;
518 }
519 if dry_run {
520 anstream::println!("would request reviews from {list} in {}", review.id);
521 continue;
522 }
523 let output = review_provider.request_reviewers(&review, reviewers)?;
524 anstream::println!("requested reviews from {list} in {}", review.id);
525 if !output.is_empty() {
526 println!("{output}");
527 }
528 }
529 Ok(())
530}
531
532fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
533 let mut branch_parents = Vec::new();
534 for branch in branches {
535 let Some(parent) = stack::parent_of(branch)? else {
536 bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
537 };
538 branch_parents.push((branch.to_owned(), parent));
539 }
540 Ok(branch_parents)
541}
542
543fn submit_branch(
544 review_provider: &dyn ReviewProvider,
545 branch: &str,
546 parent: &str,
547 dry_run: bool,
548 draft: bool,
549 title: Option<&str>,
550) -> Result<SubmitAction> {
551 if let Some(review) = review_provider.review_for_branch(branch)? {
552 if review.base == parent {
553 if dry_run {
554 anstream::println!(
555 "would skip {} -> {} ({})",
556 review.branch,
557 review.base,
558 review.id
559 );
560 } else {
561 anstream::println!(
562 "{}",
563 style::dim(&format!(
564 "{} already targets {} ({})",
565 review.branch, review.base, review.id
566 ))
567 );
568 }
569 return Ok(SubmitAction::Skipped);
570 }
571
572 let output = if dry_run {
573 String::new()
574 } else {
575 review_provider.update_review_base(&review, parent)?
576 };
577 anstream::println!(
578 "{} {} -> {} {}",
579 if dry_run { "would update" } else { "updated" },
580 style::branch(&review.branch),
581 style::branch(parent),
582 style::dim(&format!("({})", review.id))
583 );
584 if !output.is_empty() {
585 println!("{output}");
586 }
587 } else {
588 let output = if dry_run {
589 String::new()
590 } else {
591 review_provider.create_review(branch, parent, draft, title)?
592 };
593 anstream::println!(
594 "{} {} -> {}{}",
595 if dry_run { "would create" } else { "created" },
596 style::branch(branch),
597 style::branch(parent),
598 title.map_or_else(String::new, |title| format!(" titled \"{title}\""))
599 );
600 if !output.is_empty() {
601 println!("{output}");
602 }
603 return Ok(SubmitAction::Created);
604 }
605
606 Ok(SubmitAction::Updated)
607}
608
609#[derive(Debug, Default)]
610struct SubmitSummary {
611 created: usize,
612 updated: usize,
613 skipped: usize,
614}
615
616impl SubmitSummary {
617 fn record(&mut self, action: SubmitAction) {
618 match action {
619 SubmitAction::Created => self.created += 1,
620 SubmitAction::Updated => self.updated += 1,
621 SubmitAction::Skipped => self.skipped += 1,
622 }
623 }
624}
625
626#[derive(Debug, Clone, Copy, Eq, PartialEq)]
627enum SubmitAction {
628 Created,
629 Updated,
630 Skipped,
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636
637 fn home() -> Option<PathBuf> {
638 Some(PathBuf::from("/home/dev"))
639 }
640
641 #[test]
642 fn expand_tilde_resolves_a_bare_tilde_and_subpaths() {
643 assert_eq!(
644 expand_tilde_with(PathBuf::from("~"), home()),
645 PathBuf::from("/home/dev")
646 );
647 assert_eq!(
648 expand_tilde_with(PathBuf::from("~/notes/pr.md"), home()),
649 PathBuf::from("/home/dev/notes/pr.md")
650 );
651 }
652
653 #[test]
654 fn expand_tilde_leaves_other_paths_untouched() {
655 for raw in ["/etc/pr.md", "notes/pr.md", "~alice/pr.md", "docs/~x.md"] {
658 assert_eq!(
659 expand_tilde_with(PathBuf::from(raw), home()),
660 PathBuf::from(raw)
661 );
662 }
663 }
664
665 #[test]
666 fn expand_tilde_passes_through_when_home_is_unset() {
667 assert_eq!(
668 expand_tilde_with(PathBuf::from("~/pr.md"), None),
669 PathBuf::from("~/pr.md")
670 );
671 }
672
673 fn reviewers(raw: &[&str]) -> Vec<String> {
674 normalize_reviewers(
675 &raw.iter()
676 .map(|entry| (*entry).to_owned())
677 .collect::<Vec<_>>(),
678 )
679 }
680
681 #[test]
682 fn normalize_reviewers_strips_at_and_trims() {
683 assert_eq!(reviewers(&["@foo", "@bar"]), vec!["foo", "bar"]);
685 assert_eq!(reviewers(&["foo", "bar"]), vec!["foo", "bar"]);
686 assert_eq!(reviewers(&[" @foo ", " bar"]), vec!["foo", "bar"]);
687 }
688
689 #[test]
690 fn normalize_reviewers_keeps_team_paths_but_drops_the_at() {
691 assert_eq!(
693 reviewers(&["@my-org/backend", "acme/team"]),
694 vec!["my-org/backend", "acme/team"]
695 );
696 }
697
698 #[test]
699 fn normalize_reviewers_drops_blanks_and_dedupes_in_order() {
700 assert_eq!(
701 reviewers(&["foo", "", " ", "@foo", "bar", "@bar"]),
702 vec!["foo", "bar"]
703 );
704 }
705
706 #[test]
707 fn normalize_reviewers_preserves_the_copilot_at_prefix() {
708 assert_eq!(reviewers(&["@copilot"]), vec!["@copilot"]);
711 assert_eq!(reviewers(&["copilot"]), vec!["@copilot"]);
712 assert_eq!(reviewers(&["@Copilot", "copilot"]), vec!["@copilot"]);
713 }
714
715 #[cfg(windows)]
716 #[test]
717 fn expand_tilde_accepts_a_backslash_on_windows() {
718 assert_eq!(
719 expand_tilde_with(PathBuf::from(r"~\notes\pr.md"), home()),
720 PathBuf::from("/home/dev").join(r"notes\pr.md")
721 );
722 }
723}