Skip to content

Commit 84419ae

Browse files
ryan-williamsclaude
andcommitted
ghpr clone auto-detects current branch's PR when no spec given
Add `_detect_current_branch_pr()` which uses `gh pr view` to find the open PR for the current branch, so `ghprc` with no args works from a branch that has a PR. Also fix `UnboundLocalError` for `gist_url` when `--no-gist` is used (discovered while writing e2e tests). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9c1b1a1 commit 84419ae

4 files changed

Lines changed: 259 additions & 5 deletions

File tree

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,6 @@ ghpr = ["shell/*.bash", "shell/*.fish"]
4848

4949
[tool.pytest.ini_options]
5050
pythonpath = ["src"]
51+
markers = [
52+
"e2e: end-to-end tests requiring gh CLI auth and network access",
53+
]

src/ghpr/commands/clone.py

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,35 @@
1515
from ..config import get_pr_info_from_path
1616
from ..files import get_expected_description_filename, write_description_with_link_ref
1717
from ..gist import extract_gist_footer, add_gist_footer, create_gist, DEFAULT_GIST_REMOTE, find_gist_remote
18-
from ..patterns import parse_pr_spec, GIST_ID_PATTERN
18+
from ..patterns import parse_pr_spec, GIST_ID_PATTERN, GITHUB_ITEM_URL_PATTERN
19+
20+
21+
def _detect_current_branch_pr() -> tuple[str | None, str | None, str | None, str | None]:
22+
"""Try to find an open PR for the current branch.
23+
24+
Uses `gh pr view` which finds the PR associated with the current branch.
25+
26+
Returns:
27+
Tuple of (owner, repo, number, item_type) or (None, None, None, None).
28+
"""
29+
from subprocess import DEVNULL
30+
try:
31+
data = proc.json(
32+
'gh', 'pr', 'view', '--json', 'number,url',
33+
log=None, err_ok=True, stderr=DEVNULL,
34+
)
35+
if data and data.get('number'):
36+
url = data.get('url', '')
37+
match = GITHUB_ITEM_URL_PATTERN.match(url)
38+
if match:
39+
owner, repo, _, number = match.groups()
40+
return owner, repo, number, 'pr'
41+
# Fallback: use repo view for owner/repo
42+
repo_data = proc.json('gh', 'repo', 'view', '--json', 'owner,name', log=None)
43+
return repo_data['owner']['login'], repo_data['name'], str(data['number']), 'pr'
44+
except Exception:
45+
pass
46+
return None, None, None, None
1947

2048

2149
def clone(
@@ -49,9 +77,12 @@ def clone(
4977
err("Use owner/repo#number format.")
5078
exit(1)
5179
else:
52-
# Try to infer from current directory
53-
owner, repo, number = get_pr_info_from_path()
54-
item_type = None
80+
# Try to detect PR for the current branch
81+
owner, repo, number, item_type = _detect_current_branch_pr()
82+
if not number:
83+
# Fall back to inferring from directory structure
84+
owner, repo, number = get_pr_info_from_path()
85+
item_type = None
5586

5687
if not all([owner, repo, number]):
5788
err("Error: Could not determine PR/Issue to clone")
@@ -121,9 +152,9 @@ def clone(
121152
proc.run('git', 'commit', '-m', f'Initial clone of {item_label} {owner}/{repo}#{number}', log=None)
122153

123154
# Create or use existing gist unless --no-gist was specified
155+
gist_url = None
124156
if not no_gist:
125157
gist_id = None
126-
gist_url = None
127158

128159
# Check if PR already has a gist in its footer
129160
if existing_gist_url:

tests/test_clone_e2e.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
"""End-to-end tests for ghpr clone.
2+
3+
These tests require `gh` CLI authentication and network access.
4+
Skip with: pytest -m 'not e2e'
5+
"""
6+
7+
import os
8+
import subprocess
9+
from pathlib import Path
10+
from shutil import which
11+
from tempfile import TemporaryDirectory
12+
13+
import pytest
14+
15+
16+
def gh_authenticated() -> bool:
17+
"""Check if `gh` CLI is installed and authenticated."""
18+
if not which('gh'):
19+
return False
20+
try:
21+
result = subprocess.run(
22+
['gh', 'auth', 'status'],
23+
capture_output=True, timeout=10,
24+
)
25+
return result.returncode == 0
26+
except Exception:
27+
return False
28+
29+
30+
requires_gh = pytest.mark.skipif(
31+
not gh_authenticated(),
32+
reason='requires authenticated gh CLI',
33+
)
34+
e2e = pytest.mark.e2e
35+
36+
37+
@requires_gh
38+
@e2e
39+
class TestCloneE2E:
40+
"""End-to-end clone tests using real GitHub PRs."""
41+
42+
def test_clone_pr_no_gist(self):
43+
"""Clone a known PR with --no-gist, verify directory structure."""
44+
with TemporaryDirectory() as tmpdir:
45+
result = subprocess.run(
46+
[
47+
'ghpr', 'clone',
48+
'https://github.com/runsascoded/ghpr/pull/6',
49+
'--no-gist',
50+
'-d', os.path.join(tmpdir, 'pr6'),
51+
],
52+
capture_output=True, text=True, timeout=30,
53+
)
54+
assert result.returncode == 0, f"clone failed: {result.stderr}"
55+
56+
pr_dir = Path(tmpdir) / 'pr6'
57+
assert pr_dir.is_dir()
58+
59+
# Should have a description file
60+
desc_files = list(pr_dir.glob('*.md'))
61+
assert len(desc_files) >= 1, f"No .md files in {pr_dir}"
62+
63+
# Description file should be repo#number.md
64+
desc_names = [f.name for f in desc_files if not f.name.startswith('z')]
65+
assert 'ghpr#6.md' in desc_names
66+
67+
# Should have git config set
68+
owner = subprocess.run(
69+
['git', 'config', 'pr.owner'],
70+
capture_output=True, text=True, cwd=pr_dir,
71+
).stdout.strip()
72+
assert owner == 'runsascoded'
73+
74+
repo = subprocess.run(
75+
['git', 'config', 'pr.repo'],
76+
capture_output=True, text=True, cwd=pr_dir,
77+
).stdout.strip()
78+
assert repo == 'ghpr'
79+
80+
pr_type = subprocess.run(
81+
['git', 'config', 'pr.type'],
82+
capture_output=True, text=True, cwd=pr_dir,
83+
).stdout.strip()
84+
assert pr_type == 'pr'
85+
86+
def test_clone_pr_with_gist_reuse(self):
87+
"""Clone a PR that has an existing gist footer; verify gist is reused."""
88+
with TemporaryDirectory() as tmpdir:
89+
result = subprocess.run(
90+
[
91+
'ghpr', 'clone',
92+
'https://github.com/marin-community/marin/pull/1723',
93+
'--no-comments',
94+
'-d', os.path.join(tmpdir, 'pr1723'),
95+
],
96+
capture_output=True, text=True, timeout=60,
97+
)
98+
assert result.returncode == 0, f"clone failed: {result.stderr}"
99+
100+
pr_dir = Path(tmpdir) / 'pr1723'
101+
assert pr_dir.is_dir()
102+
103+
# Should have detected and reused existing gist
104+
assert 'Found existing gist' in result.stderr, (
105+
f"Expected gist reuse message in stderr:\n{result.stderr}"
106+
)
107+
108+
# Gist remote should be configured
109+
gist_remote = subprocess.run(
110+
['git', 'config', 'pr.gist'],
111+
capture_output=True, text=True, cwd=pr_dir,
112+
).stdout.strip()
113+
assert gist_remote, "pr.gist config not set (gist not reused)"
114+
115+
# Gist should be added as a git remote
116+
remotes = subprocess.run(
117+
['git', 'remote', '-v'],
118+
capture_output=True, text=True, cwd=pr_dir,
119+
).stdout
120+
assert 'gist.github.com' in remotes
121+
122+
def test_clone_issue_no_gist(self):
123+
"""Clone a known issue, verify it's detected as an issue."""
124+
with TemporaryDirectory() as tmpdir:
125+
result = subprocess.run(
126+
[
127+
'ghpr', 'clone',
128+
'https://github.com/marin-community/marin/issues/1773',
129+
'--no-gist', '--no-comments',
130+
'-d', os.path.join(tmpdir, 'issue1773'),
131+
],
132+
capture_output=True, text=True, timeout=30,
133+
)
134+
assert result.returncode == 0, f"clone failed: {result.stderr}"
135+
136+
pr_dir = Path(tmpdir) / 'issue1773'
137+
assert pr_dir.is_dir()
138+
139+
pr_type = subprocess.run(
140+
['git', 'config', 'pr.type'],
141+
capture_output=True, text=True, cwd=pr_dir,
142+
).stdout.strip()
143+
assert pr_type == 'issue'

tests/test_detect_pr.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"""Tests for _detect_current_branch_pr."""
2+
3+
from unittest.mock import patch, MagicMock
4+
5+
import pytest
6+
7+
from ghpr.commands.clone import _detect_current_branch_pr
8+
9+
10+
class TestDetectCurrentBranchPR:
11+
"""Unit tests for detecting the current branch's PR."""
12+
13+
def test_detects_pr_from_gh_pr_view(self):
14+
"""When `gh pr view` returns valid data, extract owner/repo/number."""
15+
mock_data = {
16+
'number': 100,
17+
'url': 'https://github.com/Quantum-Accelerators/electrai/pull/100',
18+
}
19+
with patch('ghpr.commands.clone.proc') as mock_proc:
20+
mock_proc.json.return_value = mock_data
21+
owner, repo, number, item_type = _detect_current_branch_pr()
22+
assert owner == 'Quantum-Accelerators'
23+
assert repo == 'electrai'
24+
assert number == '100'
25+
assert item_type == 'pr'
26+
27+
def test_fallback_to_repo_view_when_url_missing(self):
28+
"""When URL doesn't match pattern, fall back to `gh repo view`."""
29+
pr_data = {'number': 42, 'url': ''}
30+
repo_data = {'owner': {'login': 'someorg'}, 'name': 'somerepo'}
31+
with patch('ghpr.commands.clone.proc') as mock_proc:
32+
mock_proc.json.side_effect = [pr_data, repo_data]
33+
owner, repo, number, item_type = _detect_current_branch_pr()
34+
assert owner == 'someorg'
35+
assert repo == 'somerepo'
36+
assert number == '42'
37+
assert item_type == 'pr'
38+
39+
def test_returns_none_when_no_pr(self):
40+
"""When `gh pr view` fails (no PR for branch), return None tuple."""
41+
with patch('ghpr.commands.clone.proc') as mock_proc:
42+
mock_proc.json.side_effect = Exception('no PR found')
43+
result = _detect_current_branch_pr()
44+
assert result == (None, None, None, None)
45+
46+
def test_returns_none_when_empty_response(self):
47+
"""When `gh pr view` returns None/empty, return None tuple."""
48+
with patch('ghpr.commands.clone.proc') as mock_proc:
49+
mock_proc.json.return_value = None
50+
result = _detect_current_branch_pr()
51+
assert result == (None, None, None, None)
52+
53+
def test_returns_none_when_number_missing(self):
54+
"""When response lacks a number field, return None tuple."""
55+
with patch('ghpr.commands.clone.proc') as mock_proc:
56+
mock_proc.json.return_value = {'url': 'https://github.com/a/b/pull/1'}
57+
result = _detect_current_branch_pr()
58+
assert result == (None, None, None, None)
59+
60+
def test_parses_various_url_formats(self):
61+
"""Verify URL parsing for different GitHub URL patterns."""
62+
cases = [
63+
(
64+
'https://github.com/org/repo/pull/1',
65+
('org', 'repo', '1', 'pr'),
66+
),
67+
(
68+
'https://github.com/my-org/my-repo/pull/999',
69+
('my-org', 'my-repo', '999', 'pr'),
70+
),
71+
]
72+
for url, expected in cases:
73+
data = {'number': int(expected[2]), 'url': url}
74+
with patch('ghpr.commands.clone.proc') as mock_proc:
75+
mock_proc.json.return_value = data
76+
result = _detect_current_branch_pr()
77+
assert result == expected, f"Failed for URL: {url}"

0 commit comments

Comments
 (0)