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