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 = "stk.provider";
9const REMOTE_KEY: &str = "stk.remote";
10const PUSH_ON_SUBMIT_KEY: &str = "stk.pushOnSubmit";
11const DEFAULT_REMOTE: &str = "origin";
12
13#[derive(Debug, Clone, Copy, Eq, PartialEq)]
14pub enum ProviderKind {
15    GitHub,
16    GitLab,
17}
18
19impl ProviderKind {
20    fn parse(value: &str) -> Option<Self> {
21        match value.to_ascii_lowercase().as_str() {
22            "github" | "gh" => Some(Self::GitHub),
23            "gitlab" | "glab" => Some(Self::GitLab),
24            _ => None,
25        }
26    }
27}
28
29impl fmt::Display for ProviderKind {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        match self {
32            Self::GitHub => write!(formatter, "github"),
33            Self::GitLab => write!(formatter, "gitlab"),
34        }
35    }
36}
37
38#[derive(Debug, Eq, PartialEq)]
39pub struct DetectedProvider {
40    pub kind: ProviderKind,
41    pub source: ProviderSource,
42}
43
44#[derive(Debug, Eq, PartialEq)]
45pub enum ProviderSource {
46    Config,
47    Remote { remote: String, url: String },
48}
49
50impl fmt::Display for ProviderSource {
51    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
52        match self {
53            Self::Config => write!(formatter, "config"),
54            Self::Remote { remote, url } => write!(formatter, "remote {remote} ({url})"),
55        }
56    }
57}
58
59#[derive(Debug, Eq, PartialEq)]
60pub enum ReviewState {
61    Open,
62    Merged,
63    Closed,
64    Unknown(String),
65}
66
67#[derive(Debug, Eq, PartialEq)]
68pub struct ReviewRequest {
69    pub id: String,
70    pub branch: String,
71    pub base: String,
72    pub state: ReviewState,
73    pub url: String,
74    pub title: String,
75}
76
77pub trait ReviewProvider {
78    fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>>;
79
80    fn create_review(&self, branch: &str, base: &str) -> Result<String>;
81
82    fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String>;
83
84    fn review_body(&self, review: &ReviewRequest) -> Result<String>;
85
86    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
87}
88
89struct GitHubProvider;
90
91struct GitLabProvider;
92
93impl ReviewProvider for GitHubProvider {
94    fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>> {
95        let output = command_output(
96            "gh",
97            &[
98                "pr",
99                "list",
100                "--head",
101                branch,
102                "--json",
103                "number,state,baseRefName,headRefName,url,title",
104            ],
105        )?;
106        if let Some(review) = parse_github_review(&output)? {
107            return Ok(Some(review));
108        }
109
110        // gh pr list only returns open pull requests by default; check merged
111        // ones too so cleanup can see landed reviews.
112        let output = command_output(
113            "gh",
114            &[
115                "pr",
116                "list",
117                "--head",
118                branch,
119                "--state",
120                "merged",
121                "--json",
122                "number,state,baseRefName,headRefName,url,title",
123            ],
124        )?;
125        parse_github_review(&output)
126    }
127
128    fn create_review(&self, branch: &str, base: &str) -> Result<String> {
129        command_output(
130            "gh",
131            &["pr", "create", "--head", branch, "--base", base, "--fill"],
132        )
133    }
134
135    fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String> {
136        command_output("gh", &["pr", "edit", review.id_value(), "--base", base])
137    }
138
139    fn review_body(&self, review: &ReviewRequest) -> Result<String> {
140        let output = command_output("gh", &["pr", "view", review.id_value(), "--json", "body"])?;
141        parse_body_field(&output, "body")
142    }
143
144    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String> {
145        command_output("gh", &["pr", "edit", review.id_value(), "--body", body])
146    }
147}
148
149impl ReviewProvider for GitLabProvider {
150    fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>> {
151        let output = command_output(
152            "glab",
153            &["mr", "list", "--source-branch", branch, "--output", "json"],
154        )?;
155        if let Some(review) = parse_gitlab_review(&output)? {
156            return Ok(Some(review));
157        }
158
159        // glab mr list only returns open merge requests by default; check
160        // merged ones too so cleanup can see landed reviews.
161        let output = command_output(
162            "glab",
163            &[
164                "mr",
165                "list",
166                "--source-branch",
167                branch,
168                "--merged",
169                "--output",
170                "json",
171            ],
172        )?;
173        parse_gitlab_review(&output)
174    }
175
176    fn create_review(&self, branch: &str, base: &str) -> Result<String> {
177        command_output(
178            "glab",
179            &[
180                "mr",
181                "create",
182                "--source-branch",
183                branch,
184                "--target-branch",
185                base,
186                "--fill",
187            ],
188        )
189    }
190
191    fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String> {
192        command_output(
193            "glab",
194            &["mr", "update", review.id_value(), "--target-branch", base],
195        )
196    }
197
198    fn review_body(&self, review: &ReviewRequest) -> Result<String> {
199        let output = command_output(
200            "glab",
201            &["mr", "view", review.id_value(), "--output", "json"],
202        )?;
203        parse_body_field(&output, "description")
204    }
205
206    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String> {
207        command_output(
208            "glab",
209            &["mr", "update", review.id_value(), "--description", body],
210        )
211    }
212}
213
214fn parse_body_field(output: &str, field: &str) -> Result<String> {
215    let value: serde_json::Value =
216        serde_json::from_str(output).context("failed to parse provider JSON")?;
217    Ok(value
218        .get(field)
219        .and_then(serde_json::Value::as_str)
220        .unwrap_or_default()
221        .to_owned())
222}
223
224pub fn print_provider() -> Result<()> {
225    let provider = detect_provider()?;
226    println!("{} ({})", provider.kind, provider.source);
227    Ok(())
228}
229
230pub fn print_review(branch: Option<&str>) -> Result<()> {
231    let branch = branch
232        .map(str::to_owned)
233        .map_or_else(git::current_branch, Ok)?;
234    let provider = detect_provider()?;
235    let review_provider = review_provider(provider.kind);
236
237    let Some(review) = review_provider.review_for_branch(&branch)? else {
238        bail!("no {} review found for {branch}", provider.kind);
239    };
240
241    println!(
242        "{} {} -> {} {} {}",
243        review.id, review.branch, review.base, review.state, review.url
244    );
245    Ok(())
246}
247
248pub fn print_status(branch: Option<&str>) -> Result<()> {
249    let branch = branch
250        .map(str::to_owned)
251        .map_or_else(git::current_branch, Ok)?;
252    let parent = stack::parent_for_branch(&branch)?;
253    let children = stack::children_for_branch(&branch)?;
254
255    println!("branch: {branch}");
256    match parent.as_deref() {
257        Some(parent) => println!("parent: {parent}"),
258        None => println!("parent: none"),
259    }
260    if children.is_empty() {
261        println!("children: none");
262    } else {
263        println!("children: {}", children.join(", "));
264    }
265
266    let provider = detect_provider()?;
267    println!("provider: {} ({})", provider.kind, provider.source);
268    let review_provider = review_provider(provider.kind);
269
270    let Some(review) = review_provider.review_for_branch(&branch)? else {
271        println!("review: none");
272        return Ok(());
273    };
274
275    println!(
276        "review: {} {} {} -> {}",
277        review.id, review.state, review.branch, review.base
278    );
279    println!("url: {}", review.url);
280
281    if let Some(parent) = parent
282        && parent != review.base
283    {
284        println!(
285            "warning: review base is {}, local parent is {}",
286            review.base, parent
287        );
288    }
289
290    Ok(())
291}
292
293pub fn sync_stack(branch: Option<&str>, dry_run: bool) -> Result<()> {
294    let branches = match branch {
295        Some(branch) => vec![branch.to_owned()],
296        None => git::local_branches()?,
297    };
298
299    let provider = detect_provider()?;
300    let review_provider = review_provider(provider.kind);
301    let mut synced = 0;
302    let mut skipped = 0;
303
304    for branch in branches {
305        let Some(review) = review_provider.review_for_branch(&branch)? else {
306            println!("skipped {branch}: no {} review found", provider.kind);
307            skipped += 1;
308            continue;
309        };
310
311        if review.branch != branch {
312            println!(
313                "skipped {branch}: {} review belongs to {}",
314                provider.kind, review.branch
315            );
316            skipped += 1;
317            continue;
318        }
319
320        if review.branch == review.base {
321            bail!("refusing to set {branch} as its own stack parent");
322        }
323
324        if !dry_run {
325            git::config_set(&parent_key(&branch), &review.base)?;
326            stack::record_base(&branch, &review.base);
327        }
328        println!(
329            "{} {} -> {} ({})",
330            if dry_run { "would sync" } else { "synced" },
331            review.branch,
332            review.base,
333            review.id
334        );
335        synced += 1;
336    }
337
338    println!(
339        "sync complete: {synced} {}synced, {skipped} skipped",
340        if dry_run { "would be " } else { "" }
341    );
342    Ok(())
343}
344
345pub fn submit(
346    branch: Option<&str>,
347    submit_stack: bool,
348    dry_run: bool,
349    push_mode: crate::cli::PushMode,
350) -> Result<()> {
351    let branch = branch
352        .map(str::to_owned)
353        .map_or_else(git::current_branch, Ok)?;
354
355    let branches = if submit_stack {
356        stack::branch_and_descendants(&branch)?
357    } else {
358        vec![branch]
359    };
360
361    let branch_parents = branch_parents(&branches)?;
362
363    // Push after stack validation but before any provider calls: creating a
364    // review requires the branch to exist remotely, and -u --force-with-lease
365    // covers both first pushes and safely updating rebased branches.
366    let push = match push_mode {
367        crate::cli::PushMode::Config => git::config_get_bool(PUSH_ON_SUBMIT_KEY)?.unwrap_or(false),
368        crate::cli::PushMode::Enabled => true,
369        crate::cli::PushMode::Disabled => false,
370    };
371    if push {
372        let remote = git::config_get(REMOTE_KEY)?.unwrap_or_else(|| DEFAULT_REMOTE.to_owned());
373        if dry_run {
374            println!("would push {} to {remote}", branches.join(" "));
375        } else {
376            git::push_set_upstream_force_with_lease(&remote, &branches)?;
377            println!("pushed {} to {remote}", branches.join(" "));
378        }
379    }
380
381    let provider = detect_provider()?;
382    let review_provider = review_provider(provider.kind);
383    let mut summary = SubmitSummary::default();
384
385    for (branch, parent) in &branch_parents {
386        summary.record(submit_branch(
387            review_provider.as_ref(),
388            branch,
389            parent,
390            dry_run,
391        )?);
392    }
393
394    // After every review exists, write the stack overview into each body.
395    if submit_stack {
396        update_stack_notes(review_provider.as_ref(), &branch_parents, dry_run)?;
397    }
398
399    println!(
400        "submit complete: {} created, {} updated, {} skipped",
401        summary.created, summary.updated, summary.skipped
402    );
403    Ok(())
404}
405
406const STACK_NOTE_START: &str = "<!-- git-stk:stack -->";
407const STACK_NOTE_END: &str = "<!-- /git-stk:stack -->";
408const TOOL_URL: &str = "https://github.com/lararosekelley/git-stk";
409
410/// Maintain a stack overview in every review body: the full PR list
411/// leaf-first, the trunk at the bottom, and a pointing emoji marking the
412/// review being viewed. Lives between marker comments so resubmits replace
413/// it in place, and self-repairs if the markers were hand-edited away.
414fn update_stack_notes(
415    review_provider: &dyn ReviewProvider,
416    branch_parents: &[(String, String)],
417    dry_run: bool,
418) -> Result<()> {
419    // The bottom branch's parent is the base the whole stack sits on.
420    let Some(trunk) = branch_parents.first().map(|(_, parent)| parent.clone()) else {
421        return Ok(());
422    };
423
424    let mut entries = Vec::new();
425    for (branch, _) in branch_parents {
426        match review_provider.review_for_branch(branch)? {
427            Some(review) if review.branch == *branch => entries.push(review),
428            _ => {
429                // Without every review the overview would be wrong for all of
430                // them (dry runs never created the missing ones).
431                if !dry_run {
432                    println!("skipped stack notes: no review found for {branch}");
433                }
434                return Ok(());
435            }
436        }
437    }
438
439    for index in 0..entries.len() {
440        let note = build_stack_note(&entries, index, &trunk);
441        let review = &entries[index];
442
443        if dry_run {
444            println!("would update stack note in {}", review.id);
445            continue;
446        }
447
448        let body = review_provider.review_body(review)?;
449        let updated = body_with_stack_note(&body, &note);
450        if updated == body {
451            continue;
452        }
453
454        review_provider.update_review_body(review, &updated)?;
455        println!("updated stack note in {}", review.id);
456    }
457
458    Ok(())
459}
460
461/// Render the overview for one review: every PR in the stack leaf-first as a
462/// linked bullet, a pointer on the review being viewed, the trunk in
463/// backticks at the bottom, and a footer crediting the tool.
464fn build_stack_note(entries: &[ReviewRequest], current: usize, trunk: &str) -> String {
465    let mut lines = Vec::new();
466    for (index, entry) in entries.iter().enumerate().rev() {
467        let label = if entry.title.is_empty() {
468            entry.id.clone()
469        } else {
470            format!("{} ({})", entry.title, entry.id)
471        };
472        let mut line = format!("- [{label}]({})", entry.url);
473        if index == current {
474            line.push_str(" \u{1F448}");
475        }
476        lines.push(line);
477    }
478    lines.push(format!("- `{trunk}`"));
479
480    format!(
481        "{}\n\n---\n\nStack managed by [git-stk]({TOOL_URL})",
482        lines.join("\n")
483    )
484}
485
486/// Replace the marker-delimited stack note in a review body, appending it at
487/// the end. Damaged markup (orphaned or reordered markers, duplicates) is
488/// stripped first, so the section self-repairs on the next submit.
489fn body_with_stack_note(body: &str, note: &str) -> String {
490    let section = format!("{STACK_NOTE_START}\n{note}\n{STACK_NOTE_END}");
491    let cleaned = strip_stack_notes(body);
492
493    if cleaned.trim().is_empty() {
494        section
495    } else {
496        format!("{}\n\n{section}", cleaned.trim_end())
497    }
498}
499
500/// Remove every well-formed marker section and any orphaned markers.
501fn strip_stack_notes(body: &str) -> String {
502    let mut result = body.to_owned();
503
504    while let Some(start) = result.find(STACK_NOTE_START) {
505        match result[start..].find(STACK_NOTE_END) {
506            Some(end_offset) => {
507                let end = start + end_offset + STACK_NOTE_END.len();
508                result.replace_range(start..end, "");
509            }
510            None => result.replace_range(start..start + STACK_NOTE_START.len(), ""),
511        }
512    }
513    while let Some(start) = result.find(STACK_NOTE_END) {
514        result.replace_range(start..start + STACK_NOTE_END.len(), "");
515    }
516
517    // Collapse the blank-line craters left behind by removed sections.
518    while result.contains("\n\n\n") {
519        result = result.replace("\n\n\n", "\n\n");
520    }
521    result
522}
523
524/// Rebuild or verify local stack metadata. For branches missing a parent,
525/// try the provider's review base first, then nearest-ancestor inference.
526/// For branches with a parent, verify it exists and the recorded fork point
527/// is still valid, re-deriving it when stale.
528pub fn repair(dry_run: bool) -> Result<()> {
529    let branches = git::local_branches()?;
530    let trunk = stack::trunk_branch(&branches);
531
532    // Provider lookup is best effort: repair must work without a remote or
533    // an authenticated gh/glab.
534    let provider = detect_provider()
535        .ok()
536        .map(|provider| (provider.kind, review_provider(provider.kind)));
537
538    let mut repaired = 0;
539    let mut verified = 0;
540    let mut unresolved = 0;
541
542    for branch in &branches {
543        if Some(branch.as_str()) == trunk.as_deref() {
544            continue;
545        }
546
547        if let Some(parent) = stack::parent_for_branch(branch)? {
548            if !branches.contains(&parent) {
549                println!(
550                    "{branch}: parent {parent} does not exist locally; \
551                     fix with `git stk adopt` or `git stk detach {branch}`"
552                );
553                unresolved += 1;
554                continue;
555            }
556
557            let base_valid = matches!(
558                stack::base_for_branch(branch)?,
559                Some(base) if git::is_ancestor(&base, branch).unwrap_or(false)
560            );
561            if base_valid {
562                verified += 1;
563            } else {
564                println!(
565                    "{branch}: {} fork point from {parent}",
566                    if dry_run {
567                        "would re-record"
568                    } else {
569                        "re-recorded"
570                    }
571                );
572                if !dry_run {
573                    stack::record_base(branch, &parent);
574                }
575                repaired += 1;
576            }
577            continue;
578        }
579
580        let mut found: Option<(String, String)> = None;
581        if let Some((kind, review_provider)) = &provider
582            && let Ok(Some(review)) = review_provider.review_for_branch(branch)
583            && review.branch == *branch
584            && review.base != *branch
585        {
586            if branches.contains(&review.base) {
587                found = Some((review.base.clone(), format!("{kind} review {}", review.id)));
588            } else {
589                println!(
590                    "{branch}: review {} targets {}, which is not a local branch",
591                    review.id, review.base
592                );
593            }
594        }
595
596        if found.is_none() {
597            match nearest_ancestor_branch(branch, &branches)? {
598                Ancestry::One(parent) => found = Some((parent, "ancestry".to_owned())),
599                Ancestry::None => {
600                    println!(
601                        "{branch}: no parent found; attach manually with \
602                         `git stk adopt {branch} --parent <parent>`"
603                    );
604                }
605                Ancestry::Ambiguous(candidates) => {
606                    println!(
607                        "{branch}: ambiguous parent candidates ({}); attach manually with \
608                         `git stk adopt`",
609                        candidates.join(", ")
610                    );
611                }
612            }
613        }
614
615        match found {
616            Some((parent, source)) => {
617                println!(
618                    "{branch}: {} parent {parent} (from {source})",
619                    if dry_run { "would set" } else { "set" }
620                );
621                if !dry_run {
622                    stack::set_parent_for_branch(branch, &parent)?;
623                    stack::record_base(branch, &parent);
624                }
625                repaired += 1;
626            }
627            None => unresolved += 1,
628        }
629    }
630
631    println!(
632        "repair complete: {repaired} {}repaired, {verified} verified, {unresolved} unresolved",
633        if dry_run { "would be " } else { "" }
634    );
635    Ok(())
636}
637
638enum Ancestry {
639    One(String),
640    None,
641    Ambiguous(Vec<String>),
642}
643
644/// Find the nearest other local branch whose tip is a strict ancestor of
645/// `branch` - the best guess at its stack parent.
646fn nearest_ancestor_branch(branch: &str, branches: &[String]) -> Result<Ancestry> {
647    let tip = git::rev_parse(branch)?;
648
649    let mut candidates: Vec<(String, String)> = Vec::new();
650    for other in branches {
651        if other == branch {
652            continue;
653        }
654        let other_tip = git::rev_parse(other)?;
655        // Equal tips (e.g. a just-created branch) leave the direction
656        // ambiguous, so they are not usable candidates.
657        if other_tip != tip && git::is_ancestor(other, branch)? {
658            candidates.push((other.clone(), other_tip));
659        }
660    }
661
662    // Keep only the nearest candidates: drop any that are ancestors of
663    // another candidate (i.e. further from the branch).
664    let nearest: Vec<String> = candidates
665        .iter()
666        .filter(|(candidate, candidate_tip)| {
667            !candidates.iter().any(|(other, other_tip)| {
668                other != candidate
669                    && other_tip != candidate_tip
670                    && git::is_ancestor(candidate, other).unwrap_or(false)
671            })
672        })
673        .map(|(candidate, _)| candidate.clone())
674        .collect();
675
676    Ok(match nearest.len() {
677        0 => Ancestry::None,
678        1 => Ancestry::One(nearest.into_iter().next().expect("one candidate")),
679        _ => Ancestry::Ambiguous(nearest),
680    })
681}
682
683pub fn cleanup(branch: Option<&str>, dry_run: bool, delete_branch: bool) -> Result<()> {
684    let branch = branch
685        .map(str::to_owned)
686        .map_or_else(git::current_branch, Ok)?;
687    let branches = stack::branch_and_descendants(&branch)?;
688    let current_branch = git::current_branch()?;
689    let provider = detect_provider()?;
690    let review_provider = review_provider(provider.kind);
691    let mut cleaned = 0;
692    let mut skipped = 0;
693
694    for branch in branches {
695        let Some(review) = review_provider.review_for_branch(&branch)? else {
696            println!("skipped {branch}: no {} review found", provider.kind);
697            skipped += 1;
698            continue;
699        };
700
701        if review.state != ReviewState::Merged {
702            println!("skipped {branch}: review {} is {}", review.id, review.state);
703            skipped += 1;
704            continue;
705        }
706
707        cleanup_merged_branch(review_provider.as_ref(), &branch, dry_run)?;
708        cleanup_branch_deletion(&branch, &current_branch, dry_run, delete_branch)?;
709        cleaned += 1;
710    }
711
712    println!("cleanup complete: {cleaned} cleaned, {skipped} skipped");
713    Ok(())
714}
715
716fn cleanup_merged_branch(
717    review_provider: &dyn ReviewProvider,
718    branch: &str,
719    dry_run: bool,
720) -> Result<()> {
721    let parent = stack::parent_for_branch(branch)?;
722    let descendants = stack::branch_and_descendants(branch)?;
723    let direct_children: Vec<_> = descendants
724        .into_iter()
725        .skip(1)
726        .filter_map(|child| match stack::parent_for_branch(&child) {
727            Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
728            Ok(_) => None,
729            Err(error) => Some(Err(error)),
730        })
731        .collect::<Result<_>>()?;
732
733    for child in direct_children {
734        match parent.as_deref() {
735            Some(parent) => {
736                println!(
737                    "{} retarget {child} -> {parent}",
738                    if dry_run { "would" } else { "will" }
739                );
740                update_child_review_base(review_provider, &child, parent, dry_run)?;
741                if !dry_run {
742                    // Record the fork point off the merged branch before
743                    // retargeting, so the next restack replays only the
744                    // child's own commits even after a squash merge.
745                    if let Ok(base) = git::merge_base(branch, &child) {
746                        stack::set_base_for_branch(&child, &base)?;
747                    }
748                    stack::set_parent_for_branch(&child, parent)?;
749                }
750            }
751            None => {
752                println!("{} detach {child}", if dry_run { "would" } else { "will" });
753                if !dry_run {
754                    stack::unset_parent_for_branch(&child)?;
755                    stack::unset_base_for_branch(&child)?;
756                }
757            }
758        }
759    }
760
761    println!("{} detach {branch}", if dry_run { "would" } else { "will" });
762    if !dry_run {
763        stack::unset_parent_for_branch(branch)?;
764        stack::unset_base_for_branch(branch)?;
765    }
766
767    Ok(())
768}
769
770fn cleanup_branch_deletion(
771    branch: &str,
772    current_branch: &str,
773    dry_run: bool,
774    delete_branch: bool,
775) -> Result<()> {
776    if !delete_branch {
777        return Ok(());
778    }
779
780    if branch == current_branch {
781        bail!("refusing to delete currently checked out branch {branch}");
782    }
783
784    println!(
785        "{} delete branch {branch}",
786        if dry_run { "would" } else { "will" }
787    );
788    if !dry_run {
789        git::delete_branch(branch)?;
790    }
791
792    Ok(())
793}
794
795fn update_child_review_base(
796    review_provider: &dyn ReviewProvider,
797    child: &str,
798    parent: &str,
799    dry_run: bool,
800) -> Result<()> {
801    let Some(review) = review_provider.review_for_branch(child)? else {
802        return Ok(());
803    };
804
805    if review.state == ReviewState::Merged || review.base == parent {
806        return Ok(());
807    }
808
809    println!(
810        "{} update review {} -> {} ({})",
811        if dry_run { "would" } else { "will" },
812        review.branch,
813        parent,
814        review.id
815    );
816    if !dry_run {
817        let output = review_provider.update_review_base(&review, parent)?;
818        if !output.is_empty() {
819            println!("{output}");
820        }
821    }
822
823    Ok(())
824}
825
826fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
827    let mut branch_parents = Vec::new();
828    for branch in branches {
829        let Some(parent) = stack::parent_for_branch(branch)? else {
830            bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
831        };
832        branch_parents.push((branch.to_owned(), parent));
833    }
834    Ok(branch_parents)
835}
836
837fn submit_branch(
838    review_provider: &dyn ReviewProvider,
839    branch: &str,
840    parent: &str,
841    dry_run: bool,
842) -> Result<SubmitAction> {
843    if let Some(review) = review_provider.review_for_branch(branch)? {
844        if review.base == parent {
845            if dry_run {
846                println!(
847                    "would skip {} -> {} ({})",
848                    review.branch, review.base, review.id
849                );
850            } else {
851                println!(
852                    "{} already targets {} ({})",
853                    review.branch, review.base, review.id
854                );
855            }
856            return Ok(SubmitAction::Skipped);
857        }
858
859        let output = if dry_run {
860            String::new()
861        } else {
862            review_provider.update_review_base(&review, parent)?
863        };
864        println!(
865            "{} {} -> {} ({})",
866            if dry_run { "would update" } else { "updated" },
867            review.branch,
868            parent,
869            review.id
870        );
871        if !output.is_empty() {
872            println!("{output}");
873        }
874    } else {
875        let output = if dry_run {
876            String::new()
877        } else {
878            review_provider.create_review(branch, parent)?
879        };
880        println!(
881            "{} {branch} -> {parent}",
882            if dry_run { "would create" } else { "created" }
883        );
884        if !output.is_empty() {
885            println!("{output}");
886        }
887        return Ok(SubmitAction::Created);
888    }
889
890    Ok(SubmitAction::Updated)
891}
892
893#[derive(Debug, Default)]
894struct SubmitSummary {
895    created: usize,
896    updated: usize,
897    skipped: usize,
898}
899
900impl SubmitSummary {
901    fn record(&mut self, action: SubmitAction) {
902        match action {
903            SubmitAction::Created => self.created += 1,
904            SubmitAction::Updated => self.updated += 1,
905            SubmitAction::Skipped => self.skipped += 1,
906        }
907    }
908}
909
910#[derive(Debug, Clone, Copy, Eq, PartialEq)]
911enum SubmitAction {
912    Created,
913    Updated,
914    Skipped,
915}
916
917pub fn detect_provider() -> Result<DetectedProvider> {
918    if let Some(value) = git::config_get(PROVIDER_KEY)? {
919        let Some(kind) = ProviderKind::parse(&value) else {
920            bail!("unsupported stk.provider value {value:?}; expected github or gitlab");
921        };
922
923        return Ok(DetectedProvider {
924            kind,
925            source: ProviderSource::Config,
926        });
927    }
928
929    let remote = git::config_get(REMOTE_KEY)?.unwrap_or_else(|| DEFAULT_REMOTE.to_owned());
930    let Some(url) = git::remote_url(&remote)? else {
931        bail!("could not detect provider: remote {remote:?} does not exist");
932    };
933
934    let Some(kind) = detect_provider_from_url(&url) else {
935        bail!("could not detect provider from remote {remote} ({url})");
936    };
937
938    Ok(DetectedProvider {
939        kind,
940        source: ProviderSource::Remote { remote, url },
941    })
942}
943
944fn detect_provider_from_url(url: &str) -> Option<ProviderKind> {
945    let normalized = url.to_ascii_lowercase();
946
947    if normalized.contains("github.com:") || normalized.contains("github.com/") {
948        Some(ProviderKind::GitHub)
949    } else if normalized.contains("gitlab.com:") || normalized.contains("gitlab.com/") {
950        Some(ProviderKind::GitLab)
951    } else {
952        None
953    }
954}
955
956fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
957    match kind {
958        ProviderKind::GitHub => Box::new(GitHubProvider),
959        ProviderKind::GitLab => Box::new(GitLabProvider),
960    }
961}
962
963fn command_output(program: &str, args: &[&str]) -> Result<String> {
964    let output = Command::new(program)
965        .args(args)
966        .output()
967        .with_context(|| format!("failed to run {program}"))?;
968
969    if output.status.success() {
970        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
971    } else {
972        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
973        if stderr.is_empty() {
974            Err(anyhow!("{program} exited with status {}", output.status))
975        } else {
976            Err(anyhow!("{program} failed: {stderr}"))
977        }
978    }
979}
980
981fn parse_github_review(output: &str) -> Result<Option<ReviewRequest>> {
982    let Some(review) = first_json_item(output)? else {
983        return Ok(None);
984    };
985
986    Ok(Some(ReviewRequest {
987        id: format!("#{}", required_string(&review, &["number"])?),
988        branch: required_string(&review, &["headRefName"])?,
989        base: required_string(&review, &["baseRefName"])?,
990        state: parse_state(&required_string(&review, &["state"])?),
991        url: required_string(&review, &["url"])?,
992        title: optional_string(&review, "title"),
993    }))
994}
995
996fn parse_gitlab_review(output: &str) -> Result<Option<ReviewRequest>> {
997    let Some(review) = first_json_item(output)? else {
998        return Ok(None);
999    };
1000
1001    Ok(Some(ReviewRequest {
1002        id: format!("!{}", required_string(&review, &["iid", "id"])?),
1003        branch: required_string(&review, &["source_branch", "sourceBranch"])?,
1004        base: required_string(&review, &["target_branch", "targetBranch"])?,
1005        state: parse_state(&required_string(&review, &["state"])?),
1006        url: required_string(&review, &["web_url", "webUrl", "url"])?,
1007        title: optional_string(&review, "title"),
1008    }))
1009}
1010
1011fn optional_string(value: &Value, key: &str) -> String {
1012    value
1013        .get(key)
1014        .and_then(Value::as_str)
1015        .unwrap_or_default()
1016        .to_owned()
1017}
1018
1019fn first_json_item(output: &str) -> Result<Option<Value>> {
1020    let value: Value = serde_json::from_str(output).context("failed to parse provider JSON")?;
1021    match value {
1022        Value::Array(items) => Ok(items.into_iter().next()),
1023        Value::Object(_) => Ok(Some(value)),
1024        _ => bail!("provider JSON must be an object or array"),
1025    }
1026}
1027
1028fn required_string(value: &Value, keys: &[&str]) -> Result<String> {
1029    for key in keys {
1030        if let Some(field) = value.get(*key) {
1031            if let Some(value) = field.as_str() {
1032                return Ok(value.to_owned());
1033            }
1034            if let Some(value) = field.as_i64() {
1035                return Ok(value.to_string());
1036            }
1037            if let Some(value) = field.as_u64() {
1038                return Ok(value.to_string());
1039            }
1040        }
1041    }
1042
1043    bail!(
1044        "provider JSON missing required field: {}",
1045        keys.join(" or ")
1046    )
1047}
1048
1049fn parse_state(state: &str) -> ReviewState {
1050    match state.to_ascii_lowercase().as_str() {
1051        "open" | "opened" => ReviewState::Open,
1052        "merged" => ReviewState::Merged,
1053        "closed" => ReviewState::Closed,
1054        _ => ReviewState::Unknown(state.to_owned()),
1055    }
1056}
1057
1058fn parent_key(branch: &str) -> String {
1059    format!("branch.{branch}.stkParent")
1060}
1061
1062impl fmt::Display for ReviewState {
1063    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1064        match self {
1065            Self::Open => write!(formatter, "open"),
1066            Self::Merged => write!(formatter, "merged"),
1067            Self::Closed => write!(formatter, "closed"),
1068            Self::Unknown(state) => write!(formatter, "{state}"),
1069        }
1070    }
1071}
1072
1073impl ReviewRequest {
1074    fn id_value(&self) -> &str {
1075        self.id
1076            .strip_prefix('#')
1077            .or_else(|| self.id.strip_prefix('!'))
1078            .unwrap_or(&self.id)
1079    }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085
1086    #[test]
1087    fn parse_github_review_reads_first_array_item() {
1088        let review = parse_github_review(
1089            r#"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}]"#,
1090        )
1091        .expect("parse review")
1092        .expect("review exists");
1093
1094        assert_eq!(
1095            review,
1096            ReviewRequest {
1097                id: "#12".to_owned(),
1098                branch: "feature/a".to_owned(),
1099                base: "main".to_owned(),
1100                state: ReviewState::Open,
1101                url: "https://github.com/owner/repo/pull/12".to_owned(),
1102                title: String::new(),
1103            }
1104        );
1105    }
1106
1107    #[test]
1108    fn parse_gitlab_review_reads_snake_case_fields() {
1109        let review = parse_gitlab_review(
1110            r#"[{"iid":34,"state":"merged","target_branch":"feature/a","source_branch":"feature/b","web_url":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
1111        )
1112        .expect("parse review")
1113        .expect("review exists");
1114
1115        assert_eq!(
1116            review,
1117            ReviewRequest {
1118                id: "!34".to_owned(),
1119                branch: "feature/b".to_owned(),
1120                base: "feature/a".to_owned(),
1121                state: ReviewState::Merged,
1122                url: "https://gitlab.com/owner/repo/-/merge_requests/34".to_owned(),
1123                title: String::new(),
1124            }
1125        );
1126    }
1127
1128    #[test]
1129    fn parse_gitlab_review_reads_camel_case_fields() {
1130        let review = parse_gitlab_review(
1131            r#"[{"id":34,"state":"closed","targetBranch":"feature/a","sourceBranch":"feature/b","webUrl":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
1132        )
1133        .expect("parse review")
1134        .expect("review exists");
1135
1136        assert_eq!(review.id, "!34");
1137        assert_eq!(review.branch, "feature/b");
1138        assert_eq!(review.base, "feature/a");
1139        assert_eq!(review.state, ReviewState::Closed);
1140        assert_eq!(
1141            review.url,
1142            "https://gitlab.com/owner/repo/-/merge_requests/34"
1143        );
1144    }
1145
1146    #[test]
1147    fn parse_review_accepts_object_output() {
1148        let review = parse_github_review(
1149            r#"{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}"#,
1150        )
1151        .expect("parse review")
1152        .expect("review exists");
1153
1154        assert_eq!(review.id, "#12");
1155    }
1156
1157    #[test]
1158    fn parse_review_empty_array_returns_none() {
1159        assert_eq!(parse_github_review("[]").expect("parse review"), None);
1160        assert_eq!(parse_gitlab_review("[]").expect("parse review"), None);
1161    }
1162
1163    #[test]
1164    fn parse_review_errors_on_missing_required_field() {
1165        let error = parse_github_review(
1166            r#"[{"number":12,"state":"OPEN","baseRefName":"main","url":"https://github.com/owner/repo/pull/12"}]"#,
1167        )
1168        .expect_err("missing head branch should fail");
1169
1170        assert!(
1171            error
1172                .to_string()
1173                .contains("provider JSON missing required field: headRefName"),
1174            "unexpected error: {error:#}"
1175        );
1176    }
1177
1178    #[test]
1179    fn parse_review_preserves_unknown_state() {
1180        let review = parse_github_review(
1181            r#"[{"number":12,"state":"READY_FOR_REVIEW","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}]"#,
1182        )
1183        .expect("parse review")
1184        .expect("review exists");
1185
1186        assert_eq!(
1187            review.state,
1188            ReviewState::Unknown("READY_FOR_REVIEW".to_owned())
1189        );
1190    }
1191
1192    fn review(id: &str, title: &str, url: &str) -> ReviewRequest {
1193        ReviewRequest {
1194            id: id.to_owned(),
1195            branch: String::new(),
1196            base: String::new(),
1197            state: ReviewState::Open,
1198            url: url.to_owned(),
1199            title: title.to_owned(),
1200        }
1201    }
1202
1203    #[test]
1204    fn build_stack_note_lists_stack_leaf_first_with_pointer_and_trunk() {
1205        let entries = vec![
1206            review("#12", "Bottom change", "https://example.com/12"),
1207            review("#13", "Top change", "https://example.com/13"),
1208        ];
1209
1210        let note = build_stack_note(&entries, 0, "main");
1211        assert_eq!(
1212            note,
1213            "- [Top change (#13)](https://example.com/13)\n\
1214             - [Bottom change (#12)](https://example.com/12) \u{1F448}\n\
1215             - `main`\n\n\
1216             ---\n\n\
1217             Stack managed by [git-stk](https://github.com/lararosekelley/git-stk)"
1218        );
1219    }
1220
1221    #[test]
1222    fn build_stack_note_falls_back_to_id_without_title() {
1223        let entries = vec![review("#12", "", "https://example.com/12")];
1224        let note = build_stack_note(&entries, 0, "main");
1225        assert!(note.contains("- [#12](https://example.com/12) \u{1F448}"));
1226    }
1227
1228    #[test]
1229    fn body_with_stack_note_appends_to_existing_body() {
1230        let updated = body_with_stack_note("Some PR description.\n", "stack list");
1231        assert_eq!(
1232            updated,
1233            "Some PR description.\n\n<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
1234        );
1235    }
1236
1237    #[test]
1238    fn body_with_stack_note_fills_empty_body() {
1239        let updated = body_with_stack_note("", "stack list");
1240        assert_eq!(
1241            updated,
1242            "<!-- git-stk:stack -->\nstack list\n<!-- /git-stk:stack -->"
1243        );
1244    }
1245
1246    #[test]
1247    fn body_with_stack_note_replaces_existing_note() {
1248        let body = "Intro.\n\n<!-- git-stk:stack -->\nold list\n<!-- /git-stk:stack -->\n\nOutro.";
1249        let updated = body_with_stack_note(body, "new list");
1250        assert_eq!(
1251            updated,
1252            "Intro.\n\nOutro.\n\n<!-- git-stk:stack -->\nnew list\n<!-- /git-stk:stack -->"
1253        );
1254    }
1255
1256    #[test]
1257    fn body_with_stack_note_is_idempotent() {
1258        let body = body_with_stack_note("Description.", "stack list");
1259        assert_eq!(body_with_stack_note(&body, "stack list"), body);
1260    }
1261
1262    #[test]
1263    fn body_with_stack_note_repairs_orphaned_start_marker() {
1264        let body = "Intro.\n\n<!-- git-stk:stack -->\nleftover text";
1265        let updated = body_with_stack_note(body, "fresh list");
1266        assert_eq!(
1267            updated,
1268            "Intro.\n\nleftover text\n\n<!-- git-stk:stack -->\nfresh list\n<!-- /git-stk:stack -->"
1269        );
1270    }
1271
1272    #[test]
1273    fn body_with_stack_note_repairs_orphaned_end_marker() {
1274        let body = "Intro.\nstray\n<!-- /git-stk:stack -->\nOutro.";
1275        let updated = body_with_stack_note(body, "fresh list");
1276        assert!(updated.matches("<!-- git-stk:stack -->").count() == 1);
1277        assert!(updated.matches("<!-- /git-stk:stack -->").count() == 1);
1278        assert!(updated.contains("Intro.\nstray"));
1279        assert!(updated.ends_with("<!-- /git-stk:stack -->"));
1280    }
1281
1282    #[test]
1283    fn body_with_stack_note_repairs_reversed_and_duplicate_markers() {
1284        let body = "<!-- /git-stk:stack -->\nA\n<!-- git-stk:stack -->\nB\n\
1285                    <!-- git-stk:stack -->\nC\n<!-- /git-stk:stack -->\nD";
1286        let updated = body_with_stack_note(body, "fresh list");
1287        assert_eq!(updated.matches("<!-- git-stk:stack -->").count(), 1);
1288        assert_eq!(updated.matches("<!-- /git-stk:stack -->").count(), 1);
1289        assert!(updated.contains("fresh list"));
1290        assert!(updated.ends_with("<!-- /git-stk:stack -->"));
1291    }
1292
1293    #[test]
1294    fn parse_body_field_reads_field_and_defaults_empty() {
1295        assert_eq!(
1296            parse_body_field(r#"{"body":"hello"}"#, "body").expect("parse body"),
1297            "hello"
1298        );
1299        assert_eq!(
1300            parse_body_field(r#"{"description":null}"#, "description").expect("parse body"),
1301            ""
1302        );
1303        assert_eq!(parse_body_field(r#"{}"#, "body").expect("parse body"), "");
1304    }
1305}