diff --git a/multi-review/run-multi-review.py b/multi-review/run-multi-review.py index 752a32d..bb619bb 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) @@ -441,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], - 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 +564,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() @@ -666,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 @@ -683,11 +793,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