From 07544d00669a9cf11e14cbc5183055cd428b4867 Mon Sep 17 00:00:00 2001 From: svtter Date: Thu, 21 May 2026 16:55:48 +0800 Subject: [PATCH 1/8] feat(multi-review): output single coordinator comment with noise filtering - Add _filter_noise() to strip CLI boilerplate (tool calls, log lines, session metadata) from reviewer output - Add cleanup_reviewer_comments() to delete per-reviewer comments after coordinator synthesis is posted, keeping only the final comment - post_pr_comment() now returns comment ID for precise cleanup targeting - Keep USE_GITHUB_TOKEN=true to avoid OIDC crash in opencode CLI Co-Authored-By: Claude Opus 4.7 --- multi-review/run-multi-review.py | 110 ++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 3 deletions(-) diff --git a/multi-review/run-multi-review.py b/multi-review/run-multi-review.py index 752a32d..d083cdb 100644 --- a/multi-review/run-multi-review.py +++ b/multi-review/run-multi-review.py @@ -403,6 +403,44 @@ def _strip_ansi(raw: str) -> str: return text.strip() +_NOISE_PREFIXES = ( + "asserting permissions", + "adding reaction", + "removing reaction", + "fetching prompt data", + "checking out local branch", + "sending message to opencode", + "checking if branch is dirty", + "creating comment", + "pushing to local branch", + "opencode session ses_", + "performing one time database", + "sqlite-migration", + "database migration", +) +_TOOL_LINE_RE = re.compile(r"^\|\s+(Shell|Read|Write|Edit|Bash)\s") +_LOG_LINE_RE = re.compile(r"^\[\d{2}:\d{2}:\d{2}\.\d{3}\]\s+(INFO|WARN|ERROR|DEBUG)") + + +def _filter_noise(text: str) -> str: + """Remove opencode CLI boilerplate lines from reviewer output.""" + cleaned = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith(_NOISE_PREFIXES): + continue + if _TOOL_LINE_RE.match(stripped): + continue + if _LOG_LINE_RE.match(stripped): + continue + if "opencode.ai/s/" in stripped: + continue + if stripped.startswith("| ") and '{"' in stripped: + continue + cleaned.append(line) + return "\n".join(cleaned).strip() + + def _truncate(text: str, limit: int = 8000) -> str: text = text.strip() if len(text) > limit: @@ -412,10 +450,10 @@ def _truncate(text: str, limit: int = 8000) -> str: def format_pr_comment(coordinator_output: str, reviewer_results: list[dict[str, Any]]) -> str: """Format the final PR comment with coordinator output and collapsible reviewer details.""" - parts = [_strip_ansi(coordinator_output).strip(), "\n\n---\n**详细审查报告:**\n"] + parts = [_filter_noise(_strip_ansi(coordinator_output)).strip(), "\n\n---\n**详细审查报告:**\n"] for r in reviewer_results: status_label = "✅" if r["status"] == "success" else "⚠️" - output = _truncate(_strip_ansi(r.get("output", ""))) + output = _truncate(_filter_noise(_strip_ansi(r.get("output", "")))) parts.append( f"\n
\n{status_label} {r['name']}\n\n{output}\n
\n" ) @@ -426,7 +464,7 @@ def post_fallback_comment(reviewer_results: list[dict[str, Any]]) -> str: """Format a fallback comment with raw reviewer outputs when coordinator fails.""" parts = ["⚠️ Coordinator agent failed. Showing raw reviewer outputs:\n"] for r in reviewer_results: - output = _truncate(_strip_ansi(r.get("output", ""))) + output = _truncate(_filter_noise(_strip_ansi(r.get("output", "")))) parts.append(f"\n### {r['name']} ({r['status']})\n\n{output}\n") return "".join(parts) @@ -521,6 +559,66 @@ def cleanup_error_comments() -> None: pass +def cleanup_reviewer_comments() -> None: + """Delete per-reviewer comments, keeping only the coordinator comment. + + Called after the coordinator comment is posted. Identifies comments from + the same CI run and deletes all except the most recent one (the coordinator + synthesis). + """ + ctx = _get_pr_context() + if not ctx: + return + pr_number, repository = ctx + + github_run_id = get_env("GITHUB_RUN_ID", "") + if not github_run_id: + return + + gh_path = shutil.which("gh") + if not gh_path: + return + + run_link_pattern = f"/{repository}/actions/runs/{github_run_id}" + + try: + result = subprocess.run( + [gh_path, "api", "-H", "Accept: application/vnd.github+json", + f"/repos/{repository}/issues/{pr_number}/comments"], + capture_output=True, text=True, env=os.environ.copy(), timeout=30, + ) + if result.returncode != 0: + return + comments = json.loads(result.stdout) + except Exception: + return + + # Find comments from this CI run + run_comments = [ + c for c in comments + if run_link_pattern in c.get("body", "") + ] + + if len(run_comments) <= 1: + return + + # The last comment is the coordinator synthesis — keep it, delete the rest + to_delete = run_comments[:-1] + for comment in to_delete: + comment_id = comment.get("id") + if not comment_id: + continue + try: + subprocess.run( + [gh_path, "api", "-X", "DELETE", + f"/repos/{repository}/issues/comments/{comment_id}"], + capture_output=True, text=True, env=os.environ.copy(), timeout=10, + ) + except Exception: + pass + + print(f"Cleaned up {len(to_delete)} per-reviewer comment(s), kept coordinator comment", file=sys.stderr) + def main() -> int: try: return _main() @@ -688,6 +786,12 @@ def _main() -> int: print("Could not post to PR via gh CLI, writing to stdout as fallback", file=sys.stderr) print(comment) + # Clean up per-reviewer comments, keep only the coordinator synthesis + try: + cleanup_reviewer_comments() + except Exception as e: + print(f"Failed to cleanup reviewer comments: {e}", file=sys.stderr) + return 0 From 257cbaf22f93062ebf78573cb2dda1bffd42b351 Mon Sep 17 00:00:00 2001 From: svtter Date: Thu, 21 May 2026 17:02:44 +0800 Subject: [PATCH 2/8] fix(multi-review): use gh api for comment posting with ID return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use `gh api --input` instead of `gh pr comment` so we get the comment ID in the response. Pass this ID to cleanup_reviewer_comments() for precise targeting — avoids relying on comment ordering which is not guaranteed with concurrent reviewers. Co-Authored-By: Claude Opus 4.7 --- multi-review/run-multi-review.py | 56 +++++++++++++++++++------------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/multi-review/run-multi-review.py b/multi-review/run-multi-review.py index d083cdb..dbd1787 100644 --- a/multi-review/run-multi-review.py +++ b/multi-review/run-multi-review.py @@ -479,30 +479,35 @@ def _get_pr_context() -> tuple[str, str] | None: return match.group(1), github_repository -def post_pr_comment(body: str) -> bool: - """Post a comment to the current PR using gh CLI. Returns True on success.""" +def post_pr_comment(body: str) -> int | None: + """Post a comment to the current PR via gh api. Returns comment ID on success.""" ctx = _get_pr_context() if not ctx: - return False + return None pr_number, repository = ctx gh_path = shutil.which("gh") if not gh_path: - return False + return None try: + payload = json.dumps({"body": body}) result = subprocess.run( - [gh_path, "pr", "comment", pr_number, "--repo", repository, "--body", body], + [gh_path, "api", "-X", "POST", + f"/repos/{repository}/issues/{pr_number}/comments", + "--input", payload], capture_output=True, text=True, env=os.environ.copy(), timeout=30, ) - if result.returncode == 0: - print(f"Posted synthesized review comment to PR #{pr_number}", file=sys.stderr) - return True - print(f"Failed to post PR comment: {result.stderr}", file=sys.stderr) - return False + if result.returncode != 0: + print(f"Failed to post PR comment: {result.stderr}", file=sys.stderr) + return None + data = json.loads(result.stdout) + comment_id = data.get("id") + print(f"Posted synthesized review comment to PR #{pr_number} (id={comment_id})", file=sys.stderr) + return comment_id except Exception as e: print(f"Failed to post PR comment: {e}", file=sys.stderr) - return False + return None def cleanup_error_comments() -> None: @@ -559,12 +564,12 @@ def cleanup_error_comments() -> None: pass -def cleanup_reviewer_comments() -> None: - """Delete per-reviewer comments, keeping only the coordinator comment. +def cleanup_reviewer_comments(keep_comment_id: int | None = None) -> None: + """Delete per-reviewer comments, keeping the coordinator comment. - Called after the coordinator comment is posted. Identifies comments from - the same CI run and deletes all except the most recent one (the coordinator - synthesis). + Uses keep_comment_id (returned by post_pr_comment) to identify the + coordinator comment. Falls back to keeping the latest run comment if + keep_comment_id is unavailable. """ ctx = _get_pr_context() if not ctx: @@ -602,8 +607,11 @@ def cleanup_reviewer_comments() -> None: if len(run_comments) <= 1: return - # The last comment is the coordinator synthesis — keep it, delete the rest - to_delete = run_comments[:-1] + # Keep the coordinator comment (identified by keep_comment_id or latest) + if keep_comment_id: + to_delete = [c for c in run_comments if c.get("id") != keep_comment_id] + else: + to_delete = run_comments[:-1] for comment in to_delete: comment_id = comment.get("id") if not comment_id: @@ -764,7 +772,11 @@ def _main() -> int: if remaining_time <= 0: print("No time left for coordinator, posting raw outputs", file=sys.stderr) comment = post_fallback_comment(reviewer_results) - post_pr_comment(comment) + posted_id = post_pr_comment(comment) + try: + cleanup_reviewer_comments(keep_comment_id=posted_id) + except Exception: + pass return 0 coord_timeout = min(coordinator_timeout, remaining_time) if global_deadline else coordinator_timeout @@ -781,14 +793,14 @@ def _main() -> int: comment = post_fallback_comment(reviewer_results) # Post synthesized comment to PR - posted = post_pr_comment(comment) - if not posted: + posted_id = post_pr_comment(comment) + if not posted_id: print("Could not post to PR via gh CLI, writing to stdout as fallback", file=sys.stderr) print(comment) # Clean up per-reviewer comments, keep only the coordinator synthesis try: - cleanup_reviewer_comments() + cleanup_reviewer_comments(keep_comment_id=posted_id) except Exception as e: print(f"Failed to cleanup reviewer comments: {e}", file=sys.stderr) From eba1555114c5965b63e5396bd4fd20797413ca65 Mon Sep 17 00:00:00 2001 From: svtter Date: Thu, 21 May 2026 17:09:09 +0800 Subject: [PATCH 3/8] fix(multi-review): pipe JSON body via stdin for gh api --input gh api --input expects a file path, not inline data. Use stdin pipe (--input -) with subprocess.run(input=...) instead. Co-Authored-By: Claude Opus 4.7 --- multi-review/run-multi-review.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/multi-review/run-multi-review.py b/multi-review/run-multi-review.py index dbd1787..bb619bb 100644 --- a/multi-review/run-multi-review.py +++ b/multi-review/run-multi-review.py @@ -495,8 +495,8 @@ def post_pr_comment(body: str) -> int | None: result = subprocess.run( [gh_path, "api", "-X", "POST", f"/repos/{repository}/issues/{pr_number}/comments", - "--input", payload], - capture_output=True, text=True, env=os.environ.copy(), timeout=30, + "--input", "-"], + input=payload, capture_output=True, text=True, env=os.environ.copy(), timeout=30, ) if result.returncode != 0: print(f"Failed to post PR comment: {result.stderr}", file=sys.stderr) From 6dd8b1a4ec1b580b73ddbbe59cb5bc4e44521390 Mon Sep 17 00:00:00 2001 From: svtter Date: Thu, 21 May 2026 17:16:15 +0800 Subject: [PATCH 4/8] chore: remove standalone review and architect-review workflows multi-review already covers these use cases. Keeping separate workflows causes duplicate comments on every PR. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/architect-review.yml | 37 -------------------------- .github/workflows/review.yml | 30 --------------------- 2 files changed, 67 deletions(-) delete mode 100644 .github/workflows/architect-review.yml delete mode 100644 .github/workflows/review.yml diff --git a/.github/workflows/architect-review.yml b/.github/workflows/architect-review.yml deleted file mode 100644 index f341696..0000000 --- a/.github/workflows/architect-review.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Architect Review - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - -jobs: - architect-review: - if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - issues: write - steps: - - name: Checkout PR head - uses: actions/checkout@v6 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.ref }} - - - name: Configure git identity - # OpenCode requires git identity even in read-only mode for internal operations like diff/worktree - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - - name: Run OpenCode architect review - # TODO: switch to Svtter/opencode-actions/architect-review@v2 after release - uses: ./architect-review - with: - model: ${{ vars.MODEL_NAME }} - - github-token: ${{ secrets.GITHUB_TOKEN }} - zhipu-api-key: ${{ secrets.ZHIPU_API_KEY }} - deepseek-api-key: ${{ secrets.DEEPSEEK_API_KEY }} - opencode-go-api-key: ${{ secrets.OPENCODE_GO_API_KEY }} diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml deleted file mode 100644 index ac9f439..0000000 --- a/.github/workflows/review.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Review - -on: - pull_request: - types: [opened, synchronize, reopened, ready_for_review] - -jobs: - review: - if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - issues: write - steps: - - name: Checkout PR head - uses: actions/checkout@v6 - with: - repository: ${{ github.event.pull_request.head.repo.full_name }} - ref: ${{ github.event.pull_request.head.ref }} - - - name: Run OpenCode review - uses: Svtter/opencode-actions/review@v2 - with: - model: ${{ vars.MODEL_NAME }} - - github-token: ${{ secrets.GITHUB_TOKEN }} - zhipu-api-key: ${{ secrets.ZHIPU_API_KEY }} - deepseek-api-key: ${{ secrets.DEEPSEEK_API_KEY }} - opencode-go-api-key: ${{ secrets.OPENCODE_GO_API_KEY }} From 0b7484edbe3a8e3e8b7ad77b618f7f5fa2a8618c Mon Sep 17 00:00:00 2001 From: svtter Date: Thu, 21 May 2026 17:46:27 +0800 Subject: [PATCH 5/8] fix(multi-review): case-insensitive noise filter + continuation line skipping The noise prefix matching was case-sensitive but actual CLI output uses title case (e.g. "Asserting permissions"). Also add continuation line skipping: after a noise header, skip indented JSON-like fragments (session.id, llm.runtime, etc.) until the next blank line. Co-Authored-By: Claude Opus 4.7 --- multi-review/run-multi-review.py | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/multi-review/run-multi-review.py b/multi-review/run-multi-review.py index bb619bb..1bfc8f1 100644 --- a/multi-review/run-multi-review.py +++ b/multi-review/run-multi-review.py @@ -403,6 +403,7 @@ def _strip_ansi(raw: str) -> str: return text.strip() + _NOISE_PREFIXES = ( "asserting permissions", "adding reaction", @@ -418,16 +419,37 @@ def _strip_ansi(raw: str) -> str: "sqlite-migration", "database migration", ) +_NOISE_CONTINUATION = ( + "permission:", + "service:", + '"session.id"', + "step:", + '"llm.runtime"', + '"llm.provider"', + '"llm.model"', +) _TOOL_LINE_RE = re.compile(r"^\|\s+(Shell|Read|Write|Edit|Bash)\s") -_LOG_LINE_RE = re.compile(r"^\[\d{2}:\d{2}:\d{2}\.\d{3}\]\s+(INFO|WARN|ERROR|DEBUG)") +_LOG_LINE_RE = re.compile(r"^\[\d{2}:\d{2}\.\d{3}\]\s+(INFO|WARN|ERROR|DEBUG)") +_NOISE_BLOCK_RE = re.compile( + r'^\s*"[\w.]+":\s|' + r"^\s*[}{]$|" + r"^\s*step:\s*\d|" + r"^\s*permission:" +) def _filter_noise(text: str) -> str: """Remove opencode CLI boilerplate lines from reviewer output.""" cleaned = [] + skipping = False for line in text.splitlines(): stripped = line.strip() - if stripped.startswith(_NOISE_PREFIXES): + if not stripped: + skipping = False + cleaned.append(line) + continue + if stripped.lower().startswith(_NOISE_PREFIXES): + skipping = True continue if _TOOL_LINE_RE.match(stripped): continue @@ -437,6 +459,12 @@ def _filter_noise(text: str) -> str: continue if stripped.startswith("| ") and '{"' in stripped: continue + if skipping: + if stripped.startswith(_NOISE_CONTINUATION) or _NOISE_BLOCK_RE.match(stripped): + continue + if stripped in ("}", "{"): + continue + skipping = False cleaned.append(line) return "\n".join(cleaned).strip() From d25e84a1f9c9a1a60c2b1acdbf76b0fb46f19608 Mon Sep 17 00:00:00 2001 From: svtter Date: Thu, 21 May 2026 17:50:07 +0800 Subject: [PATCH 6/8] fix(multi-review): fetch actual review content from PR comments opencode CLI's stdout is just boilerplate (permissions, session info, tool calls). The actual AI review content is posted as a PR comment by opencode CLI itself. After each reviewer finishes, fetch that comment and use it as the real review content for coordinator synthesis and display in collapsible details. - Add _fetch_latest_bot_comment() to get the latest github-actions[bot] comment from the PR - Store fetched comment in reviewer_result["comment"] - Coordinator, format_pr_comment, and post_fallback_comment all prefer "comment" over "output" Co-Authored-By: Claude Opus 4.7 --- multi-review/run-multi-review.py | 51 +++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/multi-review/run-multi-review.py b/multi-review/run-multi-review.py index 1bfc8f1..30fbc61 100644 --- a/multi-review/run-multi-review.py +++ b/multi-review/run-multi-review.py @@ -307,7 +307,11 @@ def _run_reviewer_inner( rc, output = _run_opencode(prompt, m, effective_timeout, cache_dir=cache_dir) if rc == 0: - return {"name": name, "status": "success", "output": output} + # The actual review content was posted as a PR comment by opencode CLI. + # Fetch it so the coordinator gets real review text, not CLI noise. + comment_body = _fetch_latest_bot_comment() + return {"name": name, "status": "success", "output": output, + "comment": comment_body or output} if rc == 124: print(f"Reviewer {name} model {m} timed out after {effective_timeout}s", file=sys.stderr) @@ -348,7 +352,7 @@ def run_coordinator( ) -> str | None: """Run the coordinator agent to synthesize all reviewer outputs.""" reviews_text = "\n".join( - f"\n--- Reviewer: {r['name']} (status: {r['status']}) ---\n{_strip_ansi(r.get('output', ''))}\n" + f"\n--- Reviewer: {r['name']} (status: {r['status']}) ---\n{_strip_ansi(r.get('comment', '') or r.get('output', ''))}\n" for r in reviewer_results ) @@ -481,7 +485,7 @@ def format_pr_comment(coordinator_output: str, reviewer_results: list[dict[str, parts = [_filter_noise(_strip_ansi(coordinator_output)).strip(), "\n\n---\n**详细审查报告:**\n"] for r in reviewer_results: status_label = "✅" if r["status"] == "success" else "⚠️" - output = _truncate(_filter_noise(_strip_ansi(r.get("output", "")))) + output = _truncate(_filter_noise(_strip_ansi(r.get("comment", "") or r.get("output", "")))) parts.append( f"\n
\n{status_label} {r['name']}\n\n{output}\n
\n" ) @@ -492,7 +496,7 @@ def post_fallback_comment(reviewer_results: list[dict[str, Any]]) -> str: """Format a fallback comment with raw reviewer outputs when coordinator fails.""" parts = ["⚠️ Coordinator agent failed. Showing raw reviewer outputs:\n"] for r in reviewer_results: - output = _truncate(_filter_noise(_strip_ansi(r.get("output", "")))) + output = _truncate(_filter_noise(_strip_ansi(r.get("comment", "") or r.get("output", "")))) parts.append(f"\n### {r['name']} ({r['status']})\n\n{output}\n") return "".join(parts) @@ -507,6 +511,45 @@ def _get_pr_context() -> tuple[str, str] | None: return match.group(1), github_repository +def _fetch_latest_bot_comment(before_comment_id: int | None = None) -> str | None: + """Fetch the latest PR comment by github-actions[bot]. + + If before_comment_id is given, return the latest bot comment whose id + is strictly greater than before_comment_id. Otherwise return the + absolute latest bot comment. + """ + ctx = _get_pr_context() + if not ctx: + return None + pr_number, repository = ctx + + gh_path = shutil.which("gh") + if not gh_path: + return None + + try: + result = subprocess.run( + [gh_path, "api", "-H", "Accept: application/vnd.github+json", + f"/repos/{repository}/issues/{pr_number}/comments?per_page=10&sort=created&direction=desc"], + capture_output=True, text=True, env=os.environ.copy(), timeout=15, + ) + if result.returncode != 0: + return None + comments = json.loads(result.stdout) + except Exception: + return None + + for c in reversed(comments): + c_id = c.get("id", 0) + if before_comment_id and c_id <= before_comment_id: + continue + if c.get("user", {}).get("login") == "github-actions[bot]": + body = c.get("body", "") + if body.strip(): + return body + return None + + def post_pr_comment(body: str) -> int | None: """Post a comment to the current PR via gh api. Returns comment ID on success.""" ctx = _get_pr_context() From 4d27ec24d14c09f49cbf1cab2e631b9e95e689dd Mon Sep 17 00:00:00 2001 From: svtter Date: Thu, 21 May 2026 17:55:21 +0800 Subject: [PATCH 7/8] fix(multi-review): log/tool lines trigger skip mode + fix reversed() bug - Log lines ([HH:MM:SS] INFO...) and tool call lines (| Shell...) now set skipping=True so their JSON-like continuation lines are also filtered out - Fix _fetch_latest_bot_comment: GitHub API already returns newest first with sort=created&direction=desc, so reversed() was returning the oldest comment instead of the latest Co-Authored-By: Claude Opus 4.7 --- multi-review/run-multi-review.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/multi-review/run-multi-review.py b/multi-review/run-multi-review.py index 30fbc61..719439e 100644 --- a/multi-review/run-multi-review.py +++ b/multi-review/run-multi-review.py @@ -456,8 +456,10 @@ def _filter_noise(text: str) -> str: skipping = True continue if _TOOL_LINE_RE.match(stripped): + skipping = True continue if _LOG_LINE_RE.match(stripped): + skipping = True continue if "opencode.ai/s/" in stripped: continue @@ -539,7 +541,7 @@ def _fetch_latest_bot_comment(before_comment_id: int | None = None) -> str | Non except Exception: return None - for c in reversed(comments): + for c in comments: c_id = c.get("id", 0) if before_comment_id and c_id <= before_comment_id: continue From 86d07f520ebc0240f3b9813f5f618eb060a3ee73 Mon Sep 17 00:00:00 2001 From: svtter Date: Thu, 21 May 2026 18:02:20 +0800 Subject: [PATCH 8/8] fix(multi-review): snapshot-based comment fetching to fix race condition The previous approach fetched comments per-reviewer which caused race conditions in parallel execution -- both reviewers would fetch the same "latest" comment. New approach: 1. Snapshot all bot comment IDs before reviewers start 2. Run all reviewers in parallel (no per-reviewer fetch) 3. After all reviewers finish, fetch new bot comments in bulk and assign to reviewers by creation time order 4. Snapshot again before coordinator, fetch coordinator's posted comment after it finishes 5. Cleanup deletes all opencode-CLI comments, keeps only Python-posted This also fixes the coordinator output being pure noise -- we now fetch the coordinator's actual AI synthesis from its posted PR comment. Co-Authored-By: Claude Opus 4.7 --- multi-review/run-multi-review.py | 90 ++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 29 deletions(-) diff --git a/multi-review/run-multi-review.py b/multi-review/run-multi-review.py index 719439e..388148a 100644 --- a/multi-review/run-multi-review.py +++ b/multi-review/run-multi-review.py @@ -307,11 +307,7 @@ def _run_reviewer_inner( rc, output = _run_opencode(prompt, m, effective_timeout, cache_dir=cache_dir) if rc == 0: - # The actual review content was posted as a PR comment by opencode CLI. - # Fetch it so the coordinator gets real review text, not CLI noise. - comment_body = _fetch_latest_bot_comment() - return {"name": name, "status": "success", "output": output, - "comment": comment_body or output} + return {"name": name, "status": "success", "output": output} if rc == 124: print(f"Reviewer {name} model {m} timed out after {effective_timeout}s", file=sys.stderr) @@ -513,43 +509,58 @@ def _get_pr_context() -> tuple[str, str] | None: return match.group(1), github_repository -def _fetch_latest_bot_comment(before_comment_id: int | None = None) -> str | None: - """Fetch the latest PR comment by github-actions[bot]. - - If before_comment_id is given, return the latest bot comment whose id - is strictly greater than before_comment_id. Otherwise return the - absolute latest bot comment. - """ +def _snapshot_comment_ids() -> set[int]: + """Return the set of all current github-actions[bot] comment IDs.""" ctx = _get_pr_context() if not ctx: - return None + return set() pr_number, repository = ctx - gh_path = shutil.which("gh") if not gh_path: - return None - + return set() try: result = subprocess.run( [gh_path, "api", "-H", "Accept: application/vnd.github+json", - f"/repos/{repository}/issues/{pr_number}/comments?per_page=10&sort=created&direction=desc"], + f"/repos/{repository}/issues/{pr_number}/comments?per_page=100"], capture_output=True, text=True, env=os.environ.copy(), timeout=15, ) if result.returncode != 0: - return None + return set() comments = json.loads(result.stdout) + return {c["id"] for c in comments + if c.get("user", {}).get("login") == "github-actions[bot]" and c.get("id")} except Exception: - return None + return set() - for c in comments: - c_id = c.get("id", 0) - if before_comment_id and c_id <= before_comment_id: - continue - if c.get("user", {}).get("login") == "github-actions[bot]": - body = c.get("body", "") - if body.strip(): - return body - return None + +def _fetch_new_bot_comments(before_ids: set[int]) -> list[dict[str, Any]]: + """Fetch bot comments created after the snapshot, sorted by creation time.""" + ctx = _get_pr_context() + if not ctx: + return [] + pr_number, repository = ctx + gh_path = shutil.which("gh") + if not gh_path: + return [] + try: + result = subprocess.run( + [gh_path, "api", "-H", "Accept: application/vnd.github+json", + f"/repos/{repository}/issues/{pr_number}/comments?per_page=100"], + capture_output=True, text=True, env=os.environ.copy(), timeout=15, + ) + if result.returncode != 0: + return [] + comments = json.loads(result.stdout) + new_comments = [] + for c in comments: + if c.get("id") not in before_ids and c.get("user", {}).get("login") == "github-actions[bot]": + body = c.get("body", "") + if body.strip(): + new_comments.append({"id": c["id"], "body": body, "created_at": c.get("created_at", "")}) + new_comments.sort(key=lambda c: c["created_at"]) + return new_comments + except Exception: + return [] def post_pr_comment(body: str) -> int | None: @@ -802,6 +813,10 @@ def _main() -> int: fallback_models = [m.strip() for m in re.split(r"[\r\n,]+", fallback_models_str) if m.strip()] + # --- Snapshot existing comments before reviewers start --- + pre_comment_ids = _snapshot_comment_ids() + print(f"Snapshot {len(pre_comment_ids)} existing bot comments before review", file=sys.stderr) + # --- Run reviewers in parallel --- global_deadline = time.time() + global_timeout if global_timeout > 0 else None @@ -833,12 +848,22 @@ def _main() -> int: print(f"Reviewer {name} raised exception: {e}", file=sys.stderr) reviewer_results.append({"name": name, "status": "error", "output": str(e)}) + # --- Fetch actual review content from PR comments --- successful = [r for r in reviewer_results if r["status"] == "success"] + if successful: + new_comments = _fetch_new_bot_comments(pre_comment_ids) + print(f"Fetched {len(new_comments)} new bot comments ({len(successful)} successful reviewers)", file=sys.stderr) + # Assign new comments to successful reviewers (order matched by creation time) + for i, r in enumerate(successful): + if i < len(new_comments): + r["comment"] = new_comments[i]["body"] + r["comment_id"] = new_comments[i]["id"] + if not successful: print("All reviewers failed", file=sys.stderr) return 1 - # --- Run coordinator --- + # --- Snapshot before coordinator --- remaining_time = 0 if global_deadline: remaining_time = max(0, int(global_deadline - time.time())) @@ -853,12 +878,19 @@ def _main() -> int: return 0 coord_timeout = min(coordinator_timeout, remaining_time) if global_deadline else coordinator_timeout + pre_coord_ids = _snapshot_comment_ids() coordinator_output = run_coordinator( reviewer_results, coord_timeout, coordinator_prompt_template or None, ) + # Fetch coordinator's actual synthesis from PR comment + coord_comments = _fetch_new_bot_comments(pre_coord_ids) + if coord_comments: + coordinator_output = coord_comments[-1]["body"] + print(f"Fetched coordinator synthesis from PR comment (id={coord_comments[-1]['id']})", file=sys.stderr) + if coordinator_output: comment = format_pr_comment(coordinator_output, reviewer_results) else: