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