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 }} diff --git a/multi-review/run-multi-review.py b/multi-review/run-multi-review.py index 752a32d..388148a 100644 --- a/multi-review/run-multi-review.py +++ b/multi-review/run-multi-review.py @@ -348,7 +348,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 ) @@ -403,6 +403,74 @@ 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", +) +_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{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 not stripped: + skipping = False + cleaned.append(line) + continue + if stripped.lower().startswith(_NOISE_PREFIXES): + 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 + 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() + + def _truncate(text: str, limit: int = 8000) -> str: text = text.strip() if len(text) > limit: @@ -412,10 +480,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("comment", "") or r.get("output", "")))) parts.append( f"\n
\n{status_label} {r['name']}\n\n{output}\n
\n" ) @@ -426,7 +494,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("comment", "") or r.get("output", "")))) parts.append(f"\n### {r['name']} ({r['status']})\n\n{output}\n") return "".join(parts) @@ -441,30 +509,89 @@ 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 _snapshot_comment_ids() -> set[int]: + """Return the set of all current github-actions[bot] comment IDs.""" + ctx = _get_pr_context() + if not ctx: + return set() + pr_number, repository = ctx + gh_path = shutil.which("gh") + if not gh_path: + return set() + 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 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 set() + + +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 False + 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: + """Post a comment to the current PR via gh api. Returns comment ID on success.""" + ctx = _get_pr_context() + if not ctx: + 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], - capture_output=True, text=True, env=os.environ.copy(), timeout=30, + [gh_path, "api", "-X", "POST", + f"/repos/{repository}/issues/{pr_number}/comments", + "--input", "-"], + 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: @@ -521,6 +648,69 @@ def cleanup_error_comments() -> None: pass +def cleanup_reviewer_comments(keep_comment_id: int | None = None) -> None: + """Delete per-reviewer comments, keeping the coordinator comment. + + 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: + 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 + + # 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: + 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() @@ -623,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 @@ -654,28 +848,49 @@ 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())) 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 + 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: @@ -683,11 +898,17 @@ 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(keep_comment_id=posted_id) + except Exception as e: + print(f"Failed to cleanup reviewer comments: {e}", file=sys.stderr) + return 0