diff --git a/python/cube/commands/peer_review.py b/python/cube/commands/peer_review.py index c3c961e7..a264fb48 100644 --- a/python/cube/commands/peer_review.py +++ b/python/cube/commands/peer_review.py @@ -88,9 +88,7 @@ def _archive_decisions( # resumed judge will see prompt-level instructions to ignore # any prior decision content anyway. pass - print_info( - f"📦 Archived {len(matches)} prior decision file(s) → " f".prompts/decisions/archive/{archive_root.name}/" - ) + print_info(f"📦 Archived {len(matches)} prior decision file(s) → .prompts/decisions/archive/{archive_root.name}/") def _format_thread_context(threads: list) -> str: @@ -333,7 +331,7 @@ def _ensure_pr_review_worktree( timeout=60, ) if result.returncode != 0: - raise RuntimeError(f"PR review worktree sync failed ({' '.join(cmd)}): " f"{result.stderr.strip()}") + raise RuntimeError(f"PR review worktree sync failed ({' '.join(cmd)}): {result.stderr.strip()}") else: worktree.parent.mkdir(parents=True, exist_ok=True) add = subprocess.run( @@ -456,7 +454,7 @@ def _reconcile_panel_results( ), log_path=None, recovery_command=( - f"cube prv {task_id[len('pr-'):]}" + f"cube prv {task_id[len('pr-') :]}" if disk_status == "missing" and task_id.startswith("pr-") else None ), @@ -500,7 +498,7 @@ def _reconcile_panel_results( attempts=existing.attempts, error_message="Decision file disappeared after panel completion", log_path=existing.log_path, - recovery_command=(f"cube prv {task_id[len('pr-'):]}" if task_id.startswith("pr-") else None), + recovery_command=(f"cube prv {task_id[len('pr-') :]}" if task_id.startswith("pr-") else None), forcing_follow_up_used=False, ) ) @@ -564,7 +562,10 @@ def _run_pr_review( except Exception as e: print_warning(f"Could not fetch from origin: {e}") - if not pr.diff.strip(): + # Use the cheap boolean check (git diff --quiet, exit-status only) + # instead of fetching diff content. Judges go through the worktree + # via Read; we don't need the diff string here. + if not pr.has_changes(): print_warning("PR has no diff - nothing to review") raise typer.Exit(0) @@ -774,7 +775,7 @@ def _run_pr_review( if missing_judges: names = ", ".join(j.label for j in missing_judges) print_warning( - f"Partial panel failure: {len(missing_judges)} expected judge(s) " f"failed to produce a decision: {names}" + f"Partial panel failure: {len(missing_judges)} expected judge(s) failed to produce a decision: {names}" ) print_info("Continuing with available judge decisions...") @@ -854,11 +855,15 @@ def _run_pr_review( # Use AI deduper to intelligently combine/dedupe all feedback AND merge # human_calls across lenses asking the same question. + # Lazily compute the diff right before dedupe — capped at 8000 + # lines, fetched via local git (no API cap). The dedupe prompt + # already truncates to ~8000 chars, so this is the right place + # to enforce a hard ceiling. dedupe_result = run_async( run_dedupe_agent( feedback=all_feedback, existing_comments=existing_comments, - pr_diff=pr.diff, + pr_diff=pr.get_diff(), pr_number=pr_number, human_calls=raw_human_calls, ) @@ -937,7 +942,7 @@ def _run_pr_review( + (f" (+{len(gate.reasons) - 3} more)" if len(gate.reasons) > 3 else "") ) elif post_action == "APPROVE": - print_success(f"Auto-approve gate passed — posting APPROVE " f"({approvals}/{expected_total} judges APPROVED)") + print_success(f"Auto-approve gate passed — posting APPROVE ({approvals}/{expected_total} judges APPROVED)") # Append the gate body to the summary so the GitHub review body explains # what the gate saw (judges + reasons, or the clean approval message). summary = summary + "\n\n" + gate.body @@ -1165,7 +1170,7 @@ def _run_branch_review( names = ", ".join(j.label for j in missing_judges) missing_suffix = f" ⚠️ {len(missing_judges)} judge(s) failed to produce a decision: {names}." print_warning( - f"Partial panel failure: {len(missing_judges)} expected judge(s) " f"failed to produce a decision: {names}" + f"Partial panel failure: {len(missing_judges)} expected judge(s) failed to produce a decision: {names}" ) print_info("Continuing with available judge decisions...") @@ -1238,7 +1243,7 @@ def _run_branch_review( for inline in dedupe_result.inline_comments: comment = inline.comment console.print( - f"[yellow]{comment.severity.upper():8}[/yellow] " f"{_rich_escape(str(comment.path))}:{comment.line}" + f"[yellow]{comment.severity.upper():8}[/yellow] {_rich_escape(str(comment.path))}:{comment.line}" ) console.print(f"[dim]{_rich_escape(', '.join(inline.judges))}[/dim] {_rich_escape(comment.body)}") console.print() diff --git a/python/cube/commands/ui_review.py b/python/cube/commands/ui_review.py index d7496150..cd9bfbb9 100644 --- a/python/cube/commands/ui_review.py +++ b/python/cube/commands/ui_review.py @@ -71,7 +71,18 @@ def _fetch_pr_ui_diff(pr_number: int, repo: Optional[str] = None) -> tuple[str, raise RuntimeError("gh CLI not installed or not authenticated. Run: gh auth login") pr = fetch_pr(pr_number, cwd=str(PROJECT_ROOT), repo=repo) - ui_diff = _filter_ui_diff(pr.diff) + # UI review filters the diff by extension. Fetch with a generous + # ceiling so a huge mixed PR (lots of non-UI changes before the + # first UI file) doesn't get its UI hunks dropped to the + # truncation marker. The filter shrinks the result to UI-only; + # we cap AFTER filtering so the budget applies to relevant content. + raw_diff = pr.get_diff(max_lines=1_000_000) + ui_diff = _filter_ui_diff(raw_diff) + # Now apply a sane cap to the filtered result so prompt size is + # bounded even when the UI portion is genuinely huge. + ui_lines = ui_diff.splitlines() + if len(ui_lines) > 20_000: + ui_diff = "\n".join(ui_lines[:20_000]) + f"\n[... {len(ui_lines) - 20_000} more UI-diff lines truncated ...]" if not ui_diff.strip(): raise RuntimeError( @@ -458,7 +469,7 @@ def ui_review_command( total = len(findings["P0"]) + len(findings["P1"]) + len(findings["P2"]) if total > 0: print_success( - f"UI review complete: {len(findings['P0'])} P0, " f"{len(findings['P1'])} P1, {len(findings['P2'])} P2" + f"UI review complete: {len(findings['P0'])} P0, {len(findings['P1'])} P1, {len(findings['P2'])} P2" ) else: print_warning("No findings collected from judges") diff --git a/python/cube/github/pulls.py b/python/cube/github/pulls.py index 2874a65a..225df28c 100644 --- a/python/cube/github/pulls.py +++ b/python/cube/github/pulls.py @@ -8,12 +8,25 @@ @dataclass class PRData: - """PR metadata and diff content.""" + """PR metadata + lazy diff access. + + ``diff`` is intentionally **not eagerly fetched** any more. Judges + don't read it (they go through the worktree via Read; see + ``judge_panel.py:353`` — "Don't pull the whole PR diff. Read files + individually — it's faster"). The only consumers are: + - the empty-PR check (which doesn't need the content, just a + boolean answer — see ``has_changes()``) + - the dedupe agent (which truncates to ~8000 chars anyway) + + Eager-fetching the full diff via ``gh pr diff`` was hitting + GitHub's 20,000-line API cap (HTTP 406) on large PRs and aborting + the entire panel. Now: call ``get_diff()`` lazily only when content + is actually needed; ``has_changes()`` for the cheap boolean check. + """ number: int title: str body: str - diff: str head_branch: str base_branch: str head_sha: str @@ -21,11 +34,113 @@ class PRData: state: str = "" merged_at: str = "" + # Back-compat: callers that read ``pr.diff`` directly. None until + # someone calls ``get_diff()`` — the old eager-populated string is + # gone. Keeps the attribute name so existing code reading it sees + # "" (no diff yet) rather than AttributeError. + diff: str = "" + + # Stashed for the lazy diff computation. Populated by ``fetch_pr``; + # callers shouldn't read these directly. + _cwd: Optional[str] = None + _repo: Optional[str] = None + # Memoises the uncapped diff so a second get_diff() call with a + # larger cap doesn't re-shell out (and isn't stuck with a smaller + # cap from a prior call). The capped value lives in ``diff`` for + # back-compat; the raw lives here. + _uncapped_diff: str = "" + @property def is_merged(self) -> bool: """Return True when GitHub reports this PR as merged.""" return self.state.upper() == "MERGED" or bool(self.merged_at) + def has_changes(self) -> bool: + """Cheap boolean check: does the PR touch any files? + + Uses ``git diff --quiet`` exit status — never fetches content, + never trips the 20k-line API cap. Falls back to ``True`` if + local git can't answer (caller's empty-bail is "treat as + non-empty" so we don't skip a real PR just because git is + unreachable). + + Cross-repo safety: when ``_repo`` is set (caller passed + ``--repo other/owner``), the local checkout's branches almost + certainly don't match the target repo. Skip the local path + and let the conservative default (True) hold so a same-named + branch in this repo doesn't produce a false-empty answer. + """ + if self._repo: + return True + if self.head_branch and self.base_branch: + try: + result = subprocess.run( + ["git", "fetch", "--quiet", "origin", self.base_branch, self.head_branch], + cwd=self._cwd, + capture_output=True, + timeout=60, + ) + if result.returncode == 0: + # `--quiet` exits 0 if NO diff, 1 if there IS a diff. + diff_check = subprocess.run( + [ + "git", + "diff", + "--quiet", + f"origin/{self.base_branch}...origin/{self.head_branch}", + ], + cwd=self._cwd, + capture_output=True, + timeout=30, + ) + # git diff --quiet exit code: + # 0 = NO diff + # 1 = HAS diff + # >1 = error (bad ref, etc — fall through to True) + if diff_check.returncode == 0: + return False + if diff_check.returncode == 1: + return True + # Any other code = git couldn't answer; conservative + # fallback (let the review proceed). + except (OSError, subprocess.TimeoutExpired): + pass + # Fallback: assume there are changes (don't skip the review). + return True + + def get_diff(self, *, max_lines: int = 8000) -> str: + """Return the PR's diff, capped at ``max_lines`` lines. + + Strategy: + 1. Local-git path: ``git diff origin/...origin/`` + in the caller's cwd. No API call, no size cap from GitHub. + 2. Fallback to ``gh pr diff`` only when local git can't + resolve the refs. + 3. Truncate at ``max_lines`` per-call so each caller's cap is + independent. The uncapped diff is memoised on the instance, + so a 20k-line call after an 8k-line call recovers the lines + the smaller cap dropped — and the second call doesn't pay + a second git/gh round-trip. + """ + if not self._uncapped_diff: + self._uncapped_diff = _fetch_pr_diff( + self.number, + head_branch=self.head_branch, + base_branch=self.base_branch, + cwd=self._cwd, + repo_flags=(["--repo", self._repo] if self._repo else []), + ) + # Truncate per-call so each caller's cap is independent. + lines = self._uncapped_diff.splitlines() + if len(lines) > max_lines: + text = "\n".join(lines[:max_lines]) + f"\n[... {len(lines) - max_lines} more lines truncated ...]" + else: + text = self._uncapped_diff + # Keep self.diff populated for back-compat readers, with the + # most-recently-requested cap. + self.diff = text + return text + def check_gh_installed() -> bool: """Check if gh CLI is installed and authenticated.""" @@ -82,31 +197,160 @@ def fetch_pr(pr_number: int, cwd: Optional[str] = None, repo: Optional[str] = No except json.JSONDecodeError as e: raise RuntimeError(f"Failed to parse PR data: {e}") - # Get diff separately - diff_result = subprocess.run( - ["gh", "pr", "diff", str(pr_number)] + repo_flags, - capture_output=True, - text=True, - cwd=cwd, - ) - - if diff_result.returncode != 0: - raise RuntimeError(f"Failed to fetch PR diff: {diff_result.stderr.strip()}") - + # NOTE: we deliberately do NOT eagerly fetch the diff here. Most + # callers (the judge panel, panel orchestration) never read it — + # judges go through the worktree via Read instead. The only + # consumers are the empty-PR check (cheap boolean via + # ``has_changes()``) and the dedupe agent (lazy via ``get_diff()``, + # truncated at 8000 lines). + # + # The old eager ``gh pr diff`` call was hitting GitHub's 20,000-line + # API cap (HTTP 406) on large PRs, aborting peer review before any + # judge ran. Lazy + local-git-first removes the cap entirely. return PRData( number=data.get("number", pr_number), title=data.get("title", ""), body=data.get("body") or "", - diff=diff_result.stdout, head_branch=data.get("headRefName", ""), base_branch=data.get("baseRefName", ""), head_sha=data.get("headRefOid", ""), existing_reviews=data.get("reviews") or [], state=data.get("state", ""), merged_at=data.get("mergedAt") or "", + _cwd=cwd, + _repo=repo, ) +def _fetch_pr_diff( + pr_number: int, + *, + head_branch: str, + base_branch: str, + cwd: Optional[str], + repo_flags: list[str], +) -> str: + """Return the PR's diff text. + + Strategy: + 1. Local-git path (fast, no API cap): ``git fetch origin `` + then ``git diff origin/...origin/`` (three-dot — + diff from merge-base, matching what GitHub computes). + 2. Fall back to ``gh pr diff `` only when (1) fails — e.g. cube + is invoked outside a checkout, or origin doesn't track the PR. + The fallback can hit GitHub's 20,000-line hard cap (HTTP 406); + we surface that with a clear message pointing at the workaround. + + Why this matters: large PRs (e.g. >20k LOC diff, large vendor + bumps, generated-code regenerations) silently broke ``cube prv`` + on the API path. The local checkout has the full diff with no cap. + """ + # Cross-repo safety: when the caller targeted a non-default repo + # via ``--repo other/owner``, the LOCAL checkout's branches don't + # belong to that repo. A same-named local branch would produce a + # diff from this repo instead of the requested one — silently + # wrong output. Skip the local path entirely in that case. + cross_repo = bool(repo_flags) + if head_branch and base_branch and not cross_repo: + local_diff = _try_local_git_diff( + head_branch=head_branch, + base_branch=base_branch, + cwd=cwd, + ) + if local_diff is not None: + return local_diff + + # API fallback. Same shape as the legacy path so callers see the + # same diff format (unified diff text) on either route. Bounded + # timeout to match the local-git path — a stalled gh call must + # not hang the panel indefinitely (this PR exists for the cases + # where the GH API is slow or angry on large PRs). + try: + diff_result = subprocess.run( + ["gh", "pr", "diff", str(pr_number)] + repo_flags, + capture_output=True, + text=True, + cwd=cwd, + timeout=120, + ) + except subprocess.TimeoutExpired as e: + raise RuntimeError( + f"`gh pr diff {pr_number}` timed out after 120s. " + f"For large PRs, run cube from inside the checkout so the " + f"local-git diff path can be used instead." + ) from e + if diff_result.returncode != 0: + stderr = diff_result.stderr.strip() + hint = "" + if "exceeded the maximum" in stderr or "too_large" in stderr or "406" in stderr: + hint = ( + "\n\nThe PR is too large for GitHub's diff API (20,000-line cap). " + "cube normally falls back to local `git diff` for this case — " + "ensure the cwd is inside the cube checkout and `origin` has the " + f"PR's head + base refs (`git fetch origin {head_branch} {base_branch}`)." + ) + raise RuntimeError(f"Failed to fetch PR diff: {stderr}{hint}") + return diff_result.stdout + + +def _try_local_git_diff( + *, + head_branch: str, + base_branch: str, + cwd: Optional[str], +) -> Optional[str]: + """Compute the three-dot diff via local git. Returns None on any + failure so the caller falls back to the API path. + + Three-dot intentionally matches what ``gh pr diff`` returns (diff + from the merge-base, not the current tip of the base branch) — + keeps caller assertions stable across the two paths. + + Fetches origin first so refs are fresh; uses ``--no-tags`` and + ``--depth=...`` aren't used here because cube's CI / dev + environments are usually full clones. Failures (no origin, + detached HEAD, network) all degrade silently to the API path. + """ + if not head_branch or not base_branch: + return None + + try: + # Ensure the refs are present locally. -q to suppress noise. + # Failing to fetch (offline, no origin) is not fatal — we just + # try the diff with whatever's already there. + subprocess.run( + ["git", "fetch", "--quiet", "origin", base_branch, head_branch], + cwd=cwd, + capture_output=True, + timeout=60, + ) + except (OSError, subprocess.TimeoutExpired): + # Network / git unavailable — let diff try with stale local + # refs; the diff command itself reports failure if neither is + # resolvable. + pass + + try: + diff_result = subprocess.run( + [ + "git", + "diff", + "--no-color", + f"origin/{base_branch}...origin/{head_branch}", + ], + cwd=cwd, + capture_output=True, + text=True, + check=False, + timeout=120, + ) + except (OSError, subprocess.TimeoutExpired): + return None + if diff_result.returncode != 0: + return None + return diff_result.stdout + + def fetch_failed_ci_logs(pr_number: int, cwd: Optional[str] = None, max_log_lines: int = 200) -> list[dict]: """Fetch logs from failed CI/GHA checks on a PR. diff --git a/tests/cli/test_pulls_local_diff.py b/tests/cli/test_pulls_local_diff.py new file mode 100644 index 00000000..77113947 --- /dev/null +++ b/tests/cli/test_pulls_local_diff.py @@ -0,0 +1,409 @@ +"""Tests for the lazy / local-git-first PR diff path. + +Why this matters: judges go through the worktree via Read, NOT the +PR diff. ``judge_panel.py:353``: *"Don't pull the whole PR diff. Read +files individually — it's faster."* Eagerly fetching the diff via +``gh pr diff`` was hitting GitHub's 20,000-line API cap (HTTP 406) +and aborting peer review for no reason — judges never read it. + +The fix has two parts: + +1. **fetch_pr no longer eagerly fetches the diff** — it stores + cwd/repo on the PRData and returns immediately. Cuts a network + round-trip from every panel launch. + +2. **PRData.has_changes() / PRData.get_diff()** lazily compute when + the (rare) caller actually needs them. has_changes() is a cheap + ``git diff --quiet`` boolean. get_diff() is the local-first / + API-fallback path, capped at max_lines so the dedupe consumer + never sees more than it uses. +""" + +from __future__ import annotations + +import json +import subprocess + +import pytest +from cube.github import pulls + + +def _gh_view_stub(head: str = "feat/big-branch", base: str = "main") -> str: + return json.dumps( + { + "number": 1234, + "title": "Big PR", + "body": "body", + "headRefName": head, + "baseRefName": base, + "headRefOid": "abc123", + "reviews": [], + "state": "OPEN", + "mergedAt": None, + } + ) + + +# --------------------------------------------------------------------------- +# fetch_pr no longer eagerly fetches the diff at all. +# --------------------------------------------------------------------------- + + +def test_fetch_pr_does_not_eagerly_fetch_diff(monkeypatch): + """Hot-path contract: ``fetch_pr`` only calls ``gh pr view``. + No diff command (git or gh) runs eagerly — judges never read it, + so paying for it on every panel launch was pure overhead AND + risked the 20k-line API cap.""" + calls: list[list[str]] = [] + + def _fake_run(cmd, *args, **kwargs): + calls.append(list(cmd)) + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234) + assert data.diff == "", "diff must be empty until get_diff() is called" + assert not any(cmd[:3] == ["gh", "pr", "diff"] for cmd in calls), "API diff must NOT be eager" + assert not any(cmd[:3] == ["git", "diff", "--no-color"] for cmd in calls), "local diff must NOT be eager" + + +# --------------------------------------------------------------------------- +# get_diff: lazy, local-git first, API fallback. +# --------------------------------------------------------------------------- + + +def test_get_diff_uses_local_git_when_refs_resolvable(monkeypatch): + """When something does call get_diff(), local git is tried first. + API NOT touched in the happy case (so the 20k cap can't bite).""" + calls: list[list[str]] = [] + + def _fake_run(cmd, *args, **kwargs): + calls.append(list(cmd)) + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + if cmd[:2] == ["git", "fetch"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:3] == ["git", "diff", "--no-color"]: + return subprocess.CompletedProcess(cmd, 0, stdout="diff --git a/x b/x\n+content\n", stderr="") + if cmd[:3] == ["gh", "pr", "diff"]: + raise AssertionError("API path called despite local git succeeding") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234) + diff = data.get_diff() + assert "content" in diff + assert any(cmd[:3] == ["git", "diff", "--no-color"] for cmd in calls) + + +def test_get_diff_falls_back_to_api_when_local_git_fails(monkeypatch): + """Local git refs unresolvable → fall back to API. Keeps cube + usable outside a checkout.""" + api_called = {"v": False} + + def _fake_run(cmd, *args, **kwargs): + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + if cmd[:2] == ["git", "fetch"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:3] == ["git", "diff", "--no-color"]: + return subprocess.CompletedProcess(cmd, 128, stdout="", stderr="unknown revision") + if cmd[:3] == ["gh", "pr", "diff"]: + api_called["v"] = True + return subprocess.CompletedProcess(cmd, 0, stdout="api-diff", stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234) + assert data.get_diff() == "api-diff" + assert api_called["v"] is True + + +def test_get_diff_is_memoised(monkeypatch): + """Two calls only invoke git once. Cheap consumers (the dedupe + path) shouldn't pay twice if has_changes() already triggered it.""" + git_diff_calls = [0] + + def _fake_run(cmd, *args, **kwargs): + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + if cmd[:2] == ["git", "fetch"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:3] == ["git", "diff", "--no-color"]: + git_diff_calls[0] += 1 + return subprocess.CompletedProcess(cmd, 0, stdout="diff\n", stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234) + data.get_diff() + data.get_diff() + assert git_diff_calls[0] == 1 + + +def test_get_diff_respects_max_lines_independently_per_call(monkeypatch): + """Memoisation caches the UNCAPPED diff. A larger cap on a later + call must recover the lines the smaller cap dropped — without + paying for a second git/gh round-trip. Without this, the first + caller's max_lines silently constrains later callers.""" + git_diff_calls = [0] + big_diff = "\n".join(f"line {i}" for i in range(500)) + + def _fake_run(cmd, *args, **kwargs): + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + if cmd[:2] == ["git", "fetch"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:3] == ["git", "diff", "--no-color"]: + git_diff_calls[0] += 1 + return subprocess.CompletedProcess(cmd, 0, stdout=big_diff, stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234) + small = data.get_diff(max_lines=50) + assert "truncated" in small + big = data.get_diff(max_lines=1000) + assert "truncated" not in big, "later larger-cap call must recover the lines" + assert len(big.splitlines()) == 500 + assert git_diff_calls[0] == 1, "second call must use the memoised uncapped diff" + + +def test_api_fallback_has_timeout(monkeypatch): + """Stalled ``gh pr diff`` must not hang the panel forever. + subprocess.TimeoutExpired raised by gh propagates as a clear + RuntimeError so cube prints something actionable.""" + + def _fake_run(cmd, *args, **kwargs): + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + if cmd[:2] == ["git", "fetch"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:3] == ["git", "diff", "--no-color"]: + return subprocess.CompletedProcess(cmd, 128, stdout="", stderr="bad ref") + if cmd[:3] == ["gh", "pr", "diff"]: + # The contract: the call MUST pass a timeout= kwarg. Pin + # it by inspecting kwargs rather than letting the stub + # silently accept anything. + assert "timeout" in kwargs, "gh pr diff must be called with a timeout" + raise subprocess.TimeoutExpired(cmd, kwargs["timeout"]) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234) + with pytest.raises(RuntimeError) as exc: + data.get_diff() + assert "timed out" in str(exc.value) + + +def test_get_diff_truncates_to_max_lines(monkeypatch): + """The dedupe agent caps prompt input at ~8k chars. get_diff() + enforces a hard line ceiling so a 200k-line PR can't blow the + prompt budget even when the API somehow returns it.""" + big_diff = "\n".join(f"+line {i}" for i in range(20_000)) + + def _fake_run(cmd, *args, **kwargs): + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + if cmd[:2] == ["git", "fetch"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:3] == ["git", "diff", "--no-color"]: + return subprocess.CompletedProcess(cmd, 0, stdout=big_diff, stderr="") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234) + diff = data.get_diff(max_lines=100) + assert "truncated" in diff + assert len(diff.splitlines()) == 101 # 100 kept + the truncation marker line + + +# --------------------------------------------------------------------------- +# 20k-line API failure surfaces a workaround hint. +# --------------------------------------------------------------------------- + + +def test_too_large_api_error_includes_workaround_hint(monkeypatch): + """Both local git and gh fail; the API failure is the 20k cap. + The error must point operators at the local-git workaround so + they don't have to grep cube source.""" + + def _fake_run(cmd, *args, **kwargs): + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + if cmd[:2] == ["git", "fetch"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:3] == ["git", "diff", "--no-color"]: + return subprocess.CompletedProcess(cmd, 128, stdout="", stderr="") + if cmd[:3] == ["gh", "pr", "diff"]: + return subprocess.CompletedProcess( + cmd, + 1, + stdout="", + stderr="HTTP 406: Sorry, the diff exceeded the maximum number of lines (20000)\nPullRequest.diff too_large", + ) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234) + with pytest.raises(RuntimeError) as exc: + data.get_diff() + msg = str(exc.value) + assert "20,000-line cap" in msg or "exceeded the maximum" in msg + assert "git fetch origin" in msg, "must surface the workaround command" + + +# --------------------------------------------------------------------------- +# has_changes: cheap boolean for the empty-PR bail. +# --------------------------------------------------------------------------- + + +def test_has_changes_true_when_diff_exists(monkeypatch): + """`git diff --quiet` exits 1 when there IS a diff. has_changes + interprets that as True so the empty-PR bail proceeds correctly.""" + + def _fake_run(cmd, *args, **kwargs): + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + if cmd[:2] == ["git", "fetch"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:3] == ["git", "diff", "--quiet"]: + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="") # 1 = HAS diff + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234) + assert data.has_changes() is True + + +def test_has_changes_false_on_identical_refs(monkeypatch): + """`git diff --quiet` exits 0 when there's NO diff. has_changes + returns False so the caller bails the review (empty PR).""" + + def _fake_run(cmd, *args, **kwargs): + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + if cmd[:2] == ["git", "fetch"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:3] == ["git", "diff", "--quiet"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") # 0 = NO diff + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234) + assert data.has_changes() is False + + +def test_has_changes_falls_back_to_true_on_git_error_exit(monkeypatch): + """`git diff --quiet` exits 0 (no diff), 1 (diff), >1 (error). + Treating any non-0 as True would mask a real error as "has diff"; + treating non-1 as False would mask an error as "empty PR" and + skip the review. Conservative default on error: True so the + review proceeds rather than silently bailing.""" + + def _fake_run(cmd, *args, **kwargs): + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + if cmd[:2] == ["git", "fetch"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + if cmd[:3] == ["git", "diff", "--quiet"]: + # 128 = bad revision (git's standard error code) + return subprocess.CompletedProcess(cmd, 128, stdout="", stderr="bad revision") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234) + # NOT False (would falsely report empty PR); NOT crash. Conservative True. + assert data.has_changes() is True + + +def test_has_changes_skips_local_path_when_cross_repo(monkeypatch): + """`--repo other/owner` means the local checkout's branches + belong to THIS repo, not the target. A same-named branch would + answer based on this repo's diff — silently wrong. Skip the + local path entirely; default True so the review proceeds.""" + git_called = {"v": False} + + def _fake_run(cmd, *args, **kwargs): + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + if cmd[:2] == ["git", "fetch"] or cmd[:3] == ["git", "diff", "--quiet"]: + git_called["v"] = True + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234, repo="other/owner") + assert data.has_changes() is True + assert git_called["v"] is False, "local git must NOT be consulted for cross-repo PRs" + + +def test_get_diff_skips_local_path_when_cross_repo(monkeypatch): + """Same cross-repo safety for the content diff: skip local git, + go straight to ``gh pr diff --repo other/owner`` so the diff + actually comes from the targeted repo.""" + calls: list[list[str]] = [] + + def _fake_run(cmd, *args, **kwargs): + calls.append(list(cmd)) + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + if cmd[:3] == ["gh", "pr", "diff"]: + return subprocess.CompletedProcess(cmd, 0, stdout="cross-repo diff content", stderr="") + # Any local git call here means the cross-repo guard failed. + if cmd[:2] == ["git", "fetch"] or cmd[:3] == ["git", "diff", "--no-color"]: + raise AssertionError("local git must NOT be consulted for cross-repo PR diff") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234, repo="other/owner") + assert data.get_diff() == "cross-repo diff content" + assert any( + cmd[:3] == ["gh", "pr", "diff"] and "--repo" in cmd for cmd in calls + ), "cross-repo fetch must pass --repo flag to gh" + + +def test_has_changes_defaults_to_true_on_git_failure(monkeypatch): + """If git can't answer (offline, no origin, etc), assume the PR + has changes — better than silently skipping a real PR because the + network blipped.""" + + def _fake_run(cmd, *args, **kwargs): + if cmd[:3] == ["gh", "pr", "view"]: + return subprocess.CompletedProcess(cmd, 0, stdout=_gh_view_stub(), stderr="") + if cmd[:2] == ["git", "fetch"]: + return subprocess.CompletedProcess(cmd, 128, stdout="", stderr="fatal: no origin") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(pulls, "check_gh_installed", lambda: True) + monkeypatch.setattr(subprocess, "run", _fake_run) + + data = pulls.fetch_pr(1234) + assert data.has_changes() is True