diff --git a/graphify/prs.py b/graphify/prs.py index 9534e6c00..5bb61510b 100644 --- a/graphify/prs.py +++ b/graphify/prs.py @@ -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 # filter to PRs targeting this base (default: v8) + graphify prs --limit # max PRs per gh page (default: 20; auto-retries smaller on failure) """ from __future__ import annotations @@ -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], @@ -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: @@ -195,8 +232,27 @@ 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," @@ -204,10 +260,23 @@ def fetch_prs(repo: str | None = None, base: str | None = None, limit: int = 50) ] 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]: + 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: @@ -681,6 +750,7 @@ def triage_with_opus(prs: list[PRInfo], base: str) -> None: def cmd_prs(argv: list[str]) -> None: 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 @@ -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): @@ -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) diff --git a/tests/test_prs.py b/tests/test_prs.py index 4acc343e6..ee76cc6c8 100644 --- a/tests/test_prs.py +++ b/tests/test_prs.py @@ -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, ) @@ -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: