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    fn review_body(&self, review: &ReviewRequest) -> Result<String>;
83
84    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String>;
85}
86
87struct GitHubProvider;
88
89struct GitLabProvider;
90
91impl ReviewProvider for GitHubProvider {
92    fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>> {
93        let output = command_output(
94            "gh",
95            &[
96                "pr",
97                "list",
98                "--head",
99                branch,
100                "--json",
101                "number,state,baseRefName,headRefName,url",
102            ],
103        )?;
104        if let Some(review) = parse_github_review(&output)? {
105            return Ok(Some(review));
106        }
107
108        // gh pr list only returns open pull requests by default; check merged
109        // ones too so cleanup can see landed reviews.
110        let output = command_output(
111            "gh",
112            &[
113                "pr",
114                "list",
115                "--head",
116                branch,
117                "--state",
118                "merged",
119                "--json",
120                "number,state,baseRefName,headRefName,url",
121            ],
122        )?;
123        parse_github_review(&output)
124    }
125
126    fn create_review(&self, branch: &str, base: &str) -> Result<String> {
127        command_output(
128            "gh",
129            &["pr", "create", "--head", branch, "--base", base, "--fill"],
130        )
131    }
132
133    fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String> {
134        command_output("gh", &["pr", "edit", review.id_value(), "--base", base])
135    }
136
137    fn review_body(&self, review: &ReviewRequest) -> Result<String> {
138        let output = command_output("gh", &["pr", "view", review.id_value(), "--json", "body"])?;
139        parse_body_field(&output, "body")
140    }
141
142    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String> {
143        command_output("gh", &["pr", "edit", review.id_value(), "--body", body])
144    }
145}
146
147impl ReviewProvider for GitLabProvider {
148    fn review_for_branch(&self, branch: &str) -> Result<Option<ReviewRequest>> {
149        let output = command_output(
150            "glab",
151            &["mr", "list", "--source-branch", branch, "--output", "json"],
152        )?;
153        if let Some(review) = parse_gitlab_review(&output)? {
154            return Ok(Some(review));
155        }
156
157        // glab mr list only returns open merge requests by default; check
158        // merged ones too so cleanup can see landed reviews.
159        let output = command_output(
160            "glab",
161            &[
162                "mr",
163                "list",
164                "--source-branch",
165                branch,
166                "--merged",
167                "--output",
168                "json",
169            ],
170        )?;
171        parse_gitlab_review(&output)
172    }
173
174    fn create_review(&self, branch: &str, base: &str) -> Result<String> {
175        command_output(
176            "glab",
177            &[
178                "mr",
179                "create",
180                "--source-branch",
181                branch,
182                "--target-branch",
183                base,
184                "--fill",
185            ],
186        )
187    }
188
189    fn update_review_base(&self, review: &ReviewRequest, base: &str) -> Result<String> {
190        command_output(
191            "glab",
192            &["mr", "update", review.id_value(), "--target-branch", base],
193        )
194    }
195
196    fn review_body(&self, review: &ReviewRequest) -> Result<String> {
197        let output = command_output(
198            "glab",
199            &["mr", "view", review.id_value(), "--output", "json"],
200        )?;
201        parse_body_field(&output, "description")
202    }
203
204    fn update_review_body(&self, review: &ReviewRequest, body: &str) -> Result<String> {
205        command_output(
206            "glab",
207            &["mr", "update", review.id_value(), "--description", body],
208        )
209    }
210}
211
212fn parse_body_field(output: &str, field: &str) -> Result<String> {
213    let value: serde_json::Value =
214        serde_json::from_str(output).context("failed to parse provider JSON")?;
215    Ok(value
216        .get(field)
217        .and_then(serde_json::Value::as_str)
218        .unwrap_or_default()
219        .to_owned())
220}
221
222pub fn print_provider() -> Result<()> {
223    let provider = detect_provider()?;
224    println!("{} ({})", provider.kind, provider.source);
225    Ok(())
226}
227
228pub fn print_review(branch: Option<&str>) -> Result<()> {
229    let branch = branch
230        .map(str::to_owned)
231        .map_or_else(git::current_branch, Ok)?;
232    let provider = detect_provider()?;
233    let review_provider = review_provider(provider.kind);
234
235    let Some(review) = review_provider.review_for_branch(&branch)? else {
236        bail!("no {} review found for {branch}", provider.kind);
237    };
238
239    println!(
240        "{} {} -> {} {} {}",
241        review.id, review.branch, review.base, review.state, review.url
242    );
243    Ok(())
244}
245
246pub fn print_status(branch: Option<&str>) -> Result<()> {
247    let branch = branch
248        .map(str::to_owned)
249        .map_or_else(git::current_branch, Ok)?;
250    let parent = stack::parent_for_branch(&branch)?;
251    let children = stack::children_for_branch(&branch)?;
252
253    println!("branch: {branch}");
254    match parent.as_deref() {
255        Some(parent) => println!("parent: {parent}"),
256        None => println!("parent: none"),
257    }
258    if children.is_empty() {
259        println!("children: none");
260    } else {
261        println!("children: {}", children.join(", "));
262    }
263
264    let provider = detect_provider()?;
265    println!("provider: {} ({})", provider.kind, provider.source);
266    let review_provider = review_provider(provider.kind);
267
268    let Some(review) = review_provider.review_for_branch(&branch)? else {
269        println!("review: none");
270        return Ok(());
271    };
272
273    println!(
274        "review: {} {} {} -> {}",
275        review.id, review.state, review.branch, review.base
276    );
277    println!("url: {}", review.url);
278
279    if let Some(parent) = parent
280        && parent != review.base
281    {
282        println!(
283            "warning: review base is {}, local parent is {}",
284            review.base, parent
285        );
286    }
287
288    Ok(())
289}
290
291pub fn sync_stack(branch: Option<&str>, dry_run: bool) -> Result<()> {
292    let branches = match branch {
293        Some(branch) => vec![branch.to_owned()],
294        None => git::local_branches()?,
295    };
296
297    let provider = detect_provider()?;
298    let review_provider = review_provider(provider.kind);
299    let mut synced = 0;
300    let mut skipped = 0;
301
302    for branch in branches {
303        let Some(review) = review_provider.review_for_branch(&branch)? else {
304            println!("skipped {branch}: no {} review found", provider.kind);
305            skipped += 1;
306            continue;
307        };
308
309        if review.branch != branch {
310            println!(
311                "skipped {branch}: {} review belongs to {}",
312                provider.kind, review.branch
313            );
314            skipped += 1;
315            continue;
316        }
317
318        if review.branch == review.base {
319            bail!("refusing to set {branch} as its own stack parent");
320        }
321
322        if !dry_run {
323            git::config_set(&parent_key(&branch), &review.base)?;
324            stack::record_base(&branch, &review.base);
325        }
326        println!(
327            "{} {} -> {} ({})",
328            if dry_run { "would sync" } else { "synced" },
329            review.branch,
330            review.base,
331            review.id
332        );
333        synced += 1;
334    }
335
336    println!(
337        "sync complete: {synced} {}synced, {skipped} skipped",
338        if dry_run { "would be " } else { "" }
339    );
340    Ok(())
341}
342
343pub fn submit(branch: Option<&str>, submit_stack: bool, dry_run: bool) -> Result<()> {
344    let branch = branch
345        .map(str::to_owned)
346        .map_or_else(git::current_branch, Ok)?;
347
348    let branches = if submit_stack {
349        stack::branch_and_descendants(&branch)?
350    } else {
351        vec![branch]
352    };
353
354    let branch_parents = branch_parents(&branches)?;
355
356    let provider = detect_provider()?;
357    let review_provider = review_provider(provider.kind);
358    let mut summary = SubmitSummary::default();
359
360    for (branch, parent) in branch_parents {
361        summary.record(submit_branch(
362            review_provider.as_ref(),
363            &branch,
364            &parent,
365            dry_run,
366        )?);
367
368        if submit_stack {
369            ensure_stack_note(review_provider.as_ref(), &branch, &parent, dry_run)?;
370        }
371    }
372
373    println!(
374        "submit complete: {} created, {} updated, {} skipped",
375        summary.created, summary.updated, summary.skipped
376    );
377    Ok(())
378}
379
380const STACK_NOTE_START: &str = "<!-- git-stk:stack -->";
381const STACK_NOTE_END: &str = "<!-- /git-stk:stack -->";
382
383/// Maintain a "Depends on" line in the review body so reviewers can see the
384/// stack relationship. The line lives between marker comments, so resubmits
385/// update it in place instead of appending duplicates.
386fn ensure_stack_note(
387    review_provider: &dyn ReviewProvider,
388    branch: &str,
389    parent: &str,
390    dry_run: bool,
391) -> Result<()> {
392    // The bottom of a stack targets a branch without a review (e.g. main);
393    // nothing to link in that case.
394    let Some(parent_review) = review_provider.review_for_branch(parent)? else {
395        return Ok(());
396    };
397    let Some(review) = review_provider.review_for_branch(branch)? else {
398        return Ok(());
399    };
400
401    let note = format!("Depends on {}", parent_review.id);
402
403    if dry_run {
404        println!("would note '{note}' in {}", review.id);
405        return Ok(());
406    }
407
408    let body = review_provider.review_body(&review)?;
409    let updated = body_with_stack_note(&body, &note);
410    if updated == body {
411        return Ok(());
412    }
413
414    review_provider.update_review_body(&review, &updated)?;
415    println!("noted '{note}' in {}", review.id);
416    Ok(())
417}
418
419/// Insert or replace the marker-delimited stack note in a review body.
420fn body_with_stack_note(body: &str, note: &str) -> String {
421    let section = format!("{STACK_NOTE_START}\n{note}\n{STACK_NOTE_END}");
422
423    if let (Some(start), Some(end)) = (body.find(STACK_NOTE_START), body.find(STACK_NOTE_END))
424        && start < end
425    {
426        let mut updated = String::new();
427        updated.push_str(&body[..start]);
428        updated.push_str(&section);
429        updated.push_str(&body[end + STACK_NOTE_END.len()..]);
430        return updated;
431    }
432
433    if body.trim().is_empty() {
434        section
435    } else {
436        format!("{}\n\n{section}", body.trim_end())
437    }
438}
439
440pub fn cleanup(branch: Option<&str>, dry_run: bool, delete_branch: bool) -> Result<()> {
441    let branch = branch
442        .map(str::to_owned)
443        .map_or_else(git::current_branch, Ok)?;
444    let branches = stack::branch_and_descendants(&branch)?;
445    let current_branch = git::current_branch()?;
446    let provider = detect_provider()?;
447    let review_provider = review_provider(provider.kind);
448    let mut cleaned = 0;
449    let mut skipped = 0;
450
451    for branch in branches {
452        let Some(review) = review_provider.review_for_branch(&branch)? else {
453            println!("skipped {branch}: no {} review found", provider.kind);
454            skipped += 1;
455            continue;
456        };
457
458        if review.state != ReviewState::Merged {
459            println!("skipped {branch}: review {} is {}", review.id, review.state);
460            skipped += 1;
461            continue;
462        }
463
464        cleanup_merged_branch(review_provider.as_ref(), &branch, dry_run)?;
465        cleanup_branch_deletion(&branch, &current_branch, dry_run, delete_branch)?;
466        cleaned += 1;
467    }
468
469    println!("cleanup complete: {cleaned} cleaned, {skipped} skipped");
470    Ok(())
471}
472
473fn cleanup_merged_branch(
474    review_provider: &dyn ReviewProvider,
475    branch: &str,
476    dry_run: bool,
477) -> Result<()> {
478    let parent = stack::parent_for_branch(branch)?;
479    let descendants = stack::branch_and_descendants(branch)?;
480    let direct_children: Vec<_> = descendants
481        .into_iter()
482        .skip(1)
483        .filter_map(|child| match stack::parent_for_branch(&child) {
484            Ok(Some(child_parent)) if child_parent == branch => Some(Ok(child)),
485            Ok(_) => None,
486            Err(error) => Some(Err(error)),
487        })
488        .collect::<Result<_>>()?;
489
490    for child in direct_children {
491        match parent.as_deref() {
492            Some(parent) => {
493                println!(
494                    "{} retarget {child} -> {parent}",
495                    if dry_run { "would" } else { "will" }
496                );
497                update_child_review_base(review_provider, &child, parent, dry_run)?;
498                if !dry_run {
499                    // Record the fork point off the merged branch before
500                    // retargeting, so the next restack replays only the
501                    // child's own commits even after a squash merge.
502                    if let Ok(base) = git::merge_base(branch, &child) {
503                        stack::set_base_for_branch(&child, &base)?;
504                    }
505                    stack::set_parent_for_branch(&child, parent)?;
506                }
507            }
508            None => {
509                println!("{} detach {child}", if dry_run { "would" } else { "will" });
510                if !dry_run {
511                    stack::unset_parent_for_branch(&child)?;
512                    stack::unset_base_for_branch(&child)?;
513                }
514            }
515        }
516    }
517
518    println!("{} detach {branch}", if dry_run { "would" } else { "will" });
519    if !dry_run {
520        stack::unset_parent_for_branch(branch)?;
521        stack::unset_base_for_branch(branch)?;
522    }
523
524    Ok(())
525}
526
527fn cleanup_branch_deletion(
528    branch: &str,
529    current_branch: &str,
530    dry_run: bool,
531    delete_branch: bool,
532) -> Result<()> {
533    if !delete_branch {
534        return Ok(());
535    }
536
537    if branch == current_branch {
538        bail!("refusing to delete currently checked out branch {branch}");
539    }
540
541    println!(
542        "{} delete branch {branch}",
543        if dry_run { "would" } else { "will" }
544    );
545    if !dry_run {
546        git::delete_branch(branch)?;
547    }
548
549    Ok(())
550}
551
552fn update_child_review_base(
553    review_provider: &dyn ReviewProvider,
554    child: &str,
555    parent: &str,
556    dry_run: bool,
557) -> Result<()> {
558    let Some(review) = review_provider.review_for_branch(child)? else {
559        return Ok(());
560    };
561
562    if review.state == ReviewState::Merged || review.base == parent {
563        return Ok(());
564    }
565
566    println!(
567        "{} update review {} -> {} ({})",
568        if dry_run { "would" } else { "will" },
569        review.branch,
570        parent,
571        review.id
572    );
573    if !dry_run {
574        let output = review_provider.update_review_base(&review, parent)?;
575        if !output.is_empty() {
576            println!("{output}");
577        }
578    }
579
580    Ok(())
581}
582
583fn branch_parents(branches: &[String]) -> Result<Vec<(String, String)>> {
584    let mut branch_parents = Vec::new();
585    for branch in branches {
586        let Some(parent) = stack::parent_for_branch(branch)? else {
587            bail!("{branch} has no stack parent; run `git stk adopt` or `git stk sync` first");
588        };
589        branch_parents.push((branch.to_owned(), parent));
590    }
591    Ok(branch_parents)
592}
593
594fn submit_branch(
595    review_provider: &dyn ReviewProvider,
596    branch: &str,
597    parent: &str,
598    dry_run: bool,
599) -> Result<SubmitAction> {
600    if let Some(review) = review_provider.review_for_branch(branch)? {
601        if review.base == parent {
602            if dry_run {
603                println!(
604                    "would skip {} -> {} ({})",
605                    review.branch, review.base, review.id
606                );
607            } else {
608                println!(
609                    "{} already targets {} ({})",
610                    review.branch, review.base, review.id
611                );
612            }
613            return Ok(SubmitAction::Skipped);
614        }
615
616        let output = if dry_run {
617            String::new()
618        } else {
619            review_provider.update_review_base(&review, parent)?
620        };
621        println!(
622            "{} {} -> {} ({})",
623            if dry_run { "would update" } else { "updated" },
624            review.branch,
625            parent,
626            review.id
627        );
628        if !output.is_empty() {
629            println!("{output}");
630        }
631    } else {
632        let output = if dry_run {
633            String::new()
634        } else {
635            review_provider.create_review(branch, parent)?
636        };
637        println!(
638            "{} {branch} -> {parent}",
639            if dry_run { "would create" } else { "created" }
640        );
641        if !output.is_empty() {
642            println!("{output}");
643        }
644        return Ok(SubmitAction::Created);
645    }
646
647    Ok(SubmitAction::Updated)
648}
649
650#[derive(Debug, Default)]
651struct SubmitSummary {
652    created: usize,
653    updated: usize,
654    skipped: usize,
655}
656
657impl SubmitSummary {
658    fn record(&mut self, action: SubmitAction) {
659        match action {
660            SubmitAction::Created => self.created += 1,
661            SubmitAction::Updated => self.updated += 1,
662            SubmitAction::Skipped => self.skipped += 1,
663        }
664    }
665}
666
667#[derive(Debug, Clone, Copy, Eq, PartialEq)]
668enum SubmitAction {
669    Created,
670    Updated,
671    Skipped,
672}
673
674pub fn detect_provider() -> Result<DetectedProvider> {
675    if let Some(value) = git::config_get(PROVIDER_KEY)? {
676        let Some(kind) = ProviderKind::parse(&value) else {
677            bail!("unsupported stack.provider value {value:?}; expected github or gitlab");
678        };
679
680        return Ok(DetectedProvider {
681            kind,
682            source: ProviderSource::Config,
683        });
684    }
685
686    let remote = git::config_get(REMOTE_KEY)?.unwrap_or_else(|| DEFAULT_REMOTE.to_owned());
687    let Some(url) = git::remote_url(&remote)? else {
688        bail!("could not detect provider: remote {remote:?} does not exist");
689    };
690
691    let Some(kind) = detect_provider_from_url(&url) else {
692        bail!("could not detect provider from remote {remote} ({url})");
693    };
694
695    Ok(DetectedProvider {
696        kind,
697        source: ProviderSource::Remote { remote, url },
698    })
699}
700
701fn detect_provider_from_url(url: &str) -> Option<ProviderKind> {
702    let normalized = url.to_ascii_lowercase();
703
704    if normalized.contains("github.com:") || normalized.contains("github.com/") {
705        Some(ProviderKind::GitHub)
706    } else if normalized.contains("gitlab.com:") || normalized.contains("gitlab.com/") {
707        Some(ProviderKind::GitLab)
708    } else {
709        None
710    }
711}
712
713fn review_provider(kind: ProviderKind) -> Box<dyn ReviewProvider> {
714    match kind {
715        ProviderKind::GitHub => Box::new(GitHubProvider),
716        ProviderKind::GitLab => Box::new(GitLabProvider),
717    }
718}
719
720fn command_output(program: &str, args: &[&str]) -> Result<String> {
721    let output = Command::new(program)
722        .args(args)
723        .output()
724        .with_context(|| format!("failed to run {program}"))?;
725
726    if output.status.success() {
727        Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
728    } else {
729        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
730        if stderr.is_empty() {
731            Err(anyhow!("{program} exited with status {}", output.status))
732        } else {
733            Err(anyhow!("{program} failed: {stderr}"))
734        }
735    }
736}
737
738fn parse_github_review(output: &str) -> Result<Option<ReviewRequest>> {
739    let Some(review) = first_json_item(output)? else {
740        return Ok(None);
741    };
742
743    Ok(Some(ReviewRequest {
744        id: format!("#{}", required_string(&review, &["number"])?),
745        branch: required_string(&review, &["headRefName"])?,
746        base: required_string(&review, &["baseRefName"])?,
747        state: parse_state(&required_string(&review, &["state"])?),
748        url: required_string(&review, &["url"])?,
749    }))
750}
751
752fn parse_gitlab_review(output: &str) -> Result<Option<ReviewRequest>> {
753    let Some(review) = first_json_item(output)? else {
754        return Ok(None);
755    };
756
757    Ok(Some(ReviewRequest {
758        id: format!("!{}", required_string(&review, &["iid", "id"])?),
759        branch: required_string(&review, &["source_branch", "sourceBranch"])?,
760        base: required_string(&review, &["target_branch", "targetBranch"])?,
761        state: parse_state(&required_string(&review, &["state"])?),
762        url: required_string(&review, &["web_url", "webUrl", "url"])?,
763    }))
764}
765
766fn first_json_item(output: &str) -> Result<Option<Value>> {
767    let value: Value = serde_json::from_str(output).context("failed to parse provider JSON")?;
768    match value {
769        Value::Array(items) => Ok(items.into_iter().next()),
770        Value::Object(_) => Ok(Some(value)),
771        _ => bail!("provider JSON must be an object or array"),
772    }
773}
774
775fn required_string(value: &Value, keys: &[&str]) -> Result<String> {
776    for key in keys {
777        if let Some(field) = value.get(*key) {
778            if let Some(value) = field.as_str() {
779                return Ok(value.to_owned());
780            }
781            if let Some(value) = field.as_i64() {
782                return Ok(value.to_string());
783            }
784            if let Some(value) = field.as_u64() {
785                return Ok(value.to_string());
786            }
787        }
788    }
789
790    bail!(
791        "provider JSON missing required field: {}",
792        keys.join(" or ")
793    )
794}
795
796fn parse_state(state: &str) -> ReviewState {
797    match state.to_ascii_lowercase().as_str() {
798        "open" | "opened" => ReviewState::Open,
799        "merged" => ReviewState::Merged,
800        "closed" => ReviewState::Closed,
801        _ => ReviewState::Unknown(state.to_owned()),
802    }
803}
804
805fn parent_key(branch: &str) -> String {
806    format!("branch.{branch}.stackParent")
807}
808
809impl fmt::Display for ReviewState {
810    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
811        match self {
812            Self::Open => write!(formatter, "open"),
813            Self::Merged => write!(formatter, "merged"),
814            Self::Closed => write!(formatter, "closed"),
815            Self::Unknown(state) => write!(formatter, "{state}"),
816        }
817    }
818}
819
820impl ReviewRequest {
821    fn id_value(&self) -> &str {
822        self.id
823            .strip_prefix('#')
824            .or_else(|| self.id.strip_prefix('!'))
825            .unwrap_or(&self.id)
826    }
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832
833    #[test]
834    fn parse_github_review_reads_first_array_item() {
835        let review = parse_github_review(
836            r#"[{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}]"#,
837        )
838        .expect("parse review")
839        .expect("review exists");
840
841        assert_eq!(
842            review,
843            ReviewRequest {
844                id: "#12".to_owned(),
845                branch: "feature/a".to_owned(),
846                base: "main".to_owned(),
847                state: ReviewState::Open,
848                url: "https://github.com/owner/repo/pull/12".to_owned(),
849            }
850        );
851    }
852
853    #[test]
854    fn parse_gitlab_review_reads_snake_case_fields() {
855        let review = parse_gitlab_review(
856            r#"[{"iid":34,"state":"merged","target_branch":"feature/a","source_branch":"feature/b","web_url":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
857        )
858        .expect("parse review")
859        .expect("review exists");
860
861        assert_eq!(
862            review,
863            ReviewRequest {
864                id: "!34".to_owned(),
865                branch: "feature/b".to_owned(),
866                base: "feature/a".to_owned(),
867                state: ReviewState::Merged,
868                url: "https://gitlab.com/owner/repo/-/merge_requests/34".to_owned(),
869            }
870        );
871    }
872
873    #[test]
874    fn parse_gitlab_review_reads_camel_case_fields() {
875        let review = parse_gitlab_review(
876            r#"[{"id":34,"state":"closed","targetBranch":"feature/a","sourceBranch":"feature/b","webUrl":"https://gitlab.com/owner/repo/-/merge_requests/34"}]"#,
877        )
878        .expect("parse review")
879        .expect("review exists");
880
881        assert_eq!(review.id, "!34");
882        assert_eq!(review.branch, "feature/b");
883        assert_eq!(review.base, "feature/a");
884        assert_eq!(review.state, ReviewState::Closed);
885        assert_eq!(
886            review.url,
887            "https://gitlab.com/owner/repo/-/merge_requests/34"
888        );
889    }
890
891    #[test]
892    fn parse_review_accepts_object_output() {
893        let review = parse_github_review(
894            r#"{"number":12,"state":"OPEN","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}"#,
895        )
896        .expect("parse review")
897        .expect("review exists");
898
899        assert_eq!(review.id, "#12");
900    }
901
902    #[test]
903    fn parse_review_empty_array_returns_none() {
904        assert_eq!(parse_github_review("[]").expect("parse review"), None);
905        assert_eq!(parse_gitlab_review("[]").expect("parse review"), None);
906    }
907
908    #[test]
909    fn parse_review_errors_on_missing_required_field() {
910        let error = parse_github_review(
911            r#"[{"number":12,"state":"OPEN","baseRefName":"main","url":"https://github.com/owner/repo/pull/12"}]"#,
912        )
913        .expect_err("missing head branch should fail");
914
915        assert!(
916            error
917                .to_string()
918                .contains("provider JSON missing required field: headRefName"),
919            "unexpected error: {error:#}"
920        );
921    }
922
923    #[test]
924    fn parse_review_preserves_unknown_state() {
925        let review = parse_github_review(
926            r#"[{"number":12,"state":"READY_FOR_REVIEW","baseRefName":"main","headRefName":"feature/a","url":"https://github.com/owner/repo/pull/12"}]"#,
927        )
928        .expect("parse review")
929        .expect("review exists");
930
931        assert_eq!(
932            review.state,
933            ReviewState::Unknown("READY_FOR_REVIEW".to_owned())
934        );
935    }
936
937    #[test]
938    fn body_with_stack_note_appends_to_existing_body() {
939        let updated = body_with_stack_note("Some PR description.\n", "Depends on #12");
940        assert_eq!(
941            updated,
942            "Some PR description.\n\n<!-- git-stk:stack -->\nDepends on #12\n<!-- /git-stk:stack -->"
943        );
944    }
945
946    #[test]
947    fn body_with_stack_note_fills_empty_body() {
948        let updated = body_with_stack_note("", "Depends on !34");
949        assert_eq!(
950            updated,
951            "<!-- git-stk:stack -->\nDepends on !34\n<!-- /git-stk:stack -->"
952        );
953    }
954
955    #[test]
956    fn body_with_stack_note_replaces_existing_note() {
957        let body =
958            "Intro.\n\n<!-- git-stk:stack -->\nDepends on #12\n<!-- /git-stk:stack -->\n\nOutro.";
959        let updated = body_with_stack_note(body, "Depends on #99");
960        assert_eq!(
961            updated,
962            "Intro.\n\n<!-- git-stk:stack -->\nDepends on #99\n<!-- /git-stk:stack -->\n\nOutro."
963        );
964    }
965
966    #[test]
967    fn body_with_stack_note_is_idempotent() {
968        let body = body_with_stack_note("Description.", "Depends on #12");
969        assert_eq!(body_with_stack_note(&body, "Depends on #12"), body);
970    }
971
972    #[test]
973    fn parse_body_field_reads_field_and_defaults_empty() {
974        assert_eq!(
975            parse_body_field(r#"{"body":"hello"}"#, "body").expect("parse body"),
976            "hello"
977        );
978        assert_eq!(
979            parse_body_field(r#"{"description":null}"#, "description").expect("parse body"),
980            ""
981        );
982        assert_eq!(parse_body_field(r#"{}"#, "body").expect("parse body"), "");
983    }
984}