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 std::collections::HashMap;
82
83use git2::{FetchOptions, Repository};
84use log::debug;
85
86use crate::{
87    error::{PrError, Result},
88    get_remote_callbacks,
89};
90
91/// A parsed pull request reference from user input.
92#[derive(Debug, Clone, PartialEq, Eq)]
93pub struct PullRequest {
94    /// The PR number extracted from the reference string.
95    pub number: u32,
96    /// Optional remote name if the reference included one (e.g. `origin/pull/123/head`).
97    pub remote: Option<String>,
98}
99
100/// PR metadata fetched from the `gh` CLI.
101#[derive(Debug, Clone)]
102pub struct PrMetadata {
103    /// PR number.
104    pub number: u32,
105    /// PR title.
106    pub title: String,
107    /// GitHub login of the PR author.
108    pub author: String,
109    /// Name of the branch that the PR was created from.
110    pub head_ref: String,
111    /// Name of the branch the PR targets.
112    pub base_ref: String,
113    /// True if the PR comes from a forked repository.
114    pub is_fork: bool,
115    /// GitHub login of the fork owner, if this is a fork PR.
116    pub fork_owner: Option<String>,
117    /// Clone URL of the fork repository, if this is a fork PR.
118    pub fork_url: Option<String>,
119}
120
121/// Parse a PR reference from user input
122///
123/// Supported formats:
124/// - `#123` - GitHub shorthand
125/// - `pr#123` or `pr-123` - Explicit PR references
126/// - `https://github.com/owner/repo/pull/123` - GitHub PR URL
127/// - `origin/pull/123/head` - Direct remote ref
128///
129/// Returns `Ok(None)` if the input is not a PR reference.
130/// Returns `Ok(Some(PullRequest))` if successfully parsed.
131/// Returns `Err` if the input looks like a PR reference but is malformed.
132pub fn parse_pr_reference(input: &str) -> Result<Option<PullRequest>> {
133    // Try #123 format
134    if let Some(num_str) = input.strip_prefix('#') {
135        return parse_number(num_str, input).map(|num| {
136            Some(PullRequest {
137                number: num,
138                remote: None,
139            })
140        });
141    }
142
143    // Try pr#123 format
144    if let Some(num_str) = input.strip_prefix("pr#") {
145        return parse_number(num_str, input).map(|num| {
146            Some(PullRequest {
147                number: num,
148                remote: None,
149            })
150        });
151    }
152
153    // Try pr-123 format
154    if let Some(num_str) = input.strip_prefix("pr-") {
155        return parse_number(num_str, input).map(|num| {
156            Some(PullRequest {
157                number: num,
158                remote: None,
159            })
160        });
161    }
162
163    // Try GitHub URL: https://github.com/owner/repo/pull/123
164    if input.contains("github.com") && input.contains("/pull/") {
165        return parse_github_url(input);
166    }
167
168    // Try remote ref format: origin/pull/123/head
169    if input.contains("/pull/") && input.ends_with("/head") {
170        return parse_remote_ref(input);
171    }
172
173    // Not a PR reference
174    Ok(None)
175}
176
177/// Helper to parse a number string
178fn parse_number(num_str: &str, original_input: &str) -> Result<u32> {
179    num_str.parse::<u32>().map_err(|_| {
180        PrError::InvalidReference {
181            input: original_input.to_string(),
182        }
183        .into()
184    })
185}
186
187/// Parse GitHub PR URL
188fn parse_github_url(url: &str) -> Result<Option<PullRequest>> {
189    // Extract the PR number from URL like: https://github.com/owner/repo/pull/123
190    let parts: Vec<&str> = url.split('/').collect();
191
192    // Find "pull" in the path and get the number after it
193    for (i, &part) in parts.iter().enumerate() {
194        if part == "pull" && i + 1 < parts.len() {
195            let num_str = parts[i + 1];
196            let number = parse_number(num_str, url)?;
197            return Ok(Some(PullRequest {
198                number,
199                remote: None,
200            }));
201        }
202    }
203
204    Err(PrError::InvalidReference {
205        input: url.to_string(),
206    }
207    .into())
208}
209
210/// Parse remote ref format: origin/pull/123/head
211fn parse_remote_ref(ref_str: &str) -> Result<Option<PullRequest>> {
212    // Format: remote/pull/number/head
213    let parts: Vec<&str> = ref_str.split('/').collect();
214
215    if parts.len() >= 4 && parts[parts.len() - 3] == "pull" && parts[parts.len() - 1] == "head" {
216        let num_str = parts[parts.len() - 2];
217        let number = parse_number(num_str, ref_str)?;
218        return Ok(Some(PullRequest {
219            number,
220            remote: None,
221        }));
222    }
223
224    Err(PrError::InvalidReference {
225        input: ref_str.to_string(),
226    }
227    .into())
228}
229
230/// Return `Ok(())` if the `gh` CLI is installed and reachable in `PATH`.
231///
232/// Returns [`PrError::GhNotInstalled`] if `gh` cannot be executed.
233pub fn check_gh_available() -> Result<()> {
234    std::process::Command::new("gh")
235        .arg("--version")
236        .output()
237        .map_err(|_| PrError::GhNotInstalled)?;
238    Ok(())
239}
240
241/// Fetch PR metadata for `pr_number` using the `gh` CLI.
242///
243/// Runs `gh pr view <pr_number> --json ...` and parses the JSON output.
244/// Requires `gh` to be authenticated (`gh auth login`).
245pub fn fetch_pr_metadata(pr_number: u32) -> Result<PrMetadata> {
246    // Ensure gh is available
247    check_gh_available()?;
248
249    // Fetch PR metadata with single gh command
250    let output = std::process::Command::new("gh")
251        .args([
252            "pr",
253            "view",
254            &pr_number.to_string(),
255            "--json",
256            "number,title,author,headRefName,baseRefName,isCrossRepository,headRepository",
257        ])
258        .output()
259        .map_err(|e| PrError::GhFetchFailed {
260            message: e.to_string(),
261        })?;
262
263    if !output.status.success() {
264        let stderr = String::from_utf8_lossy(&output.stderr);
265        return Err(PrError::GhFetchFailed {
266            message: stderr.to_string(),
267        }
268        .into());
269    }
270
271    // Parse JSON response
272    let json_str = String::from_utf8_lossy(&output.stdout);
273    let json: serde_json::Value =
274        serde_json::from_str(&json_str).map_err(|e| PrError::GhJsonParseFailed {
275            message: e.to_string(),
276        })?;
277
278    // Extract fields
279    let number = json["number"]
280        .as_u64()
281        .ok_or_else(|| PrError::GhJsonParseFailed {
282            message: "Missing 'number' field".to_string(),
283        })? as u32;
284
285    let title = json["title"]
286        .as_str()
287        .ok_or_else(|| PrError::GhJsonParseFailed {
288            message: "Missing 'title' field".to_string(),
289        })?
290        .to_string();
291
292    let author = json["author"]["login"]
293        .as_str()
294        .ok_or_else(|| PrError::GhJsonParseFailed {
295            message: "Missing 'author.login' field".to_string(),
296        })?
297        .to_string();
298
299    let head_ref = json["headRefName"]
300        .as_str()
301        .ok_or_else(|| PrError::GhJsonParseFailed {
302            message: "Missing 'headRefName' field".to_string(),
303        })?
304        .to_string();
305
306    let base_ref = json["baseRefName"]
307        .as_str()
308        .ok_or_else(|| PrError::GhJsonParseFailed {
309            message: "Missing 'baseRefName' field".to_string(),
310        })?
311        .to_string();
312
313    let is_fork = json["isCrossRepository"].as_bool().unwrap_or(false);
314
315    let (fork_owner, fork_url) = if is_fork {
316        let owner = json["headRepository"]["owner"]["login"]
317            .as_str()
318            .ok_or(PrError::MissingForkOwner)?
319            .to_string();
320        let url = json["headRepository"]["url"]
321            .as_str()
322            .map(|s| s.to_string());
323        (Some(owner), url)
324    } else {
325        (None, None)
326    };
327
328    Ok(PrMetadata {
329        number,
330        title,
331        author,
332        head_ref,
333        base_ref,
334        is_fork,
335        fork_owner,
336        fork_url,
337    })
338}
339
340/// A merged PR's number and the commit its head branch pointed to when it merged.
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub struct MergedPr {
343    pub number: u32,
344    pub head_oid: String,
345}
346
347/// Most branches one GraphQL request carries. Each branch adds an alias and a variable.
348const MERGED_PR_BATCH_SIZE: usize = 100;
349
350/// Look up the merged PR for each of `branches` with batched `gh api graphql` calls.
351///
352/// One request covers up to 100 branches (an alias per branch, newest merged PR first).
353pub fn find_merged_prs(branches: &[&str]) -> Result<HashMap<String, MergedPr>> {
354    let mut found = HashMap::new();
355    for batch in branches.chunks(MERGED_PR_BATCH_SIZE) {
356        let mut cmd = std::process::Command::new("gh");
357        cmd.args(["api", "graphql", "-F", "owner={owner}", "-F", "name={repo}"]);
358        for (i, branch) in batch.iter().enumerate() {
359            cmd.arg("-f").arg(format!("h{i}={branch}"));
360        }
361        cmd.arg("-f")
362            .arg(format!("query={}", build_merged_pr_query(batch.len())));
363
364        let output = cmd.output().map_err(|e| PrError::GhFetchFailed {
365            message: e.to_string(),
366        })?;
367
368        if !output.status.success() {
369            let stderr = String::from_utf8_lossy(&output.stderr);
370            return Err(PrError::GhFetchFailed {
371                message: stderr.to_string(),
372            }
373            .into());
374        }
375
376        let json_str = String::from_utf8_lossy(&output.stdout);
377        found.extend(parse_merged_prs(&json_str, batch));
378    }
379    Ok(found)
380}
381
382/// Build the GraphQL query for `count` branches: variable `$hN` and alias `bN` each.
383///
384/// `orderBy` newest-first is required: without it `first:1` can return the oldest merged
385/// PR for a reused branch name, where `gh pr list` returns the newest.
386fn build_merged_pr_query(count: usize) -> String {
387    let mut vars = String::from("$owner:String!,$name:String!");
388    let mut aliases = String::new();
389    for i in 0..count {
390        vars.push_str(&format!(",$h{i}:String!"));
391        aliases.push_str(&format!(
392            " b{i}: pullRequests(headRefName:$h{i}, states:MERGED, first:1, \
393             orderBy:{{field:CREATED_AT, direction:DESC}}){{ nodes {{ number headRefOid }} }}"
394        ));
395    }
396    format!("query({vars}){{ repository(owner:$owner,name:$name){{{aliases} }} }}")
397}
398
399/// Map `gh api graphql` output from [`build_merged_pr_query`] back to branch names.
400///
401/// Reads `data.repository.b{i}.nodes[0]` for `branches[i]`. Any shape mismatch for a
402/// branch (missing alias, empty `nodes`, missing/non-numeric `number`, missing/non-string
403/// `headRefOid`) leaves that branch out.
404fn parse_merged_prs(json: &str, branches: &[&str]) -> HashMap<String, MergedPr> {
405    let mut found = HashMap::new();
406    let Ok(json) = serde_json::from_str::<serde_json::Value>(json) else {
407        return found;
408    };
409    let Some(repository) = json.pointer("/data/repository").filter(|r| r.is_object()) else {
410        return found;
411    };
412    for (i, branch) in branches.iter().enumerate() {
413        let Some(node) = repository.pointer(&format!("/b{i}/nodes/0")) else {
414            continue;
415        };
416        let Some(number) = node.get("number").and_then(|n| n.as_u64()) else {
417            continue;
418        };
419        let Some(head_oid) = node.get("headRefOid").and_then(|o| o.as_str()) else {
420            continue;
421        };
422        found.insert(
423            (*branch).to_string(),
424            MergedPr {
425                number: number as u32,
426                head_oid: head_oid.to_string(),
427            },
428        );
429    }
430    found
431}
432
433/// Sanitize a string for use in branch/worktree names
434fn sanitize_for_branch_name(s: &str) -> String {
435    let sanitized = s
436        .chars()
437        .map(|c| match c {
438            'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => c,
439            ' ' | '/' => '-',
440            _ => '-',
441        })
442        .collect::<String>()
443        .to_lowercase();
444
445    // Collapse multiple dashes into single dash
446    let mut result = String::new();
447    let mut last_was_dash = false;
448    for c in sanitized.chars() {
449        if c == '-' {
450            if !last_was_dash {
451                result.push(c);
452            }
453            last_was_dash = true;
454        } else {
455            result.push(c);
456            last_was_dash = false;
457        }
458    }
459
460    result.trim_matches(|c| c == '-' || c == '_').to_string()
461}
462
463/// Expand all placeholders in `format` using `metadata`.
464///
465/// Supported placeholders: `{number}`, `{title}`, `{author}`, `{branch}`.
466/// Title, author, and branch values are sanitized for use in branch/directory names.
467pub fn format_pr_name_with_metadata(format: &str, metadata: &PrMetadata) -> String {
468    format
469        .replace("{number}", &metadata.number.to_string())
470        .replace("{title}", &sanitize_for_branch_name(&metadata.title))
471        .replace("{author}", &sanitize_for_branch_name(&metadata.author))
472        .replace("{branch}", &sanitize_for_branch_name(&metadata.head_ref))
473}
474
475/// Check if a string looks like a PR reference
476///
477/// This is a quick check used for routing decisions.
478pub fn is_pr_reference(input: &str) -> bool {
479    parse_pr_reference(input).ok().flatten().is_some()
480}
481
482/// Priority tier for the shared `upstream → origin → others` remote precedence.
483///
484/// The single encoding of the precedence ADR-024 prescribes for every remote
485/// decision: [`preferred_remote_order`] sorts by it, and
486/// `resolve_remote_tracking` (worktree.rs) uses equal tiers to detect
487/// ambiguity. Lower is more preferred; all non-special remotes share a tier.
488pub fn remote_priority(remote: &str) -> usize {
489    match remote {
490        "upstream" => 0,
491        "origin" => 1,
492        _ => 2,
493    }
494}
495
496/// Returns remotes in preferred order: upstream first, then origin, then all
497/// others in configuration order (the sort is stable).
498pub fn preferred_remote_order(repo: &Repository) -> Vec<String> {
499    let Ok(remotes) = repo.remotes() else {
500        return vec![];
501    };
502    let mut all: Vec<String> = remotes
503        .iter()
504        .flatten()
505        .flatten()
506        .map(str::to_string)
507        .collect();
508    all.sort_by_key(|r| remote_priority(r));
509    all
510}
511
512/// True if any of the repository's remotes point at `github.com`.
513///
514/// `gh` can only resolve a PR against a GitHub remote, so callers use this to skip a
515/// lookup that is guaranteed to fail (e.g. prune's PR-merged signal). Handles both URL
516/// forms (`https://github.com/...` and `git@github.com:...`); a remote whose URL fails
517/// to parse is treated as non-GitHub rather than an error.
518pub fn has_github_remote(repo: &Repository) -> bool {
519    let Ok(remotes) = repo.remotes() else {
520        return false;
521    };
522    remotes.iter().flatten().flatten().any(|name| {
523        repo.find_remote(name)
524            .ok()
525            .and_then(|r| r.url().ok().map(str::to_string))
526            .and_then(|url| crate::ssh_config::extract_host_from_url(&url))
527            .is_some_and(|host| host.eq_ignore_ascii_case("github.com"))
528    })
529}
530
531/// Select which remote to use for fetching PR refs.
532///
533/// Priority: `upstream` → `origin` → first available remote.
534/// Returns [`PrError::NoRemoteConfigured`] if the repository has no remotes.
535pub fn detect_pr_remote(repo: &Repository) -> Result<String> {
536    preferred_remote_order(repo)
537        .into_iter()
538        .next()
539        .ok_or_else(|| PrError::NoRemoteConfigured.into())
540}
541
542/// Ensure a remote for a fork PR exists, then return its name.
543///
544/// For non-fork PRs this is equivalent to [`detect_pr_remote`].
545/// For fork PRs, a remote named `pr-{number}-fork` is added if it doesn't
546/// already exist, pointing at the fork's clone URL.
547pub fn setup_fork_remote(repo: &Repository, metadata: &PrMetadata) -> Result<String> {
548    if !metadata.is_fork {
549        // Not a fork - use regular remote
550        return detect_pr_remote(repo);
551    }
552
553    // Fork PR - need to add fork remote
554    let _fork_owner = metadata
555        .fork_owner
556        .as_ref()
557        .ok_or(PrError::MissingForkOwner)?;
558
559    let fork_url = metadata
560        .fork_url
561        .as_ref()
562        .ok_or(PrError::MissingForkOwner)?;
563
564    // Check if fork remote already exists
565    let fork_remote_name = format!("pr-{}-fork", metadata.number);
566
567    if repo.find_remote(&fork_remote_name).is_ok() {
568        debug!("Fork remote {} already exists", fork_remote_name);
569        return Ok(fork_remote_name);
570    }
571
572    // Add fork as remote
573    debug!("Adding fork remote: {} -> {}", fork_remote_name, fork_url);
574    repo.remote(&fork_remote_name, fork_url)
575        .map_err(|e| PrError::FetchFailed {
576            remote: fork_remote_name.clone(),
577            message: format!("Failed to add fork remote: {}", e),
578        })?;
579
580    Ok(fork_remote_name)
581}
582
583/// Fetch `branch` from `remote_name`, making it available as
584/// `refs/remotes/{remote_name}/{branch}`.
585///
586/// This is used for both fork and non-fork PRs to fetch the PR's head branch
587/// identified via `gh` CLI metadata. If the ref already exists locally the
588/// fetch is skipped.
589pub fn fetch_branch(repo: &Repository, remote_name: &str, branch: &str) -> Result<()> {
590    // Check if branch already exists locally
591    let branch_ref = format!("refs/remotes/{}/{}", remote_name, branch);
592    if repo.find_reference(&branch_ref).is_ok() {
593        debug!("Branch ref {} already exists", branch_ref);
594        return Ok(());
595    }
596
597    debug!("Fetching branch {} from remote {}", branch, remote_name);
598
599    let refspec = format!(
600        "+refs/heads/{}:refs/remotes/{}/{}",
601        branch, remote_name, branch
602    );
603
604    let remote_url = repo
605        .find_remote(remote_name)
606        .ok()
607        .and_then(|r| r.url().ok().map(str::to_string));
608    let auth = get_remote_callbacks(repo, remote_url.as_deref())?;
609    let mut fetch_options = FetchOptions::new();
610    fetch_options.remote_callbacks(auth.callbacks());
611
612    repo.find_remote(remote_name)?
613        .fetch(
614            &[refspec.as_str()],
615            Some(&mut fetch_options),
616            Some("Fetching PR branch"),
617        )
618        .map_err(|e| PrError::FetchFailed {
619            remote: remote_name.to_string(),
620            message: e.message().to_string(),
621        })?;
622
623    debug!("Successfully fetched branch {}", branch);
624    Ok(())
625}
626
627/// Format a PR worktree name using the format string
628///
629/// Replaces `{number}` placeholder with the PR number.
630pub fn format_pr_name(format: &str, pr_number: u32) -> String {
631    format.replace("{number}", &pr_number.to_string())
632}
633
634/// Prepare everything needed to create a worktree for PR `pr_number`.
635///
636/// Orchestrates the complete PR workflow:
637/// 1. Checks that `gh` CLI is available
638/// 2. Fetches PR metadata via `gh`
639/// 3. Sets up a fork remote if the PR is cross-repository
640/// 4. Fetches the PR's head branch
641/// 5. Formats the worktree name using `pr_format`
642///
643/// Returns `(worktree_name, remote_ref, base_branch)` ready for `add_worktree`.
644pub fn prepare_pr_worktree(
645    repo: &Repository,
646    pr_number: u32,
647    pr_format: &str,
648) -> Result<(String, String, String)> {
649    debug!("Preparing PR worktree for PR #{}", pr_number);
650
651    // Fetch PR metadata from gh CLI
652    let metadata = fetch_pr_metadata(pr_number)?;
653    debug!(
654        "Fetched metadata: title='{}', author='{}', is_fork={}",
655        metadata.title, metadata.author, metadata.is_fork
656    );
657
658    // Setup remote and fetch branch
659    // For fork PRs: setup fork remote and fetch from it
660    // For non-fork PRs: use existing remote (origin/upstream)
661    let remote_name = if metadata.is_fork {
662        setup_fork_remote(repo, &metadata)?
663    } else {
664        detect_pr_remote(repo)?
665    };
666
667    // Fetch the actual branch from gh CLI metadata (works for both fork and non-fork)
668    fetch_branch(repo, &remote_name, &metadata.head_ref)?;
669
670    // Format worktree name using metadata
671    let worktree_name = format_pr_name_with_metadata(pr_format, &metadata);
672    debug!("Worktree name: {}", worktree_name);
673
674    // Build remote ref using the actual branch from metadata
675    let remote_ref = format!("{}/{}", remote_name, metadata.head_ref);
676    debug!("Remote ref: {}", remote_ref);
677
678    Ok((worktree_name, remote_ref, metadata.base_ref))
679}
680
681#[cfg(test)]
682mod tests {
683    use super::*;
684
685    #[test]
686    fn test_parse_hash_number() {
687        let pr = parse_pr_reference("#123").unwrap().unwrap();
688        assert_eq!(pr.number, 123);
689        assert_eq!(pr.remote, None);
690    }
691
692    #[test]
693    fn test_parse_pr_hash_number() {
694        let pr = parse_pr_reference("pr#456").unwrap().unwrap();
695        assert_eq!(pr.number, 456);
696        assert_eq!(pr.remote, None);
697    }
698
699    #[test]
700    fn test_parse_pr_dash_number() {
701        let pr = parse_pr_reference("pr-789").unwrap().unwrap();
702        assert_eq!(pr.number, 789);
703        assert_eq!(pr.remote, None);
704    }
705
706    #[test]
707    fn test_parse_github_url() {
708        let pr = parse_pr_reference("https://github.com/owner/repo/pull/999")
709            .unwrap()
710            .unwrap();
711        assert_eq!(pr.number, 999);
712        assert_eq!(pr.remote, None);
713    }
714
715    #[test]
716    fn test_parse_remote_ref() {
717        let pr = parse_pr_reference("origin/pull/111/head").unwrap().unwrap();
718        assert_eq!(pr.number, 111);
719        assert_eq!(pr.remote, None);
720    }
721
722    #[test]
723    fn test_parse_regular_branch_name() {
724        let result = parse_pr_reference("my-feature-branch").unwrap();
725        assert!(result.is_none());
726    }
727
728    #[test]
729    fn test_parse_invalid_number() {
730        let result = parse_pr_reference("#abc");
731        assert!(result.is_err());
732    }
733
734    #[test]
735    fn test_is_pr_reference_true() {
736        assert!(is_pr_reference("#123"));
737        assert!(is_pr_reference("pr#456"));
738        assert!(is_pr_reference("pr-789"));
739        assert!(is_pr_reference("https://github.com/owner/repo/pull/999"));
740    }
741
742    #[test]
743    fn test_is_pr_reference_false() {
744        assert!(!is_pr_reference("my-branch"));
745        assert!(!is_pr_reference("feature"));
746    }
747
748    #[test]
749    fn test_format_pr_name() {
750        assert_eq!(format_pr_name("pr-{number}", 123), "pr-123");
751        assert_eq!(format_pr_name("review-{number}", 456), "review-456");
752        assert_eq!(format_pr_name("{number}-test", 789), "789-test");
753    }
754
755    #[test]
756    fn test_sanitize_branch_name() {
757        assert_eq!(sanitize_for_branch_name("Fix Bug #123"), "fix-bug-123");
758        assert_eq!(
759            sanitize_for_branch_name("Add Feature (v2)"),
760            "add-feature-v2"
761        );
762        assert_eq!(sanitize_for_branch_name("john-smith"), "john-smith");
763        assert_eq!(
764            sanitize_for_branch_name("Fix: Authentication Issue"),
765            "fix-authentication-issue"
766        );
767        assert_eq!(sanitize_for_branch_name("Test@#$%"), "test");
768    }
769
770    #[test]
771    fn test_format_with_metadata() {
772        let metadata = PrMetadata {
773            number: 123,
774            title: "Fix Authentication Bug".to_string(),
775            author: "john-smith".to_string(),
776            head_ref: "feature/fix-auth".to_string(),
777            base_ref: "main".to_string(),
778            is_fork: false,
779            fork_owner: None,
780            fork_url: None,
781        };
782
783        assert_eq!(
784            format_pr_name_with_metadata("pr-{number}", &metadata),
785            "pr-123"
786        );
787        assert_eq!(
788            format_pr_name_with_metadata("{number}-{title}", &metadata),
789            "123-fix-authentication-bug"
790        );
791        assert_eq!(
792            format_pr_name_with_metadata("{author}/pr-{number}", &metadata),
793            "john-smith/pr-123"
794        );
795        assert_eq!(
796            format_pr_name_with_metadata("{branch}-{number}", &metadata),
797            "feature-fix-auth-123"
798        );
799    }
800
801    fn graphql(members: &str) -> String {
802        format!(r#"{{"data":{{"repository":{{{members}}}}}}}"#)
803    }
804
805    #[test]
806    fn test_parse_merged_prs_empty_nodes() {
807        let json = graphql(r#""b0":{"nodes":[]}"#);
808        assert!(parse_merged_prs(&json, &["a"]).is_empty());
809    }
810
811    #[test]
812    fn test_parse_merged_prs_populated() {
813        let json = graphql(r#""b0":{"nodes":[{"number":66,"headRefOid":"abc123"}]}"#);
814        let map = parse_merged_prs(&json, &["a"]);
815        assert_eq!(
816            map.get("a"),
817            Some(&MergedPr {
818                number: 66,
819                head_oid: "abc123".to_string()
820            })
821        );
822    }
823
824    #[test]
825    fn test_parse_merged_prs_two_aliases_one_empty() {
826        let json = graphql(r#""b0":{"nodes":[]},"b1":{"nodes":[{"number":7,"headRefOid":"def"}]}"#);
827        let map = parse_merged_prs(&json, &["a", "b"]);
828        assert_eq!(map.len(), 1);
829        assert_eq!(map["b"].number, 7);
830    }
831
832    #[test]
833    fn test_parse_merged_prs_number_missing() {
834        let json = graphql(r#""b0":{"nodes":[{"headRefOid":"abc123"}]}"#);
835        assert!(parse_merged_prs(&json, &["a"]).is_empty());
836    }
837
838    #[test]
839    fn test_parse_merged_prs_number_non_numeric() {
840        let json = graphql(r#""b0":{"nodes":[{"number":"x","headRefOid":"abc123"}]}"#);
841        assert!(parse_merged_prs(&json, &["a"]).is_empty());
842    }
843
844    #[test]
845    fn test_parse_merged_prs_head_oid_missing() {
846        let json = graphql(r#""b0":{"nodes":[{"number":66}]}"#);
847        assert!(parse_merged_prs(&json, &["a"]).is_empty());
848    }
849
850    #[test]
851    fn test_parse_merged_prs_missing_alias() {
852        let json = graphql(r#""b1":{"nodes":[{"number":66,"headRefOid":"abc"}]}"#);
853        let map = parse_merged_prs(&json, &["a", "b"]);
854        assert_eq!(map.len(), 1);
855        assert!(map.contains_key("b"));
856    }
857
858    #[test]
859    fn test_parse_merged_prs_null_repository() {
860        let json = r#"{"data":{"repository":null}}"#;
861        assert!(parse_merged_prs(json, &["a"]).is_empty());
862    }
863
864    #[test]
865    fn test_parse_merged_prs_malformed_json() {
866        assert!(parse_merged_prs("not json", &["a"]).is_empty());
867    }
868
869    #[test]
870    fn test_merged_pr_query_declares_variable_and_alias_per_branch() {
871        let query = build_merged_pr_query(3);
872        assert!(query.starts_with(
873            "query($owner:String!,$name:String!,$h0:String!,$h1:String!,$h2:String!){"
874        ));
875        for i in 0..3 {
876            assert!(query.contains(&format!("b{i}: pullRequests(headRefName:$h{i}")));
877        }
878        assert!(!query.contains("$h3"));
879        assert!(query.contains("direction:DESC"));
880    }
881
882    // Integration tests requiring gh CLI (marked with #[ignore])
883    #[test]
884    #[ignore]
885    fn test_gh_cli_available() {
886        check_gh_available().expect("gh CLI should be installed");
887    }
888
889    #[test]
890    #[ignore]
891    fn test_fetch_real_pr_metadata() {
892        // Requires gh CLI and auth
893        // This test uses a real PR from a public repo (git-workon itself if available)
894        // Replace with actual PR number from your repository for testing
895        let metadata = fetch_pr_metadata(1).expect("Failed to fetch PR metadata");
896        assert_eq!(metadata.number, 1);
897        assert!(!metadata.title.is_empty());
898        assert!(!metadata.author.is_empty());
899    }
900}