Skip to main content

workon/
pr.rs

1//! Pull request support for creating worktrees from PR references.
2//!
3//! This module enables creating worktrees directly from pull request references,
4//! making it easy to review PRs in isolated worktrees.
5//!
6//! ## PR Reference Parsing
7//!
8//! Supports multiple PR reference formats:
9//! - `#123` - GitHub shorthand (most common)
10//! - `pr#123` or `pr-123` - Explicit PR references
11//! - `https://github.com/owner/repo/pull/123` - Full GitHub PR URL
12//! - `origin/pull/123/head` - Direct remote ref (less common)
13//!
14//! Parsing is lenient - if it looks like a PR reference, we'll try to extract the number.
15//!
16//! ## Smart Routing
17//!
18//! The CLI's smart routing (in main.rs) automatically detects PR references:
19//! ```bash
20//! git workon #123        # Routes to `new` command with PR reference
21//! git workon pr#123      # Same - creates PR worktree
22//! git workon feature     # Routes to `find` command (not a PR)
23//! ```
24//!
25//! ## Remote Detection Algorithm
26//!
27//! To fetch PRs, we need to determine which remote to use. The detection strategy:
28//! 1. Check for `upstream` remote (common in fork workflows)
29//! 2. Fall back to `origin` remote (most common)
30//! 3. Use first available remote (rare, but handles edge cases)
31//!
32//! This handles both direct repository workflows and fork-based workflows.
33//!
34//! ## Auto-Fetch Strategy
35//!
36//! PR branches are fetched automatically using gh CLI metadata:
37//! ```text
38//! git fetch <remote> +refs/heads/{branch}:refs/remotes/<remote>/{branch}
39//! ```
40//!
41//! Where `{branch}` is the actual branch name from the PR (obtained via gh CLI).
42//! The `+` forces the fetch even if not fast-forward, ensuring we always get the latest PR state.
43//!
44//! For fork PRs, a fork remote is automatically added and the branch is fetched from it.
45//! For non-fork PRs, the branch is fetched from the detected remote (origin/upstream).
46//!
47//! ## Worktree Naming
48//!
49//! Worktree names are generated from `workon.prFormat` config (default: `pr-{number}`):
50//! - `pr-123` (default format)
51//! - `#123` (if configured with `#{number}`)
52//! - `pull-123` (if configured with `pull-{number}`)
53//!
54//! The format must contain `{number}` placeholder.
55//!
56//! ## Example Usage
57//!
58//! ```bash
59//! # Create worktree for PR #123 (auto-detects remote, auto-fetches)
60//! git workon #123
61//!
62//! # Explicit PR reference
63//! git workon new pr#456
64//!
65//! # From GitHub URL
66//! git workon new https://github.com/user/repo/pull/789
67//!
68//! # Configure custom naming
69//! git config workon.prFormat "review-{number}"
70//! git workon #123  # Creates worktree named "review-123"
71//! ```
72//!
73//! ## gh CLI Integration
74//!
75//! PR support integrates with gh CLI for rich metadata:
76//! - **Format placeholders**: {number}, {title}, {author}, {branch}
77//! - **Fork support**: Auto-adds fork remotes and fetches fork branches
78//! - **Metadata**: Fetches PR title, author, branch names, and state
79//! - **Validation**: Checks PR exists before creating worktree
80
81use git2::{FetchOptions, Repository};
82use log::debug;
83
84use crate::{
85    error::{PrError, Result},
86    get_remote_callbacks,
87};
88
89/// A parsed pull request reference from user input.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct PullRequest {
92    /// The PR number extracted from the reference string.
93    pub number: u32,
94    /// Optional remote name if the reference included one (e.g. `origin/pull/123/head`).
95    pub remote: Option<String>,
96}
97
98/// PR metadata fetched from the `gh` CLI.
99#[derive(Debug, Clone)]
100pub struct PrMetadata {
101    /// PR number.
102    pub number: u32,
103    /// PR title.
104    pub title: String,
105    /// GitHub login of the PR author.
106    pub author: String,
107    /// Name of the branch that the PR was created from.
108    pub head_ref: String,
109    /// Name of the branch the PR targets.
110    pub base_ref: String,
111    /// True if the PR comes from a forked repository.
112    pub is_fork: bool,
113    /// GitHub login of the fork owner, if this is a fork PR.
114    pub fork_owner: Option<String>,
115    /// Clone URL of the fork repository, if this is a fork PR.
116    pub fork_url: Option<String>,
117}
118
119/// Parse a PR reference from user input
120///
121/// Supported formats:
122/// - `#123` - GitHub shorthand
123/// - `pr#123` or `pr-123` - Explicit PR references
124/// - `https://github.com/owner/repo/pull/123` - GitHub PR URL
125/// - `origin/pull/123/head` - Direct remote ref
126///
127/// Returns `Ok(None)` if the input is not a PR reference.
128/// Returns `Ok(Some(PullRequest))` if successfully parsed.
129/// Returns `Err` if the input looks like a PR reference but is malformed.
130pub fn parse_pr_reference(input: &str) -> Result<Option<PullRequest>> {
131    // Try #123 format
132    if let Some(num_str) = input.strip_prefix('#') {
133        return parse_number(num_str, input).map(|num| {
134            Some(PullRequest {
135                number: num,
136                remote: None,
137            })
138        });
139    }
140
141    // Try pr#123 format
142    if let Some(num_str) = input.strip_prefix("pr#") {
143        return parse_number(num_str, input).map(|num| {
144            Some(PullRequest {
145                number: num,
146                remote: None,
147            })
148        });
149    }
150
151    // Try pr-123 format
152    if let Some(num_str) = input.strip_prefix("pr-") {
153        return parse_number(num_str, input).map(|num| {
154            Some(PullRequest {
155                number: num,
156                remote: None,
157            })
158        });
159    }
160
161    // Try GitHub URL: https://github.com/owner/repo/pull/123
162    if input.contains("github.com") && input.contains("/pull/") {
163        return parse_github_url(input);
164    }
165
166    // Try remote ref format: origin/pull/123/head
167    if input.contains("/pull/") && input.ends_with("/head") {
168        return parse_remote_ref(input);
169    }
170
171    // Not a PR reference
172    Ok(None)
173}
174
175/// Helper to parse a number string
176fn parse_number(num_str: &str, original_input: &str) -> Result<u32> {
177    num_str.parse::<u32>().map_err(|_| {
178        PrError::InvalidReference {
179            input: original_input.to_string(),
180        }
181        .into()
182    })
183}
184
185/// Parse GitHub PR URL
186fn parse_github_url(url: &str) -> Result<Option<PullRequest>> {
187    // Extract the PR number from URL like: https://github.com/owner/repo/pull/123
188    let parts: Vec<&str> = url.split('/').collect();
189
190    // Find "pull" in the path and get the number after it
191    for (i, &part) in parts.iter().enumerate() {
192        if part == "pull" && i + 1 < parts.len() {
193            let num_str = parts[i + 1];
194            let number = parse_number(num_str, url)?;
195            return Ok(Some(PullRequest {
196                number,
197                remote: None,
198            }));
199        }
200    }
201
202    Err(PrError::InvalidReference {
203        input: url.to_string(),
204    }
205    .into())
206}
207
208/// Parse remote ref format: origin/pull/123/head
209fn parse_remote_ref(ref_str: &str) -> Result<Option<PullRequest>> {
210    // Format: remote/pull/number/head
211    let parts: Vec<&str> = ref_str.split('/').collect();
212
213    if parts.len() >= 4 && parts[parts.len() - 3] == "pull" && parts[parts.len() - 1] == "head" {
214        let num_str = parts[parts.len() - 2];
215        let number = parse_number(num_str, ref_str)?;
216        return Ok(Some(PullRequest {
217            number,
218            remote: None,
219        }));
220    }
221
222    Err(PrError::InvalidReference {
223        input: ref_str.to_string(),
224    }
225    .into())
226}
227
228/// Return `Ok(())` if the `gh` CLI is installed and reachable in `PATH`.
229///
230/// Returns [`PrError::GhNotInstalled`] if `gh` cannot be executed.
231pub fn check_gh_available() -> Result<()> {
232    std::process::Command::new("gh")
233        .arg("--version")
234        .output()
235        .map_err(|_| PrError::GhNotInstalled)?;
236    Ok(())
237}
238
239/// Fetch PR metadata for `pr_number` using the `gh` CLI.
240///
241/// Runs `gh pr view <pr_number> --json ...` and parses the JSON output.
242/// Requires `gh` to be authenticated (`gh auth login`).
243pub fn fetch_pr_metadata(pr_number: u32) -> Result<PrMetadata> {
244    // Ensure gh is available
245    check_gh_available()?;
246
247    // Fetch PR metadata with single gh command
248    let output = std::process::Command::new("gh")
249        .args([
250            "pr",
251            "view",
252            &pr_number.to_string(),
253            "--json",
254            "number,title,author,headRefName,baseRefName,isCrossRepository,headRepository",
255        ])
256        .output()
257        .map_err(|e| PrError::GhFetchFailed {
258            message: e.to_string(),
259        })?;
260
261    if !output.status.success() {
262        let stderr = String::from_utf8_lossy(&output.stderr);
263        return Err(PrError::GhFetchFailed {
264            message: stderr.to_string(),
265        }
266        .into());
267    }
268
269    // Parse JSON response
270    let json_str = String::from_utf8_lossy(&output.stdout);
271    let json: serde_json::Value =
272        serde_json::from_str(&json_str).map_err(|e| PrError::GhJsonParseFailed {
273            message: e.to_string(),
274        })?;
275
276    // Extract fields
277    let number = json["number"]
278        .as_u64()
279        .ok_or_else(|| PrError::GhJsonParseFailed {
280            message: "Missing 'number' field".to_string(),
281        })? as u32;
282
283    let title = json["title"]
284        .as_str()
285        .ok_or_else(|| PrError::GhJsonParseFailed {
286            message: "Missing 'title' field".to_string(),
287        })?
288        .to_string();
289
290    let author = json["author"]["login"]
291        .as_str()
292        .ok_or_else(|| PrError::GhJsonParseFailed {
293            message: "Missing 'author.login' field".to_string(),
294        })?
295        .to_string();
296
297    let head_ref = json["headRefName"]
298        .as_str()
299        .ok_or_else(|| PrError::GhJsonParseFailed {
300            message: "Missing 'headRefName' field".to_string(),
301        })?
302        .to_string();
303
304    let base_ref = json["baseRefName"]
305        .as_str()
306        .ok_or_else(|| PrError::GhJsonParseFailed {
307            message: "Missing 'baseRefName' field".to_string(),
308        })?
309        .to_string();
310
311    let is_fork = json["isCrossRepository"].as_bool().unwrap_or(false);
312
313    let (fork_owner, fork_url) = if is_fork {
314        let owner = json["headRepository"]["owner"]["login"]
315            .as_str()
316            .ok_or(PrError::MissingForkOwner)?
317            .to_string();
318        let url = json["headRepository"]["url"]
319            .as_str()
320            .map(|s| s.to_string());
321        (Some(owner), url)
322    } else {
323        (None, None)
324    };
325
326    Ok(PrMetadata {
327        number,
328        title,
329        author,
330        head_ref,
331        base_ref,
332        is_fork,
333        fork_owner,
334        fork_url,
335    })
336}
337
338/// A merged PR's number and the commit its head branch pointed to when it merged.
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct MergedPr {
341    pub number: u32,
342    pub head_oid: String,
343}
344
345/// Look up the merged PR for `branch`, if any, using the `gh` CLI.
346///
347/// Runs `gh pr list --head <branch> --state merged --json number,mergedAt,headRefOid
348/// --limit 1`. Matching is by branch name only, so a same-named branch from a fork is
349/// a false match; callers that care about this should already have ruled out forks
350/// another way.
351///
352/// Returns `Ok(None)` if the branch never had a PR merged under that name, or if the
353/// merged PR's `headRefOid` is missing or unparseable (callers can't compare against
354/// the branch tip without it, so this degrades the same as "no merged PR").
355/// Does not call [`check_gh_available`] itself — callers that intend to
356/// silently degrade when `gh` is unavailable should check once up front
357/// rather than pay for it on every branch.
358pub fn find_merged_pr(branch: &str) -> Result<Option<MergedPr>> {
359    let output = std::process::Command::new("gh")
360        .args([
361            "pr",
362            "list",
363            "--head",
364            branch,
365            "--state",
366            "merged",
367            "--json",
368            "number,mergedAt,headRefOid",
369            "--limit",
370            "1",
371        ])
372        .output()
373        .map_err(|e| PrError::GhFetchFailed {
374            message: e.to_string(),
375        })?;
376
377    if !output.status.success() {
378        let stderr = String::from_utf8_lossy(&output.stderr);
379        return Err(PrError::GhFetchFailed {
380            message: stderr.to_string(),
381        }
382        .into());
383    }
384
385    let json_str = String::from_utf8_lossy(&output.stdout);
386    Ok(parse_merged_pr(&json_str))
387}
388
389/// Extract a merged PR's number and head OID from
390/// `gh pr list --json number,mergedAt,headRefOid`'s output.
391///
392/// Treats any shape mismatch as "no merged PR" rather than an error: an empty array
393/// (never had a PR), missing/non-numeric `number`, missing/non-string `headRefOid`,
394/// a non-array payload, or malformed JSON. [`find_merged_pr`] already treats a `None`
395/// here as a degrade-quietly case, so there is no separate error path to preserve for
396/// these.
397fn parse_merged_pr(json: &str) -> Option<MergedPr> {
398    let json: serde_json::Value = serde_json::from_str(json).ok()?;
399    let entry = json.as_array()?.first()?;
400    let number = entry.get("number")?.as_u64()? as u32;
401    let head_oid = entry.get("headRefOid")?.as_str()?.to_string();
402    Some(MergedPr { number, head_oid })
403}
404
405/// Sanitize a string for use in branch/worktree names
406fn sanitize_for_branch_name(s: &str) -> String {
407    let sanitized = s
408        .chars()
409        .map(|c| match c {
410            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
411            ' ' | '/' => '-',
412            _ => '-',
413        })
414        .collect::<String>()
415        .to_lowercase();
416
417    // Collapse multiple dashes into single dash
418    let mut result = String::new();
419    let mut last_was_dash = false;
420    for c in sanitized.chars() {
421        if c == '-' {
422            if !last_was_dash {
423                result.push(c);
424            }
425            last_was_dash = true;
426        } else {
427            result.push(c);
428            last_was_dash = false;
429        }
430    }
431
432    result.trim_matches(|c| c == '-' || c == '_').to_string()
433}
434
435/// Expand all placeholders in `format` using `metadata`.
436///
437/// Supported placeholders: `{number}`, `{title}`, `{author}`, `{branch}`.
438/// Title, author, and branch values are sanitized for use in branch/directory names.
439pub fn format_pr_name_with_metadata(format: &str, metadata: &PrMetadata) -> String {
440    format
441        .replace("{number}", &metadata.number.to_string())
442        .replace("{title}", &sanitize_for_branch_name(&metadata.title))
443        .replace("{author}", &sanitize_for_branch_name(&metadata.author))
444        .replace("{branch}", &sanitize_for_branch_name(&metadata.head_ref))
445}
446
447/// Check if a string looks like a PR reference
448///
449/// This is a quick check used for routing decisions.
450pub fn is_pr_reference(input: &str) -> bool {
451    parse_pr_reference(input).ok().flatten().is_some()
452}
453
454/// Priority tier for the shared `upstream → origin → others` remote precedence.
455///
456/// The single encoding of the precedence ADR-024 prescribes for every remote
457/// decision: [`preferred_remote_order`] sorts by it, and
458/// `resolve_remote_tracking` (worktree.rs) uses equal tiers to detect
459/// ambiguity. Lower is more preferred; all non-special remotes share a tier.
460pub fn remote_priority(remote: &str) -> usize {
461    match remote {
462        "upstream" => 0,
463        "origin" => 1,
464        _ => 2,
465    }
466}
467
468/// Returns remotes in preferred order: upstream first, then origin, then all
469/// others in configuration order (the sort is stable).
470pub fn preferred_remote_order(repo: &Repository) -> Vec<String> {
471    let Ok(remotes) = repo.remotes() else {
472        return vec![];
473    };
474    let mut all: Vec<String> = remotes
475        .iter()
476        .flatten()
477        .flatten()
478        .map(str::to_string)
479        .collect();
480    all.sort_by_key(|r| remote_priority(r));
481    all
482}
483
484/// True if any of the repository's remotes point at `github.com`.
485///
486/// `gh` can only resolve a PR against a GitHub remote, so callers use this to skip a
487/// lookup that is guaranteed to fail (e.g. prune's PR-merged signal). Handles both URL
488/// forms (`https://github.com/...` and `git@github.com:...`); a remote whose URL fails
489/// to parse is treated as non-GitHub rather than an error.
490pub fn has_github_remote(repo: &Repository) -> bool {
491    let Ok(remotes) = repo.remotes() else {
492        return false;
493    };
494    remotes.iter().flatten().flatten().any(|name| {
495        repo.find_remote(name)
496            .ok()
497            .and_then(|r| r.url().ok().map(str::to_string))
498            .and_then(|url| crate::ssh_config::extract_host_from_url(&url))
499            .is_some_and(|host| host.eq_ignore_ascii_case("github.com"))
500    })
501}
502
503/// Select which remote to use for fetching PR refs.
504///
505/// Priority: `upstream` → `origin` → first available remote.
506/// Returns [`PrError::NoRemoteConfigured`] if the repository has no remotes.
507pub fn detect_pr_remote(repo: &Repository) -> Result<String> {
508    preferred_remote_order(repo)
509        .into_iter()
510        .next()
511        .ok_or_else(|| PrError::NoRemoteConfigured.into())
512}
513
514/// Ensure a remote for a fork PR exists, then return its name.
515///
516/// For non-fork PRs this is equivalent to [`detect_pr_remote`].
517/// For fork PRs, a remote named `pr-{number}-fork` is added if it doesn't
518/// already exist, pointing at the fork's clone URL.
519pub fn setup_fork_remote(repo: &Repository, metadata: &PrMetadata) -> Result<String> {
520    if !metadata.is_fork {
521        // Not a fork - use regular remote
522        return detect_pr_remote(repo);
523    }
524
525    // Fork PR - need to add fork remote
526    let _fork_owner = metadata
527        .fork_owner
528        .as_ref()
529        .ok_or(PrError::MissingForkOwner)?;
530
531    let fork_url = metadata
532        .fork_url
533        .as_ref()
534        .ok_or(PrError::MissingForkOwner)?;
535
536    // Check if fork remote already exists
537    let fork_remote_name = format!("pr-{}-fork", metadata.number);
538
539    if repo.find_remote(&fork_remote_name).is_ok() {
540        debug!("Fork remote {} already exists", fork_remote_name);
541        return Ok(fork_remote_name);
542    }
543
544    // Add fork as remote
545    debug!("Adding fork remote: {} -> {}", fork_remote_name, fork_url);
546    repo.remote(&fork_remote_name, fork_url)
547        .map_err(|e| PrError::FetchFailed {
548            remote: fork_remote_name.clone(),
549            message: format!("Failed to add fork remote: {}", e),
550        })?;
551
552    Ok(fork_remote_name)
553}
554
555/// Fetch `branch` from `remote_name`, making it available as
556/// `refs/remotes/{remote_name}/{branch}`.
557///
558/// This is used for both fork and non-fork PRs to fetch the PR's head branch
559/// identified via `gh` CLI metadata. If the ref already exists locally the
560/// fetch is skipped.
561pub fn fetch_branch(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> {
562    // Check if branch already exists locally
563    let branch_ref = format!("refs/remotes/{}/{}", remote_name, branch);
564    if repo.find_reference(&branch_ref).is_ok() {
565        debug!("Branch ref {} already exists", branch_ref);
566        return Ok(());
567    }
568
569    debug!("Fetching branch {} from remote {}", branch, remote_name);
570
571    let refspec = format!(
572        "+refs/heads/{}:refs/remotes/{}/{}",
573        branch, remote_name, branch
574    );
575
576    let remote_url = repo
577        .find_remote(remote_name)
578        .ok()
579        .and_then(|r| r.url().ok().map(str::to_string));
580    let auth = get_remote_callbacks(repo, remote_url.as_deref())?;
581    let mut fetch_options = FetchOptions::new();
582    fetch_options.remote_callbacks(auth.callbacks());
583
584    repo.find_remote(remote_name)?
585        .fetch(
586            &[refspec.as_str()],
587            Some(&mut fetch_options),
588            Some("Fetching PR branch"),
589        )
590        .map_err(|e| PrError::FetchFailed {
591            remote: remote_name.to_string(),
592            message: e.message().to_string(),
593        })?;
594
595    debug!("Successfully fetched branch {}", branch);
596    Ok(())
597}
598
599/// Format a PR worktree name using the format string
600///
601/// Replaces `{number}` placeholder with the PR number.
602pub fn format_pr_name(format: &str, pr_number: u32) -> String {
603    format.replace("{number}", &pr_number.to_string())
604}
605
606/// Prepare everything needed to create a worktree for PR `pr_number`.
607///
608/// Orchestrates the complete PR workflow:
609/// 1. Checks that `gh` CLI is available
610/// 2. Fetches PR metadata via `gh`
611/// 3. Sets up a fork remote if the PR is cross-repository
612/// 4. Fetches the PR's head branch
613/// 5. Formats the worktree name using `pr_format`
614///
615/// Returns `(worktree_name, remote_ref, base_branch)` ready for `add_worktree`.
616pub fn prepare_pr_worktree(
617    repo: &Repository,
618    pr_number: u32,
619    pr_format: &str,
620) -> Result<(String, String, String)> {
621    debug!("Preparing PR worktree for PR #{}", pr_number);
622
623    // Fetch PR metadata from gh CLI
624    let metadata = fetch_pr_metadata(pr_number)?;
625    debug!(
626        "Fetched metadata: title='{}', author='{}', is_fork={}",
627        metadata.title, metadata.author, metadata.is_fork
628    );
629
630    // Setup remote and fetch branch
631    // For fork PRs: setup fork remote and fetch from it
632    // For non-fork PRs: use existing remote (origin/upstream)
633    let remote_name = if metadata.is_fork {
634        setup_fork_remote(repo, &metadata)?
635    } else {
636        detect_pr_remote(repo)?
637    };
638
639    // Fetch the actual branch from gh CLI metadata (works for both fork and non-fork)
640    fetch_branch(repo, &remote_name, &metadata.head_ref)?;
641
642    // Format worktree name using metadata
643    let worktree_name = format_pr_name_with_metadata(pr_format, &metadata);
644    debug!("Worktree name: {}", worktree_name);
645
646    // Build remote ref using the actual branch from metadata
647    let remote_ref = format!("{}/{}", remote_name, metadata.head_ref);
648    debug!("Remote ref: {}", remote_ref);
649
650    Ok((worktree_name, remote_ref, metadata.base_ref))
651}
652
653#[cfg(test)]
654mod tests {
655    use super::*;
656
657    #[test]
658    fn test_parse_hash_number() {
659        let pr = parse_pr_reference("#123").unwrap().unwrap();
660        assert_eq!(pr.number, 123);
661        assert_eq!(pr.remote, None);
662    }
663
664    #[test]
665    fn test_parse_pr_hash_number() {
666        let pr = parse_pr_reference("pr#456").unwrap().unwrap();
667        assert_eq!(pr.number, 456);
668        assert_eq!(pr.remote, None);
669    }
670
671    #[test]
672    fn test_parse_pr_dash_number() {
673        let pr = parse_pr_reference("pr-789").unwrap().unwrap();
674        assert_eq!(pr.number, 789);
675        assert_eq!(pr.remote, None);
676    }
677
678    #[test]
679    fn test_parse_github_url() {
680        let pr = parse_pr_reference("https://github.com/owner/repo/pull/999")
681            .unwrap()
682            .unwrap();
683        assert_eq!(pr.number, 999);
684        assert_eq!(pr.remote, None);
685    }
686
687    #[test]
688    fn test_parse_remote_ref() {
689        let pr = parse_pr_reference("origin/pull/111/head").unwrap().unwrap();
690        assert_eq!(pr.number, 111);
691        assert_eq!(pr.remote, None);
692    }
693
694    #[test]
695    fn test_parse_regular_branch_name() {
696        let result = parse_pr_reference("my-feature-branch").unwrap();
697        assert!(result.is_none());
698    }
699
700    #[test]
701    fn test_parse_invalid_number() {
702        let result = parse_pr_reference("#abc");
703        assert!(result.is_err());
704    }
705
706    #[test]
707    fn test_is_pr_reference_true() {
708        assert!(is_pr_reference("#123"));
709        assert!(is_pr_reference("pr#456"));
710        assert!(is_pr_reference("pr-789"));
711        assert!(is_pr_reference("https://github.com/owner/repo/pull/999"));
712    }
713
714    #[test]
715    fn test_is_pr_reference_false() {
716        assert!(!is_pr_reference("my-branch"));
717        assert!(!is_pr_reference("feature"));
718    }
719
720    #[test]
721    fn test_format_pr_name() {
722        assert_eq!(format_pr_name("pr-{number}", 123), "pr-123");
723        assert_eq!(format_pr_name("review-{number}", 456), "review-456");
724        assert_eq!(format_pr_name("{number}-test", 789), "789-test");
725    }
726
727    #[test]
728    fn test_sanitize_branch_name() {
729        assert_eq!(sanitize_for_branch_name("Fix Bug #123"), "fix-bug-123");
730        assert_eq!(
731            sanitize_for_branch_name("Add Feature (v2)"),
732            "add-feature-v2"
733        );
734        assert_eq!(sanitize_for_branch_name("john-smith"), "john-smith");
735        assert_eq!(
736            sanitize_for_branch_name("Fix: Authentication Issue"),
737            "fix-authentication-issue"
738        );
739        assert_eq!(sanitize_for_branch_name("Test@#$%"), "test");
740    }
741
742    #[test]
743    fn test_format_with_metadata() {
744        let metadata = PrMetadata {
745            number: 123,
746            title: "Fix Authentication Bug".to_string(),
747            author: "john-smith".to_string(),
748            head_ref: "feature/fix-auth".to_string(),
749            base_ref: "main".to_string(),
750            is_fork: false,
751            fork_owner: None,
752            fork_url: None,
753        };
754
755        assert_eq!(
756            format_pr_name_with_metadata("pr-{number}", &metadata),
757            "pr-123"
758        );
759        assert_eq!(
760            format_pr_name_with_metadata("{number}-{title}", &metadata),
761            "123-fix-authentication-bug"
762        );
763        assert_eq!(
764            format_pr_name_with_metadata("{author}/pr-{number}", &metadata),
765            "john-smith/pr-123"
766        );
767        assert_eq!(
768            format_pr_name_with_metadata("{branch}-{number}", &metadata),
769            "feature-fix-auth-123"
770        );
771    }
772
773    #[test]
774    fn test_parse_merged_pr_empty_array() {
775        assert_eq!(parse_merged_pr("[]"), None);
776    }
777
778    #[test]
779    fn test_parse_merged_pr_populated_array() {
780        let json = r#"[{"number":66,"mergedAt":"2024-01-01T00:00:00Z","headRefOid":"abc123"}]"#;
781        assert_eq!(
782            parse_merged_pr(json),
783            Some(MergedPr {
784                number: 66,
785                head_oid: "abc123".to_string()
786            })
787        );
788    }
789
790    #[test]
791    fn test_parse_merged_pr_number_missing() {
792        let json = r#"[{"mergedAt":"2024-01-01T00:00:00Z","headRefOid":"abc123"}]"#;
793        assert_eq!(parse_merged_pr(json), None);
794    }
795
796    #[test]
797    fn test_parse_merged_pr_number_non_numeric() {
798        let json = r#"[{"number":"not-a-number","mergedAt":"2024-01-01T00:00:00Z","headRefOid":"abc123"}]"#;
799        assert_eq!(parse_merged_pr(json), None);
800    }
801
802    #[test]
803    fn test_parse_merged_pr_head_oid_missing() {
804        let json = r#"[{"number":66,"mergedAt":"2024-01-01T00:00:00Z"}]"#;
805        assert_eq!(parse_merged_pr(json), None);
806    }
807
808    #[test]
809    fn test_parse_merged_pr_non_array_payload() {
810        assert_eq!(parse_merged_pr(r#"{"number":66}"#), None);
811    }
812
813    #[test]
814    fn test_parse_merged_pr_malformed_json() {
815        assert_eq!(parse_merged_pr("not json"), None);
816    }
817
818    // Integration tests requiring gh CLI (marked with #[ignore])
819    #[test]
820    #[ignore]
821    fn test_gh_cli_available() {
822        check_gh_available().expect("gh CLI should be installed");
823    }
824
825    #[test]
826    #[ignore]
827    fn test_fetch_real_pr_metadata() {
828        // Requires gh CLI and auth
829        // This test uses a real PR from a public repo (git-workon itself if available)
830        // Replace with actual PR number from your repository for testing
831        let metadata = fetch_pr_metadata(1).expect("Failed to fetch PR metadata");
832        assert_eq!(metadata.number, 1);
833        assert!(!metadata.title.is_empty());
834        assert!(!metadata.author.is_empty());
835    }
836}