From f71116faec1624a9a20fefab6173ec9eba875e59 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 11 Mar 2026 14:10:32 +0000 Subject: [PATCH 1/3] Handle CodeRabbit rate limits as running Co-authored-by: HappyOnigiri --- .refix.yaml.sample | 4 + README.ja.md | 25 +++ README.md | 17 ++ src/auto_fixer.py | 328 +++++++++++++++++++++++++++++++++++++- src/pr_reviewer.py | 21 +++ tests/test_auto_fixer.py | 209 ++++++++++++++++++++++++ tests/test_pr_reviewer.py | 13 ++ 7 files changed, 614 insertions(+), 3 deletions(-) diff --git a/.refix.yaml.sample b/.refix.yaml.sample index 6c63f1a..66cf85e 100644 --- a/.refix.yaml.sample +++ b/.refix.yaml.sample @@ -8,6 +8,10 @@ ci_log_max_lines: 120 # Default: false auto_merge: false +# Automatically post `@coderabbitai resume` after a CodeRabbit rate-limit wait expires (Optional) +# Default: false +coderabbit_auto_resume: false + # Whether to process DRAFT PRs (Optional) # Default: false (skip DRAFT PRs) process_draft_prs: false diff --git a/README.ja.md b/README.ja.md index 003d7f1..75cdbad 100644 --- a/README.ja.md +++ b/README.ja.md @@ -20,6 +20,8 @@ - 修正後にレビュー スレッドを解決する - PR 上の状態管理コメントと `refix:running` / `refix:done` ラベルで進捗を記録する +CodeRabbit がレビュー側のレートリミットに到達した場合でも、`refix` は PR を `refix:running` のまま維持し、CI 修正とベースブランチ追従だけを進め、レビュー修正と auto-merge は再開可能になるまで保留します。 + ## 主な機能 ### レビュー要約 @@ -89,6 +91,12 @@ models: ci_log_max_lines: 120 +auto_merge: false + +coderabbit_auto_resume: false + +process_draft_prs: false + repositories: - repo: "owner/repo" user_name: "Refix Bot" @@ -149,6 +157,16 @@ PR が `refix:done` 状態になった際に自動マージします。 `false`(デフォルト)の場合、ドラフト PR はスキップされます。`true` にすると、通常のオープン PR と同様にドラフト PR も処理されます。 +#### `coderabbit_auto_resume` + +CodeRabbit のレートリミットコメントに記載された待機時間が経過したあと、自動で `@coderabbitai resume` コメントを投稿するかどうかを設定します。 + +- 型: boolean +- 必須: いいえ +- デフォルト: `false` + +レートリミット中は、`refix` は PR を `refix:running` に保ち、レビュー修正と auto-merge を止めつつ、CI 修正とベースブランチ取り込みは継続します。この設定を `true` にすると、待機時間経過後に自動で CodeRabbit の再開を促します。 + #### `repositories` `refix` が処理する対象リポジトリの一覧です。 @@ -198,6 +216,7 @@ PR が `refix:done` 状態になった際に自動マージします。 - 未知のキーは即エラーではなく、警告を出して無視されます。 - `models.summarize` で要約処理で使用するモデルを指定します。この設定は環境変数 `REFIX_MODEL_SUMMARIZE` より優先されます。 - `models.fix` で修正処理で使用するモデルを指定します。 +- `coderabbit_auto_resume` は、最新の CodeRabbit レートリミット通知より後にすでに `@coderabbitai resume` コメントがある場合は重複投稿しません。 ## GitHub Actions での実行方法 @@ -248,6 +267,12 @@ models: ci_log_max_lines: 120 +auto_merge: false + +coderabbit_auto_resume: false + +process_draft_prs: false + repositories: - repo: "your-org/your-repo" user_name: "Refix Bot" diff --git a/README.md b/README.md index 8341fc1..8301541 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,8 @@ For each configured repository, `refix` can: - resolve review threads after successful fixes, and - persist progress in a PR state comment and with labels such as `refix:running` and `refix:done`. +When CodeRabbit reports a review-side rate limit, `refix` keeps the PR in `refix:running`, still allows CI repair and base-branch catch-up, and skips review-fix / auto-merge until CodeRabbit can resume. + ## Features ### Review summarization @@ -92,6 +94,10 @@ ci_log_max_lines: 120 # Automatically merge PR when it reaches refix:done state (optional, default false) auto_merge: false +# Automatically post `@coderabbitai resume` after a CodeRabbit rate-limit wait expires +# (optional, default false) +coderabbit_auto_resume: false + # Whether to process draft PRs (optional) # Default: false (draft PRs are skipped) process_draft_prs: false @@ -157,6 +163,16 @@ Whether to include draft PRs in the processing targets. When set to `false` (the default), draft PRs are skipped. Set to `true` to process draft PRs alongside regular open PRs. +#### `coderabbit_auto_resume` + +Whether `refix` should automatically post `@coderabbitai resume` after a CodeRabbit rate-limit comment says the wait time has elapsed. + +- Type: boolean +- Required: no +- Default: `false` + +When a rate-limit notice is active, `refix` keeps the PR in `refix:running`, skips review-fix / auto-merge, and still performs CI repair plus base-branch merge handling. Enabling this option lets `refix` resume CodeRabbit automatically once the wait window has passed. + #### `repositories` List of repositories that `refix` should process. @@ -205,6 +221,7 @@ If omitted, `refix` falls back to the effective Git identity available in the ex - `repositories` must be present and must contain at least one entry. - Unknown keys are ignored with warnings rather than treated as hard errors. - `models.summarize` in YAML takes priority over the `REFIX_MODEL_SUMMARIZE` environment variable when selecting the summarization model. +- The `coderabbit_auto_resume` option only affects active CodeRabbit rate-limit comments; duplicate `@coderabbitai resume` comments are avoided when one has already been posted after the latest rate-limit notice. ## Running in CI with GitHub Actions diff --git a/src/auto_fixer.py b/src/auto_fixer.py index 3a8d13e..6fe5265 100644 --- a/src/auto_fixer.py +++ b/src/auto_fixer.py @@ -12,6 +12,7 @@ import shutil import subprocess import sys +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any from urllib.parse import quote @@ -77,7 +78,13 @@ from dotenv import load_dotenv from github_pr_fetcher import fetch_open_prs -from pr_reviewer import fetch_pr_details, fetch_pr_review_comments, fetch_review_threads, resolve_review_thread +from pr_reviewer import ( + fetch_issue_comments, + fetch_pr_details, + fetch_pr_review_comments, + fetch_review_threads, + resolve_review_thread, +) from ci_log import _log_endgroup, _log_group from summarizer import summarize_reviews from constants import SEPARATOR_LEN @@ -88,6 +95,8 @@ REFIX_RUNNING_LABEL = "refix:running" REFIX_DONE_LABEL = "refix:done" CODERABBIT_PROCESSING_MARKER = "Currently processing new changes in this PR." +CODERABBIT_RATE_LIMIT_MARKER = "Rate limit exceeded" +CODERABBIT_RESUME_COMMENT = "@coderabbitai resume" SUCCESSFUL_CI_STATES = {"SUCCESS", "SKIPPED", "NEUTRAL"} REFIX_RUNNING_LABEL_COLOR = "FBCA04" REFIX_DONE_LABEL_COLOR = "0E8A16" @@ -101,10 +110,18 @@ }, "ci_log_max_lines": 120, "auto_merge": False, + "coderabbit_auto_resume": False, "process_draft_prs": False, "repositories": [], } -ALLOWED_CONFIG_TOP_LEVEL_KEYS = {"models", "ci_log_max_lines", "auto_merge", "process_draft_prs", "repositories"} +ALLOWED_CONFIG_TOP_LEVEL_KEYS = { + "models", + "ci_log_max_lines", + "auto_merge", + "coderabbit_auto_resume", + "process_draft_prs", + "repositories", +} ALLOWED_MODEL_KEYS = {"summarize", "fix"} ALLOWED_REPOSITORY_KEYS = {"repo", "user_name", "user_email"} @@ -141,6 +158,7 @@ def load_config(filepath: str) -> dict[str, Any]: "models": dict(DEFAULT_CONFIG["models"]), "ci_log_max_lines": DEFAULT_CONFIG["ci_log_max_lines"], "auto_merge": DEFAULT_CONFIG["auto_merge"], + "coderabbit_auto_resume": DEFAULT_CONFIG["coderabbit_auto_resume"], "process_draft_prs": DEFAULT_CONFIG["process_draft_prs"], "repositories": [], } @@ -181,6 +199,13 @@ def load_config(filepath: str) -> dict[str, Any]: sys.exit(1) config["auto_merge"] = auto_merge + coderabbit_auto_resume = parsed.get("coderabbit_auto_resume") + if coderabbit_auto_resume is not None: + if not isinstance(coderabbit_auto_resume, bool): + print("Error: coderabbit_auto_resume must be a boolean.", file=sys.stderr) + sys.exit(1) + config["coderabbit_auto_resume"] = coderabbit_auto_resume + process_draft_prs = parsed.get("process_draft_prs") if process_draft_prs is not None: if not isinstance(process_draft_prs, bool): @@ -1143,6 +1168,7 @@ def _are_all_ci_checks_successful(repo: str, pr_number: int) -> bool: def _contains_coderabbit_processing_marker( pr_data: dict[str, Any], review_comments: list[dict[str, Any]], + issue_comments: list[dict[str, Any]] | None = None, ) -> bool: for review in pr_data.get("reviews", []): login = review.get("author", {}).get("login", "") @@ -1162,9 +1188,238 @@ def _contains_coderabbit_processing_marker( if _is_coderabbit_login(login) and CODERABBIT_PROCESSING_MARKER in body: return True + for comment in issue_comments or []: + login = comment.get("user", {}).get("login", "") + body = comment.get("body", "") or "" + if _is_coderabbit_login(login) and CODERABBIT_PROCESSING_MARKER in body: + return True + + return False + + +def _parse_github_timestamp(value: str | None) -> datetime | None: + if not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return None + + +def _comment_last_updated_at(comment: dict[str, Any]) -> datetime | None: + return ( + _parse_github_timestamp(str(comment.get("updated_at") or "")) + or _parse_github_timestamp(str(comment.get("updatedAt") or "")) + or _parse_github_timestamp(str(comment.get("created_at") or "")) + or _parse_github_timestamp(str(comment.get("createdAt") or "")) + ) + + +def _parse_wait_duration_seconds(text: str) -> int | None: + unit_map = { + "day": 86400, + "days": 86400, + "hour": 3600, + "hours": 3600, + "minute": 60, + "minutes": 60, + "second": 1, + "seconds": 1, + } + matches = re.findall(r"(\d+)\s+(day|days|hour|hours|minute|minutes|second|seconds)", text, flags=re.IGNORECASE) + if not matches: + return None + total = 0 + for raw_value, raw_unit in matches: + total += int(raw_value) * unit_map[raw_unit.lower()] + return total + + +def _extract_coderabbit_rate_limit_status(comment: dict[str, Any]) -> dict[str, Any] | None: + body = str(comment.get("body") or "") + if CODERABBIT_RATE_LIMIT_MARKER.lower() not in body.lower(): + return None + + wait_match = re.search( + r"Please wait\s+\*\*(?P[^*]+)\*\*\s+before requesting another review\.", + body, + flags=re.IGNORECASE, + ) + if not wait_match: + return None + + wait_text = wait_match.group("duration").strip() + wait_seconds = _parse_wait_duration_seconds(wait_text) + if wait_seconds is None: + return None + + updated_at = _comment_last_updated_at(comment) + if updated_at is None: + return None + + return { + "comment_id": comment.get("id"), + "html_url": str(comment.get("html_url") or comment.get("url") or "").strip(), + "wait_text": wait_text, + "wait_seconds": wait_seconds, + "updated_at": updated_at, + "resume_after": updated_at + timedelta(seconds=wait_seconds), + } + + +def _latest_coderabbit_activity_at( + pr_data: dict[str, Any], + review_comments: list[dict[str, Any]], + issue_comments: list[dict[str, Any]], +) -> datetime | None: + latest: datetime | None = None + + def _update(candidate: datetime | None) -> None: + nonlocal latest + if candidate is None: + return + if latest is None or candidate > latest: + latest = candidate + + for review in pr_data.get("reviews", []): + login = str(review.get("author", {}).get("login", "")) + if _is_coderabbit_login(login): + _update( + _parse_github_timestamp(str(review.get("submittedAt") or "")) + or _parse_github_timestamp(str(review.get("updatedAt") or "")) + ) + + for comment in review_comments: + login = str(comment.get("user", {}).get("login", "")) + if _is_coderabbit_login(login): + _update(_comment_last_updated_at(comment)) + + for comment in issue_comments: + login = str(comment.get("user", {}).get("login", "")) + if _is_coderabbit_login(login): + _update(_comment_last_updated_at(comment)) + + return latest + + +def _get_active_coderabbit_rate_limit( + pr_data: dict[str, Any], + review_comments: list[dict[str, Any]], + issue_comments: list[dict[str, Any]], +) -> dict[str, Any] | None: + latest_rate_limit: dict[str, Any] | None = None + for comment in issue_comments: + login = str(comment.get("user", {}).get("login", "")) + if not _is_coderabbit_login(login): + continue + rate_limit_status = _extract_coderabbit_rate_limit_status(comment) + if rate_limit_status is None: + continue + if latest_rate_limit is None or rate_limit_status["updated_at"] > latest_rate_limit["updated_at"]: + latest_rate_limit = rate_limit_status + + if latest_rate_limit is None: + return None + + latest_activity = _latest_coderabbit_activity_at(pr_data, review_comments, issue_comments) + if latest_activity is not None and latest_activity > latest_rate_limit["updated_at"]: + return None + return latest_rate_limit + + +def _has_resume_comment_after(issue_comments: list[dict[str, Any]], threshold: datetime) -> bool: + normalized_target = CODERABBIT_RESUME_COMMENT.strip().lower() + for comment in issue_comments: + body = str(comment.get("body") or "").strip().lower() + if body != normalized_target: + continue + posted_at = _comment_last_updated_at(comment) + if posted_at is not None and posted_at >= threshold: + return True + return False + + +def _format_duration(seconds: int) -> str: + seconds = max(0, seconds) + minutes, sec = divmod(seconds, 60) + hours, minutes = divmod(minutes, 60) + parts: list[str] = [] + if hours: + parts.append(f"{hours}h") + if minutes: + parts.append(f"{minutes}m") + if sec or not parts: + parts.append(f"{sec}s") + return " ".join(parts) + + +def _post_issue_comment(repo: str, pr_number: int, body: str) -> bool: + result = subprocess.run( + [ + "gh", + "api", + f"repos/{repo}/issues/{pr_number}/comments", + "-X", + "POST", + "-f", + f"body={body}", + ], + capture_output=True, + text=True, + check=False, + encoding="utf-8", + ) + if result.returncode == 0: + print(f"Posted comment to PR #{pr_number}: {body}") + return True + + print( + f"Warning: failed to post comment to PR #{pr_number}: {(result.stderr or result.stdout).strip()}", + file=sys.stderr, + ) return False +def _maybe_auto_resume_coderabbit_review( + *, + repo: str, + pr_number: int, + issue_comments: list[dict[str, Any]], + rate_limit_status: dict[str, Any] | None, + auto_resume_enabled: bool, + dry_run: bool, + summarize_only: bool, +) -> bool: + if rate_limit_status is None: + return False + if not auto_resume_enabled: + print(f"CodeRabbit rate limit detected for PR #{pr_number}; auto resume is disabled.") + return False + + resume_after = rate_limit_status["resume_after"] + now = datetime.now(timezone.utc) + if now < resume_after: + remaining = int((resume_after - now).total_seconds()) + print( + f"CodeRabbit rate limit detected for PR #{pr_number}; auto resume available in {_format_duration(remaining)}." + ) + return False + + threshold = rate_limit_status["updated_at"] + if _has_resume_comment_after(issue_comments, threshold): + print(f"Resume comment already exists after the latest CodeRabbit rate-limit notice on PR #{pr_number}.") + return False + + if dry_run: + print(f"[DRY RUN] Would post CodeRabbit resume comment to PR #{pr_number}: {CODERABBIT_RESUME_COMMENT}") + return False + if summarize_only: + print(f"Summarize-only mode: skip posting CodeRabbit resume comment to PR #{pr_number}.") + return False + + return _post_issue_comment(repo, pr_number, CODERABBIT_RESUME_COMMENT) + + def _update_done_label_if_completed( *, repo: str, @@ -1177,9 +1432,11 @@ def _update_done_label_if_completed( commits_by_phase: list[str], pr_data: dict[str, Any], review_comments: list[dict[str, Any]], + issue_comments: list[dict[str, Any]], dry_run: bool, summarize_only: bool, auto_merge_enabled: bool = False, + coderabbit_rate_limit_active: bool = False, ) -> None: if dry_run or summarize_only: return @@ -1194,10 +1451,14 @@ def _update_done_label_if_completed( if has_review_targets and (not review_fix_started or review_fix_added_commits): is_completed = False - if is_completed and _contains_coderabbit_processing_marker(pr_data, review_comments): + if is_completed and _contains_coderabbit_processing_marker(pr_data, review_comments, issue_comments): print(f"CodeRabbit is still processing PR #{pr_number}; mark as {REFIX_RUNNING_LABEL}.") is_completed = False + if is_completed and coderabbit_rate_limit_active: + print(f"CodeRabbit rate limit is active on PR #{pr_number}; keep {REFIX_RUNNING_LABEL}.") + is_completed = False + if is_completed and not _are_all_ci_checks_successful(repo, pr_number): is_completed = False @@ -1232,6 +1493,9 @@ def process_repo( fix_model = str(model_config.get("fix", DEFAULT_CONFIG["models"]["fix"])).strip() ci_log_max_lines = int(runtime_config.get("ci_log_max_lines", DEFAULT_CONFIG["ci_log_max_lines"])) auto_merge_enabled = bool(runtime_config.get("auto_merge", DEFAULT_CONFIG["auto_merge"])) + coderabbit_auto_resume_enabled = bool( + runtime_config.get("coderabbit_auto_resume", DEFAULT_CONFIG["coderabbit_auto_resume"]) + ) process_draft_prs = bool(runtime_config.get("process_draft_prs", DEFAULT_CONFIG["process_draft_prs"])) repo = repo_info["repo"] @@ -1347,6 +1611,12 @@ def process_repo( print(f"Error: could not fetch review threads: {e}", file=sys.stderr) pr_fetch_failed = True continue + try: + issue_comments = fetch_issue_comments(repo, pr_number) + except Exception as e: + print(f"Error: could not fetch issue comments: {e}", file=sys.stderr) + pr_fetch_failed = True + continue unresolved_thread_ids = set(thread_map.keys()) unresolved_comments = [] for c in review_comments: @@ -1367,9 +1637,29 @@ def process_repo( if not processed and in_thread: unresolved_comments.append(comment_item) + active_rate_limit = _get_active_coderabbit_rate_limit(pr_data, review_comments, issue_comments) + if active_rate_limit: + print( + f"CodeRabbit rate limit is active for PR #{pr_number} " + f"(wait={active_rate_limit['wait_text']}, resume_after={active_rate_limit['resume_after'].isoformat()})" + ) + if not dry_run and not summarize_only: + _set_pr_running_label(repo, pr_number) + _maybe_auto_resume_coderabbit_review( + repo=repo, + pr_number=pr_number, + issue_comments=issue_comments, + rate_limit_status=active_rate_limit, + auto_resume_enabled=coderabbit_auto_resume_enabled, + dry_run=dry_run, + summarize_only=summarize_only, + ) + has_review_targets = bool(unresolved_reviews or unresolved_comments) if not has_review_targets and not is_behind and not has_failing_ci: print(f"No unresolved reviews, not behind, and no failing CI for PR #{pr_number}") + if active_rate_limit: + processed_count += 1 _update_done_label_if_completed( repo=repo, pr_number=pr_number, @@ -1381,9 +1671,11 @@ def process_repo( commits_by_phase=[], pr_data=pr_data, review_comments=review_comments, + issue_comments=issue_comments, dry_run=dry_run, summarize_only=summarize_only, auto_merge_enabled=auto_merge_enabled, + coderabbit_rate_limit_active=bool(active_rate_limit), ) continue @@ -1636,9 +1928,37 @@ def process_repo( commits_by_phase=commits_by_phase, pr_data=pr_data, review_comments=review_comments, + issue_comments=issue_comments, + dry_run=dry_run, + summarize_only=summarize_only, + auto_merge_enabled=auto_merge_enabled, + coderabbit_rate_limit_active=bool(active_rate_limit), + ) + if commits_by_phase: + commits_added_to.append((repo, pr_number, "\n".join(commits_by_phase))) + continue + + if active_rate_limit: + print( + f"Skipping review-fix for PR #{pr_number} because CodeRabbit is rate-limited; " + "CI repair and merge-base handling already ran." + ) + _update_done_label_if_completed( + repo=repo, + pr_number=pr_number, + has_review_targets=has_review_targets, + review_fix_started=review_fix_started, + review_fix_added_commits=review_fix_added_commits, + review_fix_failed=review_fix_failed, + state_saved=state_saved, + commits_by_phase=commits_by_phase, + pr_data=pr_data, + review_comments=review_comments, + issue_comments=issue_comments, dry_run=dry_run, summarize_only=summarize_only, auto_merge_enabled=auto_merge_enabled, + coderabbit_rate_limit_active=True, ) if commits_by_phase: commits_added_to.append((repo, pr_number, "\n".join(commits_by_phase))) @@ -1839,9 +2159,11 @@ def process_repo( commits_by_phase=commits_by_phase, pr_data=pr_data, review_comments=review_comments, + issue_comments=issue_comments, dry_run=dry_run, summarize_only=summarize_only, auto_merge_enabled=auto_merge_enabled, + coderabbit_rate_limit_active=bool(active_rate_limit), ) if commits_by_phase: diff --git a/src/pr_reviewer.py b/src/pr_reviewer.py index 736b47e..608991f 100644 --- a/src/pr_reviewer.py +++ b/src/pr_reviewer.py @@ -120,6 +120,27 @@ def fetch_pr_review_comments(repo: str, pr_number: int) -> list[dict[str, Any]]: return _flatten_paginated_response(data) +def fetch_issue_comments(repo: str, pr_number: int) -> list[dict[str, Any]]: + """Fetch issue comments for a pull request via REST API.""" + cmd = [ + "gh", + "api", + f"repos/{repo}/issues/{pr_number}/comments", + "--paginate", + "--slurp", + ] + result = subprocess.run(cmd, capture_output=True, text=True, check=False, encoding="utf-8") + if result.returncode != 0: + print(f"Warning: failed to fetch issue comments: {result.stderr}", file=sys.stderr) + return [] + try: + data = json.loads(result.stdout) if result.stdout else [] + except json.JSONDecodeError: + print("Warning: failed to parse issue comments response", file=sys.stderr) + return [] + return _flatten_paginated_response(data) + + def fetch_review_threads(repo: str, pr_number: int) -> dict[int, str]: """Fetch unresolved review threads and return {comment_db_id: thread_node_id}.""" owner, name = repo.split("/") diff --git a/tests/test_auto_fixer.py b/tests/test_auto_fixer.py index ca8f81e..c9d7d21 100644 --- a/tests/test_auto_fixer.py +++ b/tests/test_auto_fixer.py @@ -176,6 +176,7 @@ def test_valid_config_with_all_keys(self, tmp_path): fix: claude-sonnet ci_log_max_lines: 250 auto_merge: true +coderabbit_auto_resume: true repositories: - repo: owner/repo1 user_name: Bot User @@ -192,6 +193,7 @@ def test_valid_config_with_all_keys(self, tmp_path): }, "ci_log_max_lines": 250, "auto_merge": True, + "coderabbit_auto_resume": True, "process_draft_prs": False, "repositories": [ { @@ -221,6 +223,7 @@ def test_optional_keys_use_defaults(self, tmp_path): assert config["models"]["fix"] == "sonnet" assert config["ci_log_max_lines"] == 120 assert config["auto_merge"] is False + assert config["coderabbit_auto_resume"] is False assert config["process_draft_prs"] is False assert config["repositories"] == [ {"repo": "owner/repo1", "user_name": None, "user_email": None} @@ -251,6 +254,19 @@ def test_process_draft_prs_can_be_enabled(self, tmp_path): config = auto_fixer.load_config(str(config_file)) assert config["process_draft_prs"] is True + def test_coderabbit_auto_resume_requires_boolean(self, tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ +coderabbit_auto_resume: "true" +repositories: + - repo: owner/repo1 +""".strip() + ) + with pytest.raises(SystemExit) as exc_info: + auto_fixer.load_config(str(config_file)) + assert exc_info.value.code == 1 + def test_process_draft_prs_type_error_exits(self, tmp_path): config_file = tmp_path / "config.yaml" config_file.write_text( @@ -640,6 +656,98 @@ def test_collect_ci_failure_materials_fetches_unique_run_logs(self): ) +class TestCodeRabbitRateLimitHelpers: + RATE_LIMIT_BODY = """ +> [!WARNING] +> ## Rate limit exceeded +> +> `@HappyOnigiri` has exceeded the limit for the number of commits that can be reviewed per hour. Please wait **5 minutes and 11 seconds** before requesting another review. +""".strip() + + def test_extract_coderabbit_rate_limit_status(self): + status = auto_fixer._extract_coderabbit_rate_limit_status( + { + "id": 55, + "body": self.RATE_LIMIT_BODY, + "updated_at": "2026-03-11T12:00:00Z", + "html_url": "https://github.com/owner/repo/issues/1#issuecomment-55", + } + ) + + assert status is not None + assert status["comment_id"] == 55 + assert status["wait_text"] == "5 minutes and 11 seconds" + assert status["wait_seconds"] == 311 + assert status["resume_after"].isoformat() == "2026-03-11T12:05:11+00:00" + + def test_get_active_coderabbit_rate_limit_ignores_stale_notice(self): + pr_data = { + "reviews": [ + { + "author": {"login": "coderabbitai"}, + "submittedAt": "2026-03-11T12:10:00Z", + } + ] + } + issue_comments = [ + { + "id": 55, + "body": self.RATE_LIMIT_BODY, + "user": {"login": "coderabbitai[bot]"}, + "updated_at": "2026-03-11T12:00:00Z", + } + ] + + status = auto_fixer._get_active_coderabbit_rate_limit(pr_data, [], issue_comments) + assert status is None + + def test_maybe_auto_resume_posts_comment_when_wait_elapsed(self): + now = auto_fixer.datetime.now(auto_fixer.timezone.utc) + status = { + "updated_at": now, + "resume_after": now - auto_fixer.timedelta(seconds=1), + } + with patch("auto_fixer._post_issue_comment", return_value=True) as mock_post: + posted = auto_fixer._maybe_auto_resume_coderabbit_review( + repo="owner/repo", + pr_number=1, + issue_comments=[], + rate_limit_status=status, + auto_resume_enabled=True, + dry_run=False, + summarize_only=False, + ) + + assert posted is True + mock_post.assert_called_once_with("owner/repo", 1, "@coderabbitai resume") + + def test_maybe_auto_resume_skips_when_resume_already_exists(self): + threshold = auto_fixer.datetime(2026, 3, 11, 12, 0, tzinfo=auto_fixer.timezone.utc) + status = { + "updated_at": threshold, + "resume_after": threshold, + } + issue_comments = [ + { + "body": "@coderabbitai resume", + "updated_at": "2026-03-11T12:01:00Z", + } + ] + with patch("auto_fixer._post_issue_comment") as mock_post: + posted = auto_fixer._maybe_auto_resume_coderabbit_review( + repo="owner/repo", + pr_number=1, + issue_comments=issue_comments, + rate_limit_status=status, + auto_resume_enabled=True, + dry_run=False, + summarize_only=False, + ) + + assert posted is False + mock_post.assert_not_called() + + class TestProcessRepo: """Thin orchestration tests for process_repo(). All external deps mocked.""" @@ -686,6 +794,7 @@ def test_draft_pr_is_processed_when_enabled(self): patch("auto_fixer.fetch_pr_details", return_value=pr_data) as mock_fetch_pr_details, patch("auto_fixer.fetch_pr_review_comments", return_value=[]), patch("auto_fixer.fetch_review_threads", return_value={}), + patch("auto_fixer.fetch_issue_comments", return_value=[]), patch("auto_fixer.get_branch_compare_status", return_value=("ahead", 0)), patch("auto_fixer.load_state_comment", return_value=make_state_comment()), patch("auto_fixer._update_done_label_if_completed"), @@ -710,6 +819,7 @@ def test_dry_run_no_external_commands(self, tmp_path, capsys): patch("auto_fixer.fetch_pr_details", return_value=pr_data), patch("auto_fixer.fetch_pr_review_comments", return_value=[]), patch("auto_fixer.fetch_review_threads", return_value={}), + patch("auto_fixer.fetch_issue_comments", return_value=[]), patch("auto_fixer.get_branch_compare_status", return_value=("ahead", 0)), patch("auto_fixer.load_state_comment", return_value=make_state_comment()), patch("auto_fixer.prepare_repository", return_value=tmp_path), @@ -782,6 +892,7 @@ def popen_side_effect(cmd, **kwargs): patch("auto_fixer.fetch_pr_details", return_value=pr_data), patch("auto_fixer.fetch_pr_review_comments", return_value=review_comments), patch("auto_fixer.fetch_review_threads", return_value=thread_map), + patch("auto_fixer.fetch_issue_comments", return_value=[]), patch("auto_fixer.get_branch_compare_status", return_value=("ahead", 0)), patch("auto_fixer.load_state_comment", return_value=make_state_comment()), patch("auto_fixer.prepare_repository", return_value=tmp_path), @@ -849,6 +960,7 @@ def run_side_effect(cmd, **kwargs): patch("auto_fixer.fetch_pr_details", return_value=pr_data), patch("auto_fixer.fetch_pr_review_comments", return_value=[]), patch("auto_fixer.fetch_review_threads", return_value={}), + patch("auto_fixer.fetch_issue_comments", return_value=[]), patch("auto_fixer.get_branch_compare_status", return_value=("behind", 1)), patch("auto_fixer.load_state_comment", return_value=make_state_comment()), patch("auto_fixer.prepare_repository", return_value=tmp_path), @@ -895,6 +1007,7 @@ def run_claude_side_effect(*, phase_label, **kwargs): patch("auto_fixer.fetch_pr_details", return_value=pr_data), patch("auto_fixer.fetch_pr_review_comments", return_value=[]), patch("auto_fixer.fetch_review_threads", return_value={}), + patch("auto_fixer.fetch_issue_comments", return_value=[]), patch("auto_fixer.get_branch_compare_status", return_value=("ahead", 0)), patch("auto_fixer.load_state_comment", return_value=make_state_comment()), patch("auto_fixer.prepare_repository", return_value=tmp_path), @@ -908,6 +1021,61 @@ def run_claude_side_effect(*, phase_label, **kwargs): assert call_order == ["ci-fix"] mock_upsert_state_comment.assert_not_called() + def test_rate_limit_skips_review_fix_but_runs_ci_and_merge_base(self, tmp_path): + prs = [{"number": 1, "title": "Test"}] + pr_data = { + "headRefName": "feature", + "baseRefName": "main", + "title": "Test", + "reviews": [ + {"id": "r1", "body": "fix review", "author": {"login": "coderabbitai[bot]"}} + ], + "statusCheckRollup": [ + {"name": "ci/test", "conclusion": "FAILURE", "detailsUrl": "https://example.com/ci/test"} + ], + } + issue_comments = [ + { + "id": 99, + "body": TestCodeRabbitRateLimitHelpers.RATE_LIMIT_BODY, + "user": {"login": "coderabbitai[bot]"}, + "updated_at": "2999-03-11T12:00:00Z", + } + ] + call_order: list[str] = [] + + def run_claude_side_effect(*, phase_label, **kwargs): + call_order.append(phase_label) + if phase_label == "ci-fix": + return "aaa111 ci fix" + raise AssertionError(f"Unexpected phase_label: {phase_label}") + + def merge_side_effect(*args, **kwargs): + call_order.append("merge-base") + return (False, False) + + with ( + patch("auto_fixer.fetch_open_prs", return_value=prs), + patch("auto_fixer.fetch_pr_details", return_value=pr_data), + patch("auto_fixer.fetch_pr_review_comments", return_value=[]), + patch("auto_fixer.fetch_review_threads", return_value={}), + patch("auto_fixer.fetch_issue_comments", return_value=issue_comments), + patch("auto_fixer.get_branch_compare_status", return_value=("behind", 1)), + patch("auto_fixer.load_state_comment", return_value=make_state_comment()), + patch("auto_fixer.prepare_repository", return_value=tmp_path), + patch("auto_fixer._collect_ci_failure_materials", return_value=[]), + patch("auto_fixer._merge_base_branch", side_effect=merge_side_effect), + patch("auto_fixer._run_claude_prompt", side_effect=run_claude_side_effect), + patch("auto_fixer._set_pr_running_label"), + patch("auto_fixer._update_done_label_if_completed") as mock_update_done, + patch("auto_fixer.summarize_reviews") as mock_summarize, + ): + auto_fixer.process_repo({"repo": "owner/repo"}) + + assert call_order == ["ci-fix", "merge-base"] + mock_summarize.assert_not_called() + assert mock_update_done.call_args.kwargs["coderabbit_rate_limit_active"] is True + def test_summarize_only_stops_before_fix_and_state_update(self, tmp_path, capsys): """summarize_only=True -> no fix model, no state comment update.""" prs = [{"number": 1, "title": "Test"}] @@ -924,6 +1092,7 @@ def test_summarize_only_stops_before_fix_and_state_update(self, tmp_path, capsys patch("auto_fixer.fetch_pr_details", return_value=pr_data), patch("auto_fixer.fetch_pr_review_comments", return_value=[]), patch("auto_fixer.fetch_review_threads", return_value={}), + patch("auto_fixer.fetch_issue_comments", return_value=[]), patch("auto_fixer.get_branch_compare_status", return_value=("ahead", 0)), patch("auto_fixer.load_state_comment", return_value=make_state_comment()), patch("auto_fixer.prepare_repository", return_value=tmp_path), @@ -954,6 +1123,7 @@ def test_summarize_only_reports_raw_text_fallback(self, capsys): patch("auto_fixer.fetch_pr_details", return_value=pr_data), patch("auto_fixer.fetch_pr_review_comments", return_value=[]), patch("auto_fixer.fetch_review_threads", return_value={}), + patch("auto_fixer.fetch_issue_comments", return_value=[]), patch("auto_fixer.get_branch_compare_status", return_value=("ahead", 0)), patch("auto_fixer.load_state_comment", return_value=make_state_comment()), patch("auto_fixer.summarize_reviews", return_value={}), @@ -981,6 +1151,7 @@ def test_summarize_only_usage_limit_raises(self): patch("auto_fixer.fetch_pr_details", return_value=pr_data), patch("auto_fixer.fetch_pr_review_comments", return_value=[]), patch("auto_fixer.fetch_review_threads", return_value={}), + patch("auto_fixer.fetch_issue_comments", return_value=[]), patch("auto_fixer.get_branch_compare_status", return_value=("ahead", 0)), patch("auto_fixer.load_state_comment", return_value=make_state_comment()), patch( @@ -1010,6 +1181,7 @@ def test_behind_merge_runs_push_no_claude(self, tmp_path, capsys): patch("auto_fixer.fetch_pr_details", return_value=pr_data), patch("auto_fixer.fetch_pr_review_comments", return_value=[]), patch("auto_fixer.fetch_review_threads", return_value={}), + patch("auto_fixer.fetch_issue_comments", return_value=[]), patch("auto_fixer.get_branch_compare_status", return_value=("behind", 0)), patch("auto_fixer.load_state_comment", return_value=make_state_comment()), patch("auto_fixer.prepare_repository", return_value=tmp_path), @@ -1045,6 +1217,7 @@ def test_done_label_does_not_skip_processing_when_behind(self, tmp_path): patch("auto_fixer.fetch_pr_details", return_value=pr_data), patch("auto_fixer.fetch_pr_review_comments", return_value=[]), patch("auto_fixer.fetch_review_threads", return_value={}), + patch("auto_fixer.fetch_issue_comments", return_value=[]), patch("auto_fixer.get_branch_compare_status", return_value=("behind", 1)), patch("auto_fixer.load_state_comment", return_value=make_state_comment()), patch("auto_fixer.prepare_repository", return_value=tmp_path) as mock_prepare, @@ -1071,6 +1244,7 @@ def test_review_fix_start_sets_running_label(self, tmp_path): patch("auto_fixer.fetch_pr_details", return_value=pr_data), patch("auto_fixer.fetch_pr_review_comments", return_value=[]), patch("auto_fixer.fetch_review_threads", return_value={}), + patch("auto_fixer.fetch_issue_comments", return_value=[]), patch("auto_fixer.get_branch_compare_status", return_value=("ahead", 0)), patch("auto_fixer.load_state_comment", return_value=make_state_comment()), patch("auto_fixer.prepare_repository", return_value=tmp_path), @@ -1187,6 +1361,7 @@ def test_update_done_label_sets_done_when_conditions_met(self): commits_by_phase=[], pr_data={"reviews": [], "comments": []}, review_comments=[], + issue_comments=[], dry_run=False, summarize_only=False, ) @@ -1213,6 +1388,7 @@ def test_update_done_label_triggers_auto_merge_when_enabled(self): commits_by_phase=[], pr_data={"reviews": [], "comments": []}, review_comments=[], + issue_comments=[], dry_run=False, summarize_only=False, auto_merge_enabled=True, @@ -1239,6 +1415,7 @@ def test_update_done_label_sets_running_when_review_fix_added_commit(self): commits_by_phase=[], pr_data={"reviews": [], "comments": []}, review_comments=[], + issue_comments=[], dry_run=False, summarize_only=False, ) @@ -1265,6 +1442,7 @@ def test_update_done_label_sets_running_when_ci_not_success(self): commits_by_phase=[], pr_data={"reviews": [], "comments": []}, review_comments=[], + issue_comments=[], dry_run=False, summarize_only=False, ) @@ -1287,6 +1465,7 @@ def test_update_done_label_skips_when_review_fix_failed(self): commits_by_phase=[], pr_data={"reviews": [], "comments": []}, review_comments=[], + issue_comments=[], dry_run=False, summarize_only=False, ) @@ -1306,6 +1485,7 @@ def test_update_done_label_skips_when_dry_run(self): commits_by_phase=[], pr_data={"reviews": [], "comments": []}, review_comments=[], + issue_comments=[], dry_run=True, summarize_only=False, ) @@ -1324,6 +1504,7 @@ def test_update_done_label_skips_when_summarize_only(self): commits_by_phase=[], pr_data={"reviews": [], "comments": []}, review_comments=[], + issue_comments=[], dry_run=False, summarize_only=True, ) @@ -1347,8 +1528,36 @@ def test_update_done_label_skips_when_coderabbit_processing(self): commits_by_phase=[], pr_data={"reviews": [], "comments": []}, review_comments=[], + issue_comments=[], + dry_run=False, + summarize_only=False, + ) + mock_ci.assert_not_called() + mock_set_done.assert_not_called() + mock_set_running.assert_called_once_with("owner/repo", 1) + + def test_update_done_label_skips_when_coderabbit_rate_limit_active(self): + with ( + patch("auto_fixer._contains_coderabbit_processing_marker", return_value=False), + patch("auto_fixer._are_all_ci_checks_successful") as mock_ci, + patch("auto_fixer._set_pr_done_label") as mock_set_done, + patch("auto_fixer._set_pr_running_label") as mock_set_running, + ): + auto_fixer._update_done_label_if_completed( + repo="owner/repo", + pr_number=1, + has_review_targets=False, + review_fix_started=False, + review_fix_added_commits=False, + review_fix_failed=False, + state_saved=True, + commits_by_phase=[], + pr_data={"reviews": [], "comments": []}, + review_comments=[], + issue_comments=[], dry_run=False, summarize_only=False, + coderabbit_rate_limit_active=True, ) mock_ci.assert_not_called() mock_set_done.assert_not_called() diff --git a/tests/test_pr_reviewer.py b/tests/test_pr_reviewer.py index 4bdac33..f16d2ed 100644 --- a/tests/test_pr_reviewer.py +++ b/tests/test_pr_reviewer.py @@ -39,3 +39,16 @@ def test_fetch_pr_review_comments_flattens_paginated_response(): comments = pr_reviewer.fetch_pr_review_comments("owner/repo", 1) assert comments == [{"id": 10, "body": "a"}, {"id": 11, "body": "b"}] + + +def test_fetch_issue_comments_flattens_paginated_response(): + result = Mock( + returncode=0, + stdout='[[{"id": 21, "body": "a"}], [{"id": 22, "body": "b"}]]', + stderr="", + ) + + with patch("pr_reviewer.subprocess.run", return_value=result): + comments = pr_reviewer.fetch_issue_comments("owner/repo", 1) + + assert comments == [{"id": 21, "body": "a"}, {"id": 22, "body": "b"}] From d448b801035ddad4ef51815b4f975b31383e7bf9 Mon Sep 17 00:00:00 2001 From: HappyOnigiri <253838257+NodeMeld@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:08:27 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(pr=5Freviewer):=20fetch=5Fissue=5Fcomme?= =?UTF-8?q?nts=E3=81=AE=E3=82=A8=E3=83=A9=E3=83=BC=E3=83=8F=E3=83=B3?= =?UTF-8?q?=E3=83=89=E3=83=AA=E3=83=B3=E3=82=B0=E3=82=92=E6=94=B9=E5=96=84?= =?UTF-8?q?=E3=81=97=E3=81=A6=E5=A4=B1=E6=95=97=E6=99=82=E3=81=AB=E4=BE=8B?= =?UTF-8?q?=E5=A4=96=E3=82=92=E9=80=81=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 結果の取得失敗やJSON解析エラー時に[]ではなくRuntimeErrorを投げるように変更し、呼び出し側(auto_fixer.py)でエラーを正しく検知・スキップできるようにした。 --- src/auto_fixer.py | 4 ++++ src/pr_reviewer.py | 8 +++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/auto_fixer.py b/src/auto_fixer.py index 6fe5265..e2e1601 100644 --- a/src/auto_fixer.py +++ b/src/auto_fixer.py @@ -1613,6 +1613,10 @@ def process_repo( continue try: issue_comments = fetch_issue_comments(repo, pr_number) + except RuntimeError as e: + print(f"Error: {e}", file=sys.stderr) + pr_fetch_failed = True + continue except Exception as e: print(f"Error: could not fetch issue comments: {e}", file=sys.stderr) pr_fetch_failed = True diff --git a/src/pr_reviewer.py b/src/pr_reviewer.py index 608991f..711b11e 100644 --- a/src/pr_reviewer.py +++ b/src/pr_reviewer.py @@ -131,13 +131,11 @@ def fetch_issue_comments(repo: str, pr_number: int) -> list[dict[str, Any]]: ] result = subprocess.run(cmd, capture_output=True, text=True, check=False, encoding="utf-8") if result.returncode != 0: - print(f"Warning: failed to fetch issue comments: {result.stderr}", file=sys.stderr) - return [] + raise RuntimeError(f"failed to fetch issue comments: {result.stderr}") try: data = json.loads(result.stdout) if result.stdout else [] - except json.JSONDecodeError: - print("Warning: failed to parse issue comments response", file=sys.stderr) - return [] + except json.JSONDecodeError as e: + raise RuntimeError(f"failed to parse issue comments response: {e}") from e return _flatten_paginated_response(data) From ac1b5cd42327243f3294d9026af52f0570a8c3fa Mon Sep 17 00:00:00 2001 From: HappyOnigiri <253838257+NodeMeld@users.noreply.github.com> Date: Thu, 12 Mar 2026 00:17:12 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(auto=5Ffixer):=20CODERABBIT=5FBOT=5FLOG?= =?UTF-8?q?IN=5FPREFIX=20=E3=82=92=20CODERABBIT=5FBOT=5FLOGIN=20=E3=81=AB?= =?UTF-8?q?=E5=A4=89=E6=9B=B4=E3=81=97=E5=AE=8C=E5=85=A8=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startswith による判定から、完全一致のみを受け付けるロジック (`coderabbitai`, `coderabbitai[bot]`) へ修正。これに伴い定数名も PREFIX から正確な名前にリネームした。 --- src/auto_fixer.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/auto_fixer.py b/src/auto_fixer.py index e2e1601..468ec21 100644 --- a/src/auto_fixer.py +++ b/src/auto_fixer.py @@ -91,7 +91,7 @@ from state_manager import StateComment, create_state_entry, load_state_comment, upsert_state_comment # REST API returns "coderabbitai[bot]", GraphQL returns "coderabbitai" -CODERABBIT_BOT_LOGIN_PREFIX = "coderabbitai" +CODERABBIT_BOT_LOGIN = "coderabbitai" REFIX_RUNNING_LABEL = "refix:running" REFIX_DONE_LABEL = "refix:done" CODERABBIT_PROCESSING_MARKER = "Currently processing new changes in this PR." @@ -980,7 +980,7 @@ def _summarization_target_ids( def _is_coderabbit_login(login: str) -> bool: - return login.startswith(CODERABBIT_BOT_LOGIN_PREFIX) + return login in (CODERABBIT_BOT_LOGIN, f"{CODERABBIT_BOT_LOGIN}[bot]") def _ensure_repo_label_exists(repo: str, label: str, *, color: str, description: str) -> bool: @@ -1584,7 +1584,7 @@ def process_repo( reviews = pr_data.get("reviews", []) unresolved_reviews = [] for r in reviews: - if not r.get("author", {}).get("login", "").startswith(CODERABBIT_BOT_LOGIN_PREFIX): + if not _is_coderabbit_login(r.get("author", {}).get("login", "")): continue review_id = _review_state_id(r) if not review_id: @@ -1626,7 +1626,7 @@ def process_repo( for c in review_comments: if not c.get("id"): continue - if not c.get("user", {}).get("login", "").startswith(CODERABBIT_BOT_LOGIN_PREFIX): + if not _is_coderabbit_login(c.get("user", {}).get("login", "")): continue rid = _inline_comment_state_id(c) comment_item = dict(c)