Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 85 additions & 11 deletions graphify/prs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
graphify prs --worktrees # show worktree → branch → PR mapping
graphify prs --conflicts # PRs sharing graph communities (merge-order risk)
graphify prs --base <branch> # filter to PRs targeting this base (default: v8)
graphify prs --limit <n> # max PRs per gh page (default: 20; auto-retries smaller on failure)
"""

from __future__ import annotations
Expand Down Expand Up @@ -138,7 +139,28 @@ def _ci_icon(status: str) -> str:

# ── GitHub data fetching ──────────────────────────────────────────────────────

def _gh(*args: str) -> list | dict | None:
_DEFAULT_PR_LIMIT = 20
_FALLBACK_PAGE_LIMITS = (20, 10)


@dataclass(frozen=True)
class GhFailure:
message: str
missing: bool = False


def _gh_err(result: subprocess.CompletedProcess[str]) -> str:
err = (result.stderr or result.stdout or "").strip()
if not err:
return f"gh exited with code {result.returncode}"
line = err.splitlines()[-1].strip()
low = line.lower()
if any(tok in low for tok in ("auth", "login", "401", "403", "not logged")):
return f"{line} (run: gh auth login)"
return line


def _gh_call(*args: str) -> tuple[list | dict | None, GhFailure | None]:
try:
result = subprocess.run(
["gh", *args],
Expand All @@ -147,11 +169,26 @@ def _gh(*args: str) -> list | dict | None:
# default text=True decode crashes on those (#1505 fixed the same in llm).
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=30
)
if result.returncode != 0:
return None
return json.loads(result.stdout)
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
return None
except FileNotFoundError:
return None, GhFailure(
"gh CLI not found. Install GitHub CLI: https://cli.github.com/",
missing=True,
)
except subprocess.TimeoutExpired:
return None, GhFailure("gh command timed out after 30s")

if result.returncode != 0:
return None, GhFailure(_gh_err(result))

try:
return json.loads(result.stdout), None
except json.JSONDecodeError as exc:
return None, GhFailure(f"gh returned invalid JSON: {exc}")


def _gh(*args: str) -> list | dict | None:
data, _ = _gh_call(*args)
return data


def _detect_default_branch(repo: str | None = None) -> str:
Expand Down Expand Up @@ -195,19 +232,51 @@ def _parse_ci(rollup: list) -> str:
return "NONE"


def fetch_prs(repo: str | None = None, base: str | None = None, limit: int = 50) -> list[PRInfo]:
resolved_base = base or _detect_default_branch(repo)
def _page_limits(limit: int) -> list[int]:
chain = [limit]
for fb in _FALLBACK_PAGE_LIMITS:
if fb < chain[-1]:
chain.append(fb)
return chain


def _parse_limit(raw: str) -> int:
try:
v = int(raw)
except ValueError:
print(f"error: --limit must be a positive integer (got {raw!r})", file=sys.stderr)
sys.exit(2)
if v <= 0:
print(f"error: --limit must be > 0 (got {v})", file=sys.stderr)
sys.exit(2)
return v


def _fetch_prs_page(repo: str | None, limit: int) -> tuple[list | dict | None, GhFailure | None]:
args = [
"pr", "list", "--state", "open", "--limit", str(limit),
"--json", "number,title,headRefName,baseRefName,author,isDraft,"
"reviewDecision,statusCheckRollup,updatedAt",
]
if repo:
args += ["--repo", repo]
return _gh_call(*args)


def fetch_prs(repo: str | None = None, base: str | None = None, limit: int = _DEFAULT_PR_LIMIT) -> list[PRInfo]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionfetch_prs()

9 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressionfetch_prs()

9 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

resolved_base = base or _detect_default_branch(repo)
last: GhFailure | None = None
raw: list | dict | None = None
for page in _page_limits(limit):
raw, last = _fetch_prs_page(repo, page)
if raw is not None:
break

raw = _gh(*args)
if raw is None:
raise RuntimeError("gh CLI not found or not authenticated. Run: gh auth login")
if last and last.missing:
raise RuntimeError(last.message)
detail = last.message if last else "unknown gh error"
raise RuntimeError(f"gh pr list failed: {detail}")

prs = []
for item in raw:
Expand Down Expand Up @@ -681,6 +750,7 @@ def triage_with_opus(prs: list[PRInfo], base: str) -> None:
def cmd_prs(argv: list[str]) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressioncmd_prs()

fans out to 12 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressioncmd_prs()

fans out to 13 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

base: str | None = None # auto-detected from repo if not given
repo: str | None = None
limit = _DEFAULT_PR_LIMIT
do_triage = False
do_worktrees = False
do_conflicts = False
Expand All @@ -705,6 +775,10 @@ def cmd_prs(argv: list[str]) -> None:
base = arg.split("=", 1)[1]
elif arg in ("--repo", "-R") and i + 1 < len(argv):
repo = argv[i + 1]; i += 1
elif arg in ("--limit", "-n") and i + 1 < len(argv):
limit = _parse_limit(argv[i + 1]); i += 1
elif arg.startswith("--limit="):
limit = _parse_limit(arg.split("=", 1)[1])
elif arg.startswith("--graph="):
graph_path = Path(arg.split("=", 1)[1])
elif arg == "--graph" and i + 1 < len(argv):
Expand All @@ -720,7 +794,7 @@ def cmd_prs(argv: list[str]) -> None:
base = _detect_default_branch(repo)

try:
prs = fetch_prs(repo=repo, base=base)
prs = fetch_prs(repo=repo, base=base, limit=limit)
except RuntimeError as e:
print(red(f" Error: {e}"), file=sys.stderr)
sys.exit(1)
Expand Down
105 changes: 105 additions & 0 deletions tests/test_prs.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,22 @@

from graphify.prs import (
PRInfo,
GhFailure,
_classify,
_gh,
_gh_call,
_page_limits,
_parse_limit,
_parse_ci,
_path_match,
build_community_labels,
compute_pr_impact,
fetch_pr_files,
fetch_prs,
fetch_worktrees,
format_prs_text,
_detect_default_branch,
_DEFAULT_PR_LIMIT,
)


Expand Down Expand Up @@ -55,6 +61,105 @@ def make_pr(
)


# ── _gh_call / fetch_prs (#2850) ─────────────────────────────────────────────

class TestGhCall:
def test_missing_gh_is_distinct_from_auth_failure(self):
with patch("graphify.prs.subprocess.run", side_effect=FileNotFoundError):
data, err = _gh_call("pr", "list")
assert data is None
assert err is not None
assert err.missing is True
assert "not found" in err.message.lower()

def test_nonzero_exit_surfaces_stderr(self):
completed = MagicMock(returncode=1, stdout="", stderr="GraphQL: HTTP 504")
with patch("graphify.prs.subprocess.run", return_value=completed):
data, err = _gh_call("pr", "list")
assert data is None
assert err is not None
assert err.missing is False
assert "504" in err.message

def test_auth_hint_only_when_stderr_suggests_auth(self):
completed = MagicMock(returncode=1, stdout="", stderr="not logged in")
with patch("graphify.prs.subprocess.run", return_value=completed):
_, err = _gh_call("pr", "list")
assert "gh auth login" in err.message

def test_504_does_not_suggest_auth_login(self):
completed = MagicMock(returncode=1, stdout="", stderr="GraphQL: HTTP 504")
with patch("graphify.prs.subprocess.run", return_value=completed):
_, err = _gh_call("pr", "list")
assert "gh auth login" not in err.message


class TestPageLimits:
def test_default_chain_from_50(self):
assert _page_limits(50) == [50, 20, 10]

def test_default_limit_chain(self):
assert _page_limits(_DEFAULT_PR_LIMIT) == [20, 10]

def test_small_limit_has_no_larger_fallback(self):
assert _page_limits(10) == [10]


class TestParseLimit:
def test_valid(self):
assert _parse_limit("20") == 20

def test_invalid_exits(self):
with pytest.raises(SystemExit) as exc:
_parse_limit("abc")
assert exc.value.code == 2

def test_nonpositive_exits(self):
with pytest.raises(SystemExit) as exc:
_parse_limit("0")
assert exc.value.code == 2


class TestFetchPrs:
_SAMPLE = [{
"number": 1,
"title": "Fix",
"headRefName": "fix",
"baseRefName": "v8",
"author": {"login": "alice"},
"isDraft": False,
"reviewDecision": "",
"statusCheckRollup": [],
"updatedAt": "2026-01-01T00:00:00Z",
}]

def test_retries_smaller_page_after_failure(self):
fail = MagicMock(returncode=1, stdout="", stderr="GraphQL: HTTP 504")
ok = MagicMock(
returncode=0,
stdout=json.dumps(self._SAMPLE),
stderr="",
)
with patch("graphify.prs.subprocess.run", side_effect=[fail, ok]), \
patch("graphify.prs._detect_default_branch", return_value="v8"):
prs = fetch_prs(limit=50)
assert len(prs) == 1
assert prs[0].number == 1

def test_missing_gh_raises_install_message(self):
with patch("graphify.prs.subprocess.run", side_effect=FileNotFoundError), \
patch("graphify.prs._detect_default_branch", return_value="v8"):
with pytest.raises(RuntimeError, match="not found"):
fetch_prs()

def test_exhausted_retries_raise_actual_gh_error(self):
fail = MagicMock(returncode=1, stdout="", stderr="GraphQL: HTTP 504")
with patch("graphify.prs.subprocess.run", return_value=fail), \
patch("graphify.prs._detect_default_branch", return_value="v8"):
with pytest.raises(RuntimeError, match="504"):
fetch_prs(limit=10)


# ── _classify ─────────────────────────────────────────────────────────────────

class TestClassify:
Expand Down