1use std::{fmt, process::Command};
2
3use anyhow::{Context, Result, anyhow, bail};
4use serde_json::Value;
5
6use crate::{git, stack};
7
8const PROVIDER_KEY: &str = "stack.provider";
9const REMOTE_KEY: &str = "stack.remote";
10const DEFAULT_REMOTE: &str = "origin";
11
12#[derive(Debug, Clone, Copy, Eq, PartialEq)]
13pub enum ProviderKind {
14 GitHub,
15 GitLab,
16}
17
18impl ProviderKind {
19 fn parse(value: &str) -> Option<Self> {
20 match value.to_ascii_lowercase().as_str() {
21 "github" | "gh" => Some(Self::GitHub),
22 "gitlab" | "glab" => Some(Self::GitLab),
23 _ => None,
24 }
25 }
26}
27
28impl fmt::Display for ProviderKind {
29 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
30 match self {
31 Self::GitHub => write!(formatter, "github"),
32 Self::GitLab => write!(formatter, "gitlab"),
33 }
34 }
35}
36
37#[derive(Debug, Eq, PartialEq)]
38pub struct DetectedProvider {
39 pub kind: ProviderKind,
40 pub source: ProviderSource,
41}
42
43#[derive(Debug, Eq, PartialEq)]
44pub enum ProviderSource {
45 Config,
46 Remote { remote: String, url: String },
47}
48
49impl fmt::Display for ProviderSource {
50 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
51 match self {
52 Self::Config => write!(formatter, "config"),
53 Self::Remote { remote, url } => write!(formatter, "remote {remote} ({url})"),
54 }
55 }
56}
57
58#[derive(Debug, Eq, PartialEq)]
59pub enum ReviewState {
60 Open,
61 Merged,
62 Closed,
63 Unknown(String),
64}
65
66#[derive(Debug, Eq, PartialEq)]
67pub struct ReviewRequest {
68 pub id: String,
69 pub branch: String,
70 pub base: String,
71 pub state: ReviewState,
72 pub url: String,
73}
74
75pub trait ReviewProvider {
76 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
77
78 fn create_review(&self, branch: &str, base: &str) -> Result<String>;
79
80 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
81}
82
83struct GitHubProvider;
84
85struct GitLabProvider;
86
87impl ReviewProvider for GitHubProvider {
88 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>> {
89 let output = command_output(
90 "gh",
91 &[
92 "pr",
93 "list",
94 "--head",
95 branch,
96 "--json",
97 "number,state,baseRefName,headRefName,url",
98 ],
99 )?;
100 parse_github_review(&output)
101 }
102
103 fn create_review(&self, branch: &str, base: &str) -> Result<String> {
104 command_output(
105 "gh",
106 &["pr", "create", "--head", branch, "--base", base, "--fill"],
107 )
108 }
109
110 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String> {
111 command_output("gh", &["pr", "edit", review.id_value(), "--base", base])
112 }
113}
114
115impl ReviewProvider for GitLabProvider {
116 fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>> {
117 let output = command_output(
118 "glab",
119 &["mr", "list", "--source-branch", branch, "--output", "json"],
120 )?;
121 parse_gitlab_review(&output)
122 }
123
124 fn create_review(&self, branch: &str, base: &str) -> Result<String> {
125 command_output(
126 "glab",
127 &[
128 "mr",
129 "create",
130 "--source-branch",
131 branch,
132 "--target-branch",
133 base,
134 "--fill",
135 ],
136 )
137 }
138
139 fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String> {
140 command_output(
141 "glab",
142 &["mr", "update", review.id_value(), "--target-branch", base],
143 )
144 }
145}
146
147pub fn print_provider() -> Result<()> {
148 let provider = detect_provider()?;
149 println!("{} ({})", provider.kind, provider.source);
150 Ok(())
151}
152
153pub fn print_review(branch: Option<&str>) -> Result<()> {
154 let branch = branch
155 .map(str::to_owned)
156 .map_or_else(git::current_branch, Ok)?;
157 let provider = detect_provider()?;
158 let review_provider = review_provider(provider.kind);
159
160 let Some(review) = review_provider.review_for_branch(&branch)? else {
161 bail!("no {} review found for {branch}", provider.kind);
162 };
163
164 println!(
165 "{} {} -> {} {} {}",
166 review.id, review.branch, review.base, review.state, review.url
167 );
168 Ok(())
169}
170
171pub fn print_status(branch: Option<&str>) -> Result<()> {
172 let branch = branch
173 .map(str::to_owned)
174 .map_or_else(git::current_branch, Ok)?;
175 let parent = stack::parent_for_branch(&branch)?;
176 let children = stack::children_for_branch(&branch)?;
177
178 println!("branch: {branch}");
179 match parent.as_deref() {
180 Some(parent) => println!("parent: {parent}"),
181 None => println!("parent: none"),
182 }
183 if children.is_empty() {
184 println!("children: none");
185 } else {
186 println!("children: {}", children.join(", "));
187 }
188
189 let provider = detect_provider()?;
190 println!("provider: {} ({})", provider.kind, provider.source);
191 let review_provider = review_provider(provider.kind);
192
193 let Some(review) = review_provider.review_for_branch(&branch)? else {
194 println!("review: none");
195 return Ok(());
196 };
197
198 println!(
199 "review: {} {} {} -> {}",
200 review.id, review.state, review.branch, review.base
201 );
202 println!("url: {}", review.url);
203
204 if let Some(parent) = parent
205 && parent != review.base
206 {
207 println!(
208 "warning: review base is {}, local parent is {}",
209 review.base, parent
210 );
211 }
212
213 Ok(())
214}
215
216pub fn sync_stack(branch: Option<&str>, dry_run: bool) -> Result<()> {
217 let branches = match branch {
218 Some(branch) => vec![branch.to_owned()],
219 None => git::local_branches()?,
220 };
221
222 let provider = detect_provider()?;
223 let review_provider = review_provider(provider.kind);
224 let mut synced = 0;
225 let mut skipped = 0;
226
227 for branch in branches {
228 let Some(review) = review_provider.review_for_branch(&branch)? else {
229 println!("skipped {branch}: no {} review found", provider.kind);
230 skipped += 1;
231 continue;
232 };
233
234 if review.branch != branch {
235 println!(
236 "skipped {branch}: {} review belongs to {}",
237 provider.kind, review.branch
238 );
239 skipped += 1;
240 continue;
241 }
242
243 if review.branch == review.base {
244 bail!("refusing to set {branch} as its own stack parent");
245 }
246
247 if !dry_run {
248 git::config_set(&parent_key(&branch), &review.base)?;
249 }
250 println!(
251 "{} {} -> {} ({})",
252 if dry_run { "would sync" } else { "synced" },
253 review.branch,
254 review.base,
255 review.id
256 );
257 synced += 1;
258 }
259
260 println!(
261 "sync complete: {synced} {}synced, {skipped} skipped",
262 if dry_run { "would be " } else { "" }
263 );
264 Ok(())
265}
266
267pub fn submit(branch: Option<&str>, submit_stack: bool, dry_run: bool) -> Result<()> {
268 let branch = branch
269 .map(str::to_owned)
270 .map_or_else(git::current_branch, Ok)?;
271
272 let branches = if submit_stack {
273 stack::branch_and_descendants(&branch)?
274 } else {
275 vec![branch]
276 };
277
278 let branch_parents = branch_parents(&branches)?;
279
280 let provider = detect_provider()?;
281 let review_provider = review_provider(provider.kind);
282 let mut summary = SubmitSummary::default();
283
284 for (branch, parent) in branch_parents {
285 summary.record(submit_branch(
286 review_provider.as_ref(),
287 &branch,
288 &parent,
289 dry_run,
290 )?);
291 }
292
293 println!(
294 "submit complete: {} created, {} updated, {} skipped",
295 summary.created, summary.updated, summary.skipped
296 );
297 Ok(())
298}
299
300pub fn cleanup(branch: Option<&str>, dry_run: bool, delete_branch: bool) -> Result<()> {
301 let branch = branch
302 .map(str::to_owned)
303 .map_or_else(git::current_branch, Ok)?;
304 let branches = stack::branch_and_descendants(&branch)?;
305 let current_branch = git::current_branch()?;
306 let provider = detect_provider()?;
307 let review_provider = review_provider(provider.kind);
308 let mut cleaned = 0;
309 let mut skipped = 0;
310
311 for branch in branches {
312 let Some(review) = review_provider.review_for_branch(&branch)? else {
313 println!("skipped {branch}: no {} review found", provider.kind);
314 skipped += 1;
315 continue;
316 };
317
318 if review.state != ReviewState::Merged {
319 println!("skipped {branch}: review {} is {}", review.id, review.state);
320 skipped += 1;
321 continue;
322 }
323
324 cleanup_merged_branch(review_provider.as_ref(), &branch, dry_run)?;
325 cleanup_branch_deletion(&branch, ¤t_branch, dry_run, delete_branch)?;
326 cleaned += 1;
327 }
328
329 println!("cleanup complete: {cleaned} cleaned, {skipped} skipped");
330 Ok(())
331}
332
333fn cleanup_merged_branch(
334 review_provider: &dyn ReviewProvider,
335 branch: &str,
336 dry_run: bool,
337) -> Result<()> {
338 let parent = stack::parent_for_branch(branch)?;
339 let descendants = stack::branch_and_descendants(branch)?;
340 let direct_children: Vec<_> = descendants
341 .into_iter()
342 .skip(1)
343 .filter_map(|child| match stack::parent_for_branch(&child) {
344 Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
345 Ok(_) => None,
346 Err(error) => Some(Err(error)),
347 })
348 .collect::<Result<_>>()?;
349
350 for child in direct_children {
351 match parent.as_deref() {
352 Some(parent) => {
353 println!(
354 "{} retarget {child} -> {parent}",
355 if dry_run { "would" } else { "will" }
356 );
357 update_child_review_base(review_provider, &child, parent, dry_run)?;
358 if !dry_run {
359 stack::set_parent_for_branch(&child, parent)?;
360 }
361 }
362 None => {
363 println!("{} detach {child}", if dry_run { "would" } else { "will" });
364 if !dry_run {
365 stack::unset_parent_for_branch(&child)?;
366 }
367 }
368 }
369 }
370
371 println!("{} detach {branch}", if dry_run { "would" } else { "will" });
372 if !dry_run {
373 stack::unset_parent_for_branch(branch)?;
374 }
375
376 Ok(())
377}
378
379fn cleanup_branch_deletion(
380 branch: &str,
381 current_branch: &str,
382 dry_run: bool,
383 delete_branch: bool,
384) -> Result<()> {
385 if !delete_branch {
386 return Ok(());
387 }
388
389 if branch == current_branch {
390 bail!("refusing to delete currently checked out branch {branch}");
391 }
392
393 println!(
394 "{} delete branch {branch}",
395 if dry_run { "would" } else { "will" }
396 );
397 if !dry_run {
398 git::delete_branch(branch)?;
399 }
400
401 Ok(())
402}
403
404fn update_child_review_base(
405 review_provider: &dyn ReviewProvider,
406 child: &str,
407 parent: &str,
408 dry_run: bool,
409) -> Result<()> {
410 let Some(review) = review_provider.review_for_branch(child)? else {
411 return Ok(());
412 };
413
414 if review.state == ReviewState::Merged || review.base == parent {
415 return Ok(());
416 }
417
418 println!(
419 "{} update review {} -> {} ({})",
420 if dry_run { "would" } else { "will" },
421 review.branch,
422 parent,
423 review.id
424 );
425 if !dry_run {
426 let output = review_provider.update_review_base(&review, parent)?;
427 if !output.is_empty() {
428 println!("{output}");
429 }
430 }
431
432 Ok(())
433}
434
435fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
436 let mut branch_parents = Vec::new();
437 for branch in branches {
438 let Some(parent) = stack::parent_for_branch(branch)? else {
439 bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
440 };
441 branch_parents.push((branch.to_owned(), parent));
442 }
443 Ok(branch_parents)
444}
445
446fn submit_branch(
447 review_provider: &dyn ReviewProvider,
448 branch: &str,
449 parent: &str,
450 dry_run: bool,
451) -> Result<SubmitAction> {
452 if let Some(review) = review_provider.review_for_branch(branch)? {
453 if review.base == parent {
454 if dry_run {
455 println!(
456 "would skip {} -> {} ({})",
457 review.branch, review.base, review.id
458 );
459 } else {
460 println!(
461 "{} already targets {} ({})",
462 review.branch, review.base, review.id
463 );
464 }
465 return Ok(SubmitAction::Skipped);
466 }
467
468 let output = if dry_run {
469 String::new()
470 } else {
471 review_provider.update_review_base(&review, parent)?
472 };
473 println!(
474 "{} {} -> {} ({})",
475 if dry_run { "would update" } else { "updated" },
476 review.branch,
477 parent,
478 review.id
479 );
480 if !output.is_empty() {
481 println!("{output}");
482 }
483 } else {
484 let output = if dry_run {
485 String::new()
486 } else {
487 review_provider.create_review(branch, parent)?
488 };
489 println!(
490 "{} {branch} -> {parent}",
491 if dry_run { "would create" } else { "created" }
492 );
493 if !output.is_empty() {
494 println!("{output}");
495 }
496 return Ok(SubmitAction::Created);
497 }
498
499 Ok(SubmitAction::Updated)
500}
501
502#[derive(Debug, Default)]
503struct SubmitSummary {
504 created: usize,
505 updated: usize,
506 skipped: usize,
507}
508
509impl SubmitSummary {
510 fn record(&mut self, action: SubmitAction) {
511 match action {
512 SubmitAction::Created => self.created += 1,
513 SubmitAction::Updated => self.updated += 1,
514 SubmitAction::Skipped => self.skipped += 1,
515 }
516 }
517}
518
519#[derive(Debug, Clone, Copy, Eq, PartialEq)]
520enum SubmitAction {
521 Created,
522 Updated,
523 Skipped,
524}
525
526pub fn detect_provider() -> Result<DetectedProvider> {
527 if let Some(value) = git::config_get(PROVIDER_KEY)? {
528 let Some(kind) = ProviderKind::parse(&value) else {
529 bail!("unsupported stack.provider value {value:?}; expected github or gitlab");
530 };
531
532 return Ok(DetectedProvider {
533 kind,
534 source: ProviderSource::Config,
535 });
536 }
537
538 let remote = git::config_get(REMOTE_KEY)?.unwrap_or_else(|| DEFAULT_REMOTE.to_owned());
539 let Some(url) = git::remote_url(&remote)? else {
540 bail!("could not detect provider: remote {remote:?} does not exist");
541 };
542
543 let Some(kind) = detect_provider_from_url(&url) else {
544 bail!("could not detect provider from remote {remote} ({url})");
545 };
546
547 Ok(DetectedProvider {
548 kind,
549 source: ProviderSource::Remote { remote, url },
550 })
551}
552
553fn detect_provider_from_url(url: &str) -> Option<ProviderKind> {
554 let normalized = url.to_ascii_lowercase();
555
556 if normalized.contains("github.com:") || normalized.contains("github.com/") {
557 Some(ProviderKind::GitHub)
558 } else if normalized.contains("gitlab.com:") || normalized.contains("gitlab.com/") {
559 Some(ProviderKind::GitLab)
560 } else {
561 None
562 }
563}
564
565fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
566 match kind {
567 ProviderKind::GitHub => Box::new(GitHubProvider),
568 ProviderKind::GitLab => Box::new(GitLabProvider),
569 }
570}
571
572fn command_output(program: &str, args: &[&str]) -> Result<String> {
573 let output = Command::new(program)
574 .args(args)
575 .output()
576 .with_context(|| format!("failed to run {program}"))?;
577
578 if output.status.success() {
579 Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
580 } else {
581 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
582 if stderr.is_empty() {
583 Err(anyhow!("{program} exited with status {}", output.status))
584 } else {
585 Err(anyhow!("{program} failed: {stderr}"))
586 }
587 }
588}
589
590fn parse_github_review(output: &str) -> Result<Option<ReviewRequest>> {
591 let Some(review) = first_json_item(output)? else {
592 return Ok(None);
593 };
594
595 Ok(Some(ReviewRequest {
596 id: format!("#{}", required_string(&review, &["number"])?),
597 branch: required_string(&review, &["headRefName"])?,
598 base: required_string(&review, &["baseRefName"])?,
599 state: parse_state(&required_string(&review, &["state"])?),
600 url: required_string(&review, &["url"])?,
601 }))
602}
603
604fn parse_gitlab_review(output: &str) -> Result<Option<ReviewRequest>> {
605 let Some(review) = first_json_item(output)? else {
606 return Ok(None);
607 };
608
609 Ok(Some(ReviewRequest {
610 id: format!("!{}", required_string(&review, &["iid", "id"])?),
611 branch: required_string(&review, &["source_branch", "sourceBranch"])?,
612 base: required_string(&review, &["target_branch", "targetBranch"])?,
613 state: parse_state(&required_string(&review, &["state"])?),
614 url: required_string(&review, &["web_url", "webUrl", "url"])?,
615 }))
616}
617
618fn first_json_item(output: &str) -> Result<Option<Value>> {
619 let value: Value = serde_json::from_str(output).context("failed to parse provider JSON")?;
620 match value {
621 Value::Array(items) => Ok(items.into_iter().next()),
622 Value::Object(_) => Ok(Some(value)),
623 _ => bail!("provider JSON must be an object or array"),
624 }
625}
626
627fn required_string(value: &Value, keys: &[&str]) -> Result<String> {
628 for key in keys {
629 if let Some(field) = value.get(*key) {
630 if let Some(value) = field.as_str() {
631 return Ok(value.to_owned());
632 }
633 if let Some(value) = field.as_i64() {
634 return Ok(value.to_string());
635 }
636 if let Some(value) = field.as_u64() {
637 return Ok(value.to_string());
638 }
639 }
640 }
641
642 bail!(
643 "provider JSON missing required field: {}",
644 keys.join(" or ")
645 )
646}
647
648fn parse_state(state: &str) -> ReviewState {
649 match state.to_ascii_lowercase().as_str() {
650 "open" | "opened" => ReviewState::Open,
651 "merged" => ReviewState::Merged,
652 "closed" => ReviewState::Closed,
653 _ => ReviewState::Unknown(state.to_owned()),
654 }
655}
656
657fn parent_key(branch: &str) -> String {
658 format!("branch.{branch}.stackParent")
659}
660
661impl fmt::Display for ReviewState {
662 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
663 match self {
664 Self::Open => write!(formatter, "open"),
665 Self::Merged => write!(formatter, "merged"),
666 Self::Closed => write!(formatter, "closed"),
667 Self::Unknown(state) => write!(formatter, "{state}"),
668 }
669 }
670}
671
672impl ReviewRequest {
673 fn id_value(&self) -> &str {
674 self.id
675 .strip_prefix('#')
676 .or_else(|| self.id.strip_prefix('!'))
677 .unwrap_or(&self.id)
678 }
679}
680
681#[cfg(test)]
682mod tests {
683 use super::*;
684
685 #[test]
686 fn parse_github_review_reads_first_array_item() {
687 let review = parse_github_review(
688 r#"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}]"#,
689 )
690 .expect("parse review")
691 .expect("review exists");
692
693 assert_eq!(
694 review,
695 ReviewRequest {
696 id: "#12".to_owned(),
697 branch: "feature/a".to_owned(),
698 base: "main".to_owned(),
699 state: ReviewState::Open,
700 url: "https://github.com/owner/repo/pull/12".to_owned(),
701 }
702 );
703 }
704
705 #[test]
706 fn parse_gitlab_review_reads_snake_case_fields() {
707 let review = parse_gitlab_review(
708 r#"[{"iid":34,"state":"merged","target_branch":"feature/a","source_branch":"feature/b","web_url":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
709 )
710 .expect("parse review")
711 .expect("review exists");
712
713 assert_eq!(
714 review,
715 ReviewRequest {
716 id: "!34".to_owned(),
717 branch: "feature/b".to_owned(),
718 base: "feature/a".to_owned(),
719 state: ReviewState::Merged,
720 url: "https://gitlab.com/owner/repo/-/merge_requests/34".to_owned(),
721 }
722 );
723 }
724
725 #[test]
726 fn parse_gitlab_review_reads_camel_case_fields() {
727 let review = parse_gitlab_review(
728 r#"[{"id":34,"state":"closed","targetBranch":"feature/a","sourceBranch":"feature/b","webUrl":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
729 )
730 .expect("parse review")
731 .expect("review exists");
732
733 assert_eq!(review.id, "!34");
734 assert_eq!(review.branch, "feature/b");
735 assert_eq!(review.base, "feature/a");
736 assert_eq!(review.state, ReviewState::Closed);
737 assert_eq!(
738 review.url,
739 "https://gitlab.com/owner/repo/-/merge_requests/34"
740 );
741 }
742
743 #[test]
744 fn parse_review_accepts_object_output() {
745 let review = parse_github_review(
746 r#"{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}"#,
747 )
748 .expect("parse review")
749 .expect("review exists");
750
751 assert_eq!(review.id, "#12");
752 }
753
754 #[test]
755 fn parse_review_empty_array_returns_none() {
756 assert_eq!(parse_github_review("[]").expect("parse review"), None);
757 assert_eq!(parse_gitlab_review("[]").expect("parse review"), None);
758 }
759
760 #[test]
761 fn parse_review_errors_on_missing_required_field() {
762 let error = parse_github_review(
763 r#"[{"number":12,"state":"OPEN","baseRefName":"main","url":"https://github.com/owner/repo/pull/12"}]"#,
764 )
765 .expect_err("missing head branch should fail");
766
767 assert!(
768 error
769 .to_string()
770 .contains("provider JSON missing required field: headRefName"),
771 "unexpected error: {error:#}"
772 );
773 }
774
775 #[test]
776 fn parse_review_preserves_unknown_state() {
777 let review = parse_github_review(
778 r#"[{"number":12,"state":"READY_FOR_REVIEW","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}]"#,
779 )
780 .expect("parse review")
781 .expect("review exists");
782
783 assert_eq!(
784 review.state,
785 ReviewState::Unknown("READY_FOR_REVIEW".to_owned())
786 );
787 }
788}