Skip to main content

git_stk/
providers.rs

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        // gh pr list only returns open pull requests by default; check merged
105        // ones too so cleanup can see landed reviews.
106        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        // glab mr list only returns open merge requests by default; check
145        // merged ones too so cleanup can see landed reviews.
146        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, &current_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}