Skip to main content

ralph/git/
pr.rs

1//! GitHub PR helpers using the `gh` CLI.
2//!
3//! Responsibilities:
4//! - Create PRs for worker branches and return structured metadata.
5//! - Merge PRs using a chosen merge method.
6//! - Query PR mergeability state.
7//!
8//! Not handled here:
9//! - Task selection or worker execution (see `commands::run::parallel`).
10//! - Direct-push parallel integration logic (see `commands::run::parallel::integration`).
11//!
12//! Invariants/assumptions:
13//! - `gh` is installed and authenticated.
14//! - Repo root points to a GitHub-backed repository.
15
16use anyhow::{Context, Result, bail};
17
18/// Merge method for PRs.
19/// NOTE: This is a local copy since the config version was removed in the direct-push rewrite.
20/// This enum is kept for backward compatibility with existing PR operations.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22#[allow(dead_code)]
23pub(crate) enum MergeMethod {
24    #[default]
25    Squash,
26    Merge,
27    Rebase,
28}
29use serde::Deserialize;
30use std::path::Path;
31use std::process::Command;
32
33#[derive(Debug, Clone)]
34#[allow(dead_code)]
35pub(crate) struct PrInfo {
36    pub number: u32,
37    #[allow(dead_code)]
38    pub url: String,
39    #[allow(dead_code)]
40    pub head: String,
41    #[allow(dead_code)]
42    pub base: String,
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub(crate) enum MergeState {
47    Clean,
48    Dirty,
49    Other(String),
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub(crate) struct PrMergeStatus {
54    pub merge_state: MergeState,
55    pub is_draft: bool,
56}
57
58#[derive(Deserialize)]
59#[allow(dead_code)]
60struct PrViewJson {
61    #[serde(rename = "mergeStateStatus")]
62    merge_state_status: String,
63    number: Option<u32>,
64    url: Option<String>,
65    #[serde(rename = "headRefName")]
66    head: Option<String>,
67    #[serde(rename = "baseRefName")]
68    base: Option<String>,
69    #[serde(rename = "isDraft")]
70    is_draft: Option<bool>,
71    state: Option<String>,
72    #[serde(rename = "merged")]
73    is_merged: Option<bool>,
74    #[serde(rename = "mergedAt")]
75    merged_at: Option<String>,
76}
77
78#[derive(Deserialize)]
79#[allow(dead_code)]
80struct RepoViewNameWithOwnerJson {
81    #[serde(rename = "nameWithOwner")]
82    name_with_owner: String,
83}
84
85/// PR lifecycle states as returned by GitHub.
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub(crate) enum PrLifecycle {
88    Open,
89    Closed,
90    Merged,
91    Unknown(String),
92}
93
94/// PR lifecycle status including lifecycle and merged flag.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub(crate) struct PrLifecycleStatus {
97    pub lifecycle: PrLifecycle,
98    pub is_merged: bool,
99}
100
101#[allow(dead_code)]
102pub(crate) fn create_pr(
103    repo_root: &Path,
104    title: &str,
105    body: &str,
106    head: &str,
107    base: &str,
108    draft: bool,
109) -> Result<PrInfo> {
110    let safe_title = title.trim();
111    if safe_title.is_empty() {
112        bail!("PR title must be non-empty");
113    }
114
115    let body = if body.trim().is_empty() {
116        "Automated by Ralph.".to_string()
117    } else {
118        body.to_string()
119    };
120
121    let mut cmd = Command::new("gh");
122    cmd.current_dir(repo_root);
123    cmd.arg("pr")
124        .arg("create")
125        .arg("--title")
126        .arg(safe_title)
127        .arg("--body")
128        .arg(body)
129        .arg("--head")
130        .arg(head)
131        .arg("--base")
132        .arg(base);
133    if draft {
134        cmd.arg("--draft");
135    }
136
137    let output = cmd
138        .output()
139        .with_context(|| format!("run gh pr create in {}", repo_root.display()))?;
140
141    if !output.status.success() {
142        let stderr = String::from_utf8_lossy(&output.stderr);
143        bail!("gh pr create failed: {}", stderr.trim());
144    }
145
146    let stdout = String::from_utf8_lossy(&output.stdout);
147    let pr_url = extract_pr_url(&stdout).ok_or_else(|| {
148        anyhow::anyhow!(
149            "Unable to parse PR URL from gh output. Output: {}",
150            stdout.trim()
151        )
152    })?;
153
154    pr_view(repo_root, &pr_url)
155}
156
157#[allow(dead_code)]
158pub(crate) fn merge_pr(
159    repo_root: &Path,
160    pr_number: u32,
161    method: MergeMethod,
162    delete_branch: bool,
163) -> Result<()> {
164    let repo_name_with_owner = gh_repo_name_with_owner(repo_root)?;
165
166    let mut cmd = Command::new("gh");
167    // Use an isolated cwd plus explicit --repo to prevent gh from mutating the
168    // coordinator working tree during merge operations.
169    cmd.current_dir(std::env::temp_dir());
170    cmd.arg("pr")
171        .arg("merge")
172        .arg(pr_number.to_string())
173        .arg("--repo")
174        .arg(&repo_name_with_owner)
175        .arg(merge_method_flag(method));
176
177    if delete_branch {
178        cmd.arg("--delete-branch");
179    }
180
181    let output = cmd.output().with_context(|| {
182        format!(
183            "run gh pr merge --repo {} in isolated cwd",
184            repo_name_with_owner
185        )
186    })?;
187
188    if !output.status.success() {
189        let stderr = String::from_utf8_lossy(&output.stderr);
190        bail!("gh pr merge failed: {}", stderr.trim());
191    }
192
193    Ok(())
194}
195
196#[allow(dead_code)]
197fn merge_method_flag(method: MergeMethod) -> &'static str {
198    match method {
199        MergeMethod::Squash => "--squash",
200        MergeMethod::Merge => "--merge",
201        MergeMethod::Rebase => "--rebase",
202    }
203}
204
205#[allow(dead_code)]
206fn gh_repo_name_with_owner(repo_root: &Path) -> Result<String> {
207    let output = Command::new("gh")
208        .current_dir(repo_root)
209        .arg("repo")
210        .arg("view")
211        .arg("--json")
212        .arg("nameWithOwner")
213        .output()
214        .with_context(|| format!("run gh repo view in {}", repo_root.display()))?;
215
216    if !output.status.success() {
217        let stderr = String::from_utf8_lossy(&output.stderr);
218        bail!("gh repo view failed: {}", stderr.trim());
219    }
220
221    parse_name_with_owner_from_repo_view_json(&output.stdout)
222}
223
224#[allow(dead_code)]
225fn parse_name_with_owner_from_repo_view_json(payload: &[u8]) -> Result<String> {
226    let repo: RepoViewNameWithOwnerJson =
227        serde_json::from_slice(payload).context("parse gh repo view json")?;
228    let trimmed = repo.name_with_owner.trim();
229    if trimmed.is_empty() {
230        bail!("gh repo view returned empty nameWithOwner");
231    }
232    Ok(trimmed.to_string())
233}
234
235#[allow(dead_code)]
236pub(crate) fn pr_merge_status(repo_root: &Path, pr_number: u32) -> Result<PrMergeStatus> {
237    let json = pr_view_json(repo_root, &pr_number.to_string())?;
238    Ok(pr_merge_status_from_view(&json))
239}
240
241/// Query PR lifecycle status from GitHub.
242#[allow(dead_code)]
243pub(crate) fn pr_lifecycle_status(repo_root: &Path, pr_number: u32) -> Result<PrLifecycleStatus> {
244    let json = pr_view_json(repo_root, &pr_number.to_string())?;
245    Ok(pr_lifecycle_status_from_view(&json))
246}
247
248#[allow(dead_code)]
249fn pr_lifecycle_status_from_view(json: &PrViewJson) -> PrLifecycleStatus {
250    let state = json.state.as_deref().unwrap_or("UNKNOWN");
251    let merged_flag = json.is_merged.unwrap_or(false) || json.merged_at.as_ref().is_some();
252
253    let lifecycle = match state {
254        "OPEN" => PrLifecycle::Open,
255        "CLOSED" => {
256            if merged_flag {
257                PrLifecycle::Merged
258            } else {
259                PrLifecycle::Closed
260            }
261        }
262        "MERGED" => PrLifecycle::Merged,
263        other => PrLifecycle::Unknown(other.to_string()),
264    };
265
266    let is_merged_final = merged_flag || matches!(lifecycle, PrLifecycle::Merged);
267
268    PrLifecycleStatus {
269        lifecycle,
270        is_merged: is_merged_final,
271    }
272}
273
274#[allow(dead_code)]
275fn pr_view(repo_root: &Path, selector: &str) -> Result<PrInfo> {
276    let json = pr_view_json(repo_root, selector)?;
277    let number = json
278        .number
279        .ok_or_else(|| anyhow::anyhow!("Missing PR number in gh response"))?;
280    let url = json
281        .url
282        .ok_or_else(|| anyhow::anyhow!("Missing PR url in gh response"))?;
283    let head = json
284        .head
285        .ok_or_else(|| anyhow::anyhow!("Missing PR head in gh response"))?;
286    let base = json
287        .base
288        .ok_or_else(|| anyhow::anyhow!("Missing PR base in gh response"))?;
289
290    Ok(PrInfo {
291        number,
292        url,
293        head,
294        base,
295    })
296}
297
298#[allow(dead_code)]
299fn pr_view_json(repo_root: &Path, selector: &str) -> Result<PrViewJson> {
300    let primary_fields = "mergeStateStatus,number,url,headRefName,baseRefName,isDraft,state,merged";
301    match run_gh_pr_view(repo_root, selector, primary_fields) {
302        Ok(json) => Ok(json),
303        Err(err) => {
304            let err_msg = err.to_string();
305            if err_msg.contains("Unknown JSON field: \"merged\"") {
306                let fallback_fields =
307                    "mergeStateStatus,number,url,headRefName,baseRefName,isDraft,state,mergedAt";
308                return run_gh_pr_view(repo_root, selector, fallback_fields).with_context(|| {
309                    "gh pr view failed after falling back to mergedAt field".to_string()
310                });
311            }
312            Err(err)
313        }
314    }
315}
316
317#[allow(dead_code)]
318fn run_gh_pr_view(repo_root: &Path, selector: &str, fields: &str) -> Result<PrViewJson> {
319    let output = Command::new("gh")
320        .current_dir(repo_root)
321        .arg("pr")
322        .arg("view")
323        .arg(selector)
324        .arg("--json")
325        .arg(fields)
326        .output()
327        .with_context(|| format!("run gh pr view in {}", repo_root.display()))?;
328
329    if !output.status.success() {
330        let stderr = String::from_utf8_lossy(&output.stderr);
331        bail!("gh pr view failed: {}", stderr.trim());
332    }
333
334    let json: PrViewJson =
335        serde_json::from_slice(&output.stdout).context("parse gh pr view json")?;
336    Ok(json)
337}
338
339#[allow(dead_code)]
340fn pr_merge_status_from_view(json: &PrViewJson) -> PrMergeStatus {
341    let merge_state = match json.merge_state_status.as_str() {
342        "CLEAN" => MergeState::Clean,
343        "DIRTY" => MergeState::Dirty,
344        other => MergeState::Other(other.to_string()),
345    };
346    PrMergeStatus {
347        merge_state,
348        is_draft: json.is_draft.unwrap_or(false),
349    }
350}
351
352#[allow(dead_code)]
353fn extract_pr_url(output: &str) -> Option<String> {
354    output
355        .lines()
356        .map(str::trim)
357        .find(|line| line.starts_with("http://") || line.starts_with("https://"))
358        .map(|line| line.to_string())
359}
360
361/// Run a gh command with GH_NO_UPDATE_NOTIFIER set to avoid noisy updater prompts.
362fn run_gh_with_no_update(args: &[&str]) -> Result<std::process::Output> {
363    std::process::Command::new("gh")
364        .args(args)
365        .env("GH_NO_UPDATE_NOTIFIER", "1")
366        .output()
367        .with_context(|| format!("run gh {}", args.join(" ")))
368}
369
370/// Check if the GitHub CLI (`gh`) is available and authenticated.
371///
372/// This is intended for preflight checks before operations that require gh,
373/// such as explicit PR management commands.
374///
375/// Returns Ok(()) if gh is on PATH and authenticated.
376/// Returns an error with a clear, actionable message if gh is missing or not authenticated.
377pub(crate) fn check_gh_available() -> Result<()> {
378    check_gh_available_with(run_gh_with_no_update)
379}
380
381/// Internal implementation that accepts a custom gh runner for testability.
382fn check_gh_available_with<F>(run_gh: F) -> Result<()>
383where
384    F: Fn(&[&str]) -> Result<std::process::Output>,
385{
386    // First, check if gh is on PATH by running --version
387    let version_output = run_gh(&["--version"]).with_context(|| {
388        "GitHub CLI (`gh`) not found on PATH. Install it from https://cli.github.com/ and re-run."
389            .to_string()
390    })?;
391
392    if !version_output.status.success() {
393        let stderr = String::from_utf8_lossy(&version_output.stderr);
394        bail!(
395            "`gh --version` failed (gh is not usable). Details: {}. Install/repair `gh` from https://cli.github.com/ and re-run.",
396            stderr.trim()
397        );
398    }
399
400    // Then, check authentication status
401    let auth_output = run_gh(&["auth", "status"]).with_context(|| {
402        "Failed to run `gh auth status`. Ensure `gh` is properly installed.".to_string()
403    })?;
404
405    if !auth_output.status.success() {
406        let stdout = String::from_utf8_lossy(&auth_output.stdout);
407        let stderr = String::from_utf8_lossy(&auth_output.stderr);
408        let details = if !stderr.is_empty() {
409            stderr.trim()
410        } else {
411            stdout.trim()
412        };
413        bail!(
414            "GitHub CLI (`gh`) is not authenticated. Run `gh auth login` and re-run. Details: {}",
415            details
416        );
417    }
418
419    Ok(())
420}
421
422#[cfg(test)]
423mod tests {
424    use super::{MergeMethod, MergeState, PrLifecycle, check_gh_available_with, extract_pr_url};
425    use super::{
426        PrViewJson, merge_method_flag, parse_name_with_owner_from_repo_view_json,
427        pr_lifecycle_status_from_view, pr_merge_status_from_view,
428    };
429
430    #[test]
431    fn extract_pr_url_picks_first_url_line() {
432        let output = "Creating pull request for feature...\nhttps://github.com/org/repo/pull/5\n";
433        let url = extract_pr_url(output).expect("url");
434        assert_eq!(url, "https://github.com/org/repo/pull/5");
435    }
436
437    #[test]
438    fn pr_merge_status_from_view_tracks_draft_flag() {
439        let json = PrViewJson {
440            merge_state_status: "CLEAN".to_string(),
441            number: Some(1),
442            url: Some("https://example.com/pr/1".to_string()),
443            head: Some("ralph/RQ-0001".to_string()),
444            base: Some("main".to_string()),
445            is_draft: Some(true),
446            state: Some("OPEN".to_string()),
447            is_merged: Some(false),
448            merged_at: None,
449        };
450
451        let status = pr_merge_status_from_view(&json);
452        assert_eq!(status.merge_state, MergeState::Clean);
453        assert!(status.is_draft);
454    }
455
456    #[test]
457    fn pr_merge_status_from_view_defaults_draft_false() {
458        let json = PrViewJson {
459            merge_state_status: "DIRTY".to_string(),
460            number: Some(2),
461            url: Some("https://example.com/pr/2".to_string()),
462            head: Some("ralph/RQ-0002".to_string()),
463            base: Some("main".to_string()),
464            is_draft: None,
465            state: Some("OPEN".to_string()),
466            is_merged: Some(false),
467            merged_at: None,
468        };
469
470        let status = pr_merge_status_from_view(&json);
471        assert_eq!(status.merge_state, MergeState::Dirty);
472        assert!(!status.is_draft);
473    }
474
475    #[test]
476    fn pr_merge_status_from_view_handles_unknown_state() {
477        let json = PrViewJson {
478            merge_state_status: "BLOCKED".to_string(),
479            number: Some(3),
480            url: Some("https://example.com/pr/3".to_string()),
481            head: Some("ralph/RQ-0003".to_string()),
482            base: Some("main".to_string()),
483            is_draft: Some(false),
484            state: Some("OPEN".to_string()),
485            is_merged: Some(false),
486            merged_at: None,
487        };
488
489        let status = pr_merge_status_from_view(&json);
490        assert_eq!(status.merge_state, MergeState::Other("BLOCKED".to_string()));
491        assert!(!status.is_draft);
492    }
493
494    #[test]
495    fn pr_lifecycle_status_from_view_open() {
496        let json = PrViewJson {
497            merge_state_status: "CLEAN".to_string(),
498            number: Some(1),
499            url: Some("https://example.com/pr/1".to_string()),
500            head: Some("ralph/RQ-0001".to_string()),
501            base: Some("main".to_string()),
502            is_draft: Some(false),
503            state: Some("OPEN".to_string()),
504            is_merged: Some(false),
505            merged_at: None,
506        };
507
508        let status = pr_lifecycle_status_from_view(&json);
509        assert!(matches!(status.lifecycle, PrLifecycle::Open));
510        assert!(!status.is_merged);
511    }
512
513    #[test]
514    fn pr_lifecycle_status_from_view_closed_not_merged() {
515        let json = PrViewJson {
516            merge_state_status: "CLEAN".to_string(),
517            number: Some(2),
518            url: Some("https://example.com/pr/2".to_string()),
519            head: Some("ralph/RQ-0002".to_string()),
520            base: Some("main".to_string()),
521            is_draft: Some(false),
522            state: Some("CLOSED".to_string()),
523            is_merged: Some(false),
524            merged_at: None,
525        };
526
527        let status = pr_lifecycle_status_from_view(&json);
528        assert!(matches!(status.lifecycle, PrLifecycle::Closed));
529        assert!(!status.is_merged);
530    }
531
532    #[test]
533    fn pr_lifecycle_status_from_view_closed_merged_at() {
534        let json = PrViewJson {
535            merge_state_status: "CLEAN".to_string(),
536            number: Some(3),
537            url: Some("https://example.com/pr/3".to_string()),
538            head: Some("ralph/RQ-0003".to_string()),
539            base: Some("main".to_string()),
540            is_draft: Some(false),
541            state: Some("CLOSED".to_string()),
542            is_merged: None,
543            merged_at: Some("2026-01-19T00:00:00Z".to_string()),
544        };
545
546        let status = pr_lifecycle_status_from_view(&json);
547        assert!(matches!(status.lifecycle, PrLifecycle::Merged));
548        assert!(status.is_merged);
549    }
550
551    #[test]
552    fn pr_lifecycle_status_from_view_closed_merged() {
553        let json = PrViewJson {
554            merge_state_status: "CLEAN".to_string(),
555            number: Some(3),
556            url: Some("https://example.com/pr/3".to_string()),
557            head: Some("ralph/RQ-0003".to_string()),
558            base: Some("main".to_string()),
559            is_draft: Some(false),
560            state: Some("CLOSED".to_string()),
561            is_merged: Some(true),
562            merged_at: None,
563        };
564
565        let status = pr_lifecycle_status_from_view(&json);
566        assert!(matches!(status.lifecycle, PrLifecycle::Merged));
567        assert!(status.is_merged);
568    }
569
570    #[test]
571    fn pr_lifecycle_status_from_view_merged_state() {
572        let json = PrViewJson {
573            merge_state_status: "CLEAN".to_string(),
574            number: Some(4),
575            url: Some("https://example.com/pr/4".to_string()),
576            head: Some("ralph/RQ-0004".to_string()),
577            base: Some("main".to_string()),
578            is_draft: Some(false),
579            state: Some("MERGED".to_string()),
580            is_merged: Some(true),
581            merged_at: None,
582        };
583
584        let status = pr_lifecycle_status_from_view(&json);
585        assert!(matches!(status.lifecycle, PrLifecycle::Merged));
586        assert!(status.is_merged);
587    }
588
589    #[test]
590    fn pr_lifecycle_status_from_view_unknown_state() {
591        let json = PrViewJson {
592            merge_state_status: "CLEAN".to_string(),
593            number: Some(5),
594            url: Some("https://example.com/pr/5".to_string()),
595            head: Some("ralph/RQ-0005".to_string()),
596            base: Some("main".to_string()),
597            is_draft: Some(false),
598            state: Some("WEIRD".to_string()),
599            is_merged: Some(false),
600            merged_at: None,
601        };
602
603        let status = pr_lifecycle_status_from_view(&json);
604        assert!(matches!(status.lifecycle, PrLifecycle::Unknown(s) if s == "WEIRD"));
605        assert!(!status.is_merged);
606    }
607
608    #[test]
609    fn check_gh_available_fails_when_gh_not_found() {
610        // Simulate gh not being on PATH (io error)
611        let run_gh = |_args: &[&str]| -> anyhow::Result<std::process::Output> {
612            Err(anyhow::anyhow!(std::io::Error::new(
613                std::io::ErrorKind::NotFound,
614                "No such file or directory"
615            )))
616        };
617
618        let result = check_gh_available_with(run_gh);
619        assert!(result.is_err());
620        let msg = result.unwrap_err().to_string();
621        assert!(msg.contains("GitHub CLI (`gh`) not found on PATH"));
622        assert!(msg.contains("https://cli.github.com/"));
623    }
624
625    #[test]
626    fn check_gh_available_fails_when_version_fails() {
627        // Simulate gh --version returning non-success
628        // Get a failing exit status by running "false" command
629        let fail_status = std::process::Command::new("false")
630            .status()
631            .expect("'false' command should exist");
632
633        let run_gh = |args: &[&str]| -> anyhow::Result<std::process::Output> {
634            if args == ["--version"] {
635                Ok(std::process::Output {
636                    status: fail_status,
637                    stdout: vec![],
638                    stderr: b"gh: command not recognized".to_vec(),
639                })
640            } else {
641                Ok(std::process::Output {
642                    status: std::process::ExitStatus::default(),
643                    stdout: vec![],
644                    stderr: vec![],
645                })
646            }
647        };
648
649        let result = check_gh_available_with(run_gh);
650        assert!(result.is_err());
651        let msg = result.unwrap_err().to_string();
652        assert!(msg.contains("`gh --version` failed"));
653        assert!(msg.contains("gh is not usable"));
654    }
655
656    #[test]
657    fn check_gh_available_fails_when_auth_fails() {
658        // Simulate gh --version succeeding but auth status failing
659        // Get a failing exit status by running "false" command
660        let fail_status = std::process::Command::new("false")
661            .status()
662            .expect("'false' command should exist");
663
664        let run_gh = |args: &[&str]| -> anyhow::Result<std::process::Output> {
665            if args == ["--version"] {
666                Ok(std::process::Output {
667                    status: std::process::ExitStatus::default(),
668                    stdout: b"gh version 2.40.0".to_vec(),
669                    stderr: vec![],
670                })
671            } else if args == ["auth", "status"] {
672                Ok(std::process::Output {
673                    status: fail_status,
674                    stdout: vec![],
675                    stderr: b"You are not logged into any GitHub hosts".to_vec(),
676                })
677            } else {
678                Ok(std::process::Output {
679                    status: std::process::ExitStatus::default(),
680                    stdout: vec![],
681                    stderr: vec![],
682                })
683            }
684        };
685
686        let result = check_gh_available_with(run_gh);
687        assert!(result.is_err());
688        let msg = result.unwrap_err().to_string();
689        assert!(msg.contains("GitHub CLI (`gh`) is not authenticated"));
690        assert!(msg.contains("gh auth login"));
691    }
692
693    #[test]
694    fn check_gh_available_succeeds_when_both_checks_pass() {
695        // Simulate both gh --version and auth status succeeding
696        let run_gh = |args: &[&str]| -> anyhow::Result<std::process::Output> {
697            if args == ["--version"] {
698                Ok(std::process::Output {
699                    status: std::process::ExitStatus::default(),
700                    stdout: b"gh version 2.40.0".to_vec(),
701                    stderr: vec![],
702                })
703            } else if args == ["auth", "status"] {
704                Ok(std::process::Output {
705                    status: std::process::ExitStatus::default(),
706                    stdout: b"Logged in to github.com as user".to_vec(),
707                    stderr: vec![],
708                })
709            } else {
710                Ok(std::process::Output {
711                    status: std::process::ExitStatus::default(),
712                    stdout: vec![],
713                    stderr: vec![],
714                })
715            }
716        };
717
718        let result = check_gh_available_with(run_gh);
719        assert!(result.is_ok());
720    }
721
722    #[test]
723    fn parse_name_with_owner_from_repo_view_json_accepts_valid_payload() {
724        let payload = br#"{ "nameWithOwner": "org/repo" }"#;
725        let result = parse_name_with_owner_from_repo_view_json(payload).expect("repo");
726        assert_eq!(result, "org/repo");
727    }
728
729    #[test]
730    fn parse_name_with_owner_from_repo_view_json_rejects_empty_value() {
731        let payload = br#"{ "nameWithOwner": "   " }"#;
732        let err = parse_name_with_owner_from_repo_view_json(payload).unwrap_err();
733        assert!(
734            err.to_string().contains("empty nameWithOwner"),
735            "unexpected error: {}",
736            err
737        );
738    }
739
740    #[test]
741    fn merge_method_flag_maps_all_variants() {
742        assert_eq!(merge_method_flag(MergeMethod::Squash), "--squash");
743        assert_eq!(merge_method_flag(MergeMethod::Merge), "--merge");
744        assert_eq!(merge_method_flag(MergeMethod::Rebase), "--rebase");
745    }
746}