diff --git a/.refix.yaml.sample b/.refix.yaml.sample index 5499348..d7d7630 100644 --- a/.refix.yaml.sample +++ b/.refix.yaml.sample @@ -11,6 +11,12 @@ models: ci_log_max_lines: 120 +# Whether to enable execution reports written by Claude (Optional) +# When enabled, runtime reports are embedded into the PR state comment +# and Claude receives instructions to append report entries to a report file. +# Default: false +execution_report: false + # Automatically merge PR when it reaches refix:done state (Optional) # When merge completes, refix:merged label is added. # Default: false diff --git a/README.ja.md b/README.ja.md index bc33a0e..d5ebd21 100644 --- a/README.ja.md +++ b/README.ja.md @@ -91,6 +91,8 @@ models: ci_log_max_lines: 120 +execution_report: false + auto_merge: false coderabbit_auto_resume: false @@ -141,6 +143,16 @@ Claude ベースの処理で使うモデル設定です。 PR に失敗中の GitHub Actions がある場合、この値でプロンプトへ渡すログ量を調整できます。値を小さくするとプロンプトは軽くなり、大きくすると文脈を多く渡せます。 +#### `execution_report` + +Claude の実行レポート機能を有効にするかどうかを設定します。 + +- 型: boolean +- 必須: いいえ +- デフォルト: `false` + +有効にすると、`refix` は各修正フェーズで Claude に構造化された実行レポートの追記を指示し、その内容を CI ログでは折りたたみ表示しつつ、PR の状態管理コメントの `実行レポート` セクションにも記録します。 + #### `auto_merge` PR が `refix:done` 状態になった際に自動マージします。 diff --git a/README.md b/README.md index 59fc511..4fd43f0 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,9 @@ models: ci_log_max_lines: 120 +# Whether to record Claude execution reports (optional, default false) +execution_report: false + # Automatically merge PR when it reaches refix:done state (optional, default false) # When merge completes, the refix:merged label is applied auto_merge: false @@ -153,6 +156,16 @@ Maximum number of failed CI log lines included in the fix prompt. Use this to control prompt size when a PR has failing GitHub Actions checks. Smaller values reduce prompt volume, while larger values include more context from failed jobs. +#### `execution_report` + +Whether to enable Claude execution reports. + +- Type: boolean +- Required: no +- Default: `false` + +When enabled, `refix` asks Claude to append structured runtime notes to a report file during each fix phase, prints that report in foldable CI log groups, and embeds the collected report into the PR state comment under `実行レポート`. + #### `auto_merge` Automatically merge the fix PR when it reaches the `refix:done` state. diff --git a/src/auto_fixer.py b/src/auto_fixer.py index a83ea59..3fe6e44 100644 --- a/src/auto_fixer.py +++ b/src/auto_fixer.py @@ -28,6 +28,11 @@ "attribution": {"commit": "", "pr": ""}, "includeCoAuthoredBy": False, } +PHASE_REPORT_TITLES = { + "ci-fix": "CI 修正", + "merge-conflict-resolution": "コンフリクト解消", + "review-fix": "レビュー修正", +} # --list-commands は他依存なしで表示するため、先に処理して exit if "--list-commands" in sys.argv or "--list-commands-en" in sys.argv: @@ -130,6 +135,7 @@ "fix": "sonnet", }, "ci_log_max_lines": 120, + "execution_report": False, "auto_merge": False, "coderabbit_auto_resume": False, "coderabbit_auto_resume_max_per_run": 1, @@ -143,6 +149,7 @@ ALLOWED_CONFIG_TOP_LEVEL_KEYS = { "models", "ci_log_max_lines", + "execution_report", "auto_merge", "coderabbit_auto_resume", "coderabbit_auto_resume_max_per_run", @@ -228,6 +235,7 @@ def load_config(filepath: str) -> dict[str, Any]: config: dict[str, Any] = { "models": dict(DEFAULT_CONFIG["models"]), "ci_log_max_lines": DEFAULT_CONFIG["ci_log_max_lines"], + "execution_report": DEFAULT_CONFIG["execution_report"], "auto_merge": DEFAULT_CONFIG["auto_merge"], "coderabbit_auto_resume": DEFAULT_CONFIG["coderabbit_auto_resume"], "coderabbit_auto_resume_max_per_run": DEFAULT_CONFIG[ @@ -273,6 +281,13 @@ def load_config(filepath: str) -> dict[str, Any]: print("Error: ci_log_max_lines must be an integer.", file=sys.stderr) sys.exit(1) + execution_report = parsed.get("execution_report") + if execution_report is not None: + if not isinstance(execution_report, bool): + print("Error: execution_report must be a boolean.", file=sys.stderr) + sys.exit(1) + config["execution_report"] = execution_report + auto_merge = parsed.get("auto_merge") if auto_merge is not None: if not isinstance(auto_merge, bool): @@ -523,30 +538,101 @@ def _build_phase_report_path( return str((reports_dir / f"pr_{pr_number}_{phase_label}.md").resolve()) -def _emit_runtime_pain_report( - *, report_path: str, phase_label: str, silent: bool, claude_failed: bool = False -) -> None: - """Print runtime pain report content when --silent is not set, or when Claude failed.""" - if silent and not claude_failed: - return +def _read_runtime_report_content(report_path: str | None) -> str: + """Read a runtime report file and return normalized content.""" + if not report_path: + return "" report_file = Path(report_path) - print(f"[report {phase_label}] {report_file}", file=sys.stderr) if not report_file.exists(): - print(" report file not found.", file=sys.stderr) + return "" + return report_file.read_text(encoding="utf-8").strip() + + +def _format_report_for_state_comment(phase_label: str, report_content: str) -> str: + """Format one phase report block for the PR state comment.""" + normalized_content = report_content.strip() + if not normalized_content: + return "" + phase_title = PHASE_REPORT_TITLES.get(phase_label, phase_label) + return f"#### {phase_title}\n\n{normalized_content}" + + +def _capture_state_comment_report( + report_blocks: list[str], phase_label: str, report_path: str | None +) -> None: + """Capture a phase report for later embedding in the PR state comment.""" + if not report_path: return try: - content = report_file.read_text(encoding="utf-8").strip() - except Exception as e: - print(f" failed to read report file: {e}", file=sys.stderr) + report_content = _read_runtime_report_content(report_path) + except OSError as exc: + print( + f"Warning: failed to read report for state comment ({phase_label}): {exc}", + file=sys.stderr, + ) return + block = _format_report_for_state_comment(phase_label, report_content) + if block: + report_blocks.append(block) + + +def _merge_state_comment_report_body( + existing_report_body: str, new_report_blocks: list[str] +) -> str: + """Merge new execution reports ahead of any existing report body.""" + parts = [block.strip() for block in new_report_blocks if block.strip()] + existing = (existing_report_body or "").strip() + if existing: + parts.append(existing) + return "\n\n".join(parts) + + +def _persist_state_comment_report_if_changed( + repo: str, + pr_number: int, + state_comment: StateComment, + report_body: str, +) -> bool: + """Persist a report-only state comment update when the content changed.""" + normalized_report_body = (report_body or "").strip() + if normalized_report_body == state_comment.report_body.strip(): + return False + upsert_state_comment(repo, pr_number, [], report_body=normalized_report_body) + return True + - if not content: - print(" report file is empty.", file=sys.stderr) +def _emit_runtime_pain_report( + *, + report_path: str | None, + phase_label: str, + silent: bool, + claude_failed: bool = False, +) -> None: + """Print runtime pain report content when --silent is not set, or when Claude failed.""" + if not report_path or (silent and not claude_failed): return + report_file = Path(report_path) + _log_group(f"Runtime report ({phase_label})") + try: + print(f"[report {phase_label}] {report_file}", file=sys.stderr) + if not report_file.exists(): + print(" レポート出力なし", file=sys.stderr) + return + try: + content = report_file.read_text(encoding="utf-8").strip() + except Exception as e: + print(f" failed to read report file: {e}", file=sys.stderr) + return - print(" --- begin report ---", file=sys.stderr) - print(content, file=sys.stderr) - print(" --- end report ---", file=sys.stderr) + if not content: + print(" レポート出力なし", file=sys.stderr) + return + + print(" --- begin report ---", file=sys.stderr) + print(content, file=sys.stderr) + print(" --- end report ---", file=sys.stderr) + finally: + _log_endgroup() def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: @@ -968,25 +1054,39 @@ def _run_claude_prompt( *, works_dir: Path, prompt: str, - report_path: str, + report_path: str | None, + report_enabled: bool, model: str, silent: bool, phase_label: str, ) -> str: - runtime_pain_report_instruction = f""" + prompt_with_report_instruction = prompt.rstrip() + "\n" + if report_enabled: + if not report_path: + raise ValueError("report_path is required when execution_report is enabled") + Path(report_path).unlink(missing_ok=True) + runtime_pain_report_instruction = f""" 以下は実行時の課題レポート作成指示です。必ず守ってください。 - 出力先ファイル: {_xml_escape(report_path)} - 出力タイミング: 作業ステップごと、または問題発生時に随時追記すること(作業の最後にまとめて書くのは禁止) - 追記方法: Bash ツール等で append すること(例: echo "..." >> {shlex.quote(report_path)}) +- 各追記エントリは次の形式を必ず守ること: + ### YYYY-MM-DD hh:mm:ss UTC {{file_path}} {{title}} + + {{details}} +- `YYYY-MM-DD hh:mm:ss UTC` は UTC で記録すること +- `{{file_path}}` には関連するファイルパスを記載すること。該当しない場合は `-` を使うこと +- `{{title}}` には短い件名を記載すること +- `{{details}}` には Markdown で具体的な状況を記載すること - 報告項目: 1. ツールのセットアップやコマンド実行時の失敗・試行錯誤 2. 実装にあたって不足していたコンテキストやファイル 3. レビューコメントの曖昧さ、解釈に迷った点 4. 妥協した点や、人間の再確認が必要と思われる不確実な修正 """ - prompt_with_report_instruction = ( - f"{prompt.rstrip()}\n\n{runtime_pain_report_instruction}\n" - ) + prompt_with_report_instruction = ( + f"{prompt.rstrip()}\n\n{runtime_pain_report_instruction}\n" + ) prompt_file = works_dir / "_review_prompt.md" prompt_file.write_text(prompt_with_report_instruction, encoding="utf-8") @@ -1004,7 +1104,8 @@ def _run_claude_prompt( print(f" cwd: {works_dir}") print(f" command: {shlex.join(claude_cmd)}") print(f" prompt file: {prompt_file}") - print(f" runtime pain report file: {report_path}") + if report_enabled and report_path: + print(f" runtime pain report file: {report_path}") if not silent: print("-" * SEPARATOR_LEN) print(prompt_with_report_instruction) @@ -2122,6 +2223,7 @@ def _process_single_pr( fix_model: str, summarize_model: str, ci_log_max_lines: int, + execution_report_enabled: bool, auto_merge_enabled: bool, coderabbit_auto_resume_enabled: bool, auto_resume_run_state: dict[str, int], @@ -2381,6 +2483,7 @@ def _process_single_pr( ) commits_by_phase: list[str] = [] + report_blocks: list[str] = [] review_fix_started = False review_fix_added_commits = False review_fix_failed = False @@ -2479,7 +2582,9 @@ def _process_single_pr( try: _log_group("Git repository setup") works_dir = prepare_repository(repo, branch_name, user_name, user_email) - reports_dir = _prepare_reports_dir(repo, works_dir) + reports_dir = ( + _prepare_reports_dir(repo, works_dir) if execution_report_enabled else None + ) _log_endgroup() except Exception as e: _log_endgroup() @@ -2519,14 +2624,17 @@ def _process_single_pr( ) else: print(f"[ci-fix] PR #{pr_number}: running CI-only Claude fix phase") + ci_report_path = ( + _build_phase_report_path(reports_dir, pr_number, "ci-fix") + if reports_dir is not None + else None + ) try: - ci_report_path = _build_phase_report_path( - reports_dir, pr_number, "ci-fix" - ) ci_commits = _run_claude_prompt( works_dir=works_dir, prompt=ci_fix_prompt, report_path=ci_report_path, + report_enabled=execution_report_enabled, model=fix_model, silent=True, phase_label="ci-fix", @@ -2537,7 +2645,26 @@ def _process_single_pr( file=sys.stderr, ) print(f" details: {e}", file=sys.stderr) + _capture_state_comment_report(report_blocks, "ci-fix", ci_report_path) + if execution_report_enabled and report_blocks: + try: + _fresh = load_state_comment(repo, pr_number) + except Exception: + _fresh = state_comment + _merged = _merge_state_comment_report_body( + _fresh.report_body, report_blocks + ) + try: + _persist_state_comment_report_if_changed( + repo, pr_number, _fresh, _merged + ) + except Exception as _save_err: + print( + f"Warning: failed to save execution report for PR #{pr_number}: {_save_err}", + file=sys.stderr, + ) raise + _capture_state_comment_report(report_blocks, "ci-fix", ci_report_path) if ci_commits: commits_by_phase.append(ci_commits) committed_prs.add((repo, pr_number)) @@ -2608,14 +2735,19 @@ def _process_single_pr( conflict_prompt = _build_conflict_resolution_prompt( pr_number, pr_data.get("title", ""), base_branch ) - try: - conflict_report_path = _build_phase_report_path( + conflict_report_path = ( + _build_phase_report_path( reports_dir, pr_number, "merge-conflict-resolution" ) + if reports_dir is not None + else None + ) + try: conflict_commits = _run_claude_prompt( works_dir=works_dir, prompt=conflict_prompt, report_path=conflict_report_path, + report_enabled=execution_report_enabled, model=fix_model, silent=silent, phase_label="merge-conflict-resolution", @@ -2626,7 +2758,34 @@ def _process_single_pr( file=sys.stderr, ) print(f" details: {e}", file=sys.stderr) + _capture_state_comment_report( + report_blocks, + "merge-conflict-resolution", + conflict_report_path, + ) + if execution_report_enabled and report_blocks: + try: + _fresh = load_state_comment(repo, pr_number) + except Exception: + _fresh = state_comment + _merged = _merge_state_comment_report_body( + _fresh.report_body, report_blocks + ) + try: + _persist_state_comment_report_if_changed( + repo, pr_number, _fresh, _merged + ) + except Exception as _save_err: + print( + f"Warning: failed to save execution report for PR #{pr_number}: {_save_err}", + file=sys.stderr, + ) raise + _capture_state_comment_report( + report_blocks, + "merge-conflict-resolution", + conflict_report_path, + ) if conflict_commits: commits_by_phase.append(conflict_commits) claude_prs.add((repo, pr_number)) @@ -2656,6 +2815,24 @@ def _process_single_pr( ) if not has_review_targets: + if execution_report_enabled: + try: + _latest = load_state_comment(repo, pr_number) + except Exception: + _latest = state_comment + merged_report_body = _merge_state_comment_report_body( + _latest.report_body, report_blocks + ) + try: + if _persist_state_comment_report_if_changed( + repo, pr_number, _latest, merged_report_body + ): + state_saved = True + except Exception as e: + print( + f"Warning: failed to update report section for PR #{pr_number}: {e}", + file=sys.stderr, + ) if ci_commits and not is_behind: unpushed_check = subprocess.run( ["git", "log", "--oneline", f"origin/{branch_name}..HEAD"], @@ -2713,6 +2890,24 @@ def _process_single_pr( ) if skip_review_fix: + if execution_report_enabled: + try: + _latest = load_state_comment(repo, pr_number) + except Exception: + _latest = state_comment + merged_report_body = _merge_state_comment_report_body( + _latest.report_body, report_blocks + ) + try: + if _persist_state_comment_report_if_changed( + repo, pr_number, _latest, merged_report_body + ): + state_saved = True + except Exception as e: + print( + f"Warning: failed to update report section for PR #{pr_number}: {e}", + file=sys.stderr, + ) print( f"Skipping review-fix for PR #{pr_number} because {skip_review_fix_reason}; " "CI repair and merge-base handling already ran." @@ -2815,17 +3010,25 @@ def _process_single_pr( _set_pr_running_label(repo, pr_number, pr_data=pr_data) _remove_running_on_exit = True review_fix_started = True - review_report_path = _build_phase_report_path( - reports_dir, pr_number, "review-fix" - ) - review_commits = _run_claude_prompt( - works_dir=works_dir, - prompt=prompt, - report_path=review_report_path, - model=fix_model, - silent=silent, - phase_label="review-fix", + review_report_path = ( + _build_phase_report_path(reports_dir, pr_number, "review-fix") + if reports_dir is not None + else None ) + try: + review_commits = _run_claude_prompt( + works_dir=works_dir, + prompt=prompt, + report_path=review_report_path, + report_enabled=execution_report_enabled, + model=fix_model, + silent=silent, + phase_label="review-fix", + ) + finally: + _capture_state_comment_report( + report_blocks, "review-fix", review_report_path + ) if review_commits: review_fix_added_commits = True commits_by_phase.append(review_commits) @@ -2941,9 +3144,29 @@ def _process_single_pr( print( f"Resolved {resolved}/{len(unresolved_comments)} review thread(s)" ) - if state_entries: + try: + _latest = load_state_comment(repo, pr_number) + except Exception: + _latest = state_comment + report_body_to_save = ( + _merge_state_comment_report_body( + _latest.report_body, report_blocks + ) + if execution_report_enabled + else _latest.report_body.strip() + ) + should_write_state_comment = bool(state_entries) or ( + execution_report_enabled + and report_body_to_save != _latest.report_body.strip() + ) + if should_write_state_comment: try: - upsert_state_comment(repo, pr_number, state_entries) + upsert_state_comment( + repo, + pr_number, + state_entries, + report_body=report_body_to_save, + ) state_saved = True except Exception as e: print( @@ -2955,6 +3178,23 @@ def _process_single_pr( _remove_running_on_exit = False except ClaudeCommandFailedError: _remove_running_on_exit = False + if execution_report_enabled and report_blocks: + try: + _fresh = load_state_comment(repo, pr_number) + except Exception: + _fresh = state_comment + _merged = _merge_state_comment_report_body( + _fresh.report_body, report_blocks + ) + try: + _persist_state_comment_report_if_changed( + repo, pr_number, _fresh, _merged + ) + except Exception as _save_err: + print( + f"Warning: failed to save execution report for PR #{pr_number}: {_save_err}", + file=sys.stderr, + ) raise except subprocess.CalledProcessError as e: review_fix_failed = True @@ -2963,6 +3203,23 @@ def _process_single_pr( print(f" stdout: {e.output.strip()}", file=sys.stderr) if e.stderr: print(f" stderr: {e.stderr.strip()}", file=sys.stderr) + if execution_report_enabled and report_blocks: + try: + _fresh = load_state_comment(repo, pr_number) + except Exception: + _fresh = state_comment + _merged = _merge_state_comment_report_body( + _fresh.report_body, report_blocks + ) + try: + _persist_state_comment_report_if_changed( + repo, pr_number, _fresh, _merged + ) + except Exception as _save_err: + print( + f"Warning: failed to save execution report for PR #{pr_number}: {_save_err}", + file=sys.stderr, + ) finally: if _remove_running_on_exit: _edit_pr_label(repo, pr_number, add=False, label=REFIX_RUNNING_LABEL) @@ -3021,6 +3278,9 @@ def process_repo( ci_log_max_lines = int( runtime_config.get("ci_log_max_lines", DEFAULT_CONFIG["ci_log_max_lines"]) ) + execution_report_enabled = bool( + runtime_config.get("execution_report", DEFAULT_CONFIG["execution_report"]) + ) auto_merge_enabled = bool( runtime_config.get("auto_merge", DEFAULT_CONFIG["auto_merge"]) ) @@ -3133,6 +3393,7 @@ def process_repo( fix_model=fix_model, summarize_model=summarize_model, ci_log_max_lines=ci_log_max_lines, + execution_report_enabled=execution_report_enabled, auto_merge_enabled=auto_merge_enabled, coderabbit_auto_resume_enabled=coderabbit_auto_resume_enabled, auto_resume_run_state=auto_resume_run_state, diff --git a/src/state_manager.py b/src/state_manager.py index efb8a79..82ab5ca 100644 --- a/src/state_manager.py +++ b/src/state_manager.py @@ -18,6 +18,9 @@ "手動で編集・削除しないでください。 -->" ) STATE_COMMENT_MAX_LENGTH = 60000 +REPORT_SECTION_START_MARKER = "" +REPORT_SECTION_END_MARKER = "" +REPORT_SECTION_SUMMARY = "実行レポート" STATE_ID_PATTERN = re.compile(r"\[(r\d+|discussion_r\d+)\](?:\([^)]+\))?") STATE_ID_FALLBACK_PATTERN = re.compile(r"\b(r\d+|discussion_r\d+)\b") LEGACY_TIMESTAMP_PATTERN = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$") @@ -26,6 +29,14 @@ re.MULTILINE, ) ARCHIVED_IDS_PATTERN = re.compile(r"") +REPORT_SECTION_PATTERN = re.compile( + re.escape(REPORT_SECTION_START_MARKER) + + r"\n
\n" + + re.escape(REPORT_SECTION_SUMMARY) + + r"\n\n(?P.*?)\n
\n" + + re.escape(REPORT_SECTION_END_MARKER), + re.DOTALL, +) DEFAULT_STATE_COMMENT_TIMEZONE = "JST" STATE_TIMEZONE_ALIASES = { "JST": "Asia/Tokyo", @@ -46,6 +57,7 @@ class StateComment: entries: list[StateEntry] processed_ids: set[str] archived_ids: set[str] + report_body: str = "" def normalize_state_timezone_name(timezone_name: str) -> str: @@ -79,6 +91,7 @@ def parse_processed_ids(text: str) -> list[str]: """Extract processed IDs from a state comment body without crashing.""" if not text: return [] + text = strip_report_section(text) matches = STATE_ID_PATTERN.findall(text) if not matches: @@ -103,6 +116,7 @@ def _normalize_legacy_processed_at(processed_at: str) -> str: def parse_state_entries(text: str) -> list[StateEntry]: """Parse structured entries from a state comment body.""" + text = strip_report_section(text) entries: list[StateEntry] = [] seen: set[str] = set() @@ -132,14 +146,97 @@ def parse_state_entries(text: str) -> list[StateEntry]: return entries +def strip_report_section(text: str) -> str: + """Remove the rendered report block from a state comment body.""" + return REPORT_SECTION_PATTERN.sub("", text or "") + + +def extract_report_body(text: str) -> str: + """Extract the markdown body stored in the execution report block.""" + match = REPORT_SECTION_PATTERN.search(text or "") + if not match: + return "" + return match.group("body").strip() + + def format_state_row(comment_id: str, url: str, processed_at: str) -> str: """Format a single markdown table row for the state comment.""" id_cell = f"[{comment_id}]({url})" if url else comment_id return f"| {id_cell} | {processed_at} |" +def _build_state_comment_body(entries: list[StateEntry], report_body: str) -> str: + """Build the visible body portion of the state comment.""" + rows = "\n".join( + format_state_row( + entry.comment_id, + entry.url, + entry.processed_at, + ) + for entry in entries + ) + body_lines = [ + STATE_COMMENT_MARKER, + STATE_COMMENT_TITLE, + STATE_COMMENT_DESCRIPTION, + "", + "
", + "対応済みレビュー一覧", + "", + "| Comment ID | 処理日時 |", + "|---|---|", + rows, + "", + "
", + ] + normalized_report_body = (report_body or "").strip() + if normalized_report_body: + body_lines.extend( + [ + "", + REPORT_SECTION_START_MARKER, + "
", + f"{REPORT_SECTION_SUMMARY}", + "", + normalized_report_body, + "", + "
", + REPORT_SECTION_END_MARKER, + ] + ) + return "\n".join(body_lines) + + +def _truncate_report_body_to_fit( + entries: list[StateEntry], report_body: str, max_length: int +) -> str: + """Truncate the report block so the state comment can still fit.""" + normalized_report_body = (report_body or "").strip() + if not normalized_report_body: + return "" + + truncation_notice = "\n\n*古い実行レポートは長さ制限のため省略されています。*" + report_scaffold_length = len(_build_state_comment_body(entries, "x")) - 1 + available_report_length = max_length - report_scaffold_length + if available_report_length <= 0: + return "" + if len(normalized_report_body) <= available_report_length: + return normalized_report_body + if available_report_length <= len(truncation_notice): + return "" + + kept = normalized_report_body[ + : available_report_length - len(truncation_notice) + ].rstrip() + if not kept: + return "" + return kept + truncation_notice + + def render_state_comment( - entries: list[StateEntry], archived_ids: set[str] | None = None + entries: list[StateEntry], + archived_ids: set[str] | None = None, + report_body: str = "", ) -> str: """Render the full state comment, trimming oldest rows if necessary.""" # accumulated_archived starts with any IDs previously archived (trimmed in prior renders). @@ -147,36 +244,14 @@ def render_state_comment( # are added here so they remain queryable even after disappearing from the table. accumulated_archived: set[str] = set(archived_ids or set()) trimmed_entries = list(entries) + truncated_report_body = (report_body or "").strip() # Iteratively remove the oldest entry from trimmed_entries until the visible portion # of the comment body fits within STATE_COMMENT_MAX_LENGTH. # format_state_row renders each entry as a markdown table row; rows are joined and # embedded in a
block. The oldest rows are dropped first to keep the most # recent processing history visible within GitHub's comment size limit. while True: - rows = "\n".join( - format_state_row( - entry.comment_id, - entry.url, - entry.processed_at, - ) - for entry in trimmed_entries - ) - body = "\n".join( - [ - STATE_COMMENT_MARKER, - STATE_COMMENT_TITLE, - STATE_COMMENT_DESCRIPTION, - "", - "
", - "処理済みレビュー一覧 (System Use Only)", - "", - "| Comment ID | 処理日時 |", - "|---|---|", - rows, - "", - "
", - ] - ) + body = _build_state_comment_body(trimmed_entries, truncated_report_body) footer = ( f"\n" if accumulated_archived @@ -184,6 +259,19 @@ def render_state_comment( ) if len(body) + len(footer) <= STATE_COMMENT_MAX_LENGTH: return body + footer + if trimmed_entries: + removed = trimmed_entries.pop(0) + accumulated_archived.add(removed.comment_id) + continue + if truncated_report_body: + shortened_report_body = _truncate_report_body_to_fit( + trimmed_entries, + truncated_report_body, + STATE_COMMENT_MAX_LENGTH - len(footer), + ) + if shortened_report_body != truncated_report_body: + truncated_report_body = shortened_report_body + continue if not trimmed_entries: # Cannot trim entries further; fit archived IDs into remaining budget remaining = STATE_COMMENT_MAX_LENGTH - len(body) @@ -214,8 +302,6 @@ def render_state_comment( ) footer = f"{prefix}{','.join(truncated_ids)}{suffix}" return body + footer - removed = trimmed_entries.pop(0) - accumulated_archived.add(removed.comment_id) def create_state_entry( @@ -305,6 +391,7 @@ def load_state_comment(repo: str, pr_number: int) -> StateComment: entries=[], processed_ids=set(), archived_ids=set(), + report_body="", ) merged_entries: list[StateEntry] = [] @@ -330,16 +417,17 @@ def load_state_comment(repo: str, pr_number: int) -> StateComment: entries=merged_entries, processed_ids={entry.comment_id for entry in merged_entries} | archived_ids, archived_ids=archived_ids, + report_body=extract_report_body(str(latest_comment.get("body") or "")), ) def upsert_state_comment( - repo: str, pr_number: int, new_entries: list[StateEntry] + repo: str, + pr_number: int, + new_entries: list[StateEntry], + report_body: str | None = None, ) -> None: """Create or update the state comment for a PR.""" - if not new_entries: - return - state = load_state_comment(repo, pr_number) merged_entries = list(state.entries) seen_ids = set(state.processed_ids) @@ -349,7 +437,15 @@ def upsert_state_comment( seen_ids.add(entry.comment_id) merged_entries.append(entry) - body = render_state_comment(merged_entries, archived_ids=state.archived_ids) + next_report_body = state.report_body if report_body is None else report_body.strip() + if not merged_entries and not next_report_body: + return + + body = render_state_comment( + merged_entries, + archived_ids=state.archived_ids, + report_body=next_report_body, + ) if state.github_comment_id is None: cmd = [ "gh", diff --git a/tests/test_auto_fixer.py b/tests/test_auto_fixer.py index e3cd7d6..3869d25 100644 --- a/tests/test_auto_fixer.py +++ b/tests/test_auto_fixer.py @@ -181,6 +181,7 @@ def test_valid_config_with_all_keys(self, tmp_path): summarize: claude-haiku fix: claude-sonnet ci_log_max_lines: 250 +execution_report: true auto_merge: true coderabbit_auto_resume: true coderabbit_auto_resume_max_per_run: 3 @@ -200,6 +201,7 @@ def test_valid_config_with_all_keys(self, tmp_path): "fix": "claude-sonnet", }, "ci_log_max_lines": 250, + "execution_report": True, "auto_merge": True, "coderabbit_auto_resume": True, "coderabbit_auto_resume_max_per_run": 3, @@ -235,6 +237,7 @@ def test_optional_keys_use_defaults(self, tmp_path): assert config["models"]["summarize"] == "haiku" assert config["models"]["fix"] == "sonnet" assert config["ci_log_max_lines"] == 120 + assert config["execution_report"] is False assert config["auto_merge"] is False assert config["coderabbit_auto_resume"] is False assert config["coderabbit_auto_resume_max_per_run"] == 1 @@ -252,6 +255,19 @@ def test_auto_merge_requires_boolean(self, tmp_path): config_file.write_text( """ auto_merge: "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_execution_report_requires_boolean(self, tmp_path): + config_file = tmp_path / "config.yaml" + config_file.write_text( + """ +execution_report: "true" repositories: - repo: owner/repo1 """.strip() @@ -1402,6 +1418,67 @@ def run_claude_side_effect(*, phase_label, **kwargs): assert call_order == ["ci-fix"] mock_upsert_state_comment.assert_not_called() + def test_ci_only_path_records_report_in_state_comment_when_enabled(self, tmp_path): + prs = [{"number": 1, "title": "Test"}] + pr_data = { + "headRefName": "feature", + "baseRefName": "main", + "title": "Test", + "reviews": [], + "statusCheckRollup": [ + { + "name": "ci/test", + "conclusion": "FAILURE", + "detailsUrl": "https://example.com/ci/test", + } + ], + } + config = { + "models": {"summarize": "haiku", "fix": "sonnet"}, + "ci_log_max_lines": 120, + "execution_report": True, + "repositories": [ + {"repo": "owner/repo", "user_name": None, "user_email": None} + ], + } + + def capture_report(report_blocks, phase_label, report_path): + assert phase_label == "ci-fix" + assert report_path + report_blocks.append( + "#### CI 修正\n\n### 2026-03-12 10:00:00 UTC src/test.py Retry" + ) + + 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=[]), + 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), + patch("auto_fixer._collect_ci_failure_materials", return_value=[]), + patch("auto_fixer._run_claude_prompt", return_value="aaa111 ci fix"), + patch( + "auto_fixer._capture_state_comment_report", + side_effect=capture_report, + ), + patch( + "auto_fixer.subprocess.run", + return_value=Mock(returncode=0, stdout="", stderr=""), + ), + patch("auto_fixer.upsert_state_comment") as mock_upsert_state_comment, + patch("auto_fixer._update_done_label_if_completed"), + ): + auto_fixer.process_repo({"repo": "owner/repo"}, config=config) + + mock_upsert_state_comment.assert_called_once() + args = mock_upsert_state_comment.call_args.args + kwargs = mock_upsert_state_comment.call_args.kwargs + assert args == ("owner/repo", 1, []) + assert "#### CI 修正" in kwargs["report_body"] + def test_rate_limit_skips_review_fix_but_runs_ci_and_merge_base(self, tmp_path): prs = [{"number": 1, "title": "Test"}] pr_data = { @@ -2269,14 +2346,17 @@ def test_usage_limit_raises(self, tmp_path, capsys): ) process.returncode = 1 report_path = tmp_path / "pr_1_review-fix.md" - report_path.write_text("- setup failed once", encoding="utf-8") + + def popen_side_effect(*args, **kwargs): + report_path.write_text("- setup failed once", encoding="utf-8") + return process with ( patch( "auto_fixer.subprocess.run", return_value=Mock(returncode=0, stdout="abc123\n", stderr=""), ), - patch("auto_fixer.subprocess.Popen", return_value=process), + patch("auto_fixer.subprocess.Popen", side_effect=popen_side_effect), patch("auto_fixer._log_group"), patch("auto_fixer._log_endgroup"), ): @@ -2285,6 +2365,7 @@ def test_usage_limit_raises(self, tmp_path, capsys): works_dir=tmp_path, prompt="fix", report_path=str(report_path.resolve()), + report_enabled=True, model="sonnet", silent=True, phase_label="review-fix", @@ -2298,14 +2379,17 @@ def test_nonzero_exit_raises_command_failed(self, tmp_path, capsys): process.communicate.return_value = ("API Error: invalid header", "") process.returncode = 1 report_path = tmp_path / "pr_1_review-fix.md" - report_path.write_text("- missing context file", encoding="utf-8") + + def popen_side_effect(*args, **kwargs): + report_path.write_text("- missing context file", encoding="utf-8") + return process with ( patch( "auto_fixer.subprocess.run", return_value=Mock(returncode=0, stdout="abc123\n", stderr=""), ), - patch("auto_fixer.subprocess.Popen", return_value=process), + patch("auto_fixer.subprocess.Popen", side_effect=popen_side_effect), patch("auto_fixer._log_group"), patch("auto_fixer._log_endgroup"), ): @@ -2314,6 +2398,7 @@ def test_nonzero_exit_raises_command_failed(self, tmp_path, capsys): works_dir=tmp_path, prompt="fix", report_path=str(report_path.resolve()), + report_enabled=True, model="sonnet", silent=True, phase_label="review-fix", @@ -2354,6 +2439,7 @@ def popen_side_effect(*args, **kwargs): works_dir=tmp_path, prompt="fix", report_path=report_path, + report_enabled=True, model="sonnet", silent=True, phase_label="review-fix", @@ -2361,6 +2447,7 @@ def popen_side_effect(*args, **kwargs): assert result == "" assert "" in captured_prompt assert report_path in captured_prompt + assert "### YYYY-MM-DD hh:mm:ss UTC {file_path} {title}" in captured_prompt def test_success_shows_report_when_not_silent_with_content(self, tmp_path, capsys): """When silent=False and report has content, report is shown in stderr.""" @@ -2368,16 +2455,19 @@ def test_success_shows_report_when_not_silent_with_content(self, tmp_path, capsy process.communicate.return_value = ("", "") process.returncode = 0 report_path = tmp_path / "pr_1_review-fix.md" - report_path.write_text( - "- ambiguous review comment\n- missing file", encoding="utf-8" - ) + + def popen_side_effect(*args, **kwargs): + report_path.write_text( + "- ambiguous review comment\n- missing file", encoding="utf-8" + ) + return process with ( patch( "auto_fixer.subprocess.run", return_value=Mock(returncode=0, stdout="", stderr=""), ), - patch("auto_fixer.subprocess.Popen", return_value=process), + patch("auto_fixer.subprocess.Popen", side_effect=popen_side_effect), patch("auto_fixer._log_group"), patch("auto_fixer._log_endgroup"), ): @@ -2385,6 +2475,7 @@ def test_success_shows_report_when_not_silent_with_content(self, tmp_path, capsy works_dir=tmp_path, prompt="fix", report_path=str(report_path.resolve()), + report_enabled=True, model="sonnet", silent=False, phase_label="review-fix", @@ -2397,7 +2488,7 @@ def test_success_shows_report_when_not_silent_with_content(self, tmp_path, capsy def test_success_shows_empty_when_not_silent_with_empty_report( self, tmp_path, capsys ): - """When silent=False and report is empty, 'report file is empty.' is shown.""" + """When silent=False and report is empty, a neutral message is shown.""" process = Mock() process.communicate.return_value = ("", "") process.returncode = 0 @@ -2417,13 +2508,14 @@ def test_success_shows_empty_when_not_silent_with_empty_report( works_dir=tmp_path, prompt="fix", report_path=str(report_path.resolve()), + report_enabled=True, model="sonnet", silent=False, phase_label="review-fix", ) err = capsys.readouterr().err assert "[report review-fix]" in err - assert "report file is empty." in err + assert "レポート出力なし" in err def test_success_does_not_show_report_when_silent(self, tmp_path, capsys): """When silent=True and success, report is not shown.""" @@ -2446,6 +2538,7 @@ def test_success_does_not_show_report_when_silent(self, tmp_path, capsys): works_dir=tmp_path, prompt="fix", report_path=str(report_path.resolve()), + report_enabled=True, model="sonnet", silent=True, phase_label="review-fix", @@ -2453,6 +2546,43 @@ def test_success_does_not_show_report_when_silent(self, tmp_path, capsys): err = capsys.readouterr().err assert "[report]" not in err + def test_report_instruction_is_omitted_when_disabled(self, tmp_path): + process = Mock() + process.communicate.return_value = ("", "") + process.returncode = 0 + captured_prompt = "" + + def popen_side_effect(*args, **kwargs): + nonlocal captured_prompt + captured_prompt = (tmp_path / "_review_prompt.md").read_text( + encoding="utf-8" + ) + return process + + with ( + patch( + "auto_fixer.subprocess.run", + side_effect=[ + Mock(returncode=0, stdout="abc123\n", stderr=""), + Mock(returncode=0, stdout="", stderr=""), + ], + ), + patch("auto_fixer.subprocess.Popen", side_effect=popen_side_effect), + patch("auto_fixer._log_group"), + patch("auto_fixer._log_endgroup"), + ): + auto_fixer._run_claude_prompt( + works_dir=tmp_path, + prompt="fix", + report_path=None, + report_enabled=False, + model="sonnet", + silent=True, + phase_label="review-fix", + ) + + assert "" not in captured_prompt + class TestExpandRepositories: def test_no_wildcard_returns_original(self): diff --git a/tests/test_state_manager.py b/tests/test_state_manager.py index cdbd976..870682f 100644 --- a/tests/test_state_manager.py +++ b/tests/test_state_manager.py @@ -104,6 +104,24 @@ def test_render_state_comment_trims_oldest_rows_to_fit_limit(monkeypatch): assert "discussion_r0" in body +def test_render_state_comment_uses_updated_review_summary_title(): + body = state_manager.render_state_comment([]) + + assert "対応済みレビュー一覧" in body + assert "System Use Only" not in body + + +def test_render_state_comment_includes_execution_report_section(): + body = state_manager.render_state_comment( + [], + report_body="#### レビュー修正\n\n### 2026-03-12 10:00:00 UTC src/foo.py Missing context", + ) + + assert state_manager.REPORT_SECTION_START_MARKER in body + assert "実行レポート" in body + assert "#### レビュー修正" in body + + def test_render_state_comment_raises_on_archived_id_overflow(monkeypatch): monkeypatch.setattr(state_manager, "STATE_COMMENT_MAX_LENGTH", 500) # Fill the comment body to near the limit using many archived IDs @@ -144,7 +162,8 @@ def test_load_state_comment_extracts_latest_marker_comment_and_ids(): url="https://github.com/owner/repo/pull/1#discussion_r123", processed_at="2026-03-11 12:00:00", ) - ] + ], + report_body="#### レビュー修正\n\n### 2026-03-12 10:00:00 UTC src/foo.py Missing context", ) result = Mock( returncode=0, @@ -174,6 +193,16 @@ def test_load_state_comment_extracts_latest_marker_comment_and_ids(): processed_at="2026-03-11 12:00:00 UTC", ) ] + assert "#### レビュー修正" in comment.report_body + + +def test_parse_processed_ids_ignores_report_section_content(): + text = state_manager.render_state_comment( + [], + report_body="#### レビュー修正\n\n- related id: discussion_r999", + ) + + assert state_manager.parse_processed_ids(text) == [] def test_upsert_state_comment_creates_when_missing(): @@ -248,3 +277,33 @@ def test_upsert_state_comment_updates_when_existing(): assert cmd[:4] == ["gh", "api", "repos/owner/repo/issues/comments/99", "-X"] assert "PATCH" in cmd assert any(arg.startswith("body=") for arg in cmd) + + +def test_upsert_state_comment_writes_report_body_without_new_entries(): + with ( + patch( + "state_manager.load_state_comment", + return_value=state_manager.StateComment( + github_comment_id=None, + body="", + entries=[], + processed_ids=set(), + archived_ids=set(), + report_body="", + ), + ), + patch( + "state_manager.subprocess.run", + return_value=Mock(returncode=0, stdout="", stderr=""), + ) as mock_run, + ): + state_manager.upsert_state_comment( + "owner/repo", + 7, + [], + report_body="#### CI 修正\n\n### 2026-03-12 10:00:00 UTC src/foo.py Retry", + ) + + cmd = mock_run.call_args.args[0] + assert cmd[:5] == ["gh", "pr", "comment", "7", "--repo"] + assert "#### CI 修正" in cmd[-1]