diff --git a/src/forge_loop/gh.py b/src/forge_loop/gh.py index cb1ab04..df547bf 100644 --- a/src/forge_loop/gh.py +++ b/src/forge_loop/gh.py @@ -15,9 +15,7 @@ def _require_repo(repo: str | None) -> str: if not repo: - raise RuntimeError( - "gh.* called without a repo; pass repo='owner/name' or set LOOP_GH_REPO" - ) + raise RuntimeError("gh.* called without a repo; pass repo='owner/name' or set LOOP_GH_REPO") return repo @@ -25,11 +23,17 @@ def top_issues(label: str, limit: int, repo: str | None = None) -> list[dict[str """Return open issues carrying ``label`` (oldest first).""" repo = _require_repo(repo) cmd = [ - "gh", "issue", "list", - "--repo", repo, - "--state", "open", - "--limit", str(limit), - "--json", "number,title,body,labels,createdAt,updatedAt", + "gh", + "issue", + "list", + "--repo", + repo, + "--state", + "open", + "--limit", + str(limit), + "--json", + "number,title,body,labels,createdAt,updatedAt", ] if label: cmd.extend(["--label", label]) @@ -43,11 +47,18 @@ def fetch_issue(issue: int, repo: str | None = None) -> dict[str, Any] | None: repo = _require_repo(repo) r = subprocess.run( [ - "gh", "issue", "view", str(issue), - "--repo", repo, - "--json", "number,title,body,labels,createdAt,updatedAt,state", + "gh", + "issue", + "view", + str(issue), + "--repo", + repo, + "--json", + "number,title,body,labels,createdAt,updatedAt,state", ], - capture_output=True, text=True, check=False, + capture_output=True, + text=True, + check=False, ) if r.returncode != 0: return None @@ -60,7 +71,8 @@ def comment(issue: int, body: str, repo: str | None = None) -> None: repo = _require_repo(repo) subprocess.run( ["gh", "issue", "comment", str(issue), "--repo", repo, "--body", body], - check=False, capture_output=True, + check=False, + capture_output=True, ) @@ -80,7 +92,8 @@ def unlabel(issue: int, label: str, repo: str | None = None) -> None: repo = _require_repo(repo) subprocess.run( ["gh", "issue", "edit", str(issue), "--repo", repo, "--remove-label", label], - check=False, capture_output=True, + check=False, + capture_output=True, ) @@ -89,7 +102,9 @@ def remove_pr_label(pr: int | str, label: str, repo: str | None = None) -> bool: repo = _require_repo(repo) r = subprocess.run( ["gh", "pr", "edit", str(pr), "--repo", repo, "--remove-label", label], - check=False, capture_output=True, text=True, + check=False, + capture_output=True, + text=True, ) return r.returncode == 0 @@ -103,12 +118,17 @@ def create_issue( """Open a new issue. Returns the new number or None on failure.""" repo = _require_repo(repo) cmd = [ - "gh", "issue", "create", - "--repo", repo, - "--title", title, - "--body", body, + "gh", + "issue", + "create", + "--repo", + repo, + "--title", + title, + "--body", + body, ] - for lab in (labels or []): + for lab in labels or []: cmd.extend(["--label", lab]) r = subprocess.run(cmd, capture_output=True, text=True, check=False) if r.returncode != 0: @@ -137,9 +157,9 @@ def update_issue( cmd.extend(["--title", title]) if body is not None: cmd.extend(["--body", body]) - for lab in (add_labels or []): + for lab in add_labels or []: cmd.extend(["--add-label", lab]) - for lab in (remove_labels or []): + for lab in remove_labels or []: cmd.extend(["--remove-label", lab]) if len(cmd) == 5: return True @@ -151,9 +171,10 @@ def pr_changed_lines(pr: int | str, repo: str | None = None) -> int: """Return additions+deletions for a PR. 0 on failure (caller falls back).""" repo = _require_repo(repo) r = subprocess.run( - ["gh", "pr", "view", str(pr), "--repo", repo, - "--json", "additions,deletions"], - capture_output=True, text=True, check=False, + ["gh", "pr", "view", str(pr), "--repo", repo, "--json", "additions,deletions"], + capture_output=True, + text=True, + check=False, ) if r.returncode != 0: return 0 @@ -168,11 +189,17 @@ def prs_by_label(label: str, limit: int, repo: str | None = None) -> list[dict[s """Return open PRs carrying ``label`` (oldest updated first).""" repo = _require_repo(repo) cmd = [ - "gh", "pr", "list", - "--repo", repo, - "--state", "open", - "--limit", str(limit), - "--json", "number,title,body,headRefName,baseRefName,url,labels,updatedAt", + "gh", + "pr", + "list", + "--repo", + repo, + "--state", + "open", + "--limit", + str(limit), + "--json", + "number,title,body,headRefName,baseRefName,url,labels,updatedAt", ] if label: cmd.extend(["--label", label]) @@ -186,17 +213,83 @@ def prs_by_label(label: str, limit: int, repo: str | None = None) -> list[dict[s return sorted(result, key=lambda p: str(p.get("updatedAt") or "")) +def prs_requiring_repair(limit: int, repo: str | None = None) -> list[dict[str, Any]]: + """Return open PRs the repair loop should revisit. + + A PR needs repair when it is explicitly critic-blocked, has unresolved + review threads, or is merge-conflicted/dirty. Review threads are not + exposed by ``gh pr list`` or ``gh pr view --comments``, so this function + enriches the open PR list with a GraphQL pass before the dispatcher decides + whether to spawn a repair worker. + """ + repo = _require_repo(repo) + prs = _open_prs(limit=max(limit, 50), repo=repo) + repairs: list[dict[str, Any]] = [] + for pr in prs: + reasons: list[str] = [] + labels = {str(label.get("name") or "") for label in pr.get("labels") or []} + if "critic:blocking" in labels: + reasons.append("critic:blocking") + + merge_state = str(pr.get("mergeStateStatus") or "").upper() + if merge_state in {"DIRTY", "CONFLICTING"}: + reasons.append(f"merge_state:{merge_state.lower()}") + + threads = unresolved_review_threads(pr["number"], repo=repo) + if threads: + reasons.append("unresolved_review_threads") + + if reasons: + enriched = dict(pr) + enriched["repairReasons"] = reasons + enriched["unresolvedReviewThreads"] = threads + repairs.append(enriched) + + return sorted(repairs, key=lambda p: str(p.get("updatedAt") or ""))[:limit] + + +def _open_prs(limit: int, repo: str) -> list[dict[str, Any]]: + cmd = [ + "gh", + "pr", + "list", + "--repo", + repo, + "--state", + "open", + "--limit", + str(limit), + "--json", + "number,title,body,headRefName,baseRefName,url,labels,updatedAt,mergeStateStatus", + ] + r = subprocess.run(cmd, capture_output=True, text=True, check=False) + if r.returncode != 0: + return [] + try: + result: list[dict[str, Any]] = json.loads(r.stdout) + except json.JSONDecodeError: + return [] + return result + + def pr_review_context(pr: int | str, repo: str | None = None) -> str: """Fetch review/comment context for a repair worker prompt.""" repo = _require_repo(repo) r = subprocess.run( [ - "gh", "pr", "view", str(pr), - "--repo", repo, + "gh", + "pr", + "view", + str(pr), + "--repo", + repo, "--comments", - "--json", "number,title,body,comments,reviews,url,headRefName", + "--json", + "number,title,body,comments,reviews,url,headRefName", ], - capture_output=True, text=True, check=False, + capture_output=True, + text=True, + check=False, ) if r.returncode != 0: return f"(failed to fetch PR review context: {r.stderr[:300]})" @@ -204,9 +297,116 @@ def pr_review_context(pr: int | str, repo: str | None = None) -> str: obj = json.loads(r.stdout) except json.JSONDecodeError: return "(failed to parse PR review context)" + obj["reviewThreads"] = review_threads(pr, repo=repo) return _format_pr_context(obj) +def unresolved_review_threads(pr: int | str, repo: str | None = None) -> list[dict[str, Any]]: + """Return unresolved PR review threads. Empty on API failure.""" + return [t for t in review_threads(pr, repo=repo) if not bool(t.get("isResolved"))] + + +def review_threads(pr: int | str, repo: str | None = None) -> list[dict[str, Any]]: + """Fetch PR review threads via GraphQL. + + GitHub's REST and ``gh pr view --comments`` output omit inline review + threads. Those are the comments operators expect a repair worker to fix, + so silently losing them makes the loop appear idle even though PRs are + still blocked. + """ + repo = _require_repo(repo) + try: + owner, name = repo.split("/", 1) + except ValueError: + return [] + query = """ + query($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100) { + nodes { + id + isResolved + isOutdated + path + line + comments(first: 20) { + nodes { + author { login } + body + url + path + line + createdAt + } + } + } + } + } + } + } + """ + r = subprocess.run( + [ + "gh", + "api", + "graphql", + "-f", + f"owner={owner}", + "-f", + f"name={name}", + "-F", + f"number={int(_pr_number(pr))}", + "-f", + f"query={query}", + ], + capture_output=True, + text=True, + check=False, + ) + if r.returncode != 0: + return [] + try: + data = json.loads(r.stdout) + except json.JSONDecodeError: + return [] + nodes = ( + data.get("data", {}) + .get("repository", {}) + .get("pullRequest", {}) + .get("reviewThreads", {}) + .get("nodes", []) + ) + if not isinstance(nodes, list): + return [] + return [_normalise_review_thread(t) for t in nodes if isinstance(t, dict)] + + +def _normalise_review_thread(thread: dict[str, Any]) -> dict[str, Any]: + comments = [] + for comment in (thread.get("comments") or {}).get("nodes") or []: + if not isinstance(comment, dict): + continue + comments.append( + { + "author": comment.get("author") or {}, + "body": comment.get("body") or "", + "url": comment.get("url") or "", + "path": comment.get("path") or thread.get("path") or "", + "line": comment.get("line") or thread.get("line") or "", + "createdAt": comment.get("createdAt") or "", + } + ) + return { + "id": thread.get("id") or "", + "isResolved": bool(thread.get("isResolved")), + "isOutdated": bool(thread.get("isOutdated")), + "path": thread.get("path") or "", + "line": thread.get("line") or "", + "comments": comments, + } + + def _format_pr_context(obj: dict[str, Any]) -> str: lines = [ f"PR #{obj.get('number')}: {obj.get('title') or ''}", @@ -271,7 +471,9 @@ def disable_pr_auto_merge(pr: int | str, repo: str | None = None) -> bool: repo = _require_repo(repo) r = subprocess.run( ["gh", "pr", "merge", str(pr), "--repo", repo, "--disable-auto"], - check=False, capture_output=True, text=True, + check=False, + capture_output=True, + text=True, ) return r.returncode == 0 @@ -299,18 +501,25 @@ def post_review_comment( } r = subprocess.run( [ - "gh", "api", - "--method", "POST", + "gh", + "api", + "--method", + "POST", f"repos/{repo}/pulls/{_pr_number(pr)}/reviews", - "--input", "-", + "--input", + "-", ], input=json.dumps(payload), - text=True, capture_output=True, check=False, + text=True, + capture_output=True, + check=False, ) return r.returncode == 0 r = subprocess.run( ["gh", "pr", "review", str(pr), "--repo", repo, "--comment", "--body", body], - capture_output=True, text=True, check=False, + capture_output=True, + text=True, + check=False, ) return r.returncode == 0 @@ -329,11 +538,18 @@ def get_issue_state(issue: int, repo: str | None = None) -> str | None: repo = _require_repo(repo) r = subprocess.run( [ - "gh", "issue", "view", str(issue), - "--repo", repo, - "--json", "state", + "gh", + "issue", + "view", + str(issue), + "--repo", + repo, + "--json", + "state", ], - capture_output=True, text=True, check=False, + capture_output=True, + text=True, + check=False, ) if r.returncode != 0: return None @@ -352,7 +568,9 @@ def pr_comment(pr: int | str, body: str, repo: str | None = None) -> bool: repo = _require_repo(repo) r = subprocess.run( ["gh", "pr", "comment", str(pr), "--repo", repo, "--body", body], - capture_output=True, text=True, check=False, + capture_output=True, + text=True, + check=False, ) return r.returncode == 0 diff --git a/src/forge_loop/runner/tick.py b/src/forge_loop/runner/tick.py index e98a18b..55fa1b6 100644 --- a/src/forge_loop/runner/tick.py +++ b/src/forge_loop/runner/tick.py @@ -16,7 +16,7 @@ from forge_loop import worker as _worker from forge_loop.config import Config from forge_loop.deploy import redeploy -from forge_loop.gh import fetch_issue, pr_review_context, prs_by_label, top_issues, unlabel +from forge_loop.gh import fetch_issue, pr_review_context, prs_requiring_repair, top_issues, unlabel from forge_loop.maintenance import run_maintenance from forge_loop.po import expand_thin_specs as _po_expand from forge_loop.runner._helpers import ( @@ -75,7 +75,7 @@ def _blocking_pr_repairs(cfg: Config) -> list[tuple[dict[str, Any], dict[str, An axis_filter = parse_filter_env() repairs: list[tuple[dict[str, Any], dict[str, Any], str]] = [] - for pr in prs_by_label("critic:blocking", cfg.parallel, repo=cfg.github_repo): + for pr in prs_requiring_repair(cfg.parallel, repo=cfg.github_repo): issue_num = _issue_number_from_pr(pr) if issue_num is None: append_event( @@ -105,6 +105,13 @@ def _blocking_pr_repairs(cfg: Config) -> list[tuple[dict[str, Any], dict[str, An axes=axis_filter, ) continue + append_event( + cfg.events_file, + "repair_pr_selected", + pr=pr.get("url"), + issue=issue_num, + reasons=pr.get("repairReasons") or [], + ) repairs.append((issue, pr, pr_review_context(pr["number"], repo=cfg.github_repo))) return repairs @@ -360,6 +367,7 @@ def _run_stuck_sweep(cfg: Config, tick: int) -> SweepReport | None: # the constructor via the env-token path without importing # githubkit when not needed. from forge_loop.gh_client import GithubkitClient + client = GithubkitClient() except Exception as ex: # noqa: BLE001 append_event( @@ -461,8 +469,7 @@ def _bus_emit(kind: str, payload: dict[str, Any]) -> None: "state": "repairing", "tick": tick, "dispatched": [ - {"issue": issue["number"], "title": issue["title"]} - for issue, _, _ in repairs + {"issue": issue["number"], "title": issue["title"]} for issue, _, _ in repairs ], }, ) @@ -524,6 +531,7 @@ def _bus_emit(kind: str, payload: dict[str, Any]) -> None: if not axis_filter: try: from forge_loop.product_vision import discover as _discover_vision + _vision = _discover_vision(cfg.repo) axis_filter = sorted({a.name.lower() for a in _vision.axes}) append_event( diff --git a/src/forge_loop/worker.py b/src/forge_loop/worker.py index 7ba08e5..4170ee7 100644 --- a/src/forge_loop/worker.py +++ b/src/forge_loop/worker.py @@ -208,13 +208,15 @@ def make_repair_brief( CONTRACT: 1. Repair the EXISTING PR branch. Do not create a new branch and do not open a new PR. -2. Address every sev1/blocking review point with production behavior and tests. -3. Preserve the original issue scope; do not add unrelated refactors. -4. Run focused tests that prove the review comments are fixed. -5. Run formatting/lint gates appropriate for touched files. -6. Commit with a message referencing #{n}. -7. Push the current branch with `git push`. -8. Leave a short PR comment summarizing the repair. +2. Address every unresolved review thread and every sev1/blocking review point with production behavior and tests. +3. If the branch is behind or conflicted, merge/rebase the current base branch and resolve conflicts in scope. +4. Preserve the original issue scope; do not add unrelated refactors. +5. Run focused tests that prove the review comments are fixed. +6. Run formatting/lint gates appropriate for touched files. +7. Commit with a message referencing #{n}. +8. Push the current branch with `git push`. +9. Resolve review threads after fixing them when the GitHub API/CLI allows it; otherwise reply/comment with the fixed evidence. +10. Leave a short PR comment summarizing the repair and remaining state. LOOP INFRASTRUCTURE — DO NOT TOUCH: - `{worktree}/.claude/settings.json` is loop-planted. Do NOT `git clean`, `rm`, or chmod it. @@ -331,10 +333,8 @@ def _prep_worktree( capture_output=True, ) if wt.exists(): - try: + with contextlib.suppress(OSError, PermissionError): shutil.rmtree(wt) - except (OSError, PermissionError): - pass # If the worker planted files owned by a different uid (subprocess # ran under a different namespace), chmod+rmtree above will silently # fail and leave the dir behind. Quarantine it so the new worktree @@ -390,19 +390,20 @@ def _prep_repair_worktree( subprocess.run(["chmod", "-R", "u+w", str(claude_dir)], capture_output=True) subprocess.run(["git", "worktree", "remove", "--force", str(wt)], cwd=repo, capture_output=True) if wt.exists(): - try: + with contextlib.suppress(OSError, PermissionError): shutil.rmtree(wt) - except (OSError, PermissionError): - pass _quarantine_if_blocking(wt) remote_ref = f"refs/remotes/origin/{branch}" subprocess.run( ["git", "fetch", "--prune", "origin", f"+refs/heads/{branch}:{remote_ref}"], - cwd=repo, capture_output=True, + cwd=repo, + capture_output=True, ) r = subprocess.run( ["git", "worktree", "add", str(wt), "-B", branch, f"origin/{branch}"], - cwd=repo, capture_output=True, text=True, + cwd=repo, + capture_output=True, + text=True, ) if r.returncode != 0: return wt, r.stderr @@ -603,15 +604,23 @@ def run_repair_worker( pr_url = pr.get("url") if not branch: return WorkerOutcome( - issue=n, title=title, pr_url=pr_url, status="failed", - duration_s=0.0, stdout_tail="missing PR headRefName", + issue=n, + title=title, + pr_url=pr_url, + status="failed", + duration_s=0.0, + stdout_tail="missing PR headRefName", error="repair-missing-branch", ) worktree, err = _prep_repair_worktree(repo, n, branch) if err is not None: return WorkerOutcome( - issue=n, title=title, pr_url=pr_url, status="failed", - duration_s=0.0, stdout_tail=err[-500:], + issue=n, + title=title, + pr_url=pr_url, + status="failed", + duration_s=0.0, + stdout_tail=err[-500:], error="repair-worktree-create-failed", ) logs_dir.mkdir(parents=True, exist_ok=True) @@ -627,13 +636,23 @@ def run_repair_worker( ) if provider == "codex": return _run_worker_codex( - issue=issue, worktree=worktree, log_path=log_path, - brief=brief, timeout_s=timeout_s, model=model, + issue=issue, + worktree=worktree, + log_path=log_path, + brief=brief, + timeout_s=timeout_s, + model=model, ) return _run_worker_sdk( - issue=issue, worktree=worktree, log_path=log_path, - brief=brief, timeout_s=timeout_s, emit=emit, tick=tick, - model=model, thinking=thinking, + issue=issue, + worktree=worktree, + log_path=log_path, + brief=brief, + timeout_s=timeout_s, + emit=emit, + tick=tick, + model=model, + thinking=thinking, allowed_mcp_servers=allowed_mcp_servers, load_timeout_ms=load_timeout_ms, strict_mcp_config=strict_mcp_config, diff --git a/tests/test_dispatch_axis_filter.py b/tests/test_dispatch_axis_filter.py index 092c181..dc6a105 100644 --- a/tests/test_dispatch_axis_filter.py +++ b/tests/test_dispatch_axis_filter.py @@ -93,7 +93,7 @@ def test_axis_match_alone_does_not_bypass_ready_gate() -> None: def test_malformed_axis_label_does_not_crash_and_excludes_under_filter() -> None: queue = [ - _make(1, READY, "axis:"), # empty slug + _make(1, READY, "axis:"), # empty slug _make(2, READY, "axis:dispatch"), ] picked = filter_issues_by_axes(queue, ["dispatch"]) @@ -140,8 +140,14 @@ def test_blocked_pr_repair_respects_axis_filter(monkeypatch: pytest.MonkeyPatch, monkeypatch.setenv(AXIS_FILTER_ENV, "dispatch") monkeypatch.setattr( tick_mod, - "prs_by_label", - lambda *_a, **_k: [{"number": 10, "url": "https://github.com/acme/widgets/pull/10", "headRefName": "loop/99-old"}], + "prs_requiring_repair", + lambda *_a, **_k: [ + { + "number": 10, + "url": "https://github.com/acme/widgets/pull/10", + "headRefName": "loop/99-old", + } + ], ) monkeypatch.setattr( tick_mod, @@ -158,3 +164,35 @@ def test_blocked_pr_repair_respects_axis_filter(monkeypatch: pytest.MonkeyPatch, assert repairs == [] assert "axis_filter_mismatch" in cfg.events_file.read_text() + + +def test_unresolved_review_thread_pr_is_selected_for_repair( + monkeypatch: pytest.MonkeyPatch, tmp_path +) -> None: + from forge_loop.config import Config + from forge_loop.runner import tick as tick_mod + + cfg = Config(repo=tmp_path, github_repo="acme/widgets") + monkeypatch.setattr( + tick_mod, + "prs_requiring_repair", + lambda *_a, **_k: [ + { + "number": 10, + "url": "https://github.com/acme/widgets/pull/10", + "headRefName": "loop/99-fix-review", + "repairReasons": ["unresolved_review_threads"], + } + ], + ) + monkeypatch.setattr(tick_mod, "fetch_issue", lambda *_a, **_k: _make(99, READY)) + monkeypatch.setattr(tick_mod, "pr_review_context", lambda *_a, **_k: "thread context") + + repairs = tick_mod._blocking_pr_repairs(cfg) + + assert len(repairs) == 1 + issue, pr, ctx = repairs[0] + assert issue["number"] == 99 + assert pr["repairReasons"] == ["unresolved_review_threads"] + assert ctx == "thread context" + assert "repair_pr_selected" in cfg.events_file.read_text() diff --git a/tests/test_gh_review_threads.py b/tests/test_gh_review_threads.py new file mode 100644 index 0000000..36a2445 --- /dev/null +++ b/tests/test_gh_review_threads.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import json +import subprocess +from typing import Any + +from forge_loop import gh + + +def _completed(stdout: dict[str, Any] | list[Any]) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess( + args=["gh"], + returncode=0, + stdout=json.dumps(stdout), + stderr="", + ) + + +def test_pr_review_context_includes_unresolved_inline_threads(monkeypatch) -> None: + calls: list[list[str]] = [] + + def fake_run(cmd: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[str]: + calls.append(cmd) + if cmd[:3] == ["gh", "pr", "view"]: + return _completed( + { + "number": 7, + "title": "Fix thing", + "body": "body", + "url": "https://github.com/o/r/pull/7", + "headRefName": "loop/42-fix-thing", + "comments": [], + "reviews": [], + } + ) + if cmd[:3] == ["gh", "api", "graphql"]: + return _completed( + { + "data": { + "repository": { + "pullRequest": { + "reviewThreads": { + "nodes": [ + { + "id": "thread-1", + "isResolved": False, + "isOutdated": False, + "path": "src/app.py", + "line": 12, + "comments": { + "nodes": [ + { + "author": {"login": "reviewer"}, + "body": "Please handle the error path.", + "url": "https://github.com/o/r/pull/7#discussion", + "path": "src/app.py", + "line": 12, + "createdAt": "2026-01-01T00:00:00Z", + } + ] + }, + } + ] + } + } + } + } + } + ) + raise AssertionError(f"unexpected command: {cmd}") + + monkeypatch.setattr(gh.subprocess, "run", fake_run) + + context = gh.pr_review_context(7, repo="o/r") + + assert "REVIEW THREADS" in context + assert "src/app.py:12 resolved=False reviewer: Please handle the error path." in context + assert any(c[:3] == ["gh", "api", "graphql"] for c in calls) + + +def test_prs_requiring_repair_detects_threads_without_critic_label(monkeypatch) -> None: + def fake_run(cmd: list[str], **_kwargs: Any) -> subprocess.CompletedProcess[str]: + if cmd[:3] == ["gh", "pr", "list"]: + return _completed( + [ + { + "number": 7, + "title": "Fix thing", + "body": "closes #42", + "headRefName": "loop/42-fix-thing", + "baseRefName": "trunk", + "url": "https://github.com/o/r/pull/7", + "labels": [], + "updatedAt": "2026-01-01T00:00:00Z", + "mergeStateStatus": "CLEAN", + } + ] + ) + if cmd[:3] == ["gh", "api", "graphql"]: + return _completed( + { + "data": { + "repository": { + "pullRequest": { + "reviewThreads": { + "nodes": [ + { + "id": "thread-1", + "isResolved": False, + "isOutdated": False, + "path": "src/app.py", + "line": 12, + "comments": {"nodes": []}, + } + ] + } + } + } + } + } + ) + raise AssertionError(f"unexpected command: {cmd}") + + monkeypatch.setattr(gh.subprocess, "run", fake_run) + + prs = gh.prs_requiring_repair(5, repo="o/r") + + assert [p["number"] for p in prs] == [7] + assert prs[0]["repairReasons"] == ["unresolved_review_threads"] + assert prs[0]["unresolvedReviewThreads"][0]["id"] == "thread-1"