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        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, &current_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}