1use anyhow::{Result, bail};
2use clap::ArgAction;
3use clap_complete::engine::ArgValueCompleter;
4
5use crate::cli::PushMode;
6use crate::commands::Run;
7use crate::completions;
8use crate::providers::{ReviewProvider, detect_review_provider};
9use crate::settings;
10use crate::style;
11use crate::{git, stack};
12
13#[derive(Debug, clap::Args)]
15pub struct Submit {
16 #[arg(add = ArgValueCompleter::new(completions::branch_candidates))]
18 branch: Option<String>,
19 #[arg(long, short = 'n', action = ArgAction::SetTrue)]
21 dry_run: bool,
22 #[arg(long, conflicts_with = "branch")]
24 stack: bool,
25 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "stack")]
27 no_stack: bool,
28 #[arg(
31 long,
32 action = ArgAction::SetTrue,
33 conflicts_with_all = ["branch", "stack", "no_stack"],
34 )]
35 downstack: bool,
36 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_push")]
38 push: bool,
39 #[arg(long, action = ArgAction::SetTrue)]
41 no_push: bool,
42 #[arg(long, short = 'd')]
45 desc: Option<String>,
46 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "no_draft")]
48 draft: bool,
49 #[arg(long, action = ArgAction::SetTrue)]
51 no_draft: bool,
52 #[arg(long, action = ArgAction::SetTrue, conflicts_with = "draft")]
54 ready: bool,
55 #[arg(long, action = ArgAction::SetTrue)]
58 rebuild_overview: bool,
59}
60
61impl Run for Submit {
62 fn run(self) -> Result<()> {
63 let submit_stack = if self.stack {
66 true
67 } else if self.no_stack || self.branch.is_some() {
68 false
69 } else {
70 settings::bool_setting(settings::SUBMIT_STACK_KEY)?
71 };
72
73 let draft = if self.draft {
76 true
77 } else if self.no_draft {
78 false
79 } else {
80 settings::bool_setting(settings::SUBMIT_DRAFT_KEY)?
81 };
82
83 submit(SubmitOptions {
84 branch: self.branch,
85 submit_stack,
86 downstack: self.downstack,
87 dry_run: self.dry_run,
88 push_mode: PushMode::from_flags(self.push, self.no_push),
89 desc: self.desc,
90 draft,
91 ready: self.ready,
92 rebuild_overview: self.rebuild_overview,
93 })
94 }
95}
96
97pub struct SubmitOptions {
100 pub branch: Option<String>,
101 pub submit_stack: bool,
102 pub downstack: bool,
103 pub dry_run: bool,
104 pub push_mode: crate::cli::PushMode,
105 pub desc: Option<String>,
106 pub draft: bool,
107 pub ready: bool,
108 pub rebuild_overview: bool,
109}
110
111pub fn submit(options: SubmitOptions) -> Result<()> {
112 let SubmitOptions {
113 branch,
114 submit_stack,
115 downstack,
116 dry_run,
117 push_mode,
118 desc,
119 draft,
120 ready,
121 rebuild_overview,
122 } = options;
123
124 let branch = branch.map_or_else(git::current_branch, Ok)?;
125 let desc_branch = branch.clone();
127
128 let branches = if downstack {
129 stack::path_from_root(&branch)?
132 } else if submit_stack {
133 stack::stack_line(&branch)?
137 } else {
138 vec![branch.clone()]
139 };
140
141 if submit_stack || downstack {
145 let trunk = stack::trunk_branch(&git::local_branches()?);
146 if Some(&branch) == trunk.as_ref() {
147 if stack::children_of(&branch)?.is_empty() {
148 bail!("no stacked branches to submit");
149 }
150 bail!("you are on the trunk ({branch}); check out a stacked branch first");
151 }
152 }
153
154 let branch_parents = branch_parents(&branches)?;
155
156 let push = settings::push_enabled(push_mode, settings::PUSH_ON_SUBMIT_KEY)?;
160 if push {
161 let remote = settings::remote()?;
162 if dry_run {
163 anstream::println!(
164 "would push {} to {remote}",
165 style::branch(&branches.join(" "))
166 );
167 } else {
168 git::push_set_upstream_force_with_lease(&remote, &branches)?;
169 anstream::println!("pushed {} to {remote}", style::branch(&branches.join(" ")));
170 stack::publish_metadata(&remote);
173 }
174 }
175
176 let (provider, review_provider) = detect_review_provider()?;
177 let mut summary = SubmitSummary::default();
178
179 let mut created = Vec::new();
180 for (branch, parent) in &branch_parents {
181 let action = submit_branch(review_provider.as_ref(), branch, parent, dry_run, draft)?;
182 if action == SubmitAction::Created {
183 created.push(branch.clone());
184 }
185 summary.record(action);
186 }
187
188 let writes_stack_overview = submit_stack || downstack;
195 let created: Vec<(String, bool)> = created
196 .into_iter()
197 .map(|branch| {
198 let managed = writes_stack_overview
199 || (desc.is_some() && branch == desc_branch)
200 || crate::notes::branch_references_issue(&branch);
201 (branch, managed)
202 })
203 .collect();
204 crate::notes::seed_template_notes(review_provider.as_ref(), provider.kind, &created, dry_run)?;
205
206 if ready {
209 for branch in &branches {
210 let Some(review) = review_provider.review_for_branch(branch)? else {
211 continue;
212 };
213 if review.branch != *branch || !review.draft {
214 continue;
215 }
216 if dry_run {
217 anstream::println!("would mark {} ready", review.id);
218 continue;
219 }
220 let output = review_provider.mark_ready(&review)?;
221 anstream::println!("marked {} ready", review.id);
222 if !output.is_empty() {
223 println!("{output}");
224 }
225 }
226 }
227
228 let renamed: Vec<(String, String)> = if submit_stack || downstack {
235 branch_parents
236 .iter()
237 .filter_map(|(branch, _)| {
238 stack::renamed_from(branch)
239 .ok()
240 .flatten()
241 .map(|old| (branch.clone(), old))
242 })
243 .collect()
244 } else {
245 Vec::new()
246 };
247 let mut reconciled: Vec<&str> = Vec::new();
251 for (branch, old) in &renamed {
252 if close_superseded_review(review_provider.as_ref(), old, dry_run)? {
253 reconciled.push(branch);
254 }
255 }
256
257 if let Some(desc) = desc {
261 crate::notes::update_description_note(
262 review_provider.as_ref(),
263 &desc_branch,
264 &desc,
265 dry_run,
266 )?;
267 }
268 crate::notes::update_closes_notes(review_provider.as_ref(), &branches, dry_run)?;
269 if submit_stack || downstack {
270 crate::notes::update_stack_notes(
271 review_provider.as_ref(),
272 &branch_parents,
273 dry_run,
274 rebuild_overview,
275 )?;
276 }
277
278 if !dry_run {
281 for branch in &reconciled {
282 stack::clear_renamed_from(branch)?;
283 }
284 }
285
286 anstream::println!(
287 "{}",
288 style::success(&format!(
289 "submit complete: {} created, {} updated, {} skipped",
290 summary.created, summary.updated, summary.skipped
291 ))
292 );
293 Ok(())
294}
295
296fn close_superseded_review(
304 review_provider: &dyn ReviewProvider,
305 old: &str,
306 dry_run: bool,
307) -> Result<bool> {
308 let Some(review) = review_provider.review_for_branch(old)? else {
309 return Ok(true);
310 };
311 if review.branch != *old {
312 return Ok(true);
313 }
314
315 if dry_run {
316 anstream::println!("would close superseded review {} for {old}", review.id);
317 return Ok(true);
318 }
319 if !crate::prompt::confirm_default_yes(&format!(
320 "close the replaced review {} for {old} and delete its branch? [Y/n] ",
321 review.id
322 ))? {
323 anstream::println!("kept review {} for {old}", review.id);
324 return Ok(false);
325 }
326
327 review_provider.close_review(&review, true)?;
328 anstream::println!("closed superseded review {} for {old}", review.id);
329 Ok(true)
330}
331
332fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
333 let mut branch_parents = Vec::new();
334 for branch in branches {
335 let Some(parent) = stack::parent_of(branch)? else {
336 bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
337 };
338 branch_parents.push((branch.to_owned(), parent));
339 }
340 Ok(branch_parents)
341}
342
343fn submit_branch(
344 review_provider: &dyn ReviewProvider,
345 branch: &str,
346 parent: &str,
347 dry_run: bool,
348 draft: bool,
349) -> Result<SubmitAction> {
350 if let Some(review) = review_provider.review_for_branch(branch)? {
351 if review.base == parent {
352 if dry_run {
353 anstream::println!(
354 "would skip {} -> {} ({})",
355 review.branch,
356 review.base,
357 review.id
358 );
359 } else {
360 anstream::println!(
361 "{}",
362 style::dim(&format!(
363 "{} already targets {} ({})",
364 review.branch, review.base, review.id
365 ))
366 );
367 }
368 return Ok(SubmitAction::Skipped);
369 }
370
371 let output = if dry_run {
372 String::new()
373 } else {
374 review_provider.update_review_base(&review, parent)?
375 };
376 anstream::println!(
377 "{} {} -> {} {}",
378 if dry_run { "would update" } else { "updated" },
379 style::branch(&review.branch),
380 style::branch(parent),
381 style::dim(&format!("({})", review.id))
382 );
383 if !output.is_empty() {
384 println!("{output}");
385 }
386 } else {
387 let output = if dry_run {
388 String::new()
389 } else {
390 review_provider.create_review(branch, parent, draft)?
391 };
392 anstream::println!(
393 "{} {} -> {}",
394 if dry_run { "would create" } else { "created" },
395 style::branch(branch),
396 style::branch(parent)
397 );
398 if !output.is_empty() {
399 println!("{output}");
400 }
401 return Ok(SubmitAction::Created);
402 }
403
404 Ok(SubmitAction::Updated)
405}
406
407#[derive(Debug, Default)]
408struct SubmitSummary {
409 created: usize,
410 updated: usize,
411 skipped: usize,
412}
413
414impl SubmitSummary {
415 fn record(&mut self, action: SubmitAction) {
416 match action {
417 SubmitAction::Created => self.created += 1,
418 SubmitAction::Updated => self.updated += 1,
419 SubmitAction::Skipped => self.skipped += 1,
420 }
421 }
422}
423
424#[derive(Debug, Clone, Copy, Eq, PartialEq)]
425enum SubmitAction {
426 Created,
427 Updated,
428 Skipped,
429}