From 14f1c22a60fdd699991f8fda888e47a9bba88fce Mon Sep 17 00:00:00 2001 From: chethanuk Date: Wed, 8 Jul 2026 09:06:02 +0400 Subject: [PATCH 01/19] feat(viewer): dark mode + system monospace font (#304) (#312) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enhance the embedded viewer stylesheet per #304: - Add automatic dark mode via `prefers-color-scheme` (no JS/toggle). The full colour palette — surfaces, text, borders, accents, task/badge brand colours — lives in `:root` custom properties, with a single dark block overriding the values. Light-mode colours are unchanged. - Declare `color-scheme: light dark` so native scrollbars and form controls follow the active theme. - Use the system monospace stack (`ui-monospace, ...`) via a `--mono` variable, replacing the hardcoded font stacks. - Dark tokens chosen to clear WCAG AA (>=4.5:1) for small text, and dim brand colours (task-main, task-default) lifted for the dark surface. Verified by rendering all three viewer pages in light and dark with a headless browser: computed colours, contrast ratios, resolved theme variables, and `color-scheme` asserted programmatically. --- internal/viewer/static/style.css | 272 +++++++++++++++++++++---------- 1 file changed, 185 insertions(+), 87 deletions(-) diff --git a/internal/viewer/static/style.css b/internal/viewer/static/style.css index e008eaf..d2a6fb5 100644 --- a/internal/viewer/static/style.css +++ b/internal/viewer/static/style.css @@ -1,73 +1,171 @@ +:root { + /* Let the browser theme native scrollbars/form controls per the active scheme */ + color-scheme: light dark; + + /* Typography */ + --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + + /* Surfaces */ + --bg: #f5f7fa; + --surface: #fff; + --surface-alt: #f6f8fa; + --surface-alt2: #f8f9fb; + --response-bg: #fefefe; + + /* Text */ + --text: #333; + --text-strong: #24292e; + --text-muted: #586069; + --text-faint: #8b949e; + + /* Borders */ + --border: #e1e4e8; + --border-light: #eaecef; + + /* Accents */ + --link: #0366d6; + --accent: #0366d6; + --accent-soft: #f0f6ff; + + /* Code */ + --code-bg: #f0f0f0; + --inline-code-bg: #eff1f3; + + /* Task accents (dot + card border) */ + --task-main: #0366d6; + --task-plan: #8250df; + --task-memory: #8b949e; + --task-relocation: #e36209; + --task-default: #57606a; + --tool-name: #8250df; + --danger: #cf222e; + + /* Badge tints (bg + fg) */ + --badge-model-bg: #ddf4ff; --badge-model-fg: #0550ae; + --badge-tokens-bg: #dafbe1; --badge-tokens-fg: #116329; + --badge-duration-bg: #fff8c5; --badge-duration-fg: #6a5700; + --badge-error-bg: #ffebe9; --badge-error-fg: #cf222e; + + /* Misc */ + --badge-neutral-bg: #e8ecf1; + --badge-neutral-fg: #586069; + --shadow: 0 1px 3px rgba(0,0,0,0.08); + --shadow-hover: 0 2px 8px rgba(0,0,0,0.08); +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0d1117; + --surface: #161b22; + --surface-alt: #21262d; + --surface-alt2: #1c2128; + --response-bg: #161b22; + + --text: #c9d1d9; + --text-strong: #e6edf3; + --text-muted: #8b949e; + --text-faint: #7d8590; + + --border: #30363d; + --border-light: #21262d; + + --link: #58a6ff; + --accent: #58a6ff; + --accent-soft: rgba(56,139,253,0.1); + + --code-bg: #21262d; + --inline-code-bg: #21262d; + + /* Task accents — lift dim brand colors for the dark surface */ + --task-main: #58a6ff; + --task-plan: #d2a8ff; + --task-default: #7d8590; + --tool-name: #d2a8ff; + --danger: #f85149; + + /* Badge tints — translucent bg + brighter fg on dark */ + --badge-model-bg: rgba(56,139,253,0.15); --badge-model-fg: #79c0ff; + --badge-tokens-bg: rgba(46,160,67,0.15); --badge-tokens-fg: #7ee787; + --badge-duration-bg: rgba(187,128,9,0.15); --badge-duration-fg: #e3b341; + --badge-error-bg: rgba(248,81,73,0.15); --badge-error-fg: #ff7b72; + + --badge-neutral-bg: #30363d; + --badge-neutral-fg: #adbac7; + --shadow: 0 1px 3px rgba(0,0,0,0.4); + --shadow-hover: 0 2px 8px rgba(0,0,0,0.5); + } +} + * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #f5f7fa; - color: #333; + background: var(--bg); + color: var(--text); line-height: 1.6; padding-bottom: 2rem; } nav.breadcrumb { - background: #fff; - border-bottom: 1px solid #e1e4e8; + background: var(--surface); + border-bottom: 1px solid var(--border); padding: 0.75rem 1.5rem; font-size: 0.9rem; } nav.breadcrumb a { - color: #0366d6; + color: var(--link); text-decoration: none; } nav.breadcrumb a:hover { text-decoration: underline; } main { max-width: 1200px; margin: 1.5rem auto; padding: 0 1rem; } -h2 { margin-bottom: 1rem; color: #24292e; } -h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } +h2 { margin-bottom: 1rem; color: var(--text-strong); } +h3 { margin: 1.5rem 0 0.75rem; color: var(--text-strong); } .table { width: 100%; border-collapse: collapse; - background: #fff; + background: var(--surface); border-radius: 6px; overflow: hidden; - box-shadow: 0 1px 3px rgba(0,0,0,0.08); + box-shadow: var(--shadow); } .table th { - background: #f6f8fa; + background: var(--surface-alt); text-align: left; padding: 0.6rem 1rem; font-size: 0.85rem; - color: #586069; - border-bottom: 1px solid #e1e4e8; + color: var(--text-muted); + border-bottom: 1px solid var(--border); } .table td { padding: 0.6rem 1rem; - border-bottom: 1px solid #eaecef; + border-bottom: 1px solid var(--border-light); font-size: 0.9rem; } .table tr:last-child td { border-bottom: none; } -.table tr:hover { background: #f6f8fa; } -.table a { color: #0366d6; text-decoration: none; } +.table tr:hover { background: var(--surface-alt); } +.table a { color: var(--link); text-decoration: none; } .table a:hover { text-decoration: underline; } .table code { - background: #f0f0f0; + background: var(--code-bg); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.85em; } .session-header { - background: #fff; + background: var(--surface); border-radius: 6px; padding: 1.25rem; margin-bottom: 1rem; - box-shadow: 0 1px 3px rgba(0,0,0,0.08); + box-shadow: var(--shadow); } .session-header h2 { margin-bottom: 0.5rem; word-break: break-all; } -.meta { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.9rem; color: #586069; } +.meta { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.9rem; color: var(--text-muted); } .meta code { - background: #f0f0f0; + background: var(--code-bg); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.85em; @@ -75,11 +173,11 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } /* Token usage summary */ .token-summary { - background: #fff; + background: var(--surface); border-radius: 6px; padding: 1.25rem; margin-bottom: 1rem; - box-shadow: 0 1px 3px rgba(0,0,0,0.08); + box-shadow: var(--shadow); } .token-stats { display: grid; @@ -91,12 +189,12 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } .token-value { font-size: 1.8rem; font-weight: 700; - color: #0366d6; + color: var(--accent); line-height: 1.2; } .token-label { font-size: 0.75rem; - color: #586069; + color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; } @@ -106,24 +204,24 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } font-size: 0.85rem; } .token-table th { - background: #f6f8fa; + background: var(--surface-alt); text-align: left; padding: 0.4rem 0.75rem; - color: #586069; - border-bottom: 1px solid #e1e4e8; + color: var(--text-muted); + border-bottom: 1px solid var(--border); } .token-table td { padding: 0.35rem 0.75rem; - border-bottom: 1px solid #eaecef; + border-bottom: 1px solid var(--border-light); } .token-table tr:last-child td { border-bottom: none; } -.token-table tbody tr:hover { background: #f6f8fa; } +.token-table tbody tr:hover { background: var(--surface-alt); } .file-list { list-style: none; padding-left: 1rem; } .file-list li { padding: 0.25rem 0; font-size: 0.9rem; - color: #586069; + color: var(--text-muted); } .file-list li::before { content: "\1F4C4 "; margin-right: 0.25rem; } @@ -132,11 +230,11 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } /* File Accordion */ .file-accordion { - background: #fff; + background: var(--surface); border-radius: 8px; margin-bottom: 0.75rem; - box-shadow: 0 1px 3px rgba(0,0,0,0.08); - border: 1px solid #e1e4e8; + box-shadow: var(--shadow); + border: 1px solid var(--border); overflow: hidden; } @@ -144,7 +242,7 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } cursor: pointer; padding: 0.85rem 1rem; font-size: 0.95rem; - color: #24292e; + color: var(--text-strong); user-select: none; display: flex; align-items: center; @@ -154,15 +252,15 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } } .file-accordion-header::-webkit-details-marker { display: none; } .file-accordion-header::marker { display: none; content: ""; } -.file-accordion-header:hover { background: #f0f6ff; } +.file-accordion-header:hover { background: var(--accent-soft); } /* Chevron icon */ .chevron { display: inline-block; width: 6px; height: 6px; - border-right: 2px solid #586069; - border-bottom: 2px solid #586069; + border-right: 2px solid var(--text-muted); + border-bottom: 2px solid var(--text-muted); transform: rotate(-45deg); transition: transform 0.2s ease; flex-shrink: 0; @@ -174,7 +272,7 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } .file-path { font-weight: 600; - font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + font-family: var(--mono); font-size: 0.88rem; flex: 1; min-width: 0; @@ -184,8 +282,8 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } } .file-count-badge { - background: #e8ecf1; - color: #586069; + background: var(--badge-neutral-bg); + color: var(--badge-neutral-fg); padding: 0.15em 0.6em; border-radius: 10px; font-size: 0.72rem; @@ -195,7 +293,7 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } } .file-accordion[open] > .file-accordion-header { - border-bottom: 1px solid #e1e4e8; + border-bottom: 1px solid var(--border); } .file-accordion-body { @@ -209,7 +307,7 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } .task-type-label { font-size: 0.72rem; text-transform: uppercase; - color: #586069; + color: var(--text-muted); margin-bottom: 0.6rem; letter-spacing: 0.06em; display: flex; @@ -225,42 +323,42 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } border-radius: 50%; flex-shrink: 0; } -.task-main .task-type-dot { background: #0366d6; } -.task-plan .task-type-dot { background: #8250df; } -.task-memory .task-type-dot { background: #8b949e; } -.task-relocation .task-type-dot { background: #e36209; } -.task-default .task-type-dot { background: #57606a; } +.task-main .task-type-dot { background: var(--task-main); } +.task-plan .task-type-dot { background: var(--task-plan); } +.task-memory .task-type-dot { background: var(--task-memory); } +.task-relocation .task-type-dot { background: var(--task-relocation); } +.task-default .task-type-dot { background: var(--task-default); } /* Card */ .card { - border: 1px solid #e1e4e8; + border: 1px solid var(--border); border-radius: 8px; margin-bottom: 0.75rem; overflow: hidden; transition: box-shadow 0.15s; } .card:hover { - box-shadow: 0 2px 8px rgba(0,0,0,0.08); + box-shadow: var(--shadow-hover); } -.task-main .card { border-left: 3px solid #0366d6; } -.task-plan .card { border-left: 3px solid #8250df; } -.task-memory .card { border-left: 3px solid #8b949e; } -.task-relocation .card { border-left: 3px solid #e36209; } +.task-main .card { border-left: 3px solid var(--task-main); } +.task-plan .card { border-left: 3px solid var(--task-plan); } +.task-memory .card { border-left: 3px solid var(--task-memory); } +.task-relocation .card { border-left: 3px solid var(--task-relocation); } .card-header { - background: #f6f8fa; + background: var(--surface-alt); padding: 0.55rem 0.85rem; font-size: 0.85rem; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; - border-bottom: 1px solid #eaecef; + border-bottom: 1px solid var(--border-light); } .request-label { font-weight: 600; - color: #24292e; + color: var(--text-strong); } /* Badges */ @@ -271,24 +369,24 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } font-weight: 500; white-space: nowrap; } -.badge-model { background: #ddf4ff; color: #0550ae; } -.badge-tokens { background: #dafbe1; color: #116329; } -.badge-duration { background: #fff8c5; color: #6a5700; } -.badge-error { background: #ffebe9; color: #cf222e; } +.badge-model { background: var(--badge-model-bg); color: var(--badge-model-fg); } +.badge-tokens { background: var(--badge-tokens-bg); color: var(--badge-tokens-fg); } +.badge-duration { background: var(--badge-duration-bg); color: var(--badge-duration-fg); } +.badge-error { background: var(--badge-error-bg); color: var(--badge-error-fg); } /* Response Content */ .card-body { padding: 0; } .response-content { - background: #fefefe; + background: var(--response-bg); border-top: none; } .response-text { padding: 0.85rem 1rem; font-size: 0.85rem; line-height: 1.65; - color: #24292e; + color: var(--text-strong); max-height: 400px; overflow-y: auto; white-space: pre-wrap; @@ -297,8 +395,8 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } /* Markdown-rendered elements */ .response-text .code-block { - background: #f6f8fa; - border: 1px solid #e1e4e8; + background: var(--surface-alt); + border: 1px solid var(--border); border-radius: 6px; padding: 0.65rem 0.85rem; margin: 0.5rem 0; @@ -312,21 +410,21 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } font-size: inherit; } .response-text .inline-code { - background: #eff1f3; + background: var(--inline-code-bg); padding: 0.15em 0.35em; border-radius: 4px; font-size: 0.88em; - font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + font-family: var(--mono); } -.response-text .md-h1 { font-size: 1.15em; font-weight: 700; margin: 0.6em 0 0.3em; color: #24292e; } -.response-text .md-h2 { font-size: 1.05em; font-weight: 600; margin: 0.5em 0 0.25em; color: #24292e; } -.response-text .md-h3 { font-size: 0.95em; font-weight: 600; margin: 0.4em 0 0.2em; color: #24292e; } +.response-text .md-h1 { font-size: 1.15em; font-weight: 700; margin: 0.6em 0 0.3em; color: var(--text-strong); } +.response-text .md-h2 { font-size: 1.05em; font-weight: 600; margin: 0.5em 0 0.25em; color: var(--text-strong); } +.response-text .md-h3 { font-size: 0.95em; font-weight: 600; margin: 0.4em 0 0.2em; color: var(--text-strong); } .response-text .md-li { padding-left: 1em; text-indent: -0.8em; margin: 0.15em 0; } /* Tool Calls Section */ .tool-calls-section { - background: #f8f9fb; - border-top: 1px solid #e1e4e8; + background: var(--surface-alt2); + border-top: 1px solid var(--border); padding: 0.65rem 0.85rem; } @@ -334,14 +432,14 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } font-size: 0.72rem; font-weight: 600; text-transform: uppercase; - color: #586069; + color: var(--text-muted); letter-spacing: 0.04em; margin-bottom: 0.5rem; } .tool-call-item { - background: #fff; - border: 1px solid #e1e4e8; + background: var(--surface); + border: 1px solid var(--border); border-radius: 6px; margin-bottom: 0.5rem; overflow: hidden; @@ -350,7 +448,7 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } .tool-call-item:last-child { margin-bottom: 0; } .tool-call-error { - border-left: 3px solid #cf222e; + border-left: 3px solid var(--danger); } .tool-call-header { @@ -363,19 +461,19 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } } .tool-icon { - color: #8b949e; + color: var(--text-faint); font-size: 0.9em; } .tool-name { - color: #8250df; - font-family: SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace; + color: var(--tool-name); + font-family: var(--mono); font-size: 0.85em; } /* Tool Detail Toggle */ .tool-detail { - border-top: 1px solid #eaecef; + border-top: 1px solid var(--border-light); } .tool-detail summary { list-style: none; @@ -387,22 +485,22 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } cursor: pointer; padding: 0.35rem 0.7rem; font-size: 0.78rem; - color: #0366d6; + color: var(--link); display: flex; align-items: center; gap: 0.35rem; user-select: none; transition: background 0.12s; } -.tool-detail-toggle:hover { background: #f0f6ff; } +.tool-detail-toggle:hover { background: var(--accent-soft); } /* Small chevron for tool details */ .chevron-sm { display: inline-block; width: 5px; height: 5px; - border-right: 1.5px solid #0366d6; - border-bottom: 1.5px solid #0366d6; + border-right: 1.5px solid var(--link); + border-bottom: 1.5px solid var(--link); transform: rotate(-45deg); transition: transform 0.2s ease; flex-shrink: 0; @@ -423,14 +521,14 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } font-size: 0.7rem; font-weight: 600; text-transform: uppercase; - color: #8b949e; + color: var(--text-faint); letter-spacing: 0.04em; margin-bottom: 0.2rem; } .tool-section pre { - background: #f6f8fa; - border: 1px solid #e1e4e8; + background: var(--surface-alt); + border: 1px solid var(--border); border-radius: 4px; padding: 0.5rem 0.65rem; font-size: 0.78rem; @@ -441,4 +539,4 @@ h3 { margin: 1.5rem 0 0.75rem; color: #24292e; } word-break: break-word; } -p { color: #586069; padding: 1rem 0; } +p { color: var(--text-muted); padding: 1rem 0; } From 46dde274d8a3d55f11210896eb9a4906d481cd52 Mon Sep 17 00:00:00 2001 From: xujiejie <80671406+yingjiexu2002@users.noreply.github.com> Date: Wed, 8 Jul 2026 13:12:21 +0800 Subject: [PATCH 02/19] Feat/telemetry http exporter (#314) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(telemetry): add OTLP HTTP exporter and print TraceID - Add HTTP/protobuf exporter support alongside existing gRPC exporter - Route based on OTEL_EXPORTER_OTLP_PROTOCOL config (http/protobuf vs grpc) - Print TraceID to stderr when telemetry is enabled for easier correlation - Add corresponding unit tests * feat(telemetry): add span coverage for LLM calls, tool execution, plan/filter phases - Add StartLLMSpan / RecordLLMResult helpers (span.go), symmetric with existing StartToolSpan / RecordToolResult - Wrap LLM completion calls in llmloop.RunPerFile with llm.request spans - Wrap all three tool execution paths in executeToolCall with tool.execute.* spans (dynamic tools, code_comment sync/async, other tools) - Add plan.execute span around executePlanPhase - Add main.loop span around RunPerFile call in executeSubtask - Add review_filter.execute span around executeReviewFilter, with comments.before / comments.filtered attributes - Record llm.error attribute on LLM failures for diagnosability - Record review.repo / review.from / review.to / review.model on the top-level review.run span - Metrics (RecordLLMRequest / RecordToolCall) are preserved alongside the new spans — they serve different purposes (aggregate dashboards vs per-run diagnosis) Verified end-to-end against Sunfire (OTLP HTTP gateway): full span tree observed for review.run -> subtask.execute -> plan.execute/main.loop/ review_filter.execute -> llm.request/tool.execute.* * fix(telemetry): address CR findings — span error handling, async span lifecycle, protocol robustness - Add span.RecordError(err) to RecordLLMResult and RecordToolResult for consistency with EndSpan - Use OTel standard pattern (span.SetStatus + span.RecordError) in error paths of review.run, plan.execute, main.loop, review_filter.execute - Move async code_comment span end into pool.Submit callback so span duration reflects actual execution time - Unify time.Since(startTime) in code_comment error path to a single dur - Remove http/json from supported OTLP protocols (not actually implemented) - Add stderr warning when unknown OTLP protocol falls back to gRPC * feat(telemetry): include trace_id in JSON output, restrict stderr to text format - Add trace_id as top-level field in jsonOutput struct (omitempty) - JSON format: trace_id in structured response for programmatic extraction - Text format: TraceID printed to stderr for human debugging - Telemetry disabled: trace_id field omitted entirely * fix: address PR review findings - loop.go: wrap async span lifecycle in defer to prevent leak on panic - exporter.go: update parseOTLPEndpoint comment to reflect gRPC+HTTP usage - scan_cmd.go: align traceID extraction and OTel error handling with review_cmd - output.go/shared.go: propagate traceID to outputJSONNoFiles for consistency - agent.go: move comments.filtered attribute before early return so 0 is distinguishable from not-executed * feat(telemetry): address PR review — http/json routing, LLM span coverage, trace_id tests - Route http/json to HTTP exporter (Go OTel SDK HTTP transport only supports protobuf serialization; users need HTTP transport, not JSON encoding) - Add llm.request spans to executePlanPhase, executeReviewFilter, and ReLocateComment with Usage nil-safety consistent with loop.go - Add trace_id assertions to output helper tests and emitRunResult end-to-end tests using real TracerProvider * docs: add OTLP protocol selection and endpoint format to telemetry section Sync across all 5 README language versions (en, zh-CN, ja-JP, ko-KR, ru-RU). * fix: unify time.Since in async code_comment defer to single dur variable --- README.ja-JP.md | 9 ++ README.ko-KR.md | 9 ++ README.md | 10 +++ README.ru-RU.md | 9 ++ README.zh-CN.md | 9 ++ cmd/opencodereview/emit_run_result_test.go | 61 ++++++++++++++ cmd/opencodereview/output.go | 7 +- cmd/opencodereview/output_helpers_test.go | 20 +++-- cmd/opencodereview/review_cmd.go | 16 +++- cmd/opencodereview/scan_cmd.go | 13 ++- cmd/opencodereview/shared.go | 6 +- go.mod | 2 + go.sum | 4 + internal/agent/agent.go | 56 +++++++++++-- internal/diff/relocation.go | 12 +++ internal/llmloop/loop.go | 28 ++++++- internal/telemetry/config_test.go | 2 +- internal/telemetry/exporter.go | 60 +++++++++++++- internal/telemetry/exporter_test.go | 95 ++++++++++++++++++++++ internal/telemetry/span.go | 35 ++++++++ internal/telemetry/span_test.go | 32 ++++++++ 21 files changed, 471 insertions(+), 24 deletions(-) diff --git a/README.ja-JP.md b/README.ja-JP.md index c6bb4e2..42a8cbf 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -762,6 +762,15 @@ ocr config set telemetry.otlp_endpoint localhost:4317 エクスポートデータにLLMのプロンプトとレスポンスを含めるには、`telemetry.content_logging`を設定してください。 +**プロトコル選択:** 環境変数 `OTEL_EXPORTER_OTLP_PROTOCOL` でエクスポートプロトコルを選択できます: + +| 値 | トランスポート | 説明 | +|---|---|---| +| `grpc`(デフォルト) | gRPC | デフォルトポート 4317 | +| `http/protobuf` | HTTP | デフォルトポート 4318 | + +**Endpoint 形式:** `telemetry.otlp_endpoint` は `host:port` または `http://host:port` 形式のベースURLを指定します。パスを含める必要はありません。SDKが [OTLP仕様](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request)に従いシグナルパス(例:`/v1/traces`)を自動的に付加します。 + ## コントリビューション 開発環境のセットアップ、コーディングガイドライン、プルリクエストの提出方法については[CONTRIBUTING.md](CONTRIBUTING.md)を参照してください。 diff --git a/README.ko-KR.md b/README.ko-KR.md index 74c67d9..05f709e 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -719,6 +719,15 @@ ocr config set telemetry.otlp_endpoint localhost:4317 exported data에 LLM prompt와 response를 포함하려면 `telemetry.content_logging`을 설정합니다. +**프로토콜 선택:** 환경 변수 `OTEL_EXPORTER_OTLP_PROTOCOL`로 export 프로토콜을 선택할 수 있습니다: + +| 값 | 전송 방식 | 설명 | +|---|---|---| +| `grpc` (기본값) | gRPC | 기본 포트 4317 | +| `http/protobuf` | HTTP | 기본 포트 4318 | + +**Endpoint 형식:** `telemetry.otlp_endpoint`는 `host:port` 또는 `http://host:port` 형식의 base URL을 지정합니다. 경로를 포함할 필요가 없습니다. SDK가 [OTLP 사양](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request)에 따라 signal 경로(예: `/v1/traces`)를 자동으로 추가합니다. + ## Contributing 개발 환경 설정, coding guideline, pull request 제출 방법은 [CONTRIBUTING.ko-KR.md](CONTRIBUTING.ko-KR.md)를 참고하세요. diff --git a/README.md b/README.md index ba42e2d..45463ed 100644 --- a/README.md +++ b/README.md @@ -767,6 +767,16 @@ ocr config set telemetry.otlp_endpoint localhost:4317 Set `telemetry.content_logging` to include LLM prompts and responses in exported data. +**Protocol selection:** Set the environment variable `OTEL_EXPORTER_OTLP_PROTOCOL` to choose the export protocol: + +| Value | Transport | Notes | +|---|---|---| +| `grpc` (default) | gRPC | Default port 4317 | +| `http/protobuf` | HTTP | Default port 4318 | + +**Endpoint format:** `telemetry.otlp_endpoint` expects a base URL in `host:port` or `http://host:port` format, without a path component. The SDK appends the signal path (e.g. `/v1/traces`) automatically per the [OTLP specification](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request). + + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding guidelines, and how to submit pull requests. diff --git a/README.ru-RU.md b/README.ru-RU.md index 6535ce5..21dbdff 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -764,6 +764,15 @@ ocr config set telemetry.otlp_endpoint localhost:4317 Установите `telemetry.content_logging`, чтобы включать промпты и ответы LLM в экспортируемые данные. +**Выбор протокола:** Переменная окружения `OTEL_EXPORTER_OTLP_PROTOCOL` определяет протокол экспорта: + +| Значение | Транспорт | Описание | +|---|---|---| +| `grpc` (по умолчанию) | gRPC | Порт по умолчанию 4317 | +| `http/protobuf` | HTTP | Порт по умолчанию 4318 | + +**Формат endpoint:** `telemetry.otlp_endpoint` принимает базовый URL в формате `host:port` или `http://host:port` без компонента пути. SDK автоматически добавляет путь сигнала (например, `/v1/traces`) в соответствии со [спецификацией OTLP](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request). + ## Участие в разработке В [CONTRIBUTING.ru-RU.md](CONTRIBUTING.ru-RU.md) описаны настройка окружения разработки, рекомендации по коду и порядок отправки pull request'ов. diff --git a/README.zh-CN.md b/README.zh-CN.md index 1f2c2e4..ddf3c30 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -752,6 +752,15 @@ ocr config set telemetry.otlp_endpoint localhost:4317 设置 `telemetry.content_logging` 可在导出数据中包含 LLM 提示词和响应。 +**协议选择:** 通过环境变量 `OTEL_EXPORTER_OTLP_PROTOCOL` 选择导出协议: + +| 值 | 传输方式 | 说明 | +|---|---|---| +| `grpc`(默认) | gRPC | 默认端口 4317 | +| `http/protobuf` | HTTP | 默认端口 4318 | + +**Endpoint 格式:** `telemetry.otlp_endpoint` 的值为 `host:port` 或 `http://host:port`,无需包含路径。SDK 会根据 [OTLP 规范](https://opentelemetry.io/docs/specs/otlp/#otlphttp-request)自动追加信号路径(如 `/v1/traces`)。 + ## 贡献 参见 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md) 了解开发环境搭建、编码规范以及如何提交 Pull Request。 diff --git a/cmd/opencodereview/emit_run_result_test.go b/cmd/opencodereview/emit_run_result_test.go index 4082ca2..3770fee 100644 --- a/cmd/opencodereview/emit_run_result_test.go +++ b/cmd/opencodereview/emit_run_result_test.go @@ -7,6 +7,9 @@ import ( "testing" "time" + "go.opentelemetry.io/otel" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "github.com/open-code-review/open-code-review/internal/agent" "github.com/open-code-review/open-code-review/internal/model" ) @@ -175,3 +178,61 @@ func TestEmitRunResult_NilQuietHandle(t *testing.T) { }) _ = got } + +func TestEmitRunResult_JSONTraceIDFromContext(t *testing.T) { + tp := sdktrace.NewTracerProvider() + defer tp.Shutdown(context.Background()) + otel.SetTracerProvider(tp) + + ctx, span := tp.Tracer("test").Start(context.Background(), "test-root") + wantTraceID := span.SpanContext().TraceID().String() + defer span.End() + + ag := &mockResultProvider{ + filesReviewed: 2, + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + } + got := captureStdout(t, func() { + err := emitRunResult(ctx, ag, nil, time.Now(), "json", "developer", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + var out jsonOutput + if err := json.Unmarshal([]byte(got), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.TraceID != wantTraceID { + t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID) + } +} + +func TestEmitRunResult_JSONNoFilesTraceID(t *testing.T) { + tp := sdktrace.NewTracerProvider() + defer tp.Shutdown(context.Background()) + otel.SetTracerProvider(tp) + + ctx, span := tp.Tracer("test").Start(context.Background(), "test-root") + wantTraceID := span.SpanContext().TraceID().String() + defer span.End() + + ag := &mockResultProvider{filesReviewed: 0} + got := captureStdout(t, func() { + err := emitRunResult(ctx, ag, nil, time.Now(), "json", "developer", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + var out jsonOutput + if err := json.Unmarshal([]byte(got), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.Status != "skipped" { + t.Errorf("status = %q, want skipped", out.Status) + } + if out.TraceID != wantTraceID { + t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID) + } +} diff --git a/cmd/opencodereview/output.go b/cmd/opencodereview/output.go index c87b696..cabeb78 100644 --- a/cmd/opencodereview/output.go +++ b/cmd/opencodereview/output.go @@ -240,6 +240,7 @@ type jsonToolCalls struct { type jsonOutput struct { Status string `json:"status"` + TraceID string `json:"trace_id,omitempty"` Message string `json:"message,omitempty"` Summary *jsonSummary `json:"summary,omitempty"` ToolCalls *jsonToolCalls `json:"tool_calls"` @@ -263,9 +264,10 @@ func outputJSON(comments []model.LlmComment) error { func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning, filesReviewed, inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens int64, - duration time.Duration, projectSummary string, toolCalls map[string]int64) error { + duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string) error { out := jsonOutput{ Status: "success", + TraceID: traceID, Comments: comments, Summary: &jsonSummary{ FilesReviewed: filesReviewed, @@ -311,9 +313,10 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW return enc.Encode(out) } -func outputJSONNoFiles() error { +func outputJSONNoFiles(traceID string) error { out := jsonOutput{ Status: "skipped", + TraceID: traceID, Message: "No supported files changed.", Comments: []model.LlmComment{}, ToolCalls: &jsonToolCalls{ diff --git a/cmd/opencodereview/output_helpers_test.go b/cmd/opencodereview/output_helpers_test.go index d0d25b2..f3d9127 100644 --- a/cmd/opencodereview/output_helpers_test.go +++ b/cmd/opencodereview/output_helpers_test.go @@ -168,8 +168,7 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) { os.Stdout = w warnings := []agent.AgentWarning{{Type: "subtask_error", File: "x.go", Message: "fail"}} - err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil) - + err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil, "abc123trace") w.Close() os.Stdout = old @@ -188,6 +187,9 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) { if !strings.Contains(out.Message, "errors") { t.Errorf("message = %q, expected to mention errors", out.Message) } + if out.TraceID != "abc123trace" { + t.Errorf("trace_id = %q, want abc123trace", out.TraceID) + } } func TestStatusBadge(t *testing.T) { @@ -275,8 +277,7 @@ func TestOutputJSONWithWarnings(t *testing.T) { comments := []model.LlmComment{{Path: "b.go", Content: "test"}} warnings := []agent.AgentWarning{{Type: "subtask_error", File: "c.go", Message: "failed"}} - err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}) - + err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}, "trace-xyz-789") w.Close() os.Stdout = old @@ -301,6 +302,9 @@ func TestOutputJSONWithWarnings(t *testing.T) { if out.ToolCalls == nil || out.ToolCalls.Total != 3 { t.Errorf("ToolCalls.Total = %v", out.ToolCalls) } + if out.TraceID != "trace-xyz-789" { + t.Errorf("trace_id = %q, want trace-xyz-789", out.TraceID) + } } func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) { @@ -309,8 +313,7 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) { os.Stdout = w warnings := []agent.AgentWarning{{Type: "warning", Message: "something"}} - err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil) - + err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil, "") w.Close() os.Stdout = old @@ -336,7 +339,7 @@ func TestOutputJSONNoFiles(t *testing.T) { r, w, _ := os.Pipe() os.Stdout = w - err := outputJSONNoFiles() + err := outputJSONNoFiles("test-trace-id-456") w.Close() os.Stdout = old @@ -353,6 +356,9 @@ func TestOutputJSONNoFiles(t *testing.T) { if out.Status != "skipped" { t.Errorf("status = %q, want skipped", out.Status) } + if out.TraceID != "test-trace-id-456" { + t.Errorf("trace_id = %q, want test-trace-id-456", out.TraceID) + } } func captureStdout(t *testing.T, fn func()) string { diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go index 118d398..47a026d 100644 --- a/cmd/opencodereview/review_cmd.go +++ b/cmd/opencodereview/review_cmd.go @@ -13,6 +13,8 @@ import ( "github.com/open-code-review/open-code-review/internal/mcp" "github.com/open-code-review/open-code-review/internal/telemetry" "github.com/open-code-review/open-code-review/internal/tool" + + "go.opentelemetry.io/otel/codes" ) func runReview(args []string) error { @@ -104,11 +106,23 @@ func runReview(args []string) error { ctx, span := telemetry.StartSpan(context.Background(), "review.run") defer span.End() + telemetry.SetAttr(span, "review.repo", cc.RepoDir) + telemetry.SetAttr(span, "review.from", opts.from) + telemetry.SetAttr(span, "review.to", opts.to) + telemetry.SetAttr(span, "review.model", rt.Model) + var traceID string + if telemetry.IsEnabled() { + traceID = telemetry.TraceIDFromContext(ctx) + if opts.outputFormat != "json" { + fmt.Fprintf(os.Stderr, "[ocr] TraceID: %s\n", traceID) + } + } startTime := time.Now() comments, err := ag.Run(ctx) if err != nil { - telemetry.SetAttr(span, "error", err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) return fmt.Errorf("review failed: %w", err) } diff --git a/cmd/opencodereview/scan_cmd.go b/cmd/opencodereview/scan_cmd.go index 3757e98..c383579 100644 --- a/cmd/opencodereview/scan_cmd.go +++ b/cmd/opencodereview/scan_cmd.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "os" "strings" "time" @@ -11,6 +12,8 @@ import ( "github.com/open-code-review/open-code-review/internal/scan" "github.com/open-code-review/open-code-review/internal/telemetry" "github.com/open-code-review/open-code-review/internal/tool" + + "go.opentelemetry.io/otel/codes" ) // scanOptions mirrors reviewOptions for the full-scan subcommand. The two @@ -207,11 +210,19 @@ func runScan(args []string) error { ctx, span := telemetry.StartSpan(context.Background(), "scan.run") defer span.End() + var traceID string + if telemetry.IsEnabled() { + traceID = telemetry.TraceIDFromContext(ctx) + if opts.outputFormat != "json" { + fmt.Fprintf(os.Stderr, "[ocr] TraceID: %s\n", traceID) + } + } startTime := time.Now() comments, err := ag.Run(ctx) if err != nil { - telemetry.SetAttr(span, "error", err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) return fmt.Errorf("scan failed: %w", err) } diff --git a/cmd/opencodereview/shared.go b/cmd/opencodereview/shared.go index eaacf83..4d70c84 100644 --- a/cmd/opencodereview/shared.go +++ b/cmd/opencodereview/shared.go @@ -276,8 +276,10 @@ func emitRunResult( telemetry.RecordCommentsGenerated(ctx, int64(len(comments))) } + traceID := telemetry.TraceIDFromContext(ctx) + if outputFormat == "json" && len(comments) == 0 && ag.FilesReviewed() == 0 { - return outputJSONNoFiles() + return outputJSONNoFiles(traceID) } // Agent-text audiences need stdout back before PrintTraceSummary so the @@ -296,7 +298,7 @@ func emitRunResult( return outputJSONWithWarnings(comments, ag.Warnings(), ag.FilesReviewed(), ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(), ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration, - ag.ProjectSummary(), ag.ToolCalls()) + ag.ProjectSummary(), ag.ToolCalls(), traceID) } outputTextWithWarnings(comments, ag.Warnings()) if summary := ag.ProjectSummary(); summary != "" { diff --git a/go.mod b/go.mod index 88d5e03..42a346c 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,9 @@ require ( github.com/pkoukk/tiktoken-go v0.1.8 go.opentelemetry.io/otel v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 go.opentelemetry.io/otel/metric v1.44.0 diff --git a/go.sum b/go.sum index cb21c84..071f834 100644 --- a/go.sum +++ b/go.sum @@ -109,10 +109,14 @@ go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 4be6504..e7e02c9 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -22,6 +22,8 @@ import ( "github.com/open-code-review/open-code-review/internal/stdout" "github.com/open-code-review/open-code-review/internal/telemetry" "github.com/open-code-review/open-code-review/internal/tool" + + "go.opentelemetry.io/otel/codes" ) // AgentWarning is re-exported from llmloop for backwards compatibility with @@ -487,7 +489,17 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error { return nil } - err := a.runner.RunPerFile(ctx, messages, newPath) + err := func() error { + ctx, mainSpan := telemetry.StartSpan(ctx, "main.loop") + defer mainSpan.End() + telemetry.SetAttr(mainSpan, "file.path", newPath) + if err := a.runner.RunPerFile(ctx, messages, newPath); err != nil { + mainSpan.SetStatus(codes.Error, err.Error()) + mainSpan.RecordError(err) + return err + } + return nil + }() if err == nil { // REVIEW_FILTER_TASK runs after the main loop and decides which of the // just-collected comments to drop. It needs to see comments produced by @@ -503,6 +515,10 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error { // executeReviewFilter runs the REVIEW_FILTER_TASK to remove comments that are // provably incorrect based solely on the diff. Errors are logged and silently ignored. func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath string) { + ctx, span := telemetry.StartSpan(ctx, "review_filter.execute") + defer span.End() + telemetry.SetAttr(span, "file.path", newPath) + ft := a.args.Template.ReviewFilterTask if ft == nil || len(ft.Messages) == 0 { return @@ -512,6 +528,7 @@ func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath s if len(comments) == 0 { return } + telemetry.SetAttr(span, "comments.before", len(comments)) commentsJSON := buildFilterCommentsJSON(comments) @@ -534,20 +551,33 @@ func (a *Agent) executeReviewFilter(ctx context.Context, d model.Diff, newPath s rec := fs.AppendTaskRecord(session.ReviewFilterTask, messages) startTime := time.Now() + _, llmSpan := telemetry.StartLLMSpan(ctx, a.args.Model) resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ Model: a.args.Model, Messages: messages, MaxTokens: a.args.Template.MaxTokens, }) + duration := time.Since(startTime) if err != nil { - rec.SetError(err, time.Since(startTime)) + telemetry.RecordLLMResult(llmSpan, duration, 0, err) + llmSpan.End() + rec.SetError(err, duration) fmt.Fprintf(stdout.Writer(), "[ocr] Review filter failed for %s: %v\n", newPath, err) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) return } - rec.SetResponse(resp, time.Since(startTime)) + var totalTokens int64 + if resp.Usage != nil { + totalTokens = resp.Usage.TotalTokens + } + telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil) + llmSpan.End() + rec.SetResponse(resp, duration) a.runner.RecordUsage(resp.Usage) indices := parseFilterResponse(resp.Content(), len(comments)) + telemetry.SetAttr(span, "comments.filtered", len(indices)) if len(indices) == 0 { return } @@ -722,6 +752,10 @@ func (a *Agent) extFromPath(path string) string { // executePlanPhase runs the plan task for a single file, sending template messages // with resolved placeholders and collecting the LLM response as plan guidance. func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFiles, rule string) (string, error) { + ctx, span := telemetry.StartSpan(ctx, "plan.execute") + defer span.End() + telemetry.SetAttr(span, "file.path", newPath) + pt := a.args.Template.PlanTask messages := make([]llm.Message, 0, len(pt.Messages)) for _, m := range pt.Messages { @@ -740,16 +774,28 @@ func (a *Agent) executePlanPhase(ctx context.Context, newPath, rawDiff, changeFi rec := fs.AppendTaskRecord(session.PlanTask, messages) startTime := time.Now() + _, llmSpan := telemetry.StartLLMSpan(ctx, a.args.Model) resp, err := a.args.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ Model: a.args.Model, Messages: messages, MaxTokens: a.args.Template.MaxTokens, }) + duration := time.Since(startTime) if err != nil { - rec.SetError(err, time.Since(startTime)) + telemetry.RecordLLMResult(llmSpan, duration, 0, err) + llmSpan.End() + rec.SetError(err, duration) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) return "", fmt.Errorf("plan request: %w", err) } - rec.SetResponse(resp, time.Since(startTime)) + var totalTokens int64 + if resp.Usage != nil { + totalTokens = resp.Usage.TotalTokens + } + telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil) + llmSpan.End() + rec.SetResponse(resp, duration) a.runner.RecordUsage(resp.Usage) fmt.Fprintf(stdout.Writer(), "[ocr] Plan completed for %s\n", newPath) return resp.Content(), nil diff --git a/internal/diff/relocation.go b/internal/diff/relocation.go index 1f296e3..d1afff5 100644 --- a/internal/diff/relocation.go +++ b/internal/diff/relocation.go @@ -10,6 +10,7 @@ import ( "github.com/open-code-review/open-code-review/internal/llm" "github.com/open-code-review/open-code-review/internal/model" "github.com/open-code-review/open-code-review/internal/stdout" + "github.com/open-code-review/open-code-review/internal/telemetry" ) // ReLocateComment calls the LLM to regenerate a precise existing_code snippet @@ -44,15 +45,26 @@ func ReLocateComment( messages = append(messages, llm.NewTextMessage(m.Role, content)) } + startTime := time.Now() + _, llmSpan := telemetry.StartLLMSpan(ctx, modelName) resp, err := client.CompletionsWithCtx(ctx, llm.ChatRequest{ Model: modelName, Messages: messages, MaxTokens: maxTokens, }) + duration := time.Since(startTime) if err != nil { + telemetry.RecordLLMResult(llmSpan, duration, 0, err) + llmSpan.End() fmt.Fprintf(stdout.Writer(), "[ocr] Re-location LLM call failed for %s: %v\n", cm.Path, err) return false, nil, messages } + var totalTokens int64 + if resp.Usage != nil { + totalTokens = resp.Usage.TotalTokens + } + telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil) + llmSpan.End() code := extractCodeBlock(resp.Content()) if code == "" { diff --git a/internal/llmloop/loop.go b/internal/llmloop/loop.go index d206a78..38d7b3f 100644 --- a/internal/llmloop/loop.go +++ b/internal/llmloop/loop.go @@ -163,6 +163,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath rec := fs.AppendTaskRecord(session.MainTask, append([]llm.Message(nil), messages...)) startTime := time.Now() + _, llmSpan := telemetry.StartLLMSpan(ctx, r.deps.Model) resp, err := r.deps.LLMClient.CompletionsWithCtx(ctx, llm.ChatRequest{ Model: r.deps.Model, Messages: messages, @@ -172,6 +173,8 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath duration := time.Since(startTime) if err != nil { rec.SetError(err, duration) + telemetry.RecordLLMResult(llmSpan, duration, 0, err) + llmSpan.End() telemetry.RecordLLMRequest(ctx, r.deps.Model, duration, 0, "error") return fmt.Errorf("LLM completion error: %w", err) } @@ -184,6 +187,8 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath atomic.AddInt64(&r.totalCacheReadTokens, resp.Usage.CacheReadTokens) atomic.AddInt64(&r.totalCacheWriteTokens, resp.Usage.CacheWriteTokens) } + telemetry.RecordLLMResult(llmSpan, duration, totalTokens, nil) + llmSpan.End() telemetry.RecordLLMRequest(ctx, r.deps.Model, duration, totalTokens, "ok") content := resp.Content() @@ -272,14 +277,19 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T return tool.Of(fmt.Sprintf("Error parsing tool arguments for %s: %v", call.Function.Name, err)) } telemetry.PrintToolCallStarted(call.Function.Name, dynArgs) + _, toolSpan := telemetry.StartToolSpan(ctx, call.Function.Name) startTime := time.Now() result, err := p.Execute(ctx, dynArgs) dur := time.Since(startTime) if err != nil { + telemetry.RecordToolResult(toolSpan, call.Function.Name, dur.Milliseconds(), err) + toolSpan.End() telemetry.RecordToolCall(ctx, call.Function.Name, dur, false) telemetry.PrintToolCallError(call.Function.Name, err) return tool.Of(fmt.Sprintf("Error executing tool %s: %v", call.Function.Name, err)) } + telemetry.RecordToolResult(toolSpan, call.Function.Name, dur.Milliseconds(), nil) + toolSpan.End() telemetry.RecordToolCall(ctx, call.Function.Name, dur, true) telemetry.PrintToolCallFinished(call.Function.Name, dur) if rec != nil { @@ -314,10 +324,14 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T if t == tool.CodeComment { telemetry.PrintToolCallStarted(t.Name(), args) + _, toolSpan := telemetry.StartToolSpan(ctx, t.Name()) comments, errMsg := tool.ParseComments(args) if errMsg != "" { - telemetry.RecordToolCall(ctx, t.Name(), time.Since(startTime), false) + dur := time.Since(startTime) + telemetry.RecordToolResult(toolSpan, t.Name(), dur.Milliseconds(), fmt.Errorf("%s", errMsg)) + toolSpan.End() + telemetry.RecordToolCall(ctx, t.Name(), dur, false) return tool.Of(errMsg) } @@ -361,8 +375,13 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T asyncCtx := context.WithoutCancel(ctx) toolName := t.Name() pool.Submit(func() ([]model.LlmComment, error) { + defer func() { + dur := time.Since(startTime) + telemetry.RecordToolResult(toolSpan, toolName, dur.Milliseconds(), nil) + toolSpan.End() + telemetry.PrintToolCallFinished(toolName, dur) + }() resolveAndCollect(asyncCtx) - telemetry.PrintToolCallFinished(toolName, time.Since(startTime)) return []model.LlmComment{}, nil }) telemetry.RecordToolCall(asyncCtx, toolName, time.Since(startTime), true) @@ -371,6 +390,8 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T resolveAndCollect(ctx) dur := time.Since(startTime) + telemetry.RecordToolResult(toolSpan, t.Name(), dur.Milliseconds(), nil) + toolSpan.End() telemetry.RecordToolCall(ctx, t.Name(), dur, true) telemetry.PrintToolCallFinished(t.Name(), dur) if rec != nil { @@ -381,9 +402,12 @@ func (r *Runner) executeToolCall(ctx context.Context, newPath string, call llm.T // Synchronous path for all other tools telemetry.PrintToolCallStarted(t.Name(), args) + _, toolSpan := telemetry.StartToolSpan(ctx, t.Name()) result, err := p.Execute(ctx, args) dur := time.Since(startTime) ok := err == nil + telemetry.RecordToolResult(toolSpan, t.Name(), dur.Milliseconds(), err) + toolSpan.End() telemetry.RecordToolCall(ctx, t.Name(), dur, ok) if err != nil { diff --git a/internal/telemetry/config_test.go b/internal/telemetry/config_test.go index 4edb8ce..1e4011a 100644 --- a/internal/telemetry/config_test.go +++ b/internal/telemetry/config_test.go @@ -65,7 +65,7 @@ func TestResolveEnv(t *testing.T) { }, }, { - name: "otlp protocol override", + name: "otlp protocol passthrough", envs: map[string]string{"OTEL_EXPORTER_OTLP_PROTOCOL": "http/json"}, check: func(t *testing.T, cfg Config) { if cfg.OTLPProtocol != "http/json" { diff --git a/internal/telemetry/exporter.go b/internal/telemetry/exporter.go index 58638c8..bcb2b45 100644 --- a/internal/telemetry/exporter.go +++ b/internal/telemetry/exporter.go @@ -11,7 +11,9 @@ import ( sdktrace "go.opentelemetry.io/otel/sdk/trace" otlpmetricgrpc "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + otlpmetrichttp "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp" otlptracegrpc "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + otlptracehttp "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" stdoutmetric "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" stdouttrace "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" ) @@ -27,11 +29,11 @@ func newStdoutMetricExporter() (sdkmetric.Exporter, error) { } // parseOTLPEndpoint strips a http:// or https:// scheme from the endpoint and -// reports whether the connection should be insecure (plaintext gRPC). +// reports whether the connection should be plaintext (insecure). // Scheme matching is case-insensitive per RFC 3986. A bare host:port (no // scheme) is left unchanged and defaults to TLS. Any trailing slash left // over from a URL-style endpoint (e.g. "http://localhost:4317/") is -// trimmed, since otlptracegrpc/otlpmetricgrpc's WithEndpoint expects a bare +// trimmed, since both gRPC and HTTP exporters' WithEndpoint expects a bare // host:port with no path. func parseOTLPEndpoint(endpoint string) (addr string, insecure bool) { switch { @@ -44,8 +46,21 @@ func parseOTLPEndpoint(endpoint string) (addr string, insecure bool) { } } -// initOTLPProviders sets up OTLP gRPC exporters for traces and metrics. +// initOTLPProviders dispatches to the gRPC or HTTP exporter based on cfg.OTLPProtocol. func initOTLPProviders(ctx context.Context, res *resource.Resource, cfg Config) { + switch cfg.OTLPProtocol { + case "http/protobuf", "http/json": + initOTLPHTTPProviders(ctx, res, cfg) + case "", "grpc": + initOTLPGRPCProviders(ctx, res, cfg) + default: + fmt.Fprintf(os.Stderr, "[ocr] WARNING: unsupported OTLP protocol %q, falling back to gRPC\n", cfg.OTLPProtocol) + initOTLPGRPCProviders(ctx, res, cfg) + } +} + +// initOTLPGRPCProviders sets up OTLP gRPC exporters for traces and metrics. +func initOTLPGRPCProviders(ctx context.Context, res *resource.Resource, cfg Config) { addr, insecure := parseOTLPEndpoint(cfg.OTLPEndpoint) traceOpts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(addr)} @@ -83,6 +98,45 @@ func initOTLPProviders(ctx context.Context, res *resource.Resource, cfg Config) shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return mp.Shutdown(ctx) }) } +// initOTLPHTTPProviders sets up OTLP HTTP exporters for traces and metrics. +func initOTLPHTTPProviders(ctx context.Context, res *resource.Resource, cfg Config) { + addr, insecure := parseOTLPEndpoint(cfg.OTLPEndpoint) + + traceOpts := []otlptracehttp.Option{otlptracehttp.WithEndpoint(addr)} + if insecure { + traceOpts = append(traceOpts, otlptracehttp.WithInsecure()) + } + traceExp, err := otlptracehttp.New(ctx, traceOpts...) + if err != nil { + fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to create OTLP HTTP trace exporter: %v\n", err) + return + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(traceExp), + sdktrace.WithResource(res), + ) + tracerProvider = tp + shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return tp.Shutdown(ctx) }) + + metricOpts := []otlpmetrichttp.Option{otlpmetrichttp.WithEndpoint(addr)} + if insecure { + metricOpts = append(metricOpts, otlpmetrichttp.WithInsecure()) + } + metricExp, err := otlpmetrichttp.New(ctx, metricOpts...) + if err != nil { + fmt.Fprintf(os.Stderr, "[ocr] WARNING: failed to create OTLP HTTP metric exporter: %v\n", err) + return + } + + mp := sdkmetric.NewMeterProvider( + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)), + sdkmetric.WithResource(res), + ) + meterProvider = mp + shutdownFuncs = append(shutdownFuncs, func(ctx context.Context) error { return mp.Shutdown(ctx) }) +} + // initConsoleProviders sets up stdout exporters for traces and metrics. func initConsoleProviders(res *resource.Resource) { traceExp, err := newStdoutTraceExporter() diff --git a/internal/telemetry/exporter_test.go b/internal/telemetry/exporter_test.go index 95efd4b..1a3c9be 100644 --- a/internal/telemetry/exporter_test.go +++ b/internal/telemetry/exporter_test.go @@ -1,7 +1,11 @@ package telemetry import ( + "bytes" "context" + "io" + "os" + "strings" "testing" "go.opentelemetry.io/otel/sdk/resource" @@ -103,3 +107,94 @@ func TestInitOTLPProviders_InvalidEndpoint(t *testing.T) { t.Error("expected tracerProvider to be set (OTLP exporter creation is lazy)") } } + +func TestInitOTLPHTTPProviders_InvalidEndpoint(t *testing.T) { + tracerProvider = nil + meterProvider = nil + shutdownFuncs = nil + defer func() { + for _, fn := range shutdownFuncs { + _ = fn(context.Background()) + } + tracerProvider = nil + meterProvider = nil + shutdownFuncs = nil + }() + + cfg := Config{ + Exporter: "otlp", + OTLPEndpoint: "localhost:0", + OTLPProtocol: "http/protobuf", + } + initOTLPHTTPProviders(context.Background(), resource.Default(), cfg) + if tracerProvider == nil { + t.Error("expected tracerProvider to be set (OTLP HTTP exporter creation is lazy)") + } +} + +func TestInitOTLPProviders_ProtocolRouting(t *testing.T) { + cases := []struct { + name string + protocol string + wantWarning bool // default branch emits a gRPC fallback warning + }{ + {"grpc default", "grpc", false}, + {"empty defaults to grpc", "", false}, + {"http/protobuf routes to http", "http/protobuf", false}, + {"http/json routes to http", "http/json", false}, + {"unknown falls back to grpc", "foo", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tracerProvider = nil + meterProvider = nil + shutdownFuncs = nil + + // Capture stderr to assert on the fallback warning. + oldStderr := os.Stderr + r, w, _ := os.Pipe() + os.Stderr = w + + cfg := Config{ + Exporter: "otlp", + OTLPEndpoint: "localhost:0", + OTLPProtocol: tc.protocol, + } + initOTLPProviders(context.Background(), resource.Default(), cfg) + w.Close() + os.Stderr = oldStderr + + defer func() { + for _, fn := range shutdownFuncs { + _ = fn(context.Background()) + } + tracerProvider = nil + meterProvider = nil + shutdownFuncs = nil + }() + + if tracerProvider == nil { + t.Error("expected tracerProvider to be set") + } + if meterProvider == nil { + t.Error("expected meterProvider to be set") + } + if len(shutdownFuncs) != 2 { + t.Errorf("expected 2 shutdown funcs, got %d", len(shutdownFuncs)) + } + + var buf bytes.Buffer + io.Copy(&buf, r) + stderrOut := buf.String() + if tc.wantWarning { + if !strings.Contains(stderrOut, "falling back to gRPC") { + t.Errorf("expected gRPC fallback warning in stderr, got %q", stderrOut) + } + } else { + if strings.Contains(stderrOut, "falling back to gRPC") { + t.Errorf("expected no fallback warning, got %q", stderrOut) + } + } + }) + } +} diff --git a/internal/telemetry/span.go b/internal/telemetry/span.go index 04c9d4d..0b412ed 100644 --- a/internal/telemetry/span.go +++ b/internal/telemetry/span.go @@ -3,6 +3,7 @@ package telemetry import ( "context" "fmt" + "time" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" @@ -23,6 +24,16 @@ func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) return getTracer().Start(ctx, name, opts...) } +// TraceIDFromContext returns the hex-encoded trace ID of the span carried by +// ctx, or "" if ctx carries no valid span (e.g. telemetry is disabled). +func TraceIDFromContext(ctx context.Context) string { + sc := trace.SpanContextFromContext(ctx) + if !sc.TraceID().IsValid() { + return "" + } + return sc.TraceID().String() +} + // EndSpan ends the span and records error status if present. func EndSpan(span trace.Span, err error) { if err != nil { @@ -69,11 +80,35 @@ func RecordToolResult(span trace.Span, toolName string, durationMs int64, err er SetAttr(span, "tool.status", "error") SetAttr(span, "tool.error", err.Error()) span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) } else { SetAttr(span, "tool.status", "ok") } } +// StartLLMSpan creates a span for an LLM request with standard attributes. +func StartLLMSpan(ctx context.Context, model string) (context.Context, trace.Span) { + return StartSpan(ctx, "llm.request", + trace.WithAttributes(attribute.String("llm.model", model))) +} + +// RecordLLMResult sets the outcome of an LLM request on the span. +func RecordLLMResult(span trace.Span, duration time.Duration, totalTokens int64, err error) { + if span == nil { + return + } + SetAttr(span, "llm.duration_ms", duration.Milliseconds()) + SetAttr(span, "llm.total_tokens", totalTokens) + if err != nil { + SetAttr(span, "llm.status", "error") + SetAttr(span, "llm.error", err.Error()) + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + } else { + SetAttr(span, "llm.status", "ok") + } +} + // AnyToAttr converts an arbitrary value to an OTel attribute.KeyValue. func AnyToAttr(k string, v interface{}) attribute.KeyValue { switch val := v.(type) { diff --git a/internal/telemetry/span_test.go b/internal/telemetry/span_test.go index a8ca9d1..089aac0 100644 --- a/internal/telemetry/span_test.go +++ b/internal/telemetry/span_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "testing" + "time" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" @@ -138,3 +139,34 @@ func TestRecordToolResult_NilSpan(t *testing.T) { RecordToolResult(nil, "tool", 100, nil) RecordToolResult(nil, "tool", 100, fmt.Errorf("err")) } + +func TestStartLLMSpan(t *testing.T) { + setupEnabledTelemetry(t) + ctx, span := StartLLMSpan(context.Background(), "qwen-max") + if !span.SpanContext().IsValid() { + t.Error("expected valid span context") + } + if ctx == nil { + t.Error("expected non-nil context") + } + span.End() +} + +func TestRecordLLMResult_Success(t *testing.T) { + setupEnabledTelemetry(t) + _, span := StartSpan(context.Background(), "test.llm") + RecordLLMResult(span, 500*time.Millisecond, 1200, nil) + span.End() +} + +func TestRecordLLMResult_Error(t *testing.T) { + setupEnabledTelemetry(t) + _, span := StartSpan(context.Background(), "test.llm") + RecordLLMResult(span, 100*time.Millisecond, 0, errors.New("timeout")) + span.End() +} + +func TestRecordLLMResult_NilSpan(t *testing.T) { + RecordLLMResult(nil, 100*time.Millisecond, 0, nil) + RecordLLMResult(nil, 100*time.Millisecond, 0, fmt.Errorf("err")) +} From eb680d96456f34139ad69498d8d8ed75a114f40b Mon Sep 17 00:00:00 2001 From: kite Date: Wed, 8 Jul 2026 14:26:32 +0800 Subject: [PATCH 03/19] docs: remove retired Go Report Card badge (#319) Go Report Card has been sunset by its maintainers; the badge endpoint now returns "go report: retired" and the report page redirects to a farewell notice, so the badge can never render a grade. Remove it from README.md and all localized versions (zh-CN, ja-JP, ko-KR, ru-RU). --- README.ja-JP.md | 1 - README.ko-KR.md | 1 - README.md | 1 - README.ru-RU.md | 1 - README.zh-CN.md | 1 - 5 files changed, 5 deletions(-) diff --git a/README.ja-JP.md b/README.ja-JP.md index 42a8cbf..389de06 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -13,7 +13,6 @@

npm Build status - Go Report Card License Ask DeepWiki OpenSSF Best Practices diff --git a/README.ko-KR.md b/README.ko-KR.md index 05f709e..6eec684 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -13,7 +13,6 @@

npm Build status - Go Report Card License Ask DeepWiki OpenSSF Best Practices diff --git a/README.md b/README.md index 45463ed..a4b9d58 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@

npm Build status - Go Report Card License Ask DeepWiki OpenSSF Best Practices diff --git a/README.ru-RU.md b/README.ru-RU.md index 21dbdff..d5fe6d1 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -13,7 +13,6 @@

npm Build status - Go Report Card License Ask DeepWiki OpenSSF Best Practices diff --git a/README.zh-CN.md b/README.zh-CN.md index ddf3c30..17548c9 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -13,7 +13,6 @@

npm Build status - Go Report Card License Ask DeepWiki OpenSSF Best Practices From 38efeff30e4ffda3cd6b758ae86a26027a65895b Mon Sep 17 00:00:00 2001 From: "V. D'AGOSTINO" Date: Wed, 8 Jul 2026 13:46:30 +0200 Subject: [PATCH 04/19] feat(background-file) Add the background-file CLI option to read a local business context file (#206) --- .claude/commands/open-code-review.md | 1 + README.ja-JP.md | 7 + README.ko-KR.md | 7 + README.md | 7 + README.ru-RU.md | 7 + README.zh-CN.md | 7 + cmd/opencodereview/background_file.go | 135 +++++++++ cmd/opencodereview/background_file_test.go | 312 ++++++++++++++++++++ cmd/opencodereview/flags.go | 38 ++- cmd/opencodereview/flags_test.go | 14 + cmd/opencodereview/review_cmd.go | 11 + plugins/open-code-review/commands/review.md | 3 +- 12 files changed, 533 insertions(+), 16 deletions(-) create mode 100644 cmd/opencodereview/background_file.go create mode 100644 cmd/opencodereview/background_file_test.go diff --git a/.claude/commands/open-code-review.md b/.claude/commands/open-code-review.md index a58cb58..f7339cb 100644 --- a/.claude/commands/open-code-review.md +++ b/.claude/commands/open-code-review.md @@ -15,6 +15,7 @@ ocr review --audience agent [user-args] - If the user provides `--commit` or `--c`: pass through as-is. - If the user provides `--from` and `--to`: pass through as-is. - (Optional) Provide `--background "requirement context"` to review whether the requirements are correctly implemented. +- (Optional) Provide `--background-file ./requirements.md` to load the same context from a Markdown file (sanitised and limited to 8000 characters). Combined with `--background` the inline value is given first. - Capture full stdout. Set a 5-minute timeout. - If the `ocr` command is not found, install it by running `npm i -g @alibaba-group/open-code-review`. diff --git a/README.ja-JP.md b/README.ja-JP.md index 389de06..417a5c5 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -449,6 +449,7 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと | `--timeout` | — | `10` | 同時実行タスクのタイムアウト(分) | | `--audience` | — | `human` | `human`(進捗を表示)または`agent`(サマリーのみ) | | `--background` | `-b` | — | レビューのための任意の要件/ビジネスコンテキスト。`--commit`使用時に未指定の場合、コミットメッセージから自動取得 | +| `--background-file` | `-B` | — | Markdownファイルから読み込む任意の要件/ビジネスコンテキスト。`--background`と併用した場合はインラインの値が先に配置されます | | `--model` | — | — | このレビューでLLMモデルを選択または上書き | | `--rule` | — | — | カスタムJSONレビュールールへのパス | | `--max-tools` | — | 組み込み値 | ファイルごとのツール呼び出しラウンドの上限。テンプレートのデフォルトより大きい場合のみ有効 | @@ -507,6 +508,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # 要件コンテキストを提供してより的確なレビューを実施 ocr review --background "ログインAPIにレート制限を追加" +# Markdownファイルから要件コンテキストを提供 +ocr review --background-file ./docs/my_business_context.md + +# インラインのコンテキストとローカルのコンテキストファイルを組み合わせる(両方が使用されます) +ocr review --background "認証に注目" --background-file ./docs/my_business_context.md + # カスタムレビュールールを使用 ocr review --rule /path/to/my-rules.json diff --git a/README.ko-KR.md b/README.ko-KR.md index 6eec684..161cf5c 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -449,6 +449,7 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s | `--timeout` | - | `10` | 동시 task timeout(분) | | `--audience` | - | `human` | `human`(progress 표시) 또는 `agent`(summary only) | | `--background` | `-b` | - | 리뷰를 위한 선택적 요구사항/비즈니스 컨텍스트. `--commit` 사용 시 미지정이면 commit message에서 자동 추출 | +| `--background-file` | `-B` | - | Markdown 파일에서 읽어오는 선택적 요구사항/비즈니스 컨텍스트. `--background`와 함께 사용하면 inline 값이 먼저 배치됩니다 | | `--model` | - | - | 이번 리뷰에서 LLM model 선택 또는 override | | `--rule` | - | - | custom JSON review rules 경로 | | `--max-tools` | - | built-in | 파일별 최대 tool call round. template default보다 클 때만 적용 | @@ -507,6 +508,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # 요구사항 컨텍스트를 제공하여 더 정확한 리뷰 수행 ocr review --background "로그인 API에 rate limiting 추가" +# Markdown 파일에서 요구사항 컨텍스트 제공 +ocr review --background-file ./docs/my_business_context.md + +# inline 컨텍스트와 로컬 컨텍스트 파일을 함께 사용(둘 다 적용됨) +ocr review --background "인증에 집중" --background-file ./docs/my_business_context.md + # custom review rules 사용 ocr review --rule /path/to/my-rules.json diff --git a/README.md b/README.md index a4b9d58..9e62e01 100644 --- a/README.md +++ b/README.md @@ -451,6 +451,7 @@ See the [`examples/`](./examples/) directory for integration examples: | `--timeout` | — | `10` | Concurrent task timeout in minutes | | `--audience` | — | `human` | `human` (show progress) or `agent` (summary only) | | `--background` | `-b` | — | Optional requirement/business context for the review; auto-filled from commit message when using `--commit` | +| `--background-file` | `-B` | — | Optional requirement/business context from a Markdown file; Combined with `--background` the inline value is given first | | `--model` | — | — | Select or override the LLM model for this review | | `--rule` | — | — | Path to custom JSON review rules | | `--max-tools` | — | built-in | Max tool call rounds per file; only takes effect when greater than template default | @@ -512,6 +513,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # Provide requirement context for more targeted review ocr review --background "Adding rate limiting to the login API" +# Provide requirement context from a Markdown file +ocr review --background-file ./docs/my_business_context.md + +# Combine inline context with a local context file (both are used) +ocr review --background "Focus on auth" --background-file ./docs/my_business_context.md + # Use custom review rules ocr review --rule /path/to/my-rules.json diff --git a/README.ru-RU.md b/README.ru-RU.md index d5fe6d1..ef2a749 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -451,6 +451,7 @@ ocr review \ | `--timeout` | — | `10` | Таймаут конкурентной задачи в минутах | | `--audience` | — | `human` | `human` (показывать прогресс) или `agent` (только сводка) | | `--background` | `-b` | — | Необязательный контекст требований/бизнес-логики для ревью; при `--commit` автоматически заполняется из сообщения коммита | +| `--background-file` | `-B` | — | Необязательный контекст требований/бизнес-логики из Markdown-файла; при совместном использовании с `--background` встроенное значение идёт первым | | `--model` | — | — | Выбрать или переопределить LLM-модель для этого ревью | | `--rule` | — | — | Путь к пользовательским JSON-правилам ревью | | `--max-tools` | — | встроенное | Максимум раундов вызова инструментов на файл; действует, только если больше значения шаблона по умолчанию | @@ -509,6 +510,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # Передать контекст требований для более прицельного ревью ocr review --background "Добавляем rate limiting в API логина" +# Передать контекст требований из Markdown-файла +ocr review --background-file ./docs/my_business_context.md + +# Совместить встроенный контекст с локальным файлом контекста (используются оба) +ocr review --background "Фокус на аутентификации" --background-file ./docs/my_business_context.md + # Использовать собственные правила ревью ocr review --rule /path/to/my-rules.json diff --git a/README.zh-CN.md b/README.zh-CN.md index 17548c9..5d8d0db 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -449,6 +449,7 @@ ocr review \ | `--timeout` | — | `10` | 并发任务超时时间(分钟) | | `--audience` | — | `human` | `human`(显示进度)或 `agent`(仅输出摘要) | | `--background` | `-b` | — | 可选的需求/业务背景信息;使用 `--commit` 时如未指定则自动从 commit message 中提取 | +| `--background-file` | `-B` | — | 来自 Markdown 文件的可选需求/业务背景信息;与 `--background` 同时使用时,内联内容排在前面 | | `--model` | — | — | 为本次审查选择或覆盖 LLM 模型 | | `--rule` | — | — | 自定义 JSON 审查规则路径 | | `--max-tools` | — | 内置默认 | 每个文件的最大工具调用轮次;仅在大于模板默认值时生效 | @@ -507,6 +508,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6 # 提供需求背景以获得更有针对性的审查 ocr review --background "为登录 API 添加限流" +# 从 Markdown 文件提供需求背景 +ocr review --background-file ./docs/my_business_context.md + +# 将内联背景与本地背景文件结合使用(两者都会生效) +ocr review --background "关注鉴权" --background-file ./docs/my_business_context.md + # 使用自定义审查规则 ocr review --rule /path/to/my-rules.json diff --git a/cmd/opencodereview/background_file.go b/cmd/opencodereview/background_file.go new file mode 100644 index 0000000..2c14a5c --- /dev/null +++ b/cmd/opencodereview/background_file.go @@ -0,0 +1,135 @@ +package main + +import ( + "fmt" + "os" + "regexp" + "strings" + "unicode" +) + +const ( + backgroundSoftLimit = 2000 + backgroundHardLimit = 8000 + backgroundOpenTag = "" + backgroundCloseTag = "" + maxBackgroundFileBytes = 1 << 20 // 1 MB +) + +var multiNewline = regexp.MustCompile(`\n{3,}`) + +// mergeBackground combines the inline --background value (or an auto-populated +// commit message) with the content read from --background-file, separated by a +// blank line. The inline value is sanitised the same way as the file content so +// both portions are cleaned consistently. The file content is already wrapped +// and sanitised by loadBackgroundFile. +func mergeBackground(inline, fromFile string) string { + inline = sanitizeMarkdown(inline) + switch { + case inline == "": + return fromFile + case fromFile == "": + return inline + default: + return inline + "\n\n" + fromFile + } +} + +func loadBackgroundFile(path string) (string, error) { + info, err := os.Stat(path) + if err != nil { + return "", fmt.Errorf("read background file %q: %w", path, err) + } + if info.IsDir() { + return "", fmt.Errorf("background file %q is a directory, not a file", path) + } + if info.Size() > maxBackgroundFileBytes { + return "", fmt.Errorf( + "background file %q is %d bytes, exceeding the maximum of %d bytes; please provide a smaller file", + path, info.Size(), maxBackgroundFileBytes, + ) + } + + raw, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read background file %q: %w", path, err) + } + + cleaned := sanitizeMarkdown(string(raw)) + if cleaned == "" { + return "", fmt.Errorf("background file %q is empty after sanitisation", path) + } + + if strings.Contains(cleaned, backgroundOpenTag) || strings.Contains(cleaned, backgroundCloseTag) { + return "", fmt.Errorf( + "background file %q must not contain the reserved delimiters %q or %q", + path, backgroundOpenTag, backgroundCloseTag, + ) + } + + // Enforce the limits on the cleaned content only: the wrapper delimiters add + // overhead the user cannot control, so counting them would make the reported + // character count misleading. + if n := len([]rune(cleaned)); n > backgroundHardLimit { + return "", fmt.Errorf( + "background content is %d characters, exceeding the hard limit of %d (aborting)", + n, backgroundHardLimit, + ) + } else if n > backgroundSoftLimit { + fmt.Fprintf(os.Stderr, + "[ocr] --background-file content is %d characters, exceeding the recommended %d (continuing but review quality might be impacted)\n", + n, backgroundSoftLimit, + ) + } + + return backgroundOpenTag + "\n" + cleaned + "\n" + backgroundCloseTag, nil +} + +func sanitizeMarkdown(s string) string { + var b strings.Builder + b.Grow(len(s)) + + for _, r := range s { + switch r { + case '\n', '\t': + b.WriteRune(r) + continue + case '\r': + continue + } + if isForbiddenChar(r) { + continue + } + b.WriteRune(r) + } + + collapsed := multiNewline.ReplaceAllString(b.String(), "\n\n") + return strings.TrimSpace(collapsed) +} + +func isForbiddenChar(r rune) bool { + switch { + case r <= 0x1F: // C0 control characters (includes NUL) + return true + case r >= 0x7F && r <= 0x9F: // DEL and C1 control characters + return true + } + + // The runes below all belong to Unicode category Cf and are therefore + // already caught by the unicode.Is(unicode.Cf, r) check at the end. They are + // listed explicitly only as documentation of the most common invisible + // characters we strip; the switch is redundant, not a correctness necessity. + switch r { + case '\u200B', // zero-width space + '\u200C', // zero-width non-joiner + '\u200D', // zero-width joiner + '\u200E', // left-to-right mark + '\u200F', // right-to-left mark + '\u2060', // word joiner + '\u00AD', // soft hyphen + '\uFEFF': // BOM / zero-width no-break space + return true + } + + return unicode.Is(unicode.Cf, r) +} diff --git a/cmd/opencodereview/background_file_test.go b/cmd/opencodereview/background_file_test.go new file mode 100644 index 0000000..cb18f21 --- /dev/null +++ b/cmd/opencodereview/background_file_test.go @@ -0,0 +1,312 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// writeTempFile writes content to a temporary file and returns its path. +func writeTempFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "background.md") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + return path +} + +func TestLoadBackgroundFileNotFound(t *testing.T) { + _, err := loadBackgroundFile(filepath.Join(t.TempDir(), "does-not-exist.md")) + if err == nil { + t.Fatal("expected an error for a missing file, got nil") + } +} + +func TestLoadBackgroundFileEmpty(t *testing.T) { + cases := map[string]string{ + "zero bytes": "", + "whitespace only": " \n\t \n ", + "invisible only": "\u200B\u200E\u00AD\uFEFF", + } + for name, content := range cases { + t.Run(name, func(t *testing.T) { + _, err := loadBackgroundFile(writeTempFile(t, content)) + if err == nil { + t.Fatal("expected an error for empty-after-sanitisation content, got nil") + } + if !strings.Contains(err.Error(), "empty") { + t.Errorf("error = %q, want it to mention 'empty'", err) + } + }) + } +} + +func TestLoadBackgroundFileControlCharRemoval(t *testing.T) { + // Mix in NUL, bell, DEL, a C1 control char, zero-width space, BOM and an + // LTR mark around legitimate text. + content := "Hello\x00\x07world\x7f\u0085!\u200B\uFEFF\u200E" + got, err := loadBackgroundFile(writeTempFile(t, content)) + if err != nil { + t.Fatalf("loadBackgroundFile: %v", err) + } + for _, bad := range []string{"\x00", "\x07", "\x7f", "\u0085", "\u200B", "\uFEFF", "\u200E"} { + if strings.Contains(got, bad) { + t.Errorf("result still contains control/invisible char %q: %q", bad, got) + } + } + if !strings.Contains(got, "Helloworld!") { + t.Errorf("expected cleaned text to contain %q, got %q", "Helloworld!", got) + } +} + +func TestSanitizeMarkdownPreservesNewlinesAndTabs(t *testing.T) { + got := sanitizeMarkdown("line1\n\tindented\nline3") + want := "line1\n\tindented\nline3" + if got != want { + t.Errorf("sanitizeMarkdown = %q, want %q", got, want) + } +} + +func TestSanitizeMarkdownCollapsesNewlines(t *testing.T) { + got := sanitizeMarkdown("a\n\n\n\n\nb") + want := "a\n\nb" + if got != want { + t.Errorf("sanitizeMarkdown = %q, want %q", got, want) + } +} + +func TestSanitizeMarkdownNormalizesCRLF(t *testing.T) { + got := sanitizeMarkdown("a\r\nb\r\nc") + want := "a\nb\nc" + if got != want { + t.Errorf("sanitizeMarkdown = %q, want %q", got, want) + } +} + +func TestSanitizeMarkdownTrims(t *testing.T) { + got := sanitizeMarkdown(" \n hello \n ") + if got != "hello" { + t.Errorf("sanitizeMarkdown = %q, want %q", got, "hello") + } +} + +func TestLoadBackgroundFileDelimiters(t *testing.T) { + got, err := loadBackgroundFile(writeTempFile(t, "Some requirement context.")) + if err != nil { + t.Fatalf("loadBackgroundFile: %v", err) + } + if !strings.HasPrefix(got, backgroundOpenTag+"\n") { + t.Errorf("result missing opening delimiter: %q", got) + } + if !strings.HasSuffix(got, "\n"+backgroundCloseTag) { + t.Errorf("result missing closing delimiter: %q", got) + } + want := backgroundOpenTag + "\nSome requirement context.\n" + backgroundCloseTag + if got != want { + t.Errorf("result = %q, want %q", got, want) + } +} + +func TestLoadBackgroundFileRejectsReservedDelimiters(t *testing.T) { + for _, tag := range []string{backgroundOpenTag, backgroundCloseTag} { + t.Run(tag, func(t *testing.T) { + content := "Some context " + tag + " and more text." + _, err := loadBackgroundFile(writeTempFile(t, content)) + if err == nil { + t.Fatalf("expected an error for content containing %q, got nil", tag) + } + if !strings.Contains(err.Error(), "reserved delimiters") { + t.Errorf("error = %q, want it to mention 'reserved delimiters'", err) + } + }) + } +} + +func TestMergeBackgroundSanitizesInline(t *testing.T) { + t.Run("inline only", func(t *testing.T) { + // Control char, zero-width space and surrounding whitespace must be removed. + got := mergeBackground(" \x00Inline\u200B context ", "") + if got != "Inline context" { + t.Errorf("mergeBackground = %q, want %q", got, "Inline context") + } + }) + + t.Run("inline combined with file", func(t *testing.T) { + wrapped := backgroundOpenTag + "\nfrom file\n" + backgroundCloseTag + got := mergeBackground("\x07dirty\uFEFF inline\n\n\n\nend", wrapped) + if strings.ContainsRune(got, '\x07') || strings.ContainsRune(got, '\uFEFF') { + t.Errorf("inline portion was not sanitised: %q", got) + } + // Excess blank lines in the inline portion are collapsed to one. + if strings.Contains(got, "\n\n\n") { + t.Errorf("inline newlines were not collapsed: %q", got) + } + // The file portion is preserved intact. + if !strings.Contains(got, wrapped) { + t.Errorf("file portion was altered: %q", got) + } + }) +} + +func TestMergeBackground(t *testing.T) { + wrapped := backgroundOpenTag + "\nfrom file\n" + backgroundCloseTag + + t.Run("both present are combined", func(t *testing.T) { + got := mergeBackground("inline context", wrapped) + want := "inline context\n\n" + wrapped + if got != want { + t.Errorf("mergeBackground = %q, want %q", got, want) + } + // Both inputs must survive in the result. + if !strings.Contains(got, "inline context") || !strings.Contains(got, "from file") { + t.Errorf("merged background dropped one of the inputs: %q", got) + } + }) + + t.Run("inline only", func(t *testing.T) { + if got := mergeBackground("inline only", ""); got != "inline only" { + t.Errorf("mergeBackground = %q, want %q", got, "inline only") + } + }) + + t.Run("file only", func(t *testing.T) { + if got := mergeBackground("", wrapped); got != wrapped { + t.Errorf("mergeBackground = %q, want %q", got, wrapped) + } + }) +} + +func TestLoadBackgroundFileSoftLimit(t *testing.T) { + // Just above the soft limit but below the hard size limit: must succeed. + content := strings.Repeat("a", backgroundSoftLimit+100) + got, err := loadBackgroundFile(writeTempFile(t, content)) + if err != nil { + t.Fatalf("loadBackgroundFile: %v", err) + } + if !strings.Contains(got, content) { + t.Error("expected content to be preserved past the soft limit") + } +} + +func TestLoadBackgroundFileOversized(t *testing.T) { + // A file larger than maxBackgroundFileBytes must be rejected up front, + // before its content is read into memory. + content := strings.Repeat("a", maxBackgroundFileBytes+1) + _, err := loadBackgroundFile(writeTempFile(t, content)) + if err == nil { + t.Fatal("expected an error for an oversized file, got nil") + } + if !strings.Contains(err.Error(), "maximum") { + t.Errorf("error = %q, want it to mention the byte 'maximum'", err) + } +} + +func TestLoadBackgroundFileDirectory(t *testing.T) { + if _, err := loadBackgroundFile(t.TempDir()); err == nil { + t.Fatal("expected an error when the path is a directory, got nil") + } +} + +func TestLoadBackgroundFileHardLimit(t *testing.T) { + content := strings.Repeat("a", backgroundHardLimit+1) + _, err := loadBackgroundFile(writeTempFile(t, content)) + if err == nil { + t.Fatal("expected an error when exceeding the hard size limit, got nil") + } + if !strings.Contains(err.Error(), "hard limit") { + t.Errorf("error = %q, want it to mention 'hard limit'", err) + } +} + +func TestLoadBackgroundFileHardLimitExcludesWrapper(t *testing.T) { + // The wrapper delimiters must NOT count toward the limit: cleaned content of + // exactly the hard limit is accepted even though the wrapped string is longer. + content := strings.Repeat("a", backgroundHardLimit) + if _, err := loadBackgroundFile(writeTempFile(t, content)); err != nil { + t.Fatalf("cleaned content at the hard limit must be accepted, got: %v", err) + } +} + +func TestLoadBackgroundFileMultiByteRuneCount(t *testing.T) { + // Multi-byte runes must be counted as single characters, not bytes. + // A precomposed accented letter is one rune but two bytes; a string of exactly + // backgroundHardLimit runes (~2x the byte count) must still be accepted. + content := strings.Repeat("\u00E9", backgroundHardLimit) + got, err := loadBackgroundFile(writeTempFile(t, content)) + if err != nil { + t.Fatalf("loadBackgroundFile rejected content within the rune limit: %v", err) + } + if !strings.Contains(got, content) { + t.Error("expected multi-byte content to be preserved") + } +} + +// initRepoWithCommit creates a real git repository with a single commit whose +// message is `message`, and returns the repo directory and the commit hash. +func initRepoWithCommit(t *testing.T, message string) (string, string) { + t.Helper() + repo := t.TempDir() + run := func(args ...string) []byte { + cmd := exec.Command("git", args...) + cmd.Dir = repo + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v failed: %v\n%s", args, err, out) + } + return out + } + run("init", "-q") + run("config", "user.email", "test@example.com") + run("config", "user.name", "Test") + run("config", "commit.gpgsign", "false") + if err := os.WriteFile(filepath.Join(repo, "file.txt"), []byte("hello\n"), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + run("add", ".") + run("commit", "-q", "-m", message) + hash := strings.TrimSpace(string(run("rev-parse", "HEAD"))) + return repo, hash +} + +// TestBackgroundFromCommitThenFile reproduces the resolution order used by +// runReview when --background-file is supplied but --background is not: the +// inline background is first auto-filled from the commit message, then the +// background file is appended. Both must end up in the final background. +func TestBackgroundFromCommitThenFile(t *testing.T) { + const commitMsg = "Implement rate limiting on login" + repo, hash := initRepoWithCommit(t, commitMsg) + + // Mirror runReview: --background empty + --commit set -> use commit message. + background := "" + msg, err := getCommitMessage(repo, hash) + if err != nil { + t.Fatalf("getCommitMessage: %v", err) + } + if msg != commitMsg { + t.Fatalf("commit message = %q, want %q", msg, commitMsg) + } + if background == "" { + background = msg + } + + // Then --background-file is loaded and merged in. + fileBg, err := loadBackgroundFile(writeTempFile(t, "Extra context from a file.")) + if err != nil { + t.Fatalf("loadBackgroundFile: %v", err) + } + background = mergeBackground(background, fileBg) + + // The commit message must come first, followed by the wrapped file content. + if !strings.HasPrefix(background, commitMsg+"\n\n") { + t.Errorf("expected commit message to lead the background, got %q", background) + } + if !strings.Contains(background, "Extra context from a file.") { + t.Errorf("expected file content to be appended, got %q", background) + } + if !strings.Contains(background, backgroundOpenTag) || !strings.Contains(background, backgroundCloseTag) { + t.Errorf("expected file content to keep its delimiters, got %q", background) + } +} diff --git a/cmd/opencodereview/flags.go b/cmd/opencodereview/flags.go index ccda515..56a35e9 100644 --- a/cmd/opencodereview/flags.go +++ b/cmd/opencodereview/flags.go @@ -105,6 +105,7 @@ type reviewOptions struct { outputFormat string audience string // --audience: "human" (default) or "agent" background string // --background: optional requirement context + backgroundFile string // --background-file: path to a Markdown file used as background model string // --model: override resolved LLM model for this review concurrency int perFileTimeout int @@ -131,6 +132,7 @@ func parseReviewFlags(args []string) (reviewOptions, error) { a.IntVar(&opts.perFileTimeout, "timeout", 10, "concurrent task timeout in minutes") a.StringVar(&opts.audience, "audience", "human", "output audience: human (show progress) or agent (summary only)") a.StringVarP(&opts.background, "background", "b", "", "optional requirement/business context for the review") + a.StringVarP(&opts.backgroundFile, "background-file", "B", "", "optional requirement/business context from a Markdown file (combined with --background; inline value appears first when both are set)") a.StringVar(&opts.model, "model", "", "override LLM model for this review (e.g., claude-opus-4-6)") a.IntVar(&opts.maxTools, "max-tools", 0, "max tool call rounds per file (0 = template default; min 10)") a.IntVar(&opts.maxGitProcs, "max-git-procs", 16, "max concurrent git subprocesses") @@ -214,22 +216,28 @@ Examples: ocr review --preview ocr review -c abc123 -p + # Provide requirement/business context inline, from a Markdown file, or both + ocr review --background "Adding rate limiting to the login API" + ocr review --background-file ./docs/requirements.md + ocr review --background "Focus on auth" --background-file ./docs/requirements.md + Flags: - --audience string output audience: human (show progress) or agent (summary only) (default "human") - -b, --background string optional requirement/business context for the review - -c, --commit string single commit hash or tag to review (vs its parent) - -f, --format string output format: text or json (default "text") - --concurrency int max concurrent file reviews (default 8) - --max-git-procs int max concurrent git subprocesses (default 16) - --from string source ref to start diff from (e.g., 'main') - --max-tools int max tool call rounds per file (0 = template default; min 10) - --model string override LLM model for this review (e.g., claude-opus-4-6) - -p, --preview preview which files will be reviewed without running the LLM - --repo string root directory of the git repository (default: current dir) - --rule string path to JSON file with system review rules - --timeout int concurrent task timeout in minutes (default 10) - --to string target ref to end diff at (e.g., 'feature-branch') - --tools string path to JSON tools config file (default: embedded)`) + --audience string output audience: human (show progress) or agent (summary only) (default "human") + -b, --background string optional requirement/business context for the review + -B, --background-file string path to a Markdown file used as review background (combined with --background; inline value appears first when both are set) + -c, --commit string single commit hash or tag to review (vs its parent) + -f, --format string output format: text or json (default "text") + --concurrency int max concurrent file reviews (default 8) + --max-git-procs int max concurrent git subprocesses (default 16) + --from string source ref to start diff from (e.g., 'main') + --max-tools int max tool call rounds per file (0 = template default; min 10) + --model string override LLM model for this review (e.g., claude-opus-4-6) + -p, --preview preview which files will be reviewed without running the LLM + --repo string root directory of the git repository (default: current dir) + --rule string path to JSON file with system review rules + --timeout int concurrent task timeout in minutes (default 10) + --to string target ref to end diff at (e.g., 'feature-branch') + --tools string path to JSON tools config file (default: embedded)`) } // --- config subcommand --- diff --git a/cmd/opencodereview/flags_test.go b/cmd/opencodereview/flags_test.go index 15701ca..0514e98 100644 --- a/cmd/opencodereview/flags_test.go +++ b/cmd/opencodereview/flags_test.go @@ -5,6 +5,20 @@ import ( "time" ) +func TestParseReviewFlagsBackgroundFile(t *testing.T) { + for _, flag := range []string{"--background-file", "-B"} { + t.Run(flag, func(t *testing.T) { + opts, err := parseReviewFlags([]string{flag, "./docs/req.md"}) + if err != nil { + t.Fatalf("parseReviewFlags: %v", err) + } + if opts.backgroundFile != "./docs/req.md" { + t.Errorf("backgroundFile = %q, want %q", opts.backgroundFile, "./docs/req.md") + } + }) + } +} + func TestParseReviewFlagsModelOverride(t *testing.T) { opts, err := parseReviewFlags([]string{"--model", "claude-opus-4-6"}) if err != nil { diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go index 47a026d..adf553c 100644 --- a/cmd/opencodereview/review_cmd.go +++ b/cmd/opencodereview/review_cmd.go @@ -46,6 +46,17 @@ func runReview(args []string) error { } } + // Only touch the background when --background-file is set, so the existing + // --background behaviour (raw, unsanitised) is preserved for users who do + // not opt into the file-based context. + if opts.backgroundFile != "" { + fileBackground, err := loadBackgroundFile(opts.backgroundFile) + if err != nil { + return err + } + opts.background = mergeBackground(opts.background, fileBackground) + } + if opts.preview { return runPreview(cc, opts) } diff --git a/plugins/open-code-review/commands/review.md b/plugins/open-code-review/commands/review.md index 82a1e99..1e2e19a 100644 --- a/plugins/open-code-review/commands/review.md +++ b/plugins/open-code-review/commands/review.md @@ -17,6 +17,7 @@ ocr review --audience agent [user-args] - If the user provides `--commit` or `--c`: pass through as-is. - If the user provides `--from` and `--to`: pass through as-is. - (Optional) Provide `--background "requirement context"` to review whether the requirements are correctly implemented. +- (Optional) Provide `--background-file ./requirements.md` to load the same context from a Markdown file (sanitised and limited to 8000 characters). Combined with `--background` the inline value is given first. - Capture full stdout. Set a 5-minute timeout. - If the `ocr` command is not found, install it by running `npm i -g @alibaba-group/open-code-review`. @@ -32,4 +33,4 @@ Silently discard low-confidence comments. Display the remaining comments. ### Step 3: Fix -Automatically fix issues and suggestions that are worth adopting. \ No newline at end of file +Automatically fix issues and suggestions that are worth adopting. From ac1eff5bae81978a70dd7162e59ecac22cd2a767 Mon Sep 17 00:00:00 2001 From: kite Date: Wed, 8 Jul 2026 22:41:49 +0800 Subject: [PATCH 05/19] docs: add contributors image to Contributing section (#326) --- README.ja-JP.md | 6 +++++- README.ko-KR.md | 6 +++++- README.md | 6 +++++- README.ru-RU.md | 6 +++++- README.zh-CN.md | 6 +++++- 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/README.ja-JP.md b/README.ja-JP.md index 417a5c5..d6de81a 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -779,7 +779,11 @@ ocr config set telemetry.otlp_endpoint localhost:4317 ## コントリビューション -開発環境のセットアップ、コーディングガイドライン、プルリクエストの提出方法については[CONTRIBUTING.md](CONTRIBUTING.md)を参照してください。 +このプロジェクトは、貢献してくださるすべての方々のおかげで成り立っています。開発環境のセットアップ、コーディングガイドライン、プルリクエストの提出方法については[CONTRIBUTING.md](CONTRIBUTING.md)を参照してください。 + + + + ## Star History diff --git a/README.ko-KR.md b/README.ko-KR.md index 161cf5c..52f0756 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -736,7 +736,11 @@ exported data에 LLM prompt와 response를 포함하려면 `telemetry.content_lo ## Contributing -개발 환경 설정, coding guideline, pull request 제출 방법은 [CONTRIBUTING.ko-KR.md](CONTRIBUTING.ko-KR.md)를 참고하세요. +이 프로젝트는 기여해 주신 모든 분들 덕분에 존재합니다. 개발 환경 설정, coding guideline, pull request 제출 방법은 [CONTRIBUTING.ko-KR.md](CONTRIBUTING.ko-KR.md)를 참고하세요. + + + + ## Star History diff --git a/README.md b/README.md index 9e62e01..87722e3 100644 --- a/README.md +++ b/README.md @@ -785,7 +785,11 @@ Set `telemetry.content_logging` to include LLM prompts and responses in exported ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding guidelines, and how to submit pull requests. +This project exists thanks to all the people who contribute. See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, coding guidelines, and how to submit pull requests. + + + + ## Star History diff --git a/README.ru-RU.md b/README.ru-RU.md index ef2a749..6903259 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -781,7 +781,11 @@ ocr config set telemetry.otlp_endpoint localhost:4317 ## Участие в разработке -В [CONTRIBUTING.ru-RU.md](CONTRIBUTING.ru-RU.md) описаны настройка окружения разработки, рекомендации по коду и порядок отправки pull request'ов. +Этот проект существует благодаря всем, кто вносит свой вклад. В [CONTRIBUTING.ru-RU.md](CONTRIBUTING.ru-RU.md) описаны настройка окружения разработки, рекомендации по коду и порядок отправки pull request'ов. + + + + ## История звёзд diff --git a/README.zh-CN.md b/README.zh-CN.md index 5d8d0db..bc6584b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -769,7 +769,11 @@ ocr config set telemetry.otlp_endpoint localhost:4317 ## 贡献 -参见 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md) 了解开发环境搭建、编码规范以及如何提交 Pull Request。 +感谢所有为本项目做出贡献的人。参见 [CONTRIBUTING.zh-CN.md](CONTRIBUTING.zh-CN.md) 了解开发环境搭建、编码规范以及如何提交 Pull Request。 + + + + ## Star History From e2d75b732adb026faee65a754d4e7234c800aade Mon Sep 17 00:00:00 2001 From: kite Date: Wed, 8 Jul 2026 22:46:18 +0800 Subject: [PATCH 06/19] test: fix golangci-lint errcheck/staticcheck issues in test code (#323) --- cmd/opencodereview/emit_run_result_test.go | 4 +- cmd/opencodereview/git_test.go | 27 +++- cmd/opencodereview/output_helpers_test.go | 48 +++--- cmd/opencodereview/output_test.go | 4 +- cmd/opencodereview/provider_cmd_test.go | 2 +- cmd/opencodereview/provider_tui_test.go | 2 +- cmd/opencodereview/shared_test.go | 15 +- internal/agent/util_test.go | 3 + .../config/toolsconfig/toolsconfig_test.go | 8 +- internal/llm/message_test.go | 8 +- internal/llm/resolver_test.go | 144 +++++++++++++----- internal/mcp/client_test.go | 4 +- internal/mcp/stdio_test.go | 2 +- internal/model/model_test.go | 8 +- internal/pathutil/path_test.go | 2 +- internal/session/persist_test.go | 7 +- internal/telemetry/config_test.go | 28 +++- internal/telemetry/events_test.go | 2 +- internal/telemetry/exporter_test.go | 8 +- internal/telemetry/provider_test.go | 6 +- internal/viewer/handler_test.go | 2 +- internal/viewer/server_extra_test.go | 2 +- internal/viewer/store_load_test.go | 4 +- 23 files changed, 240 insertions(+), 100 deletions(-) diff --git a/cmd/opencodereview/emit_run_result_test.go b/cmd/opencodereview/emit_run_result_test.go index 3770fee..6ad71bb 100644 --- a/cmd/opencodereview/emit_run_result_test.go +++ b/cmd/opencodereview/emit_run_result_test.go @@ -181,7 +181,7 @@ func TestEmitRunResult_NilQuietHandle(t *testing.T) { func TestEmitRunResult_JSONTraceIDFromContext(t *testing.T) { tp := sdktrace.NewTracerProvider() - defer tp.Shutdown(context.Background()) + defer func() { _ = tp.Shutdown(context.Background()) }() otel.SetTracerProvider(tp) ctx, span := tp.Tracer("test").Start(context.Background(), "test-root") @@ -211,7 +211,7 @@ func TestEmitRunResult_JSONTraceIDFromContext(t *testing.T) { func TestEmitRunResult_JSONNoFilesTraceID(t *testing.T) { tp := sdktrace.NewTracerProvider() - defer tp.Shutdown(context.Background()) + defer func() { _ = tp.Shutdown(context.Background()) }() otel.SetTracerProvider(tp) ctx, span := tp.Tracer("test").Start(context.Background(), "test-root") diff --git a/cmd/opencodereview/git_test.go b/cmd/opencodereview/git_test.go index 0630a4c..4528b29 100644 --- a/cmd/opencodereview/git_test.go +++ b/cmd/opencodereview/git_test.go @@ -23,11 +23,17 @@ func initTestGitRepo(t *testing.T) string { } } f := filepath.Join(dir, "README.md") - os.WriteFile(f, []byte("hello"), 0o644) + if err := os.WriteFile(f, []byte("hello"), 0o644); err != nil { + t.Fatalf("write README: %v", err) + } cmd := exec.Command("git", "-C", dir, "add", ".") - cmd.CombinedOutput() + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git add: %v: %s", err, out) + } cmd = exec.Command("git", "-C", dir, "commit", "-m", "initial commit") - cmd.CombinedOutput() + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git commit: %v: %s", err, out) + } return dir } @@ -93,9 +99,18 @@ func TestResolveRepoDir_NotGitRepo(t *testing.T) { func TestResolveRepoDir_EmptyUsesWd(t *testing.T) { dir := initTestGitRepo(t) - origDir, _ := os.Getwd() - defer os.Chdir(origDir) - os.Chdir(dir) + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Errorf("restore chdir: %v", err) + } + }() + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } resolved, err := resolveRepoDir("") if err != nil { diff --git a/cmd/opencodereview/output_helpers_test.go b/cmd/opencodereview/output_helpers_test.go index f3d9127..31001e7 100644 --- a/cmd/opencodereview/output_helpers_test.go +++ b/cmd/opencodereview/output_helpers_test.go @@ -169,7 +169,7 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) { warnings := []agent.AgentWarning{{Type: "subtask_error", File: "x.go", Message: "fail"}} err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil, "abc123trace") - w.Close() + _ = w.Close() os.Stdout = old if err != nil { @@ -177,10 +177,12 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) { } var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) var out jsonOutput - json.Unmarshal(buf.Bytes(), &out) + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } if out.Status != "completed_with_errors" { t.Errorf("status = %q, want completed_with_errors", out.Status) } @@ -224,7 +226,7 @@ func TestOutputJSON(t *testing.T) { } err := outputJSON(comments) - w.Close() + _ = w.Close() os.Stdout = old if err != nil { @@ -232,7 +234,7 @@ func TestOutputJSON(t *testing.T) { } var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) var out jsonOutput if err := json.Unmarshal(buf.Bytes(), &out); err != nil { @@ -253,7 +255,7 @@ func TestOutputJSON_NoComments(t *testing.T) { err := outputJSON(nil) - w.Close() + _ = w.Close() os.Stdout = old if err != nil { @@ -261,10 +263,12 @@ func TestOutputJSON_NoComments(t *testing.T) { } var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) var out jsonOutput - json.Unmarshal(buf.Bytes(), &out) + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } if out.Message == "" { t.Error("expected non-empty message when no comments") } @@ -278,7 +282,7 @@ func TestOutputJSONWithWarnings(t *testing.T) { comments := []model.LlmComment{{Path: "b.go", Content: "test"}} warnings := []agent.AgentWarning{{Type: "subtask_error", File: "c.go", Message: "failed"}} err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}, "trace-xyz-789") - w.Close() + _ = w.Close() os.Stdout = old if err != nil { @@ -286,10 +290,12 @@ func TestOutputJSONWithWarnings(t *testing.T) { } var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) var out jsonOutput - json.Unmarshal(buf.Bytes(), &out) + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } if out.Status != "completed_with_errors" { t.Errorf("status = %q, want completed_with_errors", out.Status) } @@ -314,7 +320,7 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) { warnings := []agent.AgentWarning{{Type: "warning", Message: "something"}} err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil, "") - w.Close() + _ = w.Close() os.Stdout = old if err != nil { @@ -322,10 +328,12 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) { } var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) var out jsonOutput - json.Unmarshal(buf.Bytes(), &out) + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } if out.Status != "completed_with_warnings" { t.Errorf("status = %q, want completed_with_warnings", out.Status) } @@ -341,7 +349,7 @@ func TestOutputJSONNoFiles(t *testing.T) { err := outputJSONNoFiles("test-trace-id-456") - w.Close() + _ = w.Close() os.Stdout = old if err != nil { @@ -349,10 +357,12 @@ func TestOutputJSONNoFiles(t *testing.T) { } var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) var out jsonOutput - json.Unmarshal(buf.Bytes(), &out) + if err := json.Unmarshal(buf.Bytes(), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } if out.Status != "skipped" { t.Errorf("status = %q, want skipped", out.Status) } @@ -370,10 +380,10 @@ func captureStdout(t *testing.T, fn func()) string { } os.Stdout = w fn() - w.Close() + _ = w.Close() os.Stdout = old var buf bytes.Buffer - buf.ReadFrom(r) + _, _ = buf.ReadFrom(r) return buf.String() } diff --git a/cmd/opencodereview/output_test.go b/cmd/opencodereview/output_test.go index c5b8487..038cd84 100644 --- a/cmd/opencodereview/output_test.go +++ b/cmd/opencodereview/output_test.go @@ -93,8 +93,8 @@ func TestSanitizeTerminal(t *testing.T) { {"only control chars", "\x1b\x07\x00\x7f", ""}, {"unicode preserved", "代码审查 レビュー 🔍", "代码审查 レビュー 🔍"}, {"mixed safe and unsafe", "path\x1b[0m/file.go", "path[0m/file.go"}, - {"strips C1 CSI (U+009B)", "before›after", "beforeafter"}, - {"strips C1 OSC (U+009D)", "beforeafter", "beforeafter"}, + {"strips C1 CSI (U+009B)", "before\u009bafter", "beforeafter"}, + {"strips C1 OSC (U+009D)", "before\u009dafter", "beforeafter"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/opencodereview/provider_cmd_test.go b/cmd/opencodereview/provider_cmd_test.go index e8d84db..d1f03bb 100644 --- a/cmd/opencodereview/provider_cmd_test.go +++ b/cmd/opencodereview/provider_cmd_test.go @@ -364,7 +364,7 @@ func TestPrintWizardCancelled(t *testing.T) { } os.Stdout = w printWizardCancelled(tc.savedInSession, tc.scope) - w.Close() + _ = w.Close() os.Stdout = old got, err := io.ReadAll(r) if err != nil { diff --git a/cmd/opencodereview/provider_tui_test.go b/cmd/opencodereview/provider_tui_test.go index 4c9de47..624bc50 100644 --- a/cmd/opencodereview/provider_tui_test.go +++ b/cmd/opencodereview/provider_tui_test.go @@ -2169,7 +2169,7 @@ func TestProviderTUI_ApiKeyTypingShowsOneStarPerChar(t *testing.T) { m.loadExistingAPIKey() m.apiKeyInput.Focus() - for _, ch := range []rune("abc") { + for _, ch := range "abc" { result, _ := m.Update(charKey(ch)) m = result.(providerTUIModel) } diff --git a/cmd/opencodereview/shared_test.go b/cmd/opencodereview/shared_test.go index 21e7553..417a65f 100644 --- a/cmd/opencodereview/shared_test.go +++ b/cmd/opencodereview/shared_test.go @@ -79,9 +79,18 @@ func TestQuietHandle_IdempotentRestore(t *testing.T) { func TestResolveWorkingDir_CurrentDir(t *testing.T) { dir := t.TempDir() - origDir, _ := os.Getwd() - defer os.Chdir(origDir) - os.Chdir(dir) + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + defer func() { + if err := os.Chdir(origDir); err != nil { + t.Errorf("restore chdir: %v", err) + } + }() + if err := os.Chdir(dir); err != nil { + t.Fatalf("chdir: %v", err) + } absPath, isGit, err := resolveWorkingDir("", false) if err != nil { diff --git a/internal/agent/util_test.go b/internal/agent/util_test.go index af3b8ff..caf030c 100644 --- a/internal/agent/util_test.go +++ b/internal/agent/util_test.go @@ -157,6 +157,9 @@ func TestCopyMessages(t *testing.T) { } cp = append(cp, llm.NewTextMessage("user", "c")) + if len(cp) != len(orig)+1 { + t.Errorf("appended copy length = %d, want %d", len(cp), len(orig)+1) + } if len(orig) != 2 { t.Error("copyMessages: appending to copy modified original slice") } diff --git a/internal/config/toolsconfig/toolsconfig_test.go b/internal/config/toolsconfig/toolsconfig_test.go index bd25bb1..ce6bf30 100644 --- a/internal/config/toolsconfig/toolsconfig_test.go +++ b/internal/config/toolsconfig/toolsconfig_test.go @@ -31,7 +31,9 @@ func TestLoad_CustomFile(t *testing.T) { data := `[ {"name": "test_tool", "plan_task": true, "main_task": false, "definition": {"name": "test_tool"}} ]` - os.WriteFile(path, []byte(data), 0644) + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatalf("write tools.json: %v", err) + } tools, err := Load(path) if err != nil { @@ -61,7 +63,9 @@ func TestLoad_FileNotFound(t *testing.T) { func TestLoad_InvalidJSON(t *testing.T) { tmp := t.TempDir() path := filepath.Join(tmp, "tools.json") - os.WriteFile(path, []byte("not json"), 0644) + if err := os.WriteFile(path, []byte("not json"), 0644); err != nil { + t.Fatalf("write tools.json: %v", err) + } _, err := Load(path) if err == nil { diff --git a/internal/llm/message_test.go b/internal/llm/message_test.go index 53ee185..4a03499 100644 --- a/internal/llm/message_test.go +++ b/internal/llm/message_test.go @@ -243,7 +243,9 @@ func TestParseShellRC_ModelOverride(t *testing.T) { export ANTHROPIC_AUTH_TOKEN="token" export ANTHROPIC_MODEL=claude-3-opus ` - os.WriteFile(rcPath, []byte(content), 0644) + if err := os.WriteFile(rcPath, []byte(content), 0644); err != nil { + t.Fatalf("write rc: %v", err) + } ep, ok, err := parseShellRC(rcPath, "override-model") if err != nil { @@ -265,7 +267,9 @@ func TestParseShellRC_Incomplete(t *testing.T) { export ANTHROPIC_AUTH_TOKEN="token" # missing ANTHROPIC_MODEL ` - os.WriteFile(rcPath, []byte(content), 0644) + if err := os.WriteFile(rcPath, []byte(content), 0644); err != nil { + t.Fatalf("write rc: %v", err) + } _, ok, err := parseShellRC(rcPath, "") if err != nil { diff --git a/internal/llm/resolver_test.go b/internal/llm/resolver_test.go index 5b2cace..6c933aa 100644 --- a/internal/llm/resolver_test.go +++ b/internal/llm/resolver_test.go @@ -104,7 +104,9 @@ func TestResolveEndpoint_ConfigFileStripsModelSuffix(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -137,7 +139,9 @@ func TestResolveEndpoint_ConfigAnthropicDefaultsToAuthorization(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -168,7 +172,9 @@ func TestResolveEndpoint_ConfigAuthHeaderOverrideToXAPIKey(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -199,7 +205,9 @@ func TestResolveEndpoint_ConfigOpenAIIgnoresAuthHeader(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -271,7 +279,9 @@ func TestResolveEndpoint_ProviderAnthropic(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -305,7 +315,9 @@ func TestResolveEndpoint_ProviderOpenAI(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -334,7 +346,9 @@ func TestResolveEndpoint_ProviderModelOverride(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -356,7 +370,9 @@ func TestResolveEndpoint_ProviderEntryModelOverridesDefault(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -383,7 +399,9 @@ func TestResolveEndpointWithModelOverride_CustomProviderWithoutConfiguredModel(t } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpointWithModelOverride(cfgPath, "llama-3-8b") if err != nil { @@ -409,7 +427,9 @@ func TestResolveEndpoint_ProviderAPIKeyEnvFallback(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -431,7 +451,9 @@ func TestResolveEndpoint_ProviderMissingAPIKey(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -448,7 +470,9 @@ func TestResolveEndpoint_ProviderNotConfigured(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -472,7 +496,9 @@ func TestResolveEndpoint_CustomProvider(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -508,7 +534,9 @@ func TestResolveEndpoint_CustomProviderInvalidProtocol(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -531,7 +559,9 @@ func TestResolveEndpoint_CustomProviderMissingFields(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -555,7 +585,9 @@ func TestResolveEndpoint_CustomProviderModelFromTopLevel(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -578,7 +610,9 @@ func TestResolveEndpoint_LegacyLlmStillWorks(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -603,7 +637,9 @@ func TestResolveEndpoint_ProviderAnthropicURLHasMessagesSuffix(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -629,7 +665,9 @@ func TestResolveEndpoint_ProviderExtraBody(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -654,7 +692,9 @@ func TestResolveEndpointWithModelOverride_ValidModelInPresetList(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opus-4-8") if err != nil { @@ -676,7 +716,9 @@ func TestResolveEndpointWithModelOverride_InvalidModelInPresetList(t *testing.T) } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opsu-4-6") if err == nil { @@ -706,7 +748,9 @@ func TestResolveEndpointWithModelOverride_ValidModelInCustomProviderList(t *test } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpointWithModelOverride(cfgPath, "llama-3-8b") if err != nil { @@ -733,7 +777,9 @@ func TestResolveEndpointWithModelOverride_InvalidModelInCustomProviderList(t *te } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpointWithModelOverride(cfgPath, "gpt-4") if err == nil { @@ -760,7 +806,9 @@ func TestResolveEndpointWithModelOverride_NoValidationWhenNoModelList(t *testing } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpointWithModelOverride(cfgPath, "any-model-name") if err != nil { @@ -785,7 +833,9 @@ func TestResolveEndpointWithModelOverride_MergesPresetAndEntryModels(t *testing. } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } // Should accept both preset models and entry models. ep1, err := ResolveEndpointWithModelOverride(cfgPath, "claude-opus-4-8") @@ -823,7 +873,9 @@ func TestResolveEndpointWithModelOverride_LegacyConfigNoValidation(t *testing.T) } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } // Legacy config has no model list, so any override should be accepted. ep, err := ResolveEndpointWithModelOverride(cfgPath, "any-override-model") @@ -1087,7 +1139,9 @@ func TestResolveEndpoint_ProviderExtraHeaders(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -1114,7 +1168,9 @@ func TestResolveEndpoint_LegacyLlmExtraHeaders(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -1218,7 +1274,9 @@ func TestResolveEndpoint_ConfigTimeoutSec(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -1242,7 +1300,9 @@ func TestResolveEndpoint_NegativeConfigTimeoutSec(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -1308,7 +1368,9 @@ func TestResolveEndpoint_ProviderConfigTimeoutSec(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -1334,7 +1396,9 @@ func TestResolveEndpoint_ProviderConfigNegativeTimeoutSec(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -1359,7 +1423,9 @@ func TestResolveEndpoint_EnvTimeoutOverridesConfigTimeout(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -1387,7 +1453,9 @@ func TestResolveEndpoint_EnvTimeoutOverridesProviderTimeout(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } ep, err := ResolveEndpoint(cfgPath) if err != nil { @@ -1412,7 +1480,9 @@ func TestResolveEndpoint_InvalidEnvTimeoutWithConfig(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { @@ -1436,7 +1506,9 @@ func TestResolveEndpoint_NegativeEnvTimeoutWithConfig(t *testing.T) { } data, _ := json.Marshal(cfg) cfgPath := filepath.Join(t.TempDir(), "config.json") - os.WriteFile(cfgPath, data, 0644) + if err := os.WriteFile(cfgPath, data, 0644); err != nil { + t.Fatalf("write config: %v", err) + } _, err := ResolveEndpoint(cfgPath) if err == nil { diff --git a/internal/mcp/client_test.go b/internal/mcp/client_test.go index 07aee15..c5f04da 100644 --- a/internal/mcp/client_test.go +++ b/internal/mcp/client_test.go @@ -113,7 +113,7 @@ func TestProvider_Execute_Integration(t *testing.T) { } c := &Client{name: "test-srv", session: session, tools: toolsResult.Tools} - defer c.Close() + defer func() { _ = c.Close() }() p := &Provider{toolName: "greet", client: c} result, err := p.Execute(ctx, map[string]any{"name": "world"}) @@ -193,7 +193,7 @@ func TestNewClient_Integration(t *testing.T) { session: session, tools: toolsResult.Tools, } - defer c.Close() + defer func() { _ = c.Close() }() if c.Name() != "test-srv" { t.Errorf("Name() = %q", c.Name()) diff --git a/internal/mcp/stdio_test.go b/internal/mcp/stdio_test.go index ea9f3d0..b851cad 100644 --- a/internal/mcp/stdio_test.go +++ b/internal/mcp/stdio_test.go @@ -68,7 +68,7 @@ func TestNewClient_Stdio(t *testing.T) { if err != nil { t.Fatalf("NewClient: %v", err) } - defer c.Close() + defer func() { _ = c.Close() }() if c.Name() != "test-srv" { t.Errorf("Name() = %q, want %q", c.Name(), "test-srv") diff --git a/internal/model/model_test.go b/internal/model/model_test.go index 8da62b8..5ac6273 100644 --- a/internal/model/model_test.go +++ b/internal/model/model_test.go @@ -65,14 +65,18 @@ func TestPreviewEntry_ExcludeReasonOmitEmpty(t *testing.T) { } data, _ := json.Marshal(e) var m map[string]any - json.Unmarshal(data, &m) + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } if _, ok := m["exclude_reason"]; ok { t.Error("expected exclude_reason to be omitted when empty") } e.ExcludeReason = ExcludeUserRule data, _ = json.Marshal(e) - json.Unmarshal(data, &m) + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal: %v", err) + } if m["exclude_reason"] != "user_exclude" { t.Errorf("expected exclude_reason=user_exclude, got %v", m["exclude_reason"]) } diff --git a/internal/pathutil/path_test.go b/internal/pathutil/path_test.go index 3664e75..ecc4985 100644 --- a/internal/pathutil/path_test.go +++ b/internal/pathutil/path_test.go @@ -72,7 +72,7 @@ func TestCanonicalPath_RelativePath(t *testing.T) { if err != nil { t.Fatalf("Getwd: %v", err) } - t.Cleanup(func() { os.Chdir(oldWd) }) + t.Cleanup(func() { _ = os.Chdir(oldWd) }) if err := os.Chdir(dir); err != nil { t.Fatalf("Chdir: %v", err) } diff --git a/internal/session/persist_test.go b/internal/session/persist_test.go index ddf99c2..6231341 100644 --- a/internal/session/persist_test.go +++ b/internal/session/persist_test.go @@ -109,7 +109,7 @@ func readJSONLRecords(t *testing.T, path string) []map[string]any { if err != nil { t.Fatalf("open jsonl: %v", err) } - defer f.Close() + defer func() { _ = f.Close() }() var records []map[string]any scanner := bufio.NewScanner(f) @@ -165,8 +165,11 @@ func TestSetErrorWritesJSONL(t *testing.T) { if sh.persist != nil { sh.persist.mu.Lock() - sh.persist.writer.Flush() + err := sh.persist.writer.Flush() sh.persist.mu.Unlock() + if err != nil { + t.Fatalf("flush: %v", err) + } } path := sessionJSONLPath(t, repoDir, sh.SessionID) diff --git a/internal/telemetry/config_test.go b/internal/telemetry/config_test.go index 1e4011a..9a1c215 100644 --- a/internal/telemetry/config_test.go +++ b/internal/telemetry/config_test.go @@ -103,7 +103,7 @@ func TestResolveEnv(t *testing.T) { } for _, k := range envKeys { t.Setenv(k, "") - os.Unsetenv(k) + _ = os.Unsetenv(k) } for k, v := range tc.envs { t.Setenv(k, v) @@ -128,7 +128,9 @@ func TestLoadFromJSON(t *testing.T) { t.Run("malformed json returns nil error", func(t *testing.T) { tmp := t.TempDir() path := filepath.Join(tmp, "config.json") - os.WriteFile(path, []byte("{invalid json"), 0644) + if err := os.WriteFile(path, []byte("{invalid json"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } cfg := DefaultConfig() err := LoadFromJSON(&cfg, path) @@ -140,7 +142,9 @@ func TestLoadFromJSON(t *testing.T) { t.Run("no telemetry section", func(t *testing.T) { tmp := t.TempDir() path := filepath.Join(tmp, "config.json") - os.WriteFile(path, []byte(`{"other": "value"}`), 0644) + if err := os.WriteFile(path, []byte(`{"other": "value"}`), 0644); err != nil { + t.Fatalf("write file: %v", err) + } cfg := DefaultConfig() err := LoadFromJSON(&cfg, path) @@ -163,7 +167,9 @@ func TestLoadFromJSON(t *testing.T) { "content_logging": true } }` - os.WriteFile(path, []byte(data), 0644) + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatalf("write file: %v", err) + } cfg := DefaultConfig() err := LoadFromJSON(&cfg, path) @@ -188,7 +194,9 @@ func TestLoadFromJSON(t *testing.T) { tmp := t.TempDir() path := filepath.Join(tmp, "config.json") data := `{"telemetry": {"otlp_endpoint": "localhost:4317"}}` - os.WriteFile(path, []byte(data), 0644) + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatalf("write file: %v", err) + } cfg := DefaultConfig() err := LoadFromJSON(&cfg, path) @@ -204,7 +212,9 @@ func TestLoadFromJSON(t *testing.T) { tmp := t.TempDir() path := filepath.Join(tmp, "config.json") data := `{"telemetry": {"exporter": "otlp"}}` - os.WriteFile(path, []byte(data), 0644) + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatalf("write file: %v", err) + } cfg := DefaultConfig() cfg.Exporter = "custom" @@ -227,7 +237,7 @@ func TestResolveConfig(t *testing.T) { } for _, k := range envKeys { t.Setenv(k, "") - os.Unsetenv(k) + _ = os.Unsetenv(k) } cfg := ResolveConfig("") @@ -240,7 +250,9 @@ func TestResolveConfig(t *testing.T) { tmp := t.TempDir() path := filepath.Join(tmp, "config.json") data := `{"telemetry": {"enabled": false}}` - os.WriteFile(path, []byte(data), 0644) + if err := os.WriteFile(path, []byte(data), 0644); err != nil { + t.Fatalf("write file: %v", err) + } t.Setenv("OCR_ENABLE_TELEMETRY", "1") diff --git a/internal/telemetry/events_test.go b/internal/telemetry/events_test.go index 1bcd170..d9d1efa 100644 --- a/internal/telemetry/events_test.go +++ b/internal/telemetry/events_test.go @@ -54,7 +54,7 @@ func captureStderr(t *testing.T, fn func()) string { defer func() { os.Stderr = oldStderr }() fn() - w.Close() + _ = w.Close() var buf bytes.Buffer _, _ = io.Copy(&buf, r) diff --git a/internal/telemetry/exporter_test.go b/internal/telemetry/exporter_test.go index 1a3c9be..97ebaa2 100644 --- a/internal/telemetry/exporter_test.go +++ b/internal/telemetry/exporter_test.go @@ -161,7 +161,9 @@ func TestInitOTLPProviders_ProtocolRouting(t *testing.T) { OTLPProtocol: tc.protocol, } initOTLPProviders(context.Background(), resource.Default(), cfg) - w.Close() + if err := w.Close(); err != nil { + t.Fatalf("close stderr pipe: %v", err) + } os.Stderr = oldStderr defer func() { @@ -184,7 +186,9 @@ func TestInitOTLPProviders_ProtocolRouting(t *testing.T) { } var buf bytes.Buffer - io.Copy(&buf, r) + if _, err := io.Copy(&buf, r); err != nil { + t.Fatalf("read stderr pipe: %v", err) + } stderrOut := buf.String() if tc.wantWarning { if !strings.Contains(stderrOut, "falling back to gRPC") { diff --git a/internal/telemetry/provider_test.go b/internal/telemetry/provider_test.go index 3895636..bdbc380 100644 --- a/internal/telemetry/provider_test.go +++ b/internal/telemetry/provider_test.go @@ -63,7 +63,7 @@ func TestContentLogging_EnabledWithEnv(t *testing.T) { func TestContentLogging_EnabledWithoutEnv(t *testing.T) { setupEnabledTelemetry(t) t.Setenv("OCR_CONTENT_LOGGING", "") - os.Unsetenv("OCR_CONTENT_LOGGING") + _ = os.Unsetenv("OCR_CONTENT_LOGGING") if ContentLogging() { t.Error("expected ContentLogging()=false when enabled but env var not set") } @@ -112,7 +112,7 @@ func TestInit_DisabledByDefault(t *testing.T) { } for _, k := range envKeys { t.Setenv(k, "") - os.Unsetenv(k) + _ = os.Unsetenv(k) } defer func() { @@ -140,7 +140,7 @@ func TestInit_EnabledConsole(t *testing.T) { } for _, k := range envKeys { t.Setenv(k, "") - os.Unsetenv(k) + _ = os.Unsetenv(k) } defer func() { diff --git a/internal/viewer/handler_test.go b/internal/viewer/handler_test.go index c7e9179..c24349c 100644 --- a/internal/viewer/handler_test.go +++ b/internal/viewer/handler_test.go @@ -82,7 +82,7 @@ func TestHandleRepos_PermissionDenied(t *testing.T) { if err := os.Chmod(root, 0000); err != nil { t.Fatal(err) } - t.Cleanup(func() { os.Chmod(root, 0755) }) + t.Cleanup(func() { _ = os.Chmod(root, 0755) }) req := httptest.NewRequest("GET", "/", nil) rr := httptest.NewRecorder() diff --git a/internal/viewer/server_extra_test.go b/internal/viewer/server_extra_test.go index c4e8b63..04be65d 100644 --- a/internal/viewer/server_extra_test.go +++ b/internal/viewer/server_extra_test.go @@ -167,7 +167,7 @@ func TestStaticFS(t *testing.T) { if err != nil { t.Fatalf("failed to open style.css from staticFS: %v", err) } - f.Close() + _ = f.Close() } func TestResolveAllowedHostsFromEnv(t *testing.T) { diff --git a/internal/viewer/store_load_test.go b/internal/viewer/store_load_test.go index 3ed4a92..a4da1db 100644 --- a/internal/viewer/store_load_test.go +++ b/internal/viewer/store_load_test.go @@ -405,7 +405,7 @@ func TestDiscoverRepos_SkipsUnreadableSubdir(t *testing.T) { if err := os.Chmod(badRepo, 0000); err != nil { t.Fatal(err) } - t.Cleanup(func() { os.Chmod(badRepo, 0755) }) + t.Cleanup(func() { _ = os.Chmod(badRepo, 0755) }) repos, err := DiscoverRepos(root) if err != nil { @@ -439,7 +439,7 @@ func TestListSessions_SkipsUnreadableFiles(t *testing.T) { if err := os.Chmod(badPath, 0000); err != nil { t.Fatal(err) } - t.Cleanup(func() { os.Chmod(badPath, 0644) }) + t.Cleanup(func() { _ = os.Chmod(badPath, 0644) }) sessions, err := ListSessions(root, "repo") if err != nil { From e6e5da09306e6a3ae48730a84c7f7a7870d2b4a3 Mon Sep 17 00:00:00 2001 From: kite Date: Thu, 9 Jul 2026 11:00:45 +0800 Subject: [PATCH 07/19] ci: bump Go image to 1.26.5 to fix GO-2026-5856 govulncheck failure (#330) govulncheck flags GO-2026-5856 (Encrypted Client Hello privacy leak in crypto/tls), present in the Go standard library through go1.26.4 and fixed in go1.26.5. The CI and release workflows pin the golang:1.26.4 container image, so govulncheck fails with exit code 3 on every run. Bump both workflow images to golang:1.26.5. --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31557d5..ac1420e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: runs-on: self-hosted timeout-minutes: 15 container: - image: golang:1.26.4 + image: golang:1.26.5 steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 640c7a8..de4de5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,7 +11,7 @@ jobs: build: runs-on: self-hosted container: - image: golang:1.26.4 + image: golang:1.26.5 strategy: matrix: include: From 3d2886ea78a8f5b4dcfc2099d1c4582c7bbfa2f5 Mon Sep 17 00:00:00 2001 From: kite Date: Thu, 9 Jul 2026 11:14:44 +0800 Subject: [PATCH 08/19] docs: remove broken Star History section from all READMEs (#329) GitHub restricted the stargazers API (July 2026) to a repository's own admins and collaborators, so the embedded star-history.com SVG can no longer render for anonymous README viewers. Remove the Star History section from README.md and all localized versions. --- README.ja-JP.md | 4 ---- README.ko-KR.md | 4 ---- README.md | 4 ---- README.ru-RU.md | 4 ---- README.zh-CN.md | 4 ---- 5 files changed, 20 deletions(-) diff --git a/README.ja-JP.md b/README.ja-JP.md index d6de81a..ee89924 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -785,10 +785,6 @@ ocr config set telemetry.otlp_endpoint localhost:4317 -## Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/open-code-review&type=Date)](https://star-history.com/#alibaba/open-code-review&Date) - ## ライセンス [Apache-2.0](LICENSE) — Copyright 2026 Alibaba diff --git a/README.ko-KR.md b/README.ko-KR.md index 52f0756..e0e4086 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -742,10 +742,6 @@ exported data에 LLM prompt와 response를 포함하려면 `telemetry.content_lo -## Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/open-code-review&type=Date)](https://star-history.com/#alibaba/open-code-review&Date) - ## License [Apache-2.0](LICENSE) Copyright 2026 Alibaba diff --git a/README.md b/README.md index 87722e3..16cb661 100644 --- a/README.md +++ b/README.md @@ -791,10 +791,6 @@ This project exists thanks to all the people who contribute. See [CONTRIBUTING.m -## Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/open-code-review&type=Date)](https://star-history.com/#alibaba/open-code-review&Date) - ## License [Apache-2.0](LICENSE) — Copyright 2026 Alibaba diff --git a/README.ru-RU.md b/README.ru-RU.md index 6903259..4c8f391 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -787,10 +787,6 @@ ocr config set telemetry.otlp_endpoint localhost:4317 -## История звёзд - -[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/open-code-review&type=Date)](https://star-history.com/#alibaba/open-code-review&Date) - ## Лицензия [Apache-2.0](LICENSE) — Copyright 2026 Alibaba diff --git a/README.zh-CN.md b/README.zh-CN.md index bc6584b..a0ee541 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -775,10 +775,6 @@ ocr config set telemetry.otlp_endpoint localhost:4317 -## Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=alibaba/open-code-review&type=Date)](https://star-history.com/#alibaba/open-code-review&Date) - ## 许可证 [Apache-2.0](LICENSE) — Copyright 2026 Alibaba From bd05c2b07110ffaafee5adbaafddb19fe4d947ce Mon Sep 17 00:00:00 2001 From: zhuangzhewei09 Date: Thu, 9 Jul 2026 11:21:06 +0800 Subject: [PATCH 09/19] docs(README): add upgrade instructions (#321) --- README.ja-JP.md | 12 ++++++++++++ README.ko-KR.md | 12 ++++++++++++ README.md | 12 ++++++++++++ README.ru-RU.md | 12 ++++++++++++ README.zh-CN.md | 12 ++++++++++++ 5 files changed, 60 insertions(+) diff --git a/README.ja-JP.md b/README.ja-JP.md index ee89924..3a0989c 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -107,6 +107,18 @@ npm install -g @alibaba-group/open-code-review インストール後、`ocr`コマンドがグローバルに利用可能になります。 +**更新** + +NPM でインストールした場合は、手動で最新バージョンへ更新できます: + +```bash +npm install -g @alibaba-group/open-code-review@latest +``` + +NPM インストール版の `ocr` は、既定でバックグラウンドで新しいバージョンを確認し、自動的に更新します。自動更新を無効にするには、`OCR_NO_UPDATE=1` を設定してください。 + +インストールスクリプトまたは手動ダウンロードしたバイナリでインストールした場合は、同じインストール/ダウンロードコマンドを再実行すると、ローカルのバイナリを最新リリースに置き換えられます。特定のリリースタグに固定する必要がある場合は `OCR_VERSION` を使います。 + **GitHub Releaseから** 1 つのコマンドで、お使いの OS / アーキテクチャ向けの最新バイナリをインストールできます(macOS / Linux): diff --git a/README.ko-KR.md b/README.ko-KR.md index e0e4086..f3f33d1 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -107,6 +107,18 @@ npm install -g @alibaba-group/open-code-review 설치 후 `ocr` 명령을 전역에서 사용할 수 있습니다. +**업데이트** + +NPM으로 설치했다면 최신 버전으로 수동 업데이트할 수 있습니다: + +```bash +npm install -g @alibaba-group/open-code-review@latest +``` + +NPM 설치의 `ocr`은 기본적으로 백그라운드에서 새 버전을 확인하고 자동으로 업데이트합니다. 자동 업데이트를 끄려면 `OCR_NO_UPDATE=1`을 설정하세요. + +설치 스크립트나 수동 다운로드한 binary로 설치했다면 같은 설치/다운로드 명령을 다시 실행해 로컬 binary를 최신 release로 교체할 수 있습니다. 특정 release tag로 고정해야 한다면 `OCR_VERSION`을 사용하세요. + **GitHub Release 사용** 명령 한 번으로 사용 중인 OS/아키텍처에 맞는 최신 binary를 설치합니다 (macOS / Linux): diff --git a/README.md b/README.md index 16cb661..57730fb 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,18 @@ npm install -g @alibaba-group/open-code-review After installation, the `ocr` command is available globally. +**Update** + +If you installed via NPM, update manually to the latest version: + +```bash +npm install -g @alibaba-group/open-code-review@latest +``` + +NPM installations also check for newer versions in the background by default and upgrade automatically. To disable auto-updates, set `OCR_NO_UPDATE=1`. + +If you installed with the install script or a manually downloaded binary, rerun the same install/download command to replace the local binary with the latest release. Use `OCR_VERSION` when you need to pin a specific release tag. + **From GitHub Release** Install the latest binary for your OS/architecture with one command (macOS / Linux): diff --git a/README.ru-RU.md b/README.ru-RU.md index 4c8f391..6cbcc46 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -107,6 +107,18 @@ npm install -g @alibaba-group/open-code-review После установки команда `ocr` доступна глобально. +**Обновление** + +Если установка выполнена через NPM, обновите вручную до последней версии: + +```bash +npm install -g @alibaba-group/open-code-review@latest +``` + +Установка через NPM также по умолчанию проверяет новые версии в фоне и обновляется автоматически. Чтобы отключить автообновления, задайте `OCR_NO_UPDATE=1`. + +Если вы устанавливали через install script или вручную скачанный бинарный файл, повторно запустите ту же команду установки/скачивания, чтобы заменить локальный бинарный файл последним релизом. Используйте `OCR_VERSION`, если нужно зафиксировать конкретный тег релиза. + **Из GitHub Release** Установите свежий бинарный файл для вашей ОС/архитектуры одной командой (macOS / Linux): diff --git a/README.zh-CN.md b/README.zh-CN.md index a0ee541..8f288db 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -107,6 +107,18 @@ npm install -g @alibaba-group/open-code-review 安装后,`ocr` 命令即可全局使用。 +**更新** + +如果通过 NPM 安装,可手动更新到最新版本: + +```bash +npm install -g @alibaba-group/open-code-review@latest +``` + +通过 NPM 安装的 `ocr` 还会默认在后台检查新版本并自动升级;如需关闭自动更新,可设置 `OCR_NO_UPDATE=1`。 + +如果通过安装脚本或手动下载二进制文件安装,重新运行对应的安装/下载命令即可替换为最新 release。需要固定版本时,可继续通过 `OCR_VERSION` 指定 release tag。 + **从 GitHub Release 下载** 使用一条命令为你的操作系统/架构安装最新二进制文件(macOS / Linux): From f35437671cda9e2c7773dd6d8b56de7426634663 Mon Sep 17 00:00:00 2001 From: MuoDoo <36472129+MuoDoo@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:43:11 +0800 Subject: [PATCH 10/19] feat(review): add resumable sessions and session inspection (#306) * feat(review): add session resume support * Add session inspection command * fix(review): correct session resume checkpoint handling * fix(review): require completed main loop for resume checkpoints * docs: document review resume sessions * docs: refine resume session guidance --- README.ja-JP.md | 48 ++++ README.ko-KR.md | 48 ++++ README.md | 54 +++- README.ru-RU.md | 50 ++++ README.zh-CN.md | 47 ++++ cmd/opencodereview/emit_run_result_test.go | 60 +++++ cmd/opencodereview/flags.go | 9 + cmd/opencodereview/flags_test.go | 17 ++ cmd/opencodereview/main.go | 5 + cmd/opencodereview/output.go | 6 +- cmd/opencodereview/output_helpers_test.go | 6 +- cmd/opencodereview/review_cmd.go | 47 ++++ cmd/opencodereview/scan_cmd.go | 3 + cmd/opencodereview/session_cmd.go | 299 +++++++++++++++++++++ cmd/opencodereview/session_cmd_test.go | 170 ++++++++++++ cmd/opencodereview/shared.go | 14 +- internal/agent/agent.go | 157 +++++++++-- internal/agent/agent_test.go | 191 +++++++++++++ internal/agent/coverage_test.go | 32 ++- internal/llmloop/loop.go | 13 +- internal/llmloop/loop_test.go | 41 ++- internal/scan/agent.go | 11 +- internal/session/history.go | 60 ++++- internal/session/list.go | 293 ++++++++++++++++++++ internal/session/list_test.go | 189 +++++++++++++ internal/session/persist.go | 96 +++++-- internal/session/persist_test.go | 88 ++++++ internal/session/resume.go | 209 ++++++++++++++ pages/src/content/docs/en/cli-reference.md | 75 ++++++ pages/src/content/docs/ja/cli-reference.md | 73 +++++ pages/src/content/docs/zh/cli-reference.md | 71 +++++ 31 files changed, 2418 insertions(+), 64 deletions(-) create mode 100644 cmd/opencodereview/session_cmd.go create mode 100644 cmd/opencodereview/session_cmd_test.go create mode 100644 internal/session/list.go create mode 100644 internal/session/list_test.go create mode 100644 internal/session/resume.go diff --git a/README.ja-JP.md b/README.ja-JP.md index 3a0989c..698fcf7 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -280,6 +280,10 @@ ocr review --from main --to feature-branch # 単一コミット ocr review --commit abc123 +# 中断した範囲または単一 commit レビューを再開 +ocr session list +ocr review --from main --to feature-branch --resume + # フルファイルスキャン — diffではなくファイル全体をレビュー(git履歴不要) ocr scan # リポジトリ全体をスキャン ocr scan --path internal/agent # ディレクトリまたは特定のファイルをスキャン @@ -443,6 +447,8 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと | `ocr config unset custom_providers.` | — | カスタムプロバイダーを削除 | | `ocr llm test` | — | LLMの疎通テスト | | `ocr llm providers` | — | ビルトインLLMプロバイダーを一覧表示 | +| `ocr session list` | `ocr sessions list`, `ocr session ls` | 保存済みレビューセッションを一覧表示 | +| `ocr session show ` | `ocr sessions show ` | 1つのセッションとファイル単位のチェックポイントを表示 | | `ocr viewer` | `ocr v` | `localhost:5483`でWebUIセッションビューアーを起動 | | `ocr version` | — | バージョン情報を表示 | @@ -456,6 +462,7 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと | `--commit` | `-c` | — | レビュー対象の単一コミット | | `--exclude` | — | — | カンマ区切りのgitignoreスタイルパターンでスキップ対象を指定;rule.jsonのexcludesとマージ | | `--preview` | `-p` | `false` | LLMを実行せずにレビュー対象ファイルをプレビュー | +| `--resume` | — | — | 以前の互換性のある範囲または単一 commit レビューセッションから再開 | | `--format` | `-f` | `text` | 出力形式:`text`または`json` | | `--concurrency` | — | `8` | ファイルレビューの最大同時実行数 | | `--timeout` | — | `10` | 同時実行タスクのタイムアウト(分) | @@ -468,6 +475,41 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと | `--max-git-procs` | — | 組み込み値 | gitサブプロセスの最大同時実行数 | | `--tools` | — | — | カスタムJSONツール設定へのパス | +#### 再開可能なレビューとセッション + +すべての `ocr review` 実行は、`~/.opencodereview/sessions/` 配下にローカル +セッションログを保存します。正常終了したテキスト出力はレビュー結果に集中し、session ID +は表示しません。保存済みセッションは `ocr session list/show` で確認でき、 +`--format json` では機械可読出力に `session_id` が含まれます。範囲または単一 commit +レビューが中断された場合は、保存済みセッションを一覧表示し、同じレビュー対象に一致するセッションから再開します: + +```bash +ocr session list +ocr session show +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +再開は意図的に厳密です。範囲レビューと単一 commit レビューのみ対応し、ワークスペースレビューは再開できません。 +現在の `--from/--to` または `--commit` は保存済みセッションと一致する必要があります。`--preview` と `--resume` は併用できません。 + +`--format json` を使用すると、再開した実行には次が含まれます: + +- `session_id` — 現在の実行の session ID +- `resume.resumed_from` — 再開元の session ID +- `resume.reused_files` — 保存済みチェックポイントから再利用したファイル数 +- `resume.rerun_files` — 現在の実行で再レビューしたファイル数 + +### `ocr session`のフラグ + +| コマンド | フラグ | デフォルト | 説明 | +|---------|------|---------|------| +| `ocr session list` | `--repo` | カレントディレクトリ | 一覧表示するセッションのリポジトリ | +| `ocr session list` | `--json` | `false` | セッション概要をJSONで出力 | +| `ocr session list` | `--limit` | `20` | 一覧表示するセッション数の上限。`0` は無制限 | +| `ocr session show ` | `--repo` | カレントディレクトリ | 確認するセッションのリポジトリ | +| `ocr session show ` | `--json` | `false` | セッションメタデータとファイル単位の項目をJSONで出力 | + ### `ocr scan`のフラグ `ocr scan` はdiffではなくファイル全体をレビューします — 不慣れなコードベースの監査、マイグレーション前のスキャン、意味のあるdiffがないディレクトリなどに有用です。非gitディレクトリでも動作します(`.gitignore` を尊重するファイルシステムウォークにフォールバック)。 @@ -513,6 +555,12 @@ ocr review --from main --to my-feature --concurrency 4 # 特定のコミットを詳細なJSON出力でレビュー ocr review --commit abc123 --format json --audience agent +# 中断した範囲または単一 commit レビューを再開 +ocr session list +ocr session show +ocr review --from main --to my-feature --resume +ocr review --commit abc123 --resume + # このレビューでモデルを選択またはオーバーライド ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 diff --git a/README.ko-KR.md b/README.ko-KR.md index f3f33d1..0733c58 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -280,6 +280,10 @@ ocr review --from main --to feature-branch # 단일 commit ocr review --commit abc123 +# 중단된 range 또는 단일 commit review 재개 +ocr session list +ocr review --from main --to feature-branch --resume + # 전체 파일 스캔 — diff 대신 파일 전체를 리뷰 (git 이력 불필요) ocr scan # 전체 repository 스캔 ocr scan --path internal/agent # 디렉터리 또는 특정 파일 스캔 @@ -443,6 +447,8 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s | `ocr config unset custom_providers.` | - | custom provider 삭제 | | `ocr llm test` | - | LLM 연결 테스트 | | `ocr llm providers` | - | built-in LLM provider 목록 표시 | +| `ocr session list` | `ocr sessions list`, `ocr session ls` | 저장된 review session 목록 표시 | +| `ocr session show ` | `ocr sessions show ` | 단일 session과 파일별 checkpoint 확인 | | `ocr viewer` | `ocr v` | `localhost:5483`에서 WebUI session viewer 실행 | | `ocr version` | - | version 정보 표시 | @@ -456,6 +462,7 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s | `--commit` | `-c` | - | 리뷰할 단일 commit | | `--exclude` | - | - | 건너뛸 파일의 쉼표 구분 gitignore 스타일 패턴; rule.json의 excludes와 병합 | | `--preview` | `-p` | `false` | LLM 실행 없이 리뷰 대상 파일 미리보기 | +| `--resume` | - | - | 이전의 호환되는 range 또는 단일 commit review session에서 재개 | | `--format` | `-f` | `text` | Output format: `text` 또는 `json` | | `--concurrency` | - | `8` | 최대 동시 파일 리뷰 수 | | `--timeout` | - | `10` | 동시 task timeout(분) | @@ -468,6 +475,41 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s | `--max-git-procs` | - | built-in | 최대 동시 git subprocess 수 | | `--tools` | - | - | custom JSON tools config 경로 | +#### Resumable Reviews and Sessions + +모든 `ocr review` 실행은 `~/.opencodereview/sessions/` 아래에 local session log를 저장합니다. +정상 완료된 text output은 review 결과에 집중하며 session ID를 출력하지 않습니다. +저장된 session은 `ocr session list/show`로 찾을 수 있고, `--format json`을 사용하면 +machine-readable output에 `session_id`가 포함됩니다. range 또는 단일 commit review가 중단된 경우, +저장된 session을 나열한 뒤 동일한 review target과 일치하는 session에서 재개합니다. + +```bash +ocr session list +ocr session show +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +Resume은 의도적으로 엄격합니다. branch range와 단일 commit review만 지원하고 workspace review는 지원하지 않습니다. +현재 `--from/--to` 또는 `--commit`은 저장된 session과 일치해야 합니다. `--preview`와 `--resume`은 함께 사용할 수 없습니다. + +`--format json`을 사용하면 재개된 run에는 다음 field가 포함됩니다. + +- `session_id`: 현재 run의 session ID +- `resume.resumed_from`: source session ID +- `resume.reused_files`: 저장된 checkpoint에서 재사용한 파일 수 +- `resume.rerun_files`: 현재 run에서 다시 review한 파일 수 + +### `ocr session` Flags + +| Command | Flag | Default | Description | +|---------|------|---------|-------------| +| `ocr session list` | `--repo` | current dir | session을 나열할 repository | +| `ocr session list` | `--json` | `false` | session summary를 JSON으로 출력 | +| `ocr session list` | `--limit` | `20` | 나열할 session 수 제한. `0`은 unlimited | +| `ocr session show ` | `--repo` | current dir | 확인할 session의 repository | +| `ocr session show ` | `--json` | `false` | session metadata와 파일별 item을 JSON으로 출력 | + ### `ocr scan` Flags `ocr scan`은 diff가 아닌 전체 파일을 리뷰합니다 — 익숙하지 않은 코드베이스 감사, 마이그레이션 전 스캔, 의미 있는 diff가 없는 디렉터리 등에 유용합니다. 비-git 디렉터리에서도 작동합니다 (`.gitignore`를 따르는 파일 시스템 탐색으로 폴백). @@ -513,6 +555,12 @@ ocr review --from main --to my-feature --concurrency 4 # 특정 commit을 verbose JSON output으로 리뷰 ocr review --commit abc123 --format json --audience agent +# 중단된 range 또는 단일 commit review 재개 +ocr session list +ocr session show +ocr review --from main --to my-feature --resume +ocr review --commit abc123 --resume + # 이번 리뷰에서 model 선택 또는 override ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 diff --git a/README.md b/README.md index 57730fb..85b0a80 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,10 @@ ocr review --from main --to feature-branch # Single commit ocr review --commit abc123 +# Resume an interrupted range or commit review +ocr session list +ocr review --from main --to feature-branch --resume + # Full-file scan — review whole files instead of a diff (no git history needed) ocr scan # scan the entire repository ocr scan --path internal/agent # scan a directory or specific files @@ -445,6 +449,8 @@ See the [`examples/`](./examples/) directory for integration examples: | `ocr config unset custom_providers.` | — | Delete a custom provider | | `ocr llm test` | — | Test LLM connectivity | | `ocr llm providers` | — | List built-in LLM providers | +| `ocr session list` | `ocr sessions list`, `ocr session ls` | List saved review sessions | +| `ocr session show ` | `ocr sessions show ` | Inspect one session and its per-file checkpoints | | `ocr viewer` | `ocr v` | Launch WebUI session viewer on `localhost:5483` | | `ocr version` | — | Show version info | @@ -458,6 +464,7 @@ See the [`examples/`](./examples/) directory for integration examples: | `--commit` | `-c` | — | Single commit to review | | `--exclude` | — | — | Comma-separated gitignore-style patterns to skip; merged with rule.json excludes | | `--preview` | `-p` | `false` | Preview which files will be reviewed without running the LLM | +| `--resume` | — | — | Resume from a previous compatible range or commit review session | | `--format` | `-f` | `text` | Output format: `text` or `json` | | `--concurrency` | — | `8` | Max concurrent file reviews | | `--timeout` | — | `10` | Concurrent task timeout in minutes | @@ -467,8 +474,45 @@ See the [`examples/`](./examples/) directory for integration examples: | `--model` | — | — | Select or override the LLM model for this review | | `--rule` | — | — | Path to custom JSON review rules | | `--max-tools` | — | built-in | Max tool call rounds per file; only takes effect when greater than template default | -| `--max-git-procs` | — | built-in | Max concurrent git subprocesses | -| `--tools` | — | — | Path to custom JSON tools config | +| `--max-git-procs` | — | `16` | Max concurrent git subprocesses | +| `--tools` | — | built-in | Path to custom JSON tools config | + +#### Resumable Reviews and Sessions + +Every `ocr review` run persists a local session log under +`~/.opencodereview/sessions/`. Successful text output stays focused on review +results and does not print the session ID; use `ocr session list/show` to find +saved sessions, or `--format json` to include `session_id` in machine-readable +output. If a range or commit review is interrupted, list the saved sessions and +resume from the one that matches the same review target: + +```bash +ocr session list +ocr session show +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +Resume is intentionally strict: it only supports branch-range and single-commit +reviews, not workspace reviews, and the current `--from/--to` or `--commit` +must match the saved session. `--preview` cannot be combined with `--resume`. + +When `--format json` is used, resumed runs include: + +- `session_id` — the current run's session ID +- `resume.resumed_from` — the source session ID +- `resume.reused_files` — files reused from saved checkpoints +- `resume.rerun_files` — files reviewed again in the current run + +### `ocr session` Flags + +| Command | Flag | Default | Description | +|---------|------|---------|-------------| +| `ocr session list` | `--repo` | current dir | Repository whose sessions should be listed | +| `ocr session list` | `--json` | `false` | Emit session summaries as JSON | +| `ocr session list` | `--limit` | `20` | Cap listed sessions; use `0` for unlimited | +| `ocr session show ` | `--repo` | current dir | Repository whose session should be inspected | +| `ocr session show ` | `--json` | `false` | Emit session metadata and per-file items as JSON | ### `ocr scan` Flags @@ -518,6 +562,12 @@ ocr review --from main --to my-feature --concurrency 4 # Review a specific commit with verbose JSON output ocr review --commit abc123 --format json --audience agent +# Resume an interrupted range or commit review +ocr session list +ocr session show +ocr review --from main --to my-feature --resume +ocr review --commit abc123 --resume + # Select or override model for this review ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 diff --git a/README.ru-RU.md b/README.ru-RU.md index 6cbcc46..936b2a5 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -280,6 +280,10 @@ ocr review --from main --to feature-branch # Один коммит ocr review --commit abc123 +# Возобновить прерванное ревью диапазона или одного коммита +ocr session list +ocr review --from main --to feature-branch --resume + # Полнофайловое сканирование — ревью целых файлов вместо диффа (история git не нужна) ocr scan # сканировать весь репозиторий ocr scan --path internal/agent # сканировать каталог или конкретные файлы @@ -445,6 +449,8 @@ ocr review \ | `ocr config unset custom_providers.` | — | Удалить пользовательского провайдера | | `ocr llm test` | — | Проверить подключение к LLM | | `ocr llm providers` | — | Показать список встроенных LLM-провайдеров | +| `ocr session list` | `ocr sessions list`, `ocr session ls` | Показать сохранённые сессии ревью | +| `ocr session show ` | `ocr sessions show ` | Показать одну сессию и её checkpoint'ы по файлам | | `ocr viewer` | `ocr v` | Запустить WebUI-просмотрщик сессий на `localhost:5483` | | `ocr version` | — | Показать информацию о версии | @@ -458,6 +464,7 @@ ocr review \ | `--commit` | `-c` | — | Один коммит для ревью | | `--exclude` | — | — | Паттерны в стиле gitignore через запятую для пропуска файлов; объединяются с excludes из rule.json | | `--preview` | `-p` | `false` | Показать, какие файлы попадут в ревью, без запуска LLM | +| `--resume` | — | — | Возобновить предыдущую совместимую сессию ревью диапазона или одного коммита | | `--format` | `-f` | `text` | Формат вывода: `text` или `json` | | `--concurrency` | — | `8` | Максимум одновременных ревью файлов | | `--timeout` | — | `10` | Таймаут конкурентной задачи в минутах | @@ -470,6 +477,43 @@ ocr review \ | `--max-git-procs` | — | встроенное | Максимум одновременных git-подпроцессов | | `--tools` | — | — | Путь к пользовательскому JSON-конфигу инструментов | +#### Возобновляемые ревью и сессии + +Каждый запуск `ocr review` сохраняет локальный журнал сессии в +`~/.opencodereview/sessions/`. Успешный текстовый вывод остаётся сфокусированным +на результате ревью и не печатает session ID. Сохранённые сессии можно найти через +`ocr session list/show`, а `--format json` добавляет `session_id` в машиночитаемый +вывод. Если ревью диапазона или одного коммита было прервано, выберите сохранённую +сессию с тем же целевым ревью и возобновите её: + +```bash +ocr session list +ocr session show +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +Возобновление намеренно строгое: поддерживаются только ревью диапазона веток и одного +коммита, но не ревью рабочей копии. Текущие `--from/--to` или `--commit` должны +совпадать с сохранённой сессией. `--preview` нельзя использовать вместе с `--resume`. + +При `--format json` возобновлённый запуск включает: + +- `session_id` — session ID текущего запуска +- `resume.resumed_from` — исходный session ID +- `resume.reused_files` — файлы, повторно использованные из сохранённых checkpoint'ов +- `resume.rerun_files` — файлы, заново проверенные в текущем запуске + +### Флаги `ocr session` + +| Команда | Флаг | По умолчанию | Описание | +|---------|------|--------------|----------| +| `ocr session list` | `--repo` | текущий каталог | Репозиторий, для которого нужно показать сессии | +| `ocr session list` | `--json` | `false` | Вывести сводки сессий в JSON | +| `ocr session list` | `--limit` | `20` | Ограничить количество сессий; `0` означает без ограничения | +| `ocr session show ` | `--repo` | текущий каталог | Репозиторий, сессию которого нужно посмотреть | +| `ocr session show ` | `--json` | `false` | Вывести метаданные сессии и элементы по файлам в JSON | + ### Флаги `ocr scan` `ocr scan` проверяет целые файлы, а не дифф — удобно для аудита незнакомой кодовой базы, предмиграционного сканирования или любого каталога без значимого диффа. Работает и в каталогах без git (используется обход файловой системы с учётом `.gitignore`). @@ -515,6 +559,12 @@ ocr review --from main --to my-feature --concurrency 4 # Ревью конкретного коммита с подробным JSON-выводом ocr review --commit abc123 --format json --audience agent +# Возобновить прерванное ревью диапазона или одного коммита +ocr session list +ocr session show +ocr review --from main --to my-feature --resume +ocr review --commit abc123 --resume + # Выбрать или переопределить модель для этого ревью ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 diff --git a/README.zh-CN.md b/README.zh-CN.md index 8f288db..e103b1e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -280,6 +280,10 @@ ocr review --from main --to feature-branch # 单个提交 ocr review --commit abc123 +# 恢复中断的区间或单 commit 评审 +ocr session list +ocr review --from main --to feature-branch --resume + # 全量文件扫描 —— 审查整个文件而非 diff(无需 git 历史) ocr scan # 扫描整个仓库 ocr scan --path internal/agent # 扫描指定目录或文件 @@ -443,6 +447,8 @@ ocr review \ | `ocr config unset custom_providers.` | — | 删除自定义供应商 | | `ocr llm test` | — | 测试 LLM 连通性 | | `ocr llm providers` | — | 列出内置 LLM 供应商 | +| `ocr session list` | `ocr sessions list`, `ocr session ls` | 列出已保存的评审会话 | +| `ocr session show ` | `ocr sessions show ` | 查看单个会话及其逐文件检查点 | | `ocr viewer` | `ocr v` | 启动 WebUI 会话查看器,地址 `localhost:5483` | | `ocr version` | — | 显示版本信息 | @@ -456,6 +462,7 @@ ocr review \ | `--commit` | `-c` | — | 审查单个提交 | | `--exclude` | — | — | 以逗号分隔的 gitignore 风格模式,用于跳过匹配文件;与 rule.json 中的 excludes 合并 | | `--preview` | `-p` | `false` | 预览将被审查的文件列表,不调用 LLM | +| `--resume` | — | — | 从之前兼容的区间或单 commit 评审会话恢复 | | `--format` | `-f` | `text` | 输出格式:`text` 或 `json` | | `--concurrency` | — | `8` | 最大并发文件审查数 | | `--timeout` | — | `10` | 并发任务超时时间(分钟) | @@ -468,6 +475,40 @@ ocr review \ | `--max-git-procs` | — | 内置默认 | 最大并发 git 子进程数 | | `--tools` | — | — | 自定义 JSON 工具配置路径 | +#### 可恢复评审与会话 + +每次 `ocr review` 都会在 `~/.opencodereview/sessions/` 下保存本地会话日志。 +正常完成的文本输出只展示评审结果,不打印 session ID;可使用 +`ocr session list/show` 查找已保存会话,或用 `--format json` 在机器可读输出中获取 +`session_id`。如果区间或单 commit 评审被中断,可列出保存的会话,并从匹配相同评审目标的会话恢复: + +```bash +ocr session list +ocr session show +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +恢复逻辑是严格的:仅支持分支区间和单 commit 评审,不支持工作区评审;当前 +`--from/--to` 或 `--commit` 必须与保存的会话一致。`--preview` 不能与 `--resume` 同时使用。 + +使用 `--format json` 时,恢复运行会包含: + +- `session_id` — 当前运行的 session ID +- `resume.resumed_from` — 来源 session ID +- `resume.reused_files` — 从已保存检查点复用的文件数 +- `resume.rerun_files` — 本次重新评审的文件数 + +### `ocr session` 参数 + +| 命令 | 参数 | 默认值 | 描述 | +|------|------|--------|------| +| `ocr session list` | `--repo` | 当前目录 | 要列出会话的仓库 | +| `ocr session list` | `--json` | `false` | 以 JSON 输出会话摘要 | +| `ocr session list` | `--limit` | `20` | 限制列出的会话数量;`0` 表示不限 | +| `ocr session show ` | `--repo` | 当前目录 | 要查看会话的仓库 | +| `ocr session show ` | `--json` | `false` | 以 JSON 输出会话元数据和逐文件条目 | + ### `ocr scan` 参数 `ocr scan` 审查整个文件而非 diff —— 适用于审计不熟悉的代码库、迁移前扫描,或任何没有有意义 diff 的目录。它也可以在非 git 目录中工作(会回退到遵循 `.gitignore` 的文件系统遍历)。 @@ -513,6 +554,12 @@ ocr review --from main --to my-feature --concurrency 4 # 审查特定提交并以 JSON 格式输出详细信息 ocr review --commit abc123 --format json --audience agent +# 恢复中断的区间或单 commit 评审 +ocr session list +ocr session show +ocr review --from main --to my-feature --resume +ocr review --commit abc123 --resume + # 为本次审查选择或覆盖模型 ocr review --model claude-opus-4-6 ocr review --commit abc123 --model claude-sonnet-4-6 diff --git a/cmd/opencodereview/emit_run_result_test.go b/cmd/opencodereview/emit_run_result_test.go index 6ad71bb..f153fe2 100644 --- a/cmd/opencodereview/emit_run_result_test.go +++ b/cmd/opencodereview/emit_run_result_test.go @@ -25,6 +25,8 @@ type mockResultProvider struct { warnings []agent.AgentWarning projectSummary string toolCalls map[string]int64 + resumeInfo *agent.ResumeInfo + sessionID string } func (m *mockResultProvider) Diffs() []model.Diff { return m.diffs } @@ -37,6 +39,8 @@ func (m *mockResultProvider) TotalCacheWriteTokens() int64 { return m.cacheWri func (m *mockResultProvider) Warnings() []agent.AgentWarning { return m.warnings } func (m *mockResultProvider) ProjectSummary() string { return m.projectSummary } func (m *mockResultProvider) ToolCalls() map[string]int64 { return m.toolCalls } +func (m *mockResultProvider) ResumeInfo() *agent.ResumeInfo { return m.resumeInfo } +func (m *mockResultProvider) SessionID() string { return m.sessionID } func TestEmitRunResult_JSONNoFiles(t *testing.T) { ag := &mockResultProvider{filesReviewed: 0} @@ -83,6 +87,32 @@ func TestEmitRunResult_JSONWithComments(t *testing.T) { } } +func TestEmitRunResult_JSONWithResumeInfo(t *testing.T) { + ag := &mockResultProvider{ + filesReviewed: 2, + resumeInfo: &agent.ResumeInfo{ + ResumedFrom: "old-session", + ReusedFiles: 1, + RerunFiles: 1, + PreviousModel: "anthropic-model", + CurrentModel: "openai-model", + }, + } + got := captureStdout(t, func() { + err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + var out jsonOutput + if err := json.Unmarshal([]byte(got), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.Resume == nil || out.Resume.ResumedFrom != "old-session" || out.Resume.ReusedFiles != 1 || out.Resume.RerunFiles != 1 { + t.Fatalf("resume = %+v", out.Resume) + } +} + func TestEmitRunResult_TextNoComments(t *testing.T) { ag := &mockResultProvider{filesReviewed: 2} got := captureStdout(t, func() { @@ -96,6 +126,19 @@ func TestEmitRunResult_TextNoComments(t *testing.T) { } } +func TestEmitRunResult_TextDoesNotPrintSuccessfulSessionHint(t *testing.T) { + ag := &mockResultProvider{filesReviewed: 2, sessionID: "session-123"} + got := captureStdout(t, func() { + err := emitRunResult(context.Background(), ag, nil, time.Now(), "text", "developer", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + if strings.Contains(got, "session-123") || strings.Contains(got, "--resume") { + t.Fatalf("successful text output should not print session ID or resume hint, got %q", got) + } +} + func TestEmitRunResult_TextWithComments(t *testing.T) { ag := &mockResultProvider{filesReviewed: 1} comments := []model.LlmComment{{Path: "a.go", Content: "rename", StartLine: 5, EndLine: 10}} @@ -236,3 +279,20 @@ func TestEmitRunResult_JSONNoFilesTraceID(t *testing.T) { t.Errorf("trace_id = %q, want %q", out.TraceID, wantTraceID) } } + +func TestEmitRunResult_JSONIncludesSessionID(t *testing.T) { + ag := &mockResultProvider{filesReviewed: 1, sessionID: "session-99"} + got := captureStdout(t, func() { + err := emitRunResult(context.Background(), ag, nil, time.Now(), "json", "developer", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + var out jsonOutput + if err := json.Unmarshal([]byte(got), &out); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if out.SessionID != "session-99" { + t.Errorf("session_id = %q, want session-99", out.SessionID) + } +} diff --git a/cmd/opencodereview/flags.go b/cmd/opencodereview/flags.go index 56a35e9..1f72bb1 100644 --- a/cmd/opencodereview/flags.go +++ b/cmd/opencodereview/flags.go @@ -101,6 +101,7 @@ type reviewOptions struct { from string to string commit string + resume string excludes string // --exclude: comma-separated gitignore-style patterns outputFormat string audience string // --audience: "human" (default) or "agent" @@ -126,6 +127,7 @@ func parseReviewFlags(args []string) (reviewOptions, error) { a.StringVar(&opts.from, "from", "", "source ref to start diff from (e.g., 'main')") a.StringVar(&opts.to, "to", "", "target ref to end diff at (e.g., 'feature-branch')") a.StringVarP(&opts.commit, "commit", "c", "", "single commit hash or tag to review (vs its parent)") + a.StringVar(&opts.resume, "resume", "", "resume from a previous review session id") a.StringVar(&opts.excludes, "exclude", "", "comma-separated gitignore-style patterns to exclude; merged with rule.json excludes") a.StringVarP(&opts.outputFormat, "format", "f", "text", "output format: text or json") a.IntVar(&opts.concurrency, "concurrency", 8, "max concurrent file reviews") @@ -164,6 +166,9 @@ func parseReviewFlags(args []string) (reviewOptions, error) { if opts.to != "" && opts.from == "" { return opts, fmt.Errorf("--from is required when --to is specified") } + if opts.preview && opts.resume != "" { + return opts, fmt.Errorf("--preview and --resume cannot be used together") + } switch opts.audience { case "human", "agent": @@ -205,6 +210,9 @@ Examples: ocr review --commit abc123 ocr review -c abc123 + # Resume a previous range review + ocr review --from master --to dev-ref --resume + # Output JSON format ocr review --format json ocr review -f json @@ -234,6 +242,7 @@ Flags: --model string override LLM model for this review (e.g., claude-opus-4-6) -p, --preview preview which files will be reviewed without running the LLM --repo string root directory of the git repository (default: current dir) + --resume string resume from a previous review session id --rule string path to JSON file with system review rules --timeout int concurrent task timeout in minutes (default 10) --to string target ref to end diff at (e.g., 'feature-branch') diff --git a/cmd/opencodereview/flags_test.go b/cmd/opencodereview/flags_test.go index 0514e98..8e25996 100644 --- a/cmd/opencodereview/flags_test.go +++ b/cmd/opencodereview/flags_test.go @@ -36,6 +36,23 @@ func TestParseReviewFlagsModelOverride(t *testing.T) { } } +func TestParseReviewFlagsResume(t *testing.T) { + opts, err := parseReviewFlags([]string{"--from", "main", "--to", "feature", "--resume", "session-123"}) + if err != nil { + t.Fatalf("parseReviewFlags: %v", err) + } + if opts.resume != "session-123" { + t.Errorf("resume = %q, want session-123", opts.resume) + } +} + +func TestParseReviewFlags_PreviewWithResume(t *testing.T) { + _, err := parseReviewFlags([]string{"--commit", "abc123", "--preview", "--resume", "session-123"}) + if err == nil { + t.Fatal("expected error for --preview with --resume") + } +} + func TestParseReviewFlags_InvalidAudience(t *testing.T) { _, err := parseReviewFlags([]string{"--audience", "robot"}) if err == nil { diff --git a/cmd/opencodereview/main.go b/cmd/opencodereview/main.go index fac044e..378e22e 100644 --- a/cmd/opencodereview/main.go +++ b/cmd/opencodereview/main.go @@ -56,6 +56,8 @@ func dispatch() error { return runRules(args[1:]) case "viewer": return runViewer(args[1:]) + case "session", "sessions": + return runSession(args[1:]) case "-h", "--help": printTopLevelUsage() return nil @@ -77,6 +79,7 @@ Commands: config Manage configuration settings llm LLM utility commands viewer Start the WebUI session viewer + session, sessions List and inspect saved review sessions version Show version information Examples: @@ -89,6 +92,7 @@ Examples: ocr config set llm.model opus-4-6 Set a config value ocr llm test Test LLM connectivity ocr llm providers List built-in providers + ocr session list List saved review sessions ocr version Show version info Use "ocr review -h" for more information about review. @@ -96,6 +100,7 @@ Use "ocr scan -h" for more information about scan. Use "ocr rules -h" for more information about rules. Use "ocr config" for more information about config. Use "ocr llm" for more information about LLM utilities. +Use "ocr session -h" for more information about session inspection. GitHub: https://github.com/alibaba/open-code-review`) } diff --git a/cmd/opencodereview/output.go b/cmd/opencodereview/output.go index cabeb78..0006164 100644 --- a/cmd/opencodereview/output.go +++ b/cmd/opencodereview/output.go @@ -247,6 +247,8 @@ type jsonOutput struct { Comments []model.LlmComment `json:"comments"` Warnings []agent.AgentWarning `json:"warnings,omitempty"` ProjectSummary string `json:"project_summary,omitempty"` + Resume *agent.ResumeInfo `json:"resume,omitempty"` + SessionID string `json:"session_id,omitempty"` } func outputJSON(comments []model.LlmComment) error { @@ -264,7 +266,7 @@ func outputJSON(comments []model.LlmComment) error { func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentWarning, filesReviewed, inputTokens, outputTokens, totalTokens, cacheReadTokens, cacheWriteTokens int64, - duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string) error { + duration time.Duration, projectSummary string, toolCalls map[string]int64, traceID string, resumeInfo *agent.ResumeInfo, sessionID string) error { out := jsonOutput{ Status: "success", TraceID: traceID, @@ -280,6 +282,8 @@ func outputJSONWithWarnings(comments []model.LlmComment, warnings []agent.AgentW Elapsed: duration.Round(time.Second).String(), }, ProjectSummary: projectSummary, + Resume: resumeInfo, + SessionID: sessionID, } var total int64 for _, v := range toolCalls { diff --git a/cmd/opencodereview/output_helpers_test.go b/cmd/opencodereview/output_helpers_test.go index 31001e7..80520b2 100644 --- a/cmd/opencodereview/output_helpers_test.go +++ b/cmd/opencodereview/output_helpers_test.go @@ -168,7 +168,7 @@ func TestOutputJSONWithWarnings_NoCommentsSubtaskError(t *testing.T) { os.Stdout = w warnings := []agent.AgentWarning{{Type: "subtask_error", File: "x.go", Message: "fail"}} - err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil, "abc123trace") + err := outputJSONWithWarnings(nil, warnings, 1, 10, 5, 15, 0, 0, time.Second, "", nil, "abc123trace", nil, "") _ = w.Close() os.Stdout = old @@ -281,7 +281,7 @@ func TestOutputJSONWithWarnings(t *testing.T) { comments := []model.LlmComment{{Path: "b.go", Content: "test"}} warnings := []agent.AgentWarning{{Type: "subtask_error", File: "c.go", Message: "failed"}} - err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}, "trace-xyz-789") + err := outputJSONWithWarnings(comments, warnings, 5, 100, 50, 150, 10, 5, 3*time.Second, "summary", map[string]int64{"file_read": 3}, "trace-xyz-789", nil, "") _ = w.Close() os.Stdout = old @@ -319,7 +319,7 @@ func TestOutputJSONWithWarnings_NoCommentsNoErrors(t *testing.T) { os.Stdout = w warnings := []agent.AgentWarning{{Type: "warning", Message: "something"}} - err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil, "") + err := outputJSONWithWarnings(nil, warnings, 2, 50, 20, 70, 0, 0, time.Second, "", nil, "", nil, "") _ = w.Close() os.Stdout = old diff --git a/cmd/opencodereview/review_cmd.go b/cmd/opencodereview/review_cmd.go index adf553c..8379156 100644 --- a/cmd/opencodereview/review_cmd.go +++ b/cmd/opencodereview/review_cmd.go @@ -11,6 +11,7 @@ import ( "github.com/open-code-review/open-code-review/internal/agent" "github.com/open-code-review/open-code-review/internal/mcp" + "github.com/open-code-review/open-code-review/internal/session" "github.com/open-code-review/open-code-review/internal/telemetry" "github.com/open-code-review/open-code-review/internal/tool" @@ -61,6 +62,11 @@ func runReview(args []string) error { return runPreview(cc, opts) } + resumeState, err := loadReviewResumeState(cc.RepoDir, opts) + if err != nil { + return err + } + rt, err := loadLLMRuntime(cc.Template, opts.toolConfigPath, opts.model) if err != nil { return err @@ -94,6 +100,7 @@ func runReview(args []string) error { From: opts.from, To: opts.to, Commit: opts.commit, + ReviewMode: reviewModeFromOptions(opts), Template: *cc.Template, SystemRule: cc.Resolver, FileFilter: cc.FileFilter, @@ -108,6 +115,7 @@ func runReview(args []string) error { Model: rt.Model, Background: opts.background, GitRunner: cc.GitRunner, + Resume: resumeState, }) // Silence progress output during execution; restored before the trace @@ -134,12 +142,51 @@ func runReview(args []string) error { if err != nil { span.SetStatus(codes.Error, err.Error()) span.RecordError(err) + if id := ag.SessionID(); id != "" { + fmt.Fprintf(os.Stderr, "[ocr] Session: %s (retry with: --resume %s)\n", id, id) + } return fmt.Errorf("review failed: %w", err) } return emitRunResult(ctx, ag, comments, startTime, opts.outputFormat, opts.audience, q) } +func loadReviewResumeState(repoDir string, opts reviewOptions) (*session.ResumeState, error) { + if opts.resume == "" { + return nil, nil + } + current := session.SessionOptions{ + ReviewMode: reviewModeFromOptions(opts), + DiffFrom: opts.from, + DiffTo: opts.to, + DiffCommit: opts.commit, + } + if current.ReviewMode == session.ReviewModeWorkspace { + return nil, fmt.Errorf("resume requires --from/--to or --commit; workspace resume is not supported") + } + state, err := session.LoadResumeState(repoDir, opts.resume) + if err != nil { + return nil, fmt.Errorf("load resume session: %w (run 'ocr session list' to see available sessions)", err) + } + if err := state.ValidateOptions(current); err != nil { + return nil, fmt.Errorf("%w (run 'ocr session list' to see available sessions)", err) + } + if state.CompletedCount() == 0 { + return nil, fmt.Errorf("resume session %q has no completed review items (run 'ocr session list' to see available sessions)", opts.resume) + } + return state, nil +} + +func reviewModeFromOptions(opts reviewOptions) string { + if opts.commit != "" { + return session.ReviewModeCommit + } + if opts.from != "" && opts.to != "" { + return session.ReviewModeRange + } + return session.ReviewModeWorkspace +} + // resolveRepoDir resolves the repo dir for `ocr rules check`. It delegates to // resolveWorkingDir(requireGit=true) so it anchors at the git top-level just // like the review path — keeping rule resolution consistent when run from a diff --git a/cmd/opencodereview/scan_cmd.go b/cmd/opencodereview/scan_cmd.go index c383579..46353ab 100644 --- a/cmd/opencodereview/scan_cmd.go +++ b/cmd/opencodereview/scan_cmd.go @@ -223,6 +223,9 @@ func runScan(args []string) error { if err != nil { span.SetStatus(codes.Error, err.Error()) span.RecordError(err) + if id := ag.SessionID(); id != "" { + fmt.Fprintf(os.Stderr, "[ocr] Session: %s\n", id) + } return fmt.Errorf("scan failed: %w", err) } diff --git a/cmd/opencodereview/session_cmd.go b/cmd/opencodereview/session_cmd.go new file mode 100644 index 0000000..805b8a5 --- /dev/null +++ b/cmd/opencodereview/session_cmd.go @@ -0,0 +1,299 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/open-code-review/open-code-review/internal/session" +) + +func runSession(args []string) error { + if len(args) == 0 { + printSessionUsage() + return nil + } + switch args[0] { + case "list", "ls": + return runSessionList(args[1:]) + case "show": + return runSessionShow(args[1:]) + case "-h", "--help": + printSessionUsage() + return nil + default: + return fmt.Errorf("unknown session sub-command: %s\nRun 'ocr session -h' for usage", args[0]) + } +} + +func runSessionList(args []string) error { + a := newOcrFlagSet("ocr session list") + var repoDir string + var asJSON bool + var limit int + a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)") + a.BoolVar(&asJSON, "json", false, "emit JSON instead of a table") + a.IntVar(&limit, "limit", 20, "cap the number of listed sessions (0 = unlimited)") + if err := a.Parse(args); err != nil { + return err + } + if a.showHelp { + printSessionListUsage() + return nil + } + + resolvedRepo, err := resolveWorkingDirForSession(repoDir) + if err != nil { + return err + } + summaries, err := session.ListSessions(resolvedRepo) + if err != nil { + return fmt.Errorf("list sessions: %w", err) + } + if limit > 0 && len(summaries) > limit { + summaries = summaries[:limit] + } + + if asJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(summaries) + } + + if len(summaries) == 0 { + fmt.Printf("No sessions found for %s\n", resolvedRepo) + return nil + } + printSessionTable(os.Stdout, summaries) + return nil +} + +func runSessionShow(args []string) error { + a := newOcrFlagSet("ocr session show") + var repoDir string + var asJSON bool + a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)") + a.BoolVar(&asJSON, "json", false, "emit JSON instead of a table") + if err := a.Parse(args); err != nil { + return err + } + if a.showHelp { + printSessionShowUsage() + return nil + } + + rest := a.fs.Args() + if len(rest) == 0 { + printSessionShowUsage() + return fmt.Errorf("session show requires a session ID") + } + sessionID := rest[0] + + resolvedRepo, err := resolveWorkingDirForSession(repoDir) + if err != nil { + return err + } + summary, items, err := session.LoadDetail(resolvedRepo, sessionID) + if err != nil { + return fmt.Errorf("load session %q: %w", sessionID, err) + } + + if asJSON { + payload := struct { + Summary *session.Summary `json:"summary"` + Items []session.ItemDetail `json:"items"` + }{Summary: summary, Items: items} + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(payload) + } + + printSessionDetail(os.Stdout, summary, items) + return nil +} + +// resolveWorkingDirForSession accepts an explicit --repo flag value and falls +// back to the current working directory. Unlike resolveRepoDir it does not +// require the target to be a git repository, so users can inspect sessions +// even after archiving a checkout. +func resolveWorkingDirForSession(input string) (string, error) { + dir, _, err := resolveWorkingDir(input, false) + if err != nil { + return "", err + } + return dir, nil +} + +func printSessionTable(w io.Writer, summaries []session.Summary) { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "SESSION ID\tMODE\tRANGE\tFILES\tCOMMENTS\tSTATUS\tSTARTED") + for _, s := range summaries { + fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n", + s.SessionID, + displayMode(s.ReviewMode), + describeRange(s), + describeFiles(s), + s.TotalComments, + describeStatus(s), + describeStart(s), + ) + } + tw.Flush() +} + +func printSessionDetail(w io.Writer, s *session.Summary, items []session.ItemDetail) { + fmt.Fprintf(w, "Session: %s\n", s.SessionID) + fmt.Fprintf(w, " File: %s\n", s.FilePath) + fmt.Fprintf(w, " Repo: %s\n", s.RepoDir) + if s.GitBranch != "" { + fmt.Fprintf(w, " Branch: %s\n", s.GitBranch) + } + if s.Model != "" { + fmt.Fprintf(w, " Model: %s\n", s.Model) + } + fmt.Fprintf(w, " Mode: %s\n", displayMode(s.ReviewMode)) + if r := describeRange(*s); r != "" && r != "-" { + fmt.Fprintf(w, " Range: %s\n", r) + } + if s.ResumedFrom != "" { + fmt.Fprintf(w, " Resumed: from session %s\n", s.ResumedFrom) + } + fmt.Fprintf(w, " Started: %s\n", describeStart(*s)) + if !s.EndTime.IsZero() { + fmt.Fprintf(w, " Ended: %s\n", s.EndTime.Local().Format("2006-01-02 15:04:05")) + } + if s.Duration > 0 { + fmt.Fprintf(w, " Duration: %s\n", s.Duration.Round(time.Second)) + } + fmt.Fprintf(w, " Status: %s\n", describeStatus(*s)) + fmt.Fprintf(w, " Files: %d completed, %d reused, %d failed\n", + s.CompletedFiles, s.ReusedFiles, s.FailedFiles) + fmt.Fprintf(w, " Comments: %d\n", s.TotalComments) + if s.LLMFailures > 0 { + fmt.Fprintf(w, " LLM err: %d\n", s.LLMFailures) + } + + if len(items) == 0 { + return + } + fmt.Fprintln(w) + fmt.Fprintln(w, "Files:") + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, " TYPE\tFILE\tCOMMENTS\tNOTE") + for _, it := range items { + note := "" + switch it.Type { + case "reused": + note = "from " + shortSessionID(it.SourceSessionID) + case "failed": + note = truncate(it.Error, 60) + } + fmt.Fprintf(tw, " %s\t%s\t%d\t%s\n", it.Type, it.FilePath, it.Comments, note) + } + tw.Flush() +} + +func displayMode(m string) string { + if m == "" { + return "-" + } + return m +} + +func describeRange(s session.Summary) string { + switch s.ReviewMode { + case session.ReviewModeRange: + if s.DiffFrom != "" || s.DiffTo != "" { + return fmt.Sprintf("%s..%s", s.DiffFrom, s.DiffTo) + } + case session.ReviewModeCommit: + if s.DiffCommit != "" { + return s.DiffCommit + } + } + return "-" +} + +func describeFiles(s session.Summary) string { + total := s.CompletedFiles + s.ReusedFiles + if s.ReusedFiles > 0 { + return fmt.Sprintf("%d (reused %d)", total, s.ReusedFiles) + } + return fmt.Sprintf("%d", total) +} + +func describeStatus(s session.Summary) string { + if s.Aborted { + return "aborted" + } + if s.FailedFiles > 0 { + return fmt.Sprintf("completed (%d fail)", s.FailedFiles) + } + return "completed" +} + +func describeStart(s session.Summary) string { + if s.StartTime.IsZero() { + return "-" + } + return s.StartTime.Local().Format("2006-01-02 15:04:05") +} + +func shortSessionID(id string) string { + if len(id) > 8 { + return id[:8] + } + return id +} + +func truncate(s string, n int) string { + s = strings.ReplaceAll(strings.ReplaceAll(s, "\n", " "), "\t", " ") + runes := []rune(s) + if len(runes) <= n { + return s + } + if n <= 1 { + return "…" + } + return string(runes[:n-1]) + "…" +} + +func printSessionUsage() { + fmt.Println(`Usage: + ocr session + +Sub-commands: + list, ls List recent review sessions for the current repo + show Show one session's metadata and per-file items + +Use "ocr session list -h" or "ocr session show -h" for details.`) +} + +func printSessionListUsage() { + fmt.Println(`Usage: + ocr session list [flags] + ocr session ls [flags] + +List review sessions previously persisted to ~/.opencodereview/sessions/. The +session id printed here can be passed to 'ocr review --resume '. + +Flags: + --repo string Root directory of the git repository (default: current dir) + --json Emit JSON instead of a table + --limit int Cap the number of listed sessions (default 20; 0 = unlimited)`) +} + +func printSessionShowUsage() { + fmt.Println(`Usage: + ocr session show [flags] + +Show metadata and per-file items for a single session. + +Flags: + --repo string Root directory of the git repository (default: current dir) + --json Emit JSON instead of a table`) +} diff --git a/cmd/opencodereview/session_cmd_test.go b/cmd/opencodereview/session_cmd_test.go new file mode 100644 index 0000000..1c32a71 --- /dev/null +++ b/cmd/opencodereview/session_cmd_test.go @@ -0,0 +1,170 @@ +package main + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/open-code-review/open-code-review/internal/model" + "github.com/open-code-review/open-code-review/internal/session" +) + +func TestRunSessionList_TextIncludesSessionID(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ + ReviewMode: session.ReviewModeCommit, + DiffCommit: "abc123", + }) + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}}) + sh.Finalize() + + got := captureStdout(t, func() { + if err := runSessionList([]string{"--repo", repoDir}); err != nil { + t.Fatalf("runSessionList: %v", err) + } + }) + + if !strings.Contains(got, sh.SessionID) { + t.Errorf("expected list output to contain session id %s, got %q", sh.SessionID, got) + } + if !strings.Contains(got, "abc123") { + t.Errorf("expected list output to contain commit range, got %q", got) + } + if !strings.Contains(got, "SESSION ID") { + t.Errorf("expected header, got %q", got) + } +} + +func TestRunSessionList_JSON(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ + ReviewMode: session.ReviewModeCommit, + DiffCommit: "abc123", + }) + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil) + sh.Finalize() + + got := captureStdout(t, func() { + if err := runSessionList([]string{"--repo", repoDir, "--json"}); err != nil { + t.Fatalf("runSessionList: %v", err) + } + }) + + var decoded []session.Summary + if err := json.Unmarshal([]byte(got), &decoded); err != nil { + t.Fatalf("unmarshal: %v (out=%q)", err, got) + } + if len(decoded) != 1 || decoded[0].SessionID != sh.SessionID { + t.Fatalf("decoded = %+v", decoded) + } +} + +func TestRunSessionList_EmptyRepo(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + got := captureStdout(t, func() { + if err := runSessionList([]string{"--repo", repoDir}); err != nil { + t.Fatalf("runSessionList: %v", err) + } + }) + if !strings.Contains(got, "No sessions found") { + t.Errorf("expected empty message, got %q", got) + } +} + +func TestRunSessionShow_Text(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ + ReviewMode: session.ReviewModeCommit, + DiffCommit: "abc123", + }) + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}}) + sh.RecordReviewItemFailed("bad.go", "bad.go", "bad.go", "fp-bad", "boom") + sh.Finalize() + + got := captureStdout(t, func() { + if err := runSessionShow([]string{"--repo", repoDir, sh.SessionID}); err != nil { + t.Fatalf("runSessionShow: %v", err) + } + }) + + for _, want := range []string{sh.SessionID, "abc123", "a.go", "bad.go", "boom", "Files:"} { + if !strings.Contains(got, want) { + t.Errorf("expected output to contain %q, got %q", want, got) + } + } +} + +func TestRunSessionShow_JSON(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := session.New(repoDir, "main", "test-model", session.SessionOptions{ + ReviewMode: session.ReviewModeCommit, + DiffCommit: "abc123", + }) + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", nil) + sh.Finalize() + + got := captureStdout(t, func() { + if err := runSessionShow([]string{"--repo", repoDir, "--json", sh.SessionID}); err != nil { + t.Fatalf("runSessionShow: %v", err) + } + }) + + var payload struct { + Summary *session.Summary `json:"summary"` + Items []session.ItemDetail `json:"items"` + } + if err := json.Unmarshal([]byte(got), &payload); err != nil { + t.Fatalf("unmarshal: %v (out=%q)", err, got) + } + if payload.Summary == nil || payload.Summary.SessionID != sh.SessionID { + t.Fatalf("summary mismatch: %+v", payload.Summary) + } + if len(payload.Items) != 1 || payload.Items[0].FilePath != "a.go" { + t.Fatalf("items = %+v", payload.Items) + } +} + +func TestRunSessionShow_MissingID(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + got := captureStdout(t, func() { + if err := runSessionShow([]string{}); err == nil { + t.Fatal("expected error for missing session id") + } + }) + if !strings.Contains(got, "session show") { + t.Errorf("expected usage output, got %q", got) + } +} + +func TestTruncateUnicode(t *testing.T) { + got := truncate("错误原因:超过限制", 6) + if !strings.HasSuffix(got, "…") { + t.Fatalf("expected ellipsis suffix, got %q", got) + } + if !strings.Contains(got, "错误") { + t.Fatalf("expected valid truncated unicode text, got %q", got) + } +} + +func TestRunSession_UnknownSubcommand(t *testing.T) { + err := runSession([]string{"bogus"}) + if err == nil { + t.Fatal("expected error for unknown sub-command") + } +} diff --git a/cmd/opencodereview/shared.go b/cmd/opencodereview/shared.go index 4d70c84..c99c1b2 100644 --- a/cmd/opencodereview/shared.go +++ b/cmd/opencodereview/shared.go @@ -251,6 +251,14 @@ type ResultProvider interface { // that skipped / failed the summary phase. ProjectSummary() string ToolCalls() map[string]int64 + // SessionID returns the persisted session identifier so callers can show it + // in JSON output or failure diagnostics. Returns "" when no session was + // created. + SessionID() string +} + +type resumeInfoProvider interface { + ResumeInfo() *agent.ResumeInfo } // emitRunResult is the post-LLM-run finalization shared by `ocr review` and @@ -295,10 +303,14 @@ func emitRunResult( } if outputFormat == "json" { + var resumeInfo *agent.ResumeInfo + if p, ok := ag.(resumeInfoProvider); ok { + resumeInfo = p.ResumeInfo() + } return outputJSONWithWarnings(comments, ag.Warnings(), ag.FilesReviewed(), ag.TotalInputTokens(), ag.TotalOutputTokens(), ag.TotalTokensUsed(), ag.TotalCacheReadTokens(), ag.TotalCacheWriteTokens(), duration, - ag.ProjectSummary(), ag.ToolCalls(), traceID) + ag.ProjectSummary(), ag.ToolCalls(), traceID, resumeInfo, ag.SessionID()) } outputTextWithWarnings(comments, ag.Warnings()) if summary := ag.ProjectSummary(); summary != "" { diff --git a/internal/agent/agent.go b/internal/agent/agent.go index e7e02c9..a50dead 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -2,6 +2,7 @@ package agent import ( "context" + "crypto/sha256" "encoding/json" "fmt" "runtime/debug" @@ -113,6 +114,9 @@ type Args struct { // Session is an optional session history instance for collecting conversation records. // When nil, a default one is created automatically with git branch auto-detected from repoDir. Session *session.SessionHistory + + // Resume is an optional read-only checkpoint index from a previous review session. + Resume *session.ResumeState } // Agent orchestrates the AI-powered code review. LLM tool-use loop / memory @@ -127,6 +131,16 @@ type Agent struct { session *session.SessionHistory subtaskFailed int64 // count of failed subtasks, accessed atomically runner *llmloop.Runner + resumeInfo *ResumeInfo +} + +// ResumeInfo summarizes file-level reuse for a resumed review. +type ResumeInfo struct { + ResumedFrom string `json:"resumed_from"` + ReusedFiles int64 `json:"reused_files"` + RerunFiles int64 `json:"rerun_files"` + PreviousModel string `json:"previous_model,omitempty"` + CurrentModel string `json:"current_model,omitempty"` } // New creates a new Agent from the given arguments. @@ -144,10 +158,11 @@ func New(args Args) *Agent { mode = reviewModeString(args.From, args.To, args.Commit) } args.Session = session.New(args.RepoDir, gitBranch, args.Model, session.SessionOptions{ - ReviewMode: mode, - DiffFrom: args.From, - DiffTo: args.To, - DiffCommit: args.Commit, + ReviewMode: mode, + DiffFrom: args.From, + DiffTo: args.To, + DiffCommit: args.Commit, + ResumedFrom: resumedFromSession(args.Resume), }) } a := &Agent{ @@ -225,6 +240,23 @@ func (a *Agent) Session() *session.SessionHistory { return a.session } +// SessionID returns the current review's session id, or "" when no session has been created. +func (a *Agent) SessionID() string { + if a == nil || a.session == nil { + return "" + } + return a.session.SessionID +} + +// ResumeInfo returns resume metadata for output. Nil means this was not a resume run. +func (a *Agent) ResumeInfo() *ResumeInfo { + if a.resumeInfo == nil { + return nil + } + info := *a.resumeInfo + return &info +} + // FilesReviewed returns the number of changed files included in this review. func (a *Agent) FilesReviewed() int64 { return int64(len(a.diffs)) @@ -327,6 +359,7 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error if len(a.diffs) == 0 { return nil, fmt.Errorf("all diffs filtered out by token size") } + toDispatch := a.applyResume(a.diffs) var wg sync.WaitGroup @@ -339,8 +372,8 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error timeout := time.Duration(a.args.ConcurrentTaskTimeout) * time.Minute var dispatched int64 - for i := range a.diffs { - if a.diffs[i].IsDeleted { + for i := range toDispatch { + if toDispatch[i].IsDeleted { continue } dispatched++ @@ -348,6 +381,7 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error sem <- struct{}{} // acquire semaphore go func(d model.Diff) { + fingerprint := reviewItemFingerprint(a.reviewMode(), d) defer wg.Done() defer func() { <-sem }() // release // A panic while reviewing one file must be isolated exactly like an @@ -359,6 +393,7 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error defer func() { if r := recover(); r != nil { atomic.AddInt64(&a.subtaskFailed, 1) + a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, fmt.Sprintf("panic: %v", r)) fmt.Fprintf(stdout.Writer(), "[ocr] Subtask panic for %s: %v\n%s\n", d.NewPath, r, debug.Stack()) telemetry.ErrorEvent(ctx, "subtask.panic", fmt.Errorf("panic: %v", r), telemetry.AnyToAttr("file.path", d.NewPath)) @@ -375,20 +410,31 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error fileCtx = ctx } - if err := a.executeSubtask(fileCtx, d); err != nil { + completed, skipReason, err := a.executeSubtask(fileCtx, d) + if err != nil { atomic.AddInt64(&a.subtaskFailed, 1) + a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, err.Error()) fmt.Fprintf(stdout.Writer(), "[ocr] Subtask error for %s: %v\n", d.NewPath, err) telemetry.ErrorEvent(fileCtx, "subtask.error", err, telemetry.AnyToAttr("file.path", d.NewPath)) a.recordWarning("subtask_error", d.NewPath, err.Error()) + return } - }(a.diffs[i]) + if !completed { + if skipReason != "" { + a.session.RecordReviewItemFailed(d.NewPath, d.OldPath, d.NewPath, fingerprint, skipReason) + } + return + } + comments := a.args.CommentCollector.CommentsForPath(d.NewPath) + a.session.RecordReviewItemDone(d.NewPath, d.OldPath, d.NewPath, fingerprint, comments) + }(toDispatch[i]) } wg.Wait() if dispatched == 0 { - return []model.LlmComment{}, nil + return a.args.CommentCollector.Comments(), nil } // All subtasks finished — collect comments from the global collector once. @@ -404,8 +450,76 @@ func (a *Agent) dispatchSubtasks(ctx context.Context) ([]model.LlmComment, error return a.args.CommentCollector.Comments(), nil } +func (a *Agent) applyResume(diffs []model.Diff) []model.Diff { + resume := a.args.Resume + if resume == nil { + return diffs + } + + mode := a.reviewMode() + toDispatch := make([]model.Diff, 0, len(diffs)) + var reused int64 + for _, d := range diffs { + if d.IsDeleted { + toDispatch = append(toDispatch, d) + continue + } + fingerprint := reviewItemFingerprint(mode, d) + item, ok := resume.Item(fingerprint) + if !ok { + toDispatch = append(toDispatch, d) + continue + } + for _, cm := range item.Comments { + a.args.CommentCollector.Add(cm) + } + a.session.RecordReviewItemReused(effectivePath(d), d.OldPath, d.NewPath, fingerprint, resume.SessionID, item.Comments) + reused++ + } + + rerun := countDispatchable(toDispatch) + a.resumeInfo = &ResumeInfo{ + ResumedFrom: resume.SessionID, + ReusedFiles: reused, + RerunFiles: rerun, + PreviousModel: resume.Model, + CurrentModel: a.args.Model, + } + fmt.Fprintf(stdout.Writer(), "[ocr] Resume %s: reusing %d file(s), reviewing %d file(s)\n", resume.SessionID, reused, rerun) + return toDispatch +} + +func countDispatchable(diffs []model.Diff) int64 { + var n int64 + for _, d := range diffs { + if !d.IsDeleted { + n++ + } + } + return n +} + +func (a *Agent) reviewMode() string { + if a.args.ReviewMode != "" { + return a.args.ReviewMode + } + return reviewModeString(a.args.From, a.args.To, a.args.Commit) +} + +func reviewItemFingerprint(mode string, d model.Diff) string { + sum := sha256.Sum256([]byte(mode + "\x00" + d.OldPath + "\x00" + d.NewPath + "\x00" + d.Diff)) + return fmt.Sprintf("%x", sum) +} + +func resumedFromSession(resume *session.ResumeState) string { + if resume == nil { + return "" + } + return resume.SessionID +} + // executeSubtask performs the Plan Phase + Main Loop for a single file. -func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error { +func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) (bool, string, error) { ctx, span := telemetry.StartSpan(ctx, "subtask.execute."+d.NewPath) defer span.End() telemetry.SetAttr(span, "file.path", d.NewPath) @@ -414,7 +528,7 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error { telemetry.SetAttr(span, "lines.deleted", d.Deletions) if ctx.Err() != nil { - return ctx.Err() + return false, "", ctx.Err() } newPath := d.NewPath @@ -448,7 +562,7 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error { // Phase 2: Main task loop if len(a.args.Template.MainTask.Messages) == 0 { - return fmt.Errorf("main_task.messages is empty in template") + return false, "", fmt.Errorf("main_task.messages is empty in template") } rawMsgs := a.args.Template.MainTask.Messages @@ -486,19 +600,20 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error { telemetry.AnyToAttr("file.path", newPath), telemetry.AnyToAttr("tokens", tokenCount), telemetry.AnyToAttr("max_tokens", maxAllowed)) - return nil + return false, msg, nil } - err := func() error { + mainCompleted, err := func() (bool, error) { ctx, mainSpan := telemetry.StartSpan(ctx, "main.loop") defer mainSpan.End() telemetry.SetAttr(mainSpan, "file.path", newPath) - if err := a.runner.RunPerFile(ctx, messages, newPath); err != nil { + completed, err := a.runner.RunPerFile(ctx, messages, newPath) + if err != nil { mainSpan.SetStatus(codes.Error, err.Error()) mainSpan.RecordError(err) - return err + return false, err } - return nil + return completed, nil }() if err == nil { // REVIEW_FILTER_TASK runs after the main loop and decides which of the @@ -509,7 +624,13 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) error { } a.executeReviewFilter(ctx, d, newPath) } - return err + if err != nil { + return false, "", err + } + if !mainCompleted { + return false, "main_task did not complete before stopping", nil + } + return true, "", nil } // executeReviewFilter runs the REVIEW_FILTER_TASK to remove comments that are diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 99f8280..24095dd 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -10,6 +10,7 @@ import ( "github.com/open-code-review/open-code-review/internal/config/toolsconfig" "github.com/open-code-review/open-code-review/internal/llm" "github.com/open-code-review/open-code-review/internal/model" + "github.com/open-code-review/open-code-review/internal/session" "github.com/open-code-review/open-code-review/internal/tool" ) @@ -407,6 +408,62 @@ func TestFilterLargeDiffs_ZeroMaxTokens(t *testing.T) { } } +func TestApplyResumeReusesCompletedItemsAcrossModels(t *testing.T) { + diffs := []model.Diff{ + {OldPath: "a.go", NewPath: "a.go", Diff: "+a", Insertions: 1}, + {OldPath: "b.go", NewPath: "b.go", Diff: "+b", Insertions: 1}, + } + fp := reviewItemFingerprint(session.ReviewModeRange, diffs[0]) + resume := &session.ResumeState{ + SessionID: "old-session", + Model: "anthropic-model", + ReviewMode: session.ReviewModeRange, + DiffFrom: "main", + DiffTo: "feature", + Items: map[string]session.ResumeItem{ + fp: { + FilePath: "a.go", + OldPath: "a.go", + NewPath: "a.go", + Fingerprint: fp, + Comments: []model.LlmComment{{ + Path: "a.go", + Content: "cached comment", + }}, + }, + }, + } + collector := tool.NewCommentCollector() + sess := session.New(t.TempDir(), "feature", "openai-model", session.SessionOptions{ + ReviewMode: session.ReviewModeRange, + DiffFrom: "main", + DiffTo: "feature", + ResumedFrom: "old-session", + }) + defer sess.Finalize() + a := New(Args{ + From: "main", + To: "feature", + Model: "openai-model", + CommentCollector: collector, + Resume: resume, + Session: sess, + }) + + toDispatch := a.applyResume(diffs) + if len(toDispatch) != 1 || toDispatch[0].NewPath != "b.go" { + t.Fatalf("toDispatch = %+v, want only b.go", toDispatch) + } + comments := collector.Comments() + if len(comments) != 1 || comments[0].Content != "cached comment" { + t.Fatalf("comments = %+v", comments) + } + info := a.ResumeInfo() + if info == nil || info.ReusedFiles != 1 || info.RerunFiles != 1 || info.PreviousModel != "anthropic-model" || info.CurrentModel != "openai-model" { + t.Fatalf("ResumeInfo = %+v", info) + } +} + func TestCountReviewable(t *testing.T) { a := New(Args{}) diffs := []model.Diff{ @@ -500,6 +557,140 @@ func TestDispatchSubtasks_WithFakeLLM(t *testing.T) { } } +func TestDispatchSubtasks_TokenThresholdSkipIsNotReusableCheckpoint(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + sess := session.New(repoDir, "feature", "fake", session.SessionOptions{ + ReviewMode: session.ReviewModeRange, + DiffFrom: "main", + DiffTo: "feature", + }) + + client := &fakeAgentClient{responses: []*llm.ChatResponse{ + agentTaskDoneResponse(), + }} + a := New(Args{ + From: "main", + To: "feature", + LLMClient: client, + Model: "fake", + Session: sess, + Template: template.Template{ + MaxTokens: 100, + MaxToolRequestTimes: 5, + MainTask: template.LlmConversation{ + Messages: []template.ChatMessage{ + {Role: "user", Content: strings.Repeat("context ", 200) + "{{diff}}"}, + }, + }, + }, + }) + diff := model.Diff{NewPath: "large-prompt.go", OldPath: "large-prompt.go", Diff: "+x", Insertions: 1} + a.diffs = []model.Diff{diff} + a.currentDate = "2025-06-26 10:00" + + comments, err := a.dispatchSubtasks(context.Background()) + if err != nil { + t.Fatalf("dispatchSubtasks: %v", err) + } + if len(comments) != 0 { + t.Fatalf("expected no comments, got %d", len(comments)) + } + if client.calls != 0 { + t.Fatalf("threshold skip should not call LLM, got %d calls", client.calls) + } + sess.Finalize() + + state, err := session.LoadResumeState(repoDir, sess.SessionID) + if err != nil { + t.Fatalf("LoadResumeState: %v", err) + } + if state.CompletedCount() != 0 { + t.Fatalf("CompletedCount = %d, want 0", state.CompletedCount()) + } + fp := reviewItemFingerprint(session.ReviewModeRange, diff) + if _, ok := state.Item(fp); ok { + t.Fatal("token-threshold skip was recorded as a reusable checkpoint") + } + summary, items, err := session.LoadDetail(repoDir, sess.SessionID) + if err != nil { + t.Fatalf("LoadDetail: %v", err) + } + if summary.CompletedFiles != 0 || summary.FailedFiles != 1 { + t.Fatalf("summary counts = completed %d failed %d, want completed 0 failed 1", summary.CompletedFiles, summary.FailedFiles) + } + if len(items) != 1 || items[0].Type != "failed" || !strings.Contains(items[0].Error, "prompt tokens") { + t.Fatalf("items = %+v, want one token-threshold failed item", items) + } +} + +func TestDispatchSubtasks_MainTaskWithoutTaskDoneIsNotReusableCheckpoint(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + sess := session.New(repoDir, "feature", "fake", session.SessionOptions{ + ReviewMode: session.ReviewModeRange, + DiffFrom: "main", + DiffTo: "feature", + }) + + emptyContent := "" + client := &fakeAgentClient{responses: []*llm.ChatResponse{{ + Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &emptyContent}}}, + Model: "fake", + Usage: &llm.UsageInfo{PromptTokens: 10, CompletionTokens: 1}, + }}} + a := New(Args{ + From: "main", + To: "feature", + LLMClient: client, + Model: "fake", + Session: sess, + Template: template.Template{ + MaxTokens: 100000, + MaxToolRequestTimes: 1, + MainTask: template.LlmConversation{ + Messages: []template.ChatMessage{{Role: "user", Content: "Review {{diff}}"}}, + }, + }, + }) + diff := model.Diff{NewPath: "needs-review.go", OldPath: "needs-review.go", Diff: "+x", Insertions: 1} + a.diffs = []model.Diff{diff} + a.currentDate = "2025-06-26 10:00" + + _, err := a.dispatchSubtasks(context.Background()) + if err != nil { + t.Fatalf("dispatchSubtasks: %v", err) + } + if client.calls != 1 { + t.Fatalf("LLM calls = %d, want 1", client.calls) + } + sess.Finalize() + + state, err := session.LoadResumeState(repoDir, sess.SessionID) + if err != nil { + t.Fatalf("LoadResumeState: %v", err) + } + if state.CompletedCount() != 0 { + t.Fatalf("CompletedCount = %d, want 0", state.CompletedCount()) + } + fp := reviewItemFingerprint(session.ReviewModeRange, diff) + if _, ok := state.Item(fp); ok { + t.Fatal("incomplete main task was recorded as a reusable checkpoint") + } + summary, items, err := session.LoadDetail(repoDir, sess.SessionID) + if err != nil { + t.Fatalf("LoadDetail: %v", err) + } + if summary.CompletedFiles != 0 || summary.FailedFiles != 1 { + t.Fatalf("summary counts = completed %d failed %d, want completed 0 failed 1", summary.CompletedFiles, summary.FailedFiles) + } + if len(items) != 1 || items[0].Type != "failed" || !strings.Contains(items[0].Error, "main_task did not complete") { + t.Fatalf("items = %+v, want one incomplete-main failed item", items) + } +} + func TestDispatchSubtasks_AllDeleted(t *testing.T) { client := &fakeAgentClient{} a := New(Args{ diff --git a/internal/agent/coverage_test.go b/internal/agent/coverage_test.go index f580942..018c86c 100644 --- a/internal/agent/coverage_test.go +++ b/internal/agent/coverage_test.go @@ -412,10 +412,16 @@ func TestExecuteSubtask_EmptyMainTask(t *testing.T) { }) a.currentDate = "2025-06-26 10:00" - err := a.executeSubtask(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1}) + completed, skipReason, err := a.executeSubtask(context.Background(), model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1}) if err == nil { t.Fatal("expected error for empty main_task messages") } + if completed { + t.Fatal("empty main_task should not complete review") + } + if skipReason != "" { + t.Fatalf("skipReason = %q, want empty on error", skipReason) + } if !strings.Contains(err.Error(), "main_task.messages is empty") { t.Errorf("unexpected error: %v", err) } @@ -442,10 +448,16 @@ func TestExecuteSubtask_TokenThresholdExceeded(t *testing.T) { a.currentDate = "2025-06-26 10:00" a.diffs = []model.Diff{{NewPath: "a.go", Diff: strings.Repeat("code ", 200), Insertions: 100}} - err := a.executeSubtask(context.Background(), a.diffs[0]) + completed, skipReason, err := a.executeSubtask(context.Background(), a.diffs[0]) if err != nil { t.Fatalf("unexpected error: %v", err) } + if completed { + t.Fatal("token-threshold skip should not complete review") + } + if skipReason == "" { + t.Fatal("expected skip reason for token-threshold skip") + } warnings := a.Warnings() found := false @@ -514,10 +526,16 @@ func TestExecuteSubtask_WithPlanPhase(t *testing.T) { a.currentDate = "2025-06-26 10:00" a.diffs = []model.Diff{{NewPath: "main.go", OldPath: "main.go", Diff: "+new code", Insertions: 5}} - err := a.executeSubtask(context.Background(), a.diffs[0]) + completed, skipReason, err := a.executeSubtask(context.Background(), a.diffs[0]) if err != nil { t.Fatalf("executeSubtask: %v", err) } + if !completed { + t.Fatal("expected completed review") + } + if skipReason != "" { + t.Fatalf("skipReason = %q, want empty on completed review", skipReason) + } } func TestExecuteSubtask_ContextCancelled(t *testing.T) { @@ -538,10 +556,16 @@ func TestExecuteSubtask_ContextCancelled(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - err := a.executeSubtask(ctx, model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1}) + completed, skipReason, err := a.executeSubtask(ctx, model.Diff{NewPath: "a.go", Diff: "+x", Insertions: 1}) if err == nil { t.Fatal("expected error for cancelled context") } + if completed { + t.Fatal("cancelled context should not complete review") + } + if skipReason != "" { + t.Fatalf("skipReason = %q, want empty on error", skipReason) + } } func TestExecuteReviewFilter_WithTimeout(t *testing.T) { diff --git a/internal/llmloop/loop.go b/internal/llmloop/loop.go index 38d7b3f..ee0fe6a 100644 --- a/internal/llmloop/loop.go +++ b/internal/llmloop/loop.go @@ -144,8 +144,9 @@ func (r *Runner) CollectPendingComments() []model.LlmComment { // It sends messages with the configured tool definitions, executes any // tool calls returned by the model, and collects review comments until // task_done is called or limits are reached. Token usage and warnings -// are aggregated on the Runner across all files. -func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath string) error { +// are aggregated on the Runner across all files. The returned bool is true +// only when the model explicitly calls task_done. +func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath string) (bool, error) { toolReqCount := r.deps.Template.MaxToolRequestTimes const maxConsecutiveEmptyRounds = 3 consecutiveEmptyRounds := 0 @@ -153,7 +154,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath for toolReqCount > 0 { select { case <-ctx.Done(): - return ctx.Err() + return false, ctx.Err() default: } @@ -176,7 +177,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath telemetry.RecordLLMResult(llmSpan, duration, 0, err) llmSpan.End() telemetry.RecordLLMRequest(ctx, r.deps.Model, duration, 0, "error") - return fmt.Errorf("LLM completion error: %w", err) + return false, fmt.Errorf("LLM completion error: %w", err) } rec.SetResponse(resp, duration) totalTokens := int64(0) @@ -233,7 +234,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath } if taskCompleted { - break + return true, nil } if !hasValidResult { consecutiveEmptyRounds++ @@ -256,7 +257,7 @@ func (r *Runner) RunPerFile(ctx context.Context, messages []llm.Message, newPath if toolReqCount <= 0 { fmt.Fprintf(stdout.Writer(), "[ocr] Max tool requests reached for %s.\n", newPath) } - return nil + return false, nil } // executeToolCall dispatches a single tool call from the LLM response and diff --git a/internal/llmloop/loop_test.go b/internal/llmloop/loop_test.go index 5b6d449..352fc20 100644 --- a/internal/llmloop/loop_test.go +++ b/internal/llmloop/loop_test.go @@ -99,10 +99,13 @@ func TestRunPerFile_TaskDoneImmediately(t *testing.T) { runner := NewRunner(deps) msgs := []llm.Message{llm.NewTextMessage("user", "review this file")} - err := runner.RunPerFile(context.Background(), msgs, "main.go") + completed, err := runner.RunPerFile(context.Background(), msgs, "main.go") if err != nil { t.Fatalf("RunPerFile: %v", err) } + if !completed { + t.Fatal("expected task_done to complete RunPerFile") + } if client.calls != 1 { t.Errorf("expected 1 LLM call, got %d", client.calls) } @@ -123,10 +126,13 @@ func TestRunPerFile_ToolCallThenDone(t *testing.T) { runner := NewRunner(deps) msgs := []llm.Message{llm.NewTextMessage("user", "review")} - err := runner.RunPerFile(context.Background(), msgs, "main.go") + completed, err := runner.RunPerFile(context.Background(), msgs, "main.go") if err != nil { t.Fatalf("RunPerFile: %v", err) } + if !completed { + t.Fatal("expected task_done to complete RunPerFile") + } if client.calls != 2 { t.Errorf("expected 2 LLM calls, got %d", client.calls) } @@ -149,10 +155,13 @@ func TestRunPerFile_ContextCancelled(t *testing.T) { cancel() msgs := []llm.Message{llm.NewTextMessage("user", "review")} - err := runner.RunPerFile(ctx, msgs, "main.go") + completed, err := runner.RunPerFile(ctx, msgs, "main.go") if err == nil { t.Error("expected error for cancelled context") } + if completed { + t.Fatal("cancelled context should not complete RunPerFile") + } } func TestRunPerFile_UnknownTool(t *testing.T) { @@ -179,15 +188,39 @@ func TestRunPerFile_UnknownTool(t *testing.T) { runner := NewRunner(deps) msgs := []llm.Message{llm.NewTextMessage("user", "review")} - err := runner.RunPerFile(context.Background(), msgs, "main.go") + completed, err := runner.RunPerFile(context.Background(), msgs, "main.go") if err != nil { t.Fatalf("RunPerFile: %v", err) } + if !completed { + t.Fatal("expected task_done to complete RunPerFile") + } if client.calls != 2 { t.Errorf("expected 2 calls, got %d", client.calls) } } +func TestRunPerFile_MaxToolRequestsWithoutTaskDoneDoesNotComplete(t *testing.T) { + content := "" + client := &fakeClient{responses: []*llm.ChatResponse{{ + Choices: []llm.Choice{{Message: llm.ResponseMessage{Content: &content}}}, + Model: "fake", + Usage: &llm.UsageInfo{PromptTokens: 5, CompletionTokens: 5}, + }}} + deps := newTestDeps(client) + deps.Template.MaxToolRequestTimes = 1 + runner := NewRunner(deps) + + msgs := []llm.Message{llm.NewTextMessage("user", "review")} + completed, err := runner.RunPerFile(context.Background(), msgs, "main.go") + if err != nil { + t.Fatalf("RunPerFile: %v", err) + } + if completed { + t.Fatal("RunPerFile completed without task_done") + } +} + func TestRunner_RecordWarning(t *testing.T) { deps := newTestDeps(&fakeClient{}) runner := NewRunner(deps) diff --git a/internal/scan/agent.go b/internal/scan/agent.go index d9095e9..aa0b834 100644 --- a/internal/scan/agent.go +++ b/internal/scan/agent.go @@ -154,6 +154,14 @@ func toLoopTemplate(s template.ScanTemplate) template.Template { // Session returns the session history associated with this Agent. func (a *Agent) Session() *session.SessionHistory { return a.session } +// SessionID returns the current scan's session id, or "" when no session has been created. +func (a *Agent) SessionID() string { + if a == nil || a.session == nil { + return "" + } + return a.session.SessionID +} + // FilesReviewed returns the number of items included in this scan. func (a *Agent) FilesReviewed() int64 { return int64(len(a.items)) } @@ -553,7 +561,8 @@ func (a *Agent) executeSubtask(ctx context.Context, it model.ScanItem) error { return nil } - return a.runner.RunPerFile(ctx, messages, it.Path) + _, err := a.runner.RunPerFile(ctx, messages, it.Path) + return err } // maybeRunPlan invokes PLAN_TASK on the file and returns a human-readable diff --git a/internal/session/history.go b/internal/session/history.go index 6ece4da..c64ac88 100644 --- a/internal/session/history.go +++ b/internal/session/history.go @@ -10,6 +10,7 @@ import ( "time" "github.com/open-code-review/open-code-review/internal/llm" + "github.com/open-code-review/open-code-review/internal/model" ) // TaskType identifies the kind of LLM request within a file subtask. @@ -42,6 +43,7 @@ type SessionHistory struct { DiffFrom string DiffTo string DiffCommit string + ResumedFrom string StartTime time.Time EndTime time.Time persist *jsonlWriter @@ -96,10 +98,11 @@ type ToolResultRecord struct { // SessionOptions holds optional metadata for a new session. type SessionOptions struct { - ReviewMode string - DiffFrom string - DiffTo string - DiffCommit string + ReviewMode string + DiffFrom string + DiffTo string + DiffCommit string + ResumedFrom string } // New creates a new SessionHistory with the given repo directory. @@ -114,6 +117,7 @@ func New(repoDir, gitBranch, model string, opts SessionOptions) *SessionHistory DiffFrom: opts.DiffFrom, DiffTo: opts.DiffTo, DiffCommit: opts.DiffCommit, + ResumedFrom: opts.ResumedFrom, StartTime: time.Now(), FileSessions: make(map[string]*FileSession), } @@ -147,6 +151,54 @@ func (sh *SessionHistory) GetOrCreateFileSession(filePath string) *FileSession { return fs } +// RecordReviewItemDone persists the file-level checkpoint used by resume. +func (sh *SessionHistory) RecordReviewItemDone(filePath, oldPath, newPath, fingerprint string, comments []model.LlmComment) { + if sh == nil { + return + } + if filePath == "" { + filePath = newPath + } + if filePath != "" { + sh.GetOrCreateFileSession(filePath) + } + if p := sh.persist; p != nil { + p.WriteReviewItemDone(filePath, oldPath, newPath, fingerprint, comments) + } +} + +// RecordReviewItemReused records that this run reused a checkpoint from another session. +func (sh *SessionHistory) RecordReviewItemReused(filePath, oldPath, newPath, fingerprint, sourceSessionID string, comments []model.LlmComment) { + if sh == nil { + return + } + if filePath == "" { + filePath = newPath + } + if filePath != "" { + sh.GetOrCreateFileSession(filePath) + } + if p := sh.persist; p != nil { + p.WriteReviewItemReused(filePath, oldPath, newPath, fingerprint, sourceSessionID, comments) + } +} + +// RecordReviewItemFailed persists an incomplete file-level checkpoint. +func (sh *SessionHistory) RecordReviewItemFailed(filePath, oldPath, newPath, fingerprint, errorMsg string) { + if sh == nil { + return + } + if filePath == "" { + filePath = newPath + } + if filePath != "" { + sh.GetOrCreateFileSession(filePath) + } + if p := sh.persist; p != nil { + p.WriteReviewItemFailed(filePath, oldPath, newPath, fingerprint, errorMsg) + } +} + // Finalize marks the session as complete, sets the end time, and persists // the final summary record. func (sh *SessionHistory) Finalize() { diff --git a/internal/session/list.go b/internal/session/list.go new file mode 100644 index 0000000..3e91494 --- /dev/null +++ b/internal/session/list.go @@ -0,0 +1,293 @@ +package session + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// Summary is a compact digest of one persisted session, suitable for +// listing recent runs. +type Summary struct { + SessionID string `json:"session_id"` + FilePath string `json:"file_path"` + RepoDir string `json:"repo_dir"` + GitBranch string `json:"git_branch,omitempty"` + Model string `json:"model,omitempty"` + ReviewMode string `json:"review_mode,omitempty"` + DiffFrom string `json:"diff_from,omitempty"` + DiffTo string `json:"diff_to,omitempty"` + DiffCommit string `json:"diff_commit,omitempty"` + ResumedFrom string `json:"resumed_from,omitempty"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time,omitempty"` + Duration time.Duration `json:"duration_ns,omitempty"` + CompletedFiles int `json:"completed_files"` + FailedFiles int `json:"failed_files"` + ReusedFiles int `json:"reused_files"` + TotalComments int `json:"total_comments"` + LLMFailures int64 `json:"llm_failures"` + Aborted bool `json:"aborted"` +} + +// ItemDetail describes one file-level record within a session, used by `ocr session show`. +type ItemDetail struct { + Type string `json:"type"` + Timestamp time.Time `json:"timestamp"` + FilePath string `json:"file_path"` + OldPath string `json:"old_path,omitempty"` + NewPath string `json:"new_path,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` + Comments int `json:"comments"` + SourceSessionID string `json:"source_session_id,omitempty"` + Error string `json:"error,omitempty"` +} + +// summaryRecord is a superset of resumeRecord that also carries session_end fields. +type summaryRecord struct { + Type string `json:"type"` + SessionID string `json:"sessionId"` + Timestamp string `json:"timestamp"` + Cwd string `json:"cwd"` + GitBranch string `json:"gitBranch"` + Model string `json:"model"` + ReviewMode string `json:"reviewMode"` + DiffFrom string `json:"diffFrom"` + DiffTo string `json:"diffTo"` + DiffCommit string `json:"diffCommit"` + ResumedFrom string `json:"resumedFrom"` + FilePath string `json:"filePath"` + OldPath string `json:"oldPath"` + NewPath string `json:"newPath"` + Fingerprint string `json:"fingerprint"` + SourceSessionID string `json:"sourceSessionId"` + Error string `json:"error"` + Comments json.RawMessage `json:"comments"` + FilesReviewed []string `json:"files_reviewed"` + DurationSeconds float64 `json:"duration_seconds"` + LLMFailures int64 `json:"llm_failures"` +} + +// SessionsDir returns the on-disk directory that holds JSONL session files +// for a given repository. It does not create the directory. +func SessionsDir(repoDir string) (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + return filepath.Join(home, ".opencodereview", sessionSubDir, encodeRepoPath(repoDir)), nil +} + +// ListSessions enumerates all persisted sessions for the given repository +// directory, sorted by StartTime descending (most recent first). Missing +// directories return an empty slice with no error. +func ListSessions(repoDir string) ([]Summary, error) { + dir, err := SessionsDir(repoDir) + if err != nil { + return nil, err + } + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read sessions dir %q: %w", dir, err) + } + summaries := make([]Summary, 0, len(entries)) + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".jsonl") { + continue + } + sessionID := strings.TrimSuffix(name, ".jsonl") + summary, err := loadSummaryFromFile(filepath.Join(dir, name), sessionID, repoDir) + if err != nil { + continue + } + summaries = append(summaries, *summary) + } + sort.Slice(summaries, func(i, j int) bool { + return summaries[i].StartTime.After(summaries[j].StartTime) + }) + return summaries, nil +} + +// LoadSummary loads a single session's Summary. Errors when the session +// file is missing or unreadable. +func LoadSummary(repoDir, sessionID string) (*Summary, error) { + path, err := SessionFilePath(repoDir, sessionID) + if err != nil { + return nil, err + } + return loadSummaryFromFile(path, sessionID, repoDir) +} + +// LoadDetail returns the summary plus per-file item records for one session. +func LoadDetail(repoDir, sessionID string) (*Summary, []ItemDetail, error) { + path, err := SessionFilePath(repoDir, sessionID) + if err != nil { + return nil, nil, err + } + summary := &Summary{ + SessionID: sessionID, + FilePath: path, + RepoDir: repoDir, + Aborted: true, + } + var items []ItemDetail + err = walkSessionFile(path, func(rec summaryRecord) { + applyRecordToSummary(summary, rec) + if item, ok := recordToItem(rec); ok { + items = append(items, item) + } + }) + if err != nil { + return nil, nil, err + } + if summary.SessionID == "" { + summary.SessionID = sessionID + } + return summary, items, nil +} + +func loadSummaryFromFile(path, sessionID, repoDir string) (*Summary, error) { + summary := &Summary{ + SessionID: sessionID, + FilePath: path, + RepoDir: repoDir, + Aborted: true, + } + if err := walkSessionFile(path, func(rec summaryRecord) { + applyRecordToSummary(summary, rec) + }); err != nil { + return nil, err + } + if summary.SessionID == "" { + summary.SessionID = sessionID + } + return summary, nil +} + +func walkSessionFile(path string, apply func(summaryRecord)) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("open session %q: %w", path, err) + } + defer f.Close() + + reader := bufio.NewReader(f) + for { + line, readErr := reader.ReadBytes('\n') + if len(line) > 0 { + var rec summaryRecord + if err := json.Unmarshal(line, &rec); err == nil { + apply(rec) + } + } + if readErr == io.EOF { + return nil + } + if readErr != nil { + return fmt.Errorf("read session %q: %w", path, readErr) + } + } +} + +func applyRecordToSummary(s *Summary, rec summaryRecord) { + ts := parseRecordTime(rec.Timestamp) + switch rec.Type { + case "session_start": + if rec.SessionID != "" { + s.SessionID = rec.SessionID + } + if rec.Cwd != "" { + s.RepoDir = rec.Cwd + } + s.GitBranch = rec.GitBranch + s.Model = rec.Model + s.ReviewMode = rec.ReviewMode + s.DiffFrom = rec.DiffFrom + s.DiffTo = rec.DiffTo + s.DiffCommit = rec.DiffCommit + s.ResumedFrom = rec.ResumedFrom + if !ts.IsZero() { + s.StartTime = ts + } + case "review_item_done": + s.CompletedFiles++ + s.TotalComments += countCommentsRaw(rec.Comments) + case "review_item_reused": + s.ReusedFiles++ + s.TotalComments += countCommentsRaw(rec.Comments) + case "review_item_failed": + s.FailedFiles++ + case "session_end": + s.Aborted = false + if s.CompletedFiles == 0 && s.ReusedFiles == 0 && s.FailedFiles == 0 && len(rec.FilesReviewed) > 0 { + s.CompletedFiles = len(rec.FilesReviewed) + } + if !ts.IsZero() { + s.EndTime = ts + } + if rec.DurationSeconds > 0 { + s.Duration = time.Duration(rec.DurationSeconds * float64(time.Second)) + } else if !s.EndTime.IsZero() && !s.StartTime.IsZero() { + s.Duration = s.EndTime.Sub(s.StartTime) + } + s.LLMFailures = rec.LLMFailures + } +} + +func recordToItem(rec summaryRecord) (ItemDetail, bool) { + switch rec.Type { + case "review_item_done", "review_item_reused", "review_item_failed": + default: + return ItemDetail{}, false + } + kind := strings.TrimPrefix(rec.Type, "review_item_") + filePath := rec.FilePath + if filePath == "" { + filePath = rec.NewPath + } + return ItemDetail{ + Type: kind, + Timestamp: parseRecordTime(rec.Timestamp), + FilePath: filePath, + OldPath: rec.OldPath, + NewPath: rec.NewPath, + Fingerprint: rec.Fingerprint, + Comments: countCommentsRaw(rec.Comments), + SourceSessionID: rec.SourceSessionID, + Error: rec.Error, + }, true +} + +func countCommentsRaw(raw json.RawMessage) int { + if len(raw) == 0 { + return 0 + } + var arr []json.RawMessage + if err := json.Unmarshal(raw, &arr); err != nil { + return 0 + } + return len(arr) +} + +func parseRecordTime(s string) time.Time { + if s == "" { + return time.Time{} + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t + } + if t, err := time.Parse(time.RFC3339Nano, s); err == nil { + return t + } + return time.Time{} +} diff --git a/internal/session/list_test.go b/internal/session/list_test.go new file mode 100644 index 0000000..2c5c394 --- /dev/null +++ b/internal/session/list_test.go @@ -0,0 +1,189 @@ +package session + +import ( + "path/filepath" + "testing" + "time" + + "github.com/open-code-review/open-code-review/internal/model" +) + +func TestListSessions_EmptyRepoReturnsNil(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + + got, err := ListSessions(t.TempDir()) + if err != nil { + t.Fatalf("ListSessions: %v", err) + } + if len(got) != 0 { + t.Errorf("expected empty result, got %d entries", len(got)) + } +} + +func TestListSessions_SortsAndAggregates(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + older := writeTestSession(t, repoDir, "feature-a", "commit-x", []model.LlmComment{ + {Path: "a.go", Content: "one"}, + }, 1, 0, true) + time.Sleep(1100 * time.Millisecond) + newer := writeTestSession(t, repoDir, "feature-a", "commit-y", []model.LlmComment{ + {Path: "b.go", Content: "one"}, + {Path: "b.go", Content: "two"}, + }, 2, 1, false) + + got, err := ListSessions(repoDir) + if err != nil { + t.Fatalf("ListSessions: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 sessions, got %d", len(got)) + } + if got[0].SessionID != newer { + t.Errorf("expected newest first, got %q vs %q", got[0].SessionID, newer) + } + if got[1].SessionID != older { + t.Errorf("expected older second, got %q", got[1].SessionID) + } + + if !got[0].Aborted { + t.Errorf("newest session was interrupted; expected Aborted=true") + } + if got[1].Aborted { + t.Errorf("older session was finalized; expected Aborted=false") + } + if got[0].TotalComments != 2 { + t.Errorf("newest TotalComments = %d, want 2", got[0].TotalComments) + } + if got[0].FailedFiles != 1 { + t.Errorf("newest FailedFiles = %d, want 1", got[0].FailedFiles) + } + if got[0].CompletedFiles != 2 { + t.Errorf("newest CompletedFiles = %d, want 2", got[0].CompletedFiles) + } +} + +func TestLoadDetail_ReturnsItems(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := New(repoDir, "main", "test-model", SessionOptions{ + ReviewMode: ReviewModeCommit, + DiffCommit: "abc123", + }) + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", []model.LlmComment{{Path: "a.go", Content: "note"}}) + sh.RecordReviewItemReused("b.go", "b.go", "b.go", "fp-b", "prior-session", []model.LlmComment{{Path: "b.go", Content: "cached"}}) + sh.RecordReviewItemFailed("c.go", "c.go", "c.go", "fp-c", "boom") + sh.Finalize() + + summary, items, err := LoadDetail(repoDir, sh.SessionID) + if err != nil { + t.Fatalf("LoadDetail: %v", err) + } + if summary.CompletedFiles != 1 || summary.ReusedFiles != 1 || summary.FailedFiles != 1 { + t.Fatalf("summary = %+v", summary) + } + if summary.TotalComments != 2 { + t.Errorf("TotalComments = %d, want 2", summary.TotalComments) + } + if summary.Aborted { + t.Errorf("summary should not be aborted after Finalize") + } + if len(items) != 3 { + t.Fatalf("expected 3 items, got %d", len(items)) + } + byType := map[string]ItemDetail{} + for _, it := range items { + byType[it.Type] = it + } + if reused := byType["reused"]; reused.SourceSessionID != "prior-session" { + t.Errorf("reused source = %q, want prior-session", reused.SourceSessionID) + } + if failed := byType["failed"]; failed.Error != "boom" { + t.Errorf("failed error = %q, want boom", failed.Error) + } + if done := byType["done"]; done.Comments != 1 { + t.Errorf("done comments = %d, want 1", done.Comments) + } +} + +func TestLoadSummary_FallsBackToSessionEndFilesReviewed(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + repoDir := t.TempDir() + + sh := New(repoDir, "main", "test-model", SessionOptions{ + ReviewMode: ReviewModeWorkspace, + }) + sh.GetOrCreateFileSession("legacy-a.go") + sh.GetOrCreateFileSession("legacy-b.go") + sh.Finalize() + + summary, items, err := LoadDetail(repoDir, sh.SessionID) + if err != nil { + t.Fatalf("LoadDetail: %v", err) + } + if summary.CompletedFiles != 2 { + t.Fatalf("CompletedFiles = %d, want 2", summary.CompletedFiles) + } + if summary.ReusedFiles != 0 || summary.FailedFiles != 0 { + t.Fatalf("unexpected checkpoint counts: %+v", summary) + } + if len(items) != 0 { + t.Fatalf("legacy session should not synthesize item details, got %d", len(items)) + } +} + +func TestLoadSummary_MissingFile(t *testing.T) { + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + if _, err := LoadSummary(t.TempDir(), "nonexistent"); err == nil { + t.Fatal("expected error for missing session") + } +} + +// writeTestSession creates a real JSONL session using the persistence layer +// so tests exercise the same on-disk format that ListSessions consumes. +// It returns the session id. +func writeTestSession(t *testing.T, repoDir, from, to string, comments []model.LlmComment, doneCount, failedCount int, finalize bool) string { + t.Helper() + sh := New(repoDir, "main", "test-model", SessionOptions{ + ReviewMode: ReviewModeRange, + DiffFrom: from, + DiffTo: to, + }) + for i := 0; i < doneCount; i++ { + filePath := filepath.Base(t.TempDir()) + ".go" + var perFile []model.LlmComment + if i < len(comments) { + perFile = []model.LlmComment{comments[i]} + } + sh.RecordReviewItemDone(filePath, filePath, filePath, "fp-"+filePath, perFile) + } + for i := 0; i < failedCount; i++ { + filePath := "failed-" + filepath.Base(t.TempDir()) + ".go" + sh.RecordReviewItemFailed(filePath, filePath, filePath, "fp-fail-"+filePath, "test error") + } + if finalize { + sh.Finalize() + } else { + // Simulate an aborted run: flush the writer without emitting session_end. + if sh.persist != nil { + sh.persist.mu.Lock() + if sh.persist.writer != nil { + sh.persist.writer.Flush() + } + if sh.persist.file != nil { + _ = sh.persist.file.Close() + } + sh.persist.writer = nil + sh.persist.file = nil + sh.persist.mu.Unlock() + } + } + return sh.SessionID +} diff --git a/internal/session/persist.go b/internal/session/persist.go index 143d299..464da1b 100644 --- a/internal/session/persist.go +++ b/internal/session/persist.go @@ -11,6 +11,8 @@ import ( "strings" "sync" "time" + + "github.com/open-code-review/open-code-review/internal/model" ) var sessionSubDir = "sessions" @@ -19,31 +21,33 @@ var sessionSubDir = "sessions" // $HOME/.opencodereview/sessions//.jsonl. // It is safe for concurrent use by multiple goroutines. type jsonlWriter struct { - mu sync.Mutex - sessionID string - repoDir string - gitBranch string - model string - reviewMode string - diffFrom string - diffTo string - diffCommit string - file *os.File - writer *bufio.Writer - lastUUID string // tracks chain of records via parentUuid + mu sync.Mutex + sessionID string + repoDir string + gitBranch string + model string + reviewMode string + diffFrom string + diffTo string + diffCommit string + resumedFrom string + file *os.File + writer *bufio.Writer + lastUUID string // tracks chain of records via parentUuid } // newJSONLWriter creates and opens a new JSONL writer for the given session. func newJSONLWriter(sessionID, repoDir, gitBranch, model string, opts SessionOptions) (*jsonlWriter, error) { jw := &jsonlWriter{ - sessionID: sessionID, - repoDir: repoDir, - gitBranch: gitBranch, - model: model, - reviewMode: opts.ReviewMode, - diffFrom: opts.DiffFrom, - diffTo: opts.DiffTo, - diffCommit: opts.DiffCommit, + sessionID: sessionID, + repoDir: repoDir, + gitBranch: gitBranch, + model: model, + reviewMode: opts.ReviewMode, + diffFrom: opts.DiffFrom, + diffTo: opts.DiffTo, + diffCommit: opts.DiffCommit, + resumedFrom: opts.ResumedFrom, } if err := jw.open(); err != nil { return nil, err @@ -148,6 +152,9 @@ func (jw *jsonlWriter) WriteSessionStart(startTime time.Time) string { if jw.diffCommit != "" { rec["diffCommit"] = jw.diffCommit } + if jw.resumedFrom != "" { + rec["resumedFrom"] = jw.resumedFrom + } jw.mu.Lock() defer jw.mu.Unlock() @@ -156,6 +163,55 @@ func (jw *jsonlWriter) WriteSessionStart(startTime time.Time) string { return uuid } +// WriteReviewItemDone writes a file-level resume checkpoint for a completed diff. +func (jw *jsonlWriter) WriteReviewItemDone(filePath, oldPath, newPath, fingerprint string, comments []model.LlmComment) string { + return jw.writeReviewItemRecord("review_item_done", filePath, oldPath, newPath, fingerprint, "", "", comments) +} + +// WriteReviewItemReused writes a checkpoint reused from a previous session. +func (jw *jsonlWriter) WriteReviewItemReused(filePath, oldPath, newPath, fingerprint, sourceSessionID string, comments []model.LlmComment) string { + return jw.writeReviewItemRecord("review_item_reused", filePath, oldPath, newPath, fingerprint, sourceSessionID, "", comments) +} + +// WriteReviewItemFailed writes a file-level checkpoint for a failed diff. +func (jw *jsonlWriter) WriteReviewItemFailed(filePath, oldPath, newPath, fingerprint, errorMsg string) string { + return jw.writeReviewItemRecord("review_item_failed", filePath, oldPath, newPath, fingerprint, "", errorMsg, nil) +} + +func (jw *jsonlWriter) writeReviewItemRecord(recordType, filePath, oldPath, newPath, fingerprint, sourceSessionID, errorMsg string, comments []model.LlmComment) string { + uuid := generateUUID() + + jw.mu.Lock() + defer jw.mu.Unlock() + rec := map[string]any{ + "uuid": uuid, + "parentUuid": jw.lastUUID, + "type": recordType, + "sessionId": jw.sessionID, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "filePath": filePath, + "oldPath": oldPath, + "newPath": newPath, + "fingerprint": fingerprint, + "model": jw.model, + } + if len(comments) > 0 { + rec["comments"] = comments + } + if sourceSessionID != "" { + rec["sourceSessionId"] = sourceSessionID + } + if errorMsg != "" { + rec["error"] = errorMsg + } + jw.writeRecordLocked(rec) + if jw.writer != nil { + jw.writer.Flush() + } + jw.lastUUID = uuid + return uuid +} + // WriteLLMRequest writes a request entry with the resolved messages. func (jw *jsonlWriter) WriteLLMRequest(filePath string, taskType TaskType, requestNo int, messages any) string { uuid := generateUUID() diff --git a/internal/session/persist_test.go b/internal/session/persist_test.go index 6231341..bb9119d 100644 --- a/internal/session/persist_test.go +++ b/internal/session/persist_test.go @@ -9,6 +9,8 @@ import ( "runtime" "testing" "time" + + "github.com/open-code-review/open-code-review/internal/model" ) func init() { UseTestSessions() } @@ -268,3 +270,89 @@ func TestSessionEndIncludesFailures(t *testing.T) { t.Errorf("llm_failures = %v, want 3", failures) } } + +func TestReviewItemResumeRoundTrip(t *testing.T) { + repoDir := t.TempDir() + sh := New(repoDir, "feature", "new-model", SessionOptions{ + ReviewMode: ReviewModeRange, + DiffFrom: "main", + DiffTo: "feature", + ResumedFrom: "old-session", + }) + comments := []model.LlmComment{{ + Path: "a.go", + Content: "fix this", + ExistingCode: "old()", + }} + sh.RecordReviewItemDone("a.go", "a.go", "a.go", "fp-a", comments) + sh.RecordReviewItemFailed("b.go", "b.go", "b.go", "fp-b", "rate limit") + sh.Finalize() + + state, err := LoadResumeState(repoDir, sh.SessionID) + if err != nil { + t.Fatalf("LoadResumeState: %v", err) + } + if state.SessionID != sh.SessionID { + t.Errorf("SessionID = %q, want %q", state.SessionID, sh.SessionID) + } + if state.ReviewMode != ReviewModeRange || state.DiffFrom != "main" || state.DiffTo != "feature" { + t.Errorf("metadata mismatch: %+v", state) + } + if state.CompletedCount() != 1 { + t.Fatalf("CompletedCount = %d, want 1", state.CompletedCount()) + } + item, ok := state.Item("fp-a") + if !ok { + t.Fatal("missing fp-a") + } + if item.FilePath != "a.go" || len(item.Comments) != 1 || item.Comments[0].Content != "fix this" { + t.Errorf("item mismatch: %+v", item) + } + if _, ok := state.Item("fp-b"); ok { + t.Error("failed item should not be reusable") + } + if err := state.ValidateOptions(SessionOptions{ReviewMode: ReviewModeRange, DiffFrom: "main", DiffTo: "feature"}); err != nil { + t.Fatalf("ValidateOptions: %v", err) + } + + records := readJSONLRecords(t, sessionJSONLPath(t, repoDir, sh.SessionID)) + var foundStart bool + for _, r := range records { + if r["type"] == "session_start" { + foundStart = true + if r["resumedFrom"] != "old-session" { + t.Errorf("resumedFrom = %v, want old-session", r["resumedFrom"]) + } + } + } + if !foundStart { + t.Fatal("missing session_start") + } +} + +func TestResumeStateValidateOptionsRejectsMismatchedRange(t *testing.T) { + state := &ResumeState{ + SessionID: "s1", + ReviewMode: ReviewModeRange, + DiffFrom: "main", + DiffTo: "feature", + } + err := state.ValidateOptions(SessionOptions{ReviewMode: ReviewModeRange, DiffFrom: "main", DiffTo: "other"}) + if err == nil { + t.Fatal("expected mismatch error") + } +} + +func TestResumeStateSessionStartKeepsRepoDirWhenCwdEmpty(t *testing.T) { + state := &ResumeState{RepoDir: "/repo/from/caller"} + + state.applySessionStart(resumeRecord{ + SessionID: "s1", + ReviewMode: ReviewModeCommit, + DiffCommit: "abc123", + }) + + if state.RepoDir != "/repo/from/caller" { + t.Fatalf("RepoDir = %q, want caller-provided repo dir", state.RepoDir) + } +} diff --git a/internal/session/resume.go b/internal/session/resume.go new file mode 100644 index 0000000..756037c --- /dev/null +++ b/internal/session/resume.go @@ -0,0 +1,209 @@ +package session + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/open-code-review/open-code-review/internal/model" +) + +// ResumeState is the replayed, read-only checkpoint index for one prior session. +type ResumeState struct { + SessionID string + RepoDir string + GitBranch string + Model string + ReviewMode string + DiffFrom string + DiffTo string + DiffCommit string + Items map[string]ResumeItem +} + +// ResumeItem is a completed file-level checkpoint, keyed by diff fingerprint. +type ResumeItem struct { + FilePath string + OldPath string + NewPath string + Fingerprint string + Comments []model.LlmComment +} + +type resumeRecord struct { + Type string `json:"type"` + SessionID string `json:"sessionId"` + Cwd string `json:"cwd"` + GitBranch string `json:"gitBranch"` + Model string `json:"model"` + ReviewMode string `json:"reviewMode"` + DiffFrom string `json:"diffFrom"` + DiffTo string `json:"diffTo"` + DiffCommit string `json:"diffCommit"` + FilePath string `json:"filePath"` + OldPath string `json:"oldPath"` + NewPath string `json:"newPath"` + Fingerprint string `json:"fingerprint"` + SourceSessionID string `json:"sourceSessionId"` + Error string `json:"error"` + Comments []model.LlmComment `json:"comments"` +} + +// SessionFilePath returns the JSONL path for a persisted session. +func SessionFilePath(repoDir, sessionID string) (string, error) { + if sessionID == "" { + return "", fmt.Errorf("session id is required") + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home dir: %w", err) + } + return filepath.Join(home, ".opencodereview", sessionSubDir, encodeRepoPath(repoDir), sessionID+".jsonl"), nil +} + +// LoadResumeState replays a previous session JSONL into a fingerprint index. +func LoadResumeState(repoDir, sessionID string) (*ResumeState, error) { + path, err := SessionFilePath(repoDir, sessionID) + if err != nil { + return nil, err + } + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open resume session %q: %w", sessionID, err) + } + defer f.Close() + + state := &ResumeState{ + SessionID: sessionID, + RepoDir: repoDir, + Items: make(map[string]ResumeItem), + } + reader := bufio.NewReader(f) + for { + line, readErr := reader.ReadBytes('\n') + if len(line) > 0 { + if err := state.applyResumeLine(line); err != nil { + return nil, err + } + } + if readErr == io.EOF { + break + } + if readErr != nil { + return nil, fmt.Errorf("read resume session %q: %w", sessionID, readErr) + } + } + if state.SessionID == "" { + state.SessionID = sessionID + } + return state, nil +} + +func (s *ResumeState) applyResumeLine(line []byte) error { + var rec resumeRecord + if err := json.Unmarshal(line, &rec); err != nil { + return fmt.Errorf("parse resume session %q: %w", s.SessionID, err) + } + + switch rec.Type { + case "session_start": + s.applySessionStart(rec) + case "review_item_done", "review_item_reused": + if rec.Fingerprint == "" { + return nil + } + filePath := rec.FilePath + if filePath == "" { + filePath = rec.NewPath + } + s.Items[rec.Fingerprint] = ResumeItem{ + FilePath: filePath, + OldPath: rec.OldPath, + NewPath: rec.NewPath, + Fingerprint: rec.Fingerprint, + Comments: copyLlmComments(rec.Comments), + } + case "review_item_failed": + if rec.Fingerprint != "" { + delete(s.Items, rec.Fingerprint) + } + } + return nil +} + +func (s *ResumeState) applySessionStart(rec resumeRecord) { + if rec.SessionID != "" { + s.SessionID = rec.SessionID + } + if rec.Cwd != "" { + s.RepoDir = rec.Cwd + } + s.GitBranch = rec.GitBranch + s.Model = rec.Model + s.ReviewMode = rec.ReviewMode + s.DiffFrom = rec.DiffFrom + s.DiffTo = rec.DiffTo + s.DiffCommit = rec.DiffCommit +} + +// CompletedCount returns the number of reusable file-level checkpoints. +func (s *ResumeState) CompletedCount() int { + if s == nil { + return 0 + } + return len(s.Items) +} + +// Item returns a copy of the checkpoint for fingerprint. +func (s *ResumeState) Item(fingerprint string) (ResumeItem, bool) { + if s == nil { + return ResumeItem{}, false + } + item, ok := s.Items[fingerprint] + if !ok { + return ResumeItem{}, false + } + item.Comments = copyLlmComments(item.Comments) + return item, true +} + +// ValidateOptions verifies that the requested review range matches the prior session. +func (s *ResumeState) ValidateOptions(opts SessionOptions) error { + if s == nil { + return nil + } + if opts.ReviewMode == "" || opts.ReviewMode == ReviewModeWorkspace { + return fmt.Errorf("resume requires --from/--to or --commit; workspace resume is not supported") + } + if s.ReviewMode == "" { + return fmt.Errorf("resume session %q is missing review mode metadata", s.SessionID) + } + if s.ReviewMode != opts.ReviewMode { + return fmt.Errorf("resume session review mode %q does not match current mode %q", s.ReviewMode, opts.ReviewMode) + } + switch opts.ReviewMode { + case ReviewModeRange: + if s.DiffFrom != opts.DiffFrom || s.DiffTo != opts.DiffTo { + return fmt.Errorf("resume session range %q..%q does not match current range %q..%q", s.DiffFrom, s.DiffTo, opts.DiffFrom, opts.DiffTo) + } + case ReviewModeCommit: + if s.DiffCommit != opts.DiffCommit { + return fmt.Errorf("resume session commit %q does not match current commit %q", s.DiffCommit, opts.DiffCommit) + } + default: + return fmt.Errorf("resume mode %q is not supported", opts.ReviewMode) + } + return nil +} + +func copyLlmComments(in []model.LlmComment) []model.LlmComment { + if len(in) == 0 { + return nil + } + out := make([]model.LlmComment, len(in)) + copy(out, in) + return out +} diff --git a/pages/src/content/docs/en/cli-reference.md b/pages/src/content/docs/en/cli-reference.md index c80f8cb..905b0c4 100644 --- a/pages/src/content/docs/en/cli-reference.md +++ b/pages/src/content/docs/en/cli-reference.md @@ -21,6 +21,7 @@ Commands: config Manage configuration settings llm LLM utility commands viewer Start the WebUI session viewer + session, sessions List and inspect saved review sessions version Show version information Examples: @@ -31,12 +32,14 @@ Examples: ocr config set llm.model opus-4-6 Set a config value ocr llm test Test LLM connectivity ocr llm providers List built-in providers + ocr session list List saved review sessions ocr version Show version info Use "ocr review -h" for more information about review. Use "ocr rules -h" for more information about rules. Use "ocr config" for more information about config. Use "ocr llm" for more information about LLM utilities. +Use "ocr session -h" for more information about session inspection. GitHub: https://github.com/alibaba/open-code-review ``` @@ -53,6 +56,8 @@ GitHub: https://github.com/alibaba/open-code-review | `ocr config model` | — | Interactive model-selection TUI. | | `ocr llm test` | — | Send a small chat request to verify the configured endpoint. | | `ocr llm providers` | — | List all built-in LLM providers. | +| `ocr session list` | `ocr sessions list`, `ocr session ls` | List saved review sessions. | +| `ocr session show ` | `ocr sessions show ` | Inspect one session and its per-file checkpoints. | | `ocr viewer` | — | Launch the local web UI for past review sessions (`localhost:5483`). | | `ocr version` | — | Print version, commit, platform, build date, and GitHub URL. | @@ -83,6 +88,7 @@ staged + unstaged + untracked changes in the current directory's repo. | `--to ` | — | — | Target ref to end the diff at (e.g., `feature-branch`). When set, OCR computes `merge-base(from, to)..to`. | | `--commit ` | `-c` | — | Single commit to review (vs its parent). | | `--preview` | `-p` | `false` | Run the filter pipeline but skip the LLM. Prints the file list and exclusion reasons. | +| `--resume ` | — | — | Resume from a previous compatible range or commit review session. | | `--format ` | `-f` | `text` | `text` (human-readable) or `json` (machine-readable comment array). | | `--audience ` | — | `human` | `human` streams progress lines; `agent` quiets stdout and prints only the final summary / JSON. | | `--background ` | `-b` | — | Optional requirement / business context injected into the plan + main prompts. | @@ -96,6 +102,8 @@ staged + unstaged + untracked changes in the current directory's repo. > Mode flags are mutually exclusive: pass either `--from`/`--to`, or > `--commit`, or neither (workspace mode). Mixing them is a hard error. +> `--resume` supports only range or commit reviews and cannot be combined +> with `--preview`. ### Modes @@ -135,6 +143,29 @@ ocr review -c abc123 Reviews the diff produced by `git show abc123` (i.e., the changes that single commit introduced). +### Resuming interrupted reviews + +Every `ocr review` run persists a local session log under +`~/.opencodereview/sessions/`. Successful text output stays focused on review +results and does not print the session ID; use `ocr session list/show` to find +saved sessions, or `--format json` to include `session_id` in machine-readable +output. If a range or commit review is interrupted, list saved sessions and +resume from one that matches the same review target: + +```bash +ocr session list +ocr session show +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +Resume is strict by design: + +- workspace reviews cannot be resumed +- range reviews must use the same `--from` and `--to` +- commit reviews must use the same `--commit` +- `--preview` and `--resume` cannot be used together + ### Output #### Text (default, `--audience human`) @@ -208,6 +239,8 @@ Top-level fields: | `summary` | Optional. Run aggregates: `files_reviewed`, `comments`, `total_tokens`, `input_tokens`, `output_tokens`, `cache_read_tokens` (omitempty), `cache_write_tokens` (omitempty), `elapsed`. Omitted for `skipped` runs. | | `comments` | Always present, possibly empty. Per-comment fields are the ones in the example above. | | `warnings` | Optional. Present when one or more sub-agents failed; each entry describes the affected file and the error. | +| `session_id` | Optional. Present on persisted review runs; pass this to `ocr review --resume ` when retrying compatible range or commit reviews. | +| `resume` | Optional. Present on resumed runs with `resumed_from`, `reused_files`, `rerun_files`, `previous_model`, and `current_model`. | When no files were eligible for review, JSON mode emits a `skipped` envelope instead so callers can distinguish "no changes" from "no findings": @@ -231,6 +264,48 @@ Non-fatal warnings (a single sub-agent failed, a file exceeded the token threshold, etc.) are printed inline; in JSON mode they're added to the `warnings` array. +## `ocr session` + +Lists and inspects local review session logs saved under +`~/.opencodereview/sessions/`. Use it to find a session ID, inspect +per-file checkpoint status, and resume interrupted range or commit reviews. + +```text +ocr session +ocr sessions (alias) + +Sub-commands: + list, ls List recent review sessions for the current repo + show Show one session's metadata and per-file items +``` + +### `ocr session list` + +```bash +ocr session list +ocr session list --limit 50 +ocr session list --json +``` + +| Flag | Default | Description | +|---|---|---| +| `--repo ` | current dir | Repository whose sessions should be listed. | +| `--json` | `false` | Emit session summaries as JSON. | +| `--limit ` | `20` | Cap the number of listed sessions. Use `0` for unlimited. | + +### `ocr session show` + +```bash +ocr session show +ocr session show --json +ocr session show --repo /path/to/repo +``` + +| Flag | Default | Description | +|---|---|---| +| `--repo ` | current dir | Repository whose session should be inspected. | +| `--json` | `false` | Emit session metadata and per-file items as JSON. | + ## `ocr rules` Rule introspection. There is exactly one subcommand: diff --git a/pages/src/content/docs/ja/cli-reference.md b/pages/src/content/docs/ja/cli-reference.md index 22691c7..c62da6e 100644 --- a/pages/src/content/docs/ja/cli-reference.md +++ b/pages/src/content/docs/ja/cli-reference.md @@ -20,6 +20,7 @@ Commands: config Manage configuration settings llm LLM utility commands viewer Start the WebUI session viewer + session, sessions List and inspect saved review sessions version Show version information Examples: @@ -30,12 +31,14 @@ Examples: ocr config set llm.model opus-4-6 Set a config value ocr llm test Test LLM connectivity ocr llm providers List built-in providers + ocr session list List saved review sessions ocr version Show version info Use "ocr review -h" for more information about review. Use "ocr rules -h" for more information about rules. Use "ocr config" for more information about config. Use "ocr llm" for more information about LLM utilities. +Use "ocr session -h" for more information about session inspection. GitHub: https://github.com/alibaba/open-code-review ``` @@ -52,6 +55,8 @@ GitHub: https://github.com/alibaba/open-code-review | `ocr config model` | — | 対話的な model 選択 TUI。 | | `ocr llm test` | — | 短い chat リクエストを送信し、設定されたエンドポイントを検証します。 | | `ocr llm providers` | — | 組み込みの LLM プロバイダーをすべて一覧表示します。 | +| `ocr session list` | `ocr sessions list`, `ocr session ls` | 保存されたレビューセッションを一覧表示します。 | +| `ocr session show ` | `ocr sessions show ` | 1つのセッションとファイル単位のチェックポイントを表示します。 | | `ocr viewer` | — | 過去のレビューセッション用のローカル Web UI を起動します(`localhost:5483`)。 | | `ocr version` | — | バージョン、commit、プラットフォーム、ビルド日、GitHub URL を出力します。 | @@ -79,6 +84,7 @@ ocr r [flags] (alias) | `--to ` | — | — | diff の終了 ref(例: `feature-branch`)。設定すると OCR は `merge-base(from, to)..to` を計算します。 | | `--commit ` | `-c` | — | 単一の commit をレビューします(その親との差分)。 | | `--preview` | `-p` | `false` | フィルタリングのパイプラインを実行しますが LLM はスキップします。ファイル一覧と除外理由を出力します。 | +| `--resume ` | — | — | 以前の互換性のある範囲または単一 commit レビューセッションから再開します。 | | `--format ` | `-f` | `text` | `text`(人間が読みやすい形式)または `json`(機械可読なコメント配列)。 | | `--audience ` | — | `human` | `human` は進捗行をストリーム出力します。`agent` は stdout を静音化し、最終サマリー / JSON のみを出力します。 | | `--background ` | `-b` | — | plan + main prompt に注入する、任意の要件 / 業務コンテキスト。 | @@ -92,6 +98,7 @@ ocr r [flags] (alias) > モード引数は排他です: `--from`/`--to` を渡すか、`--commit` を渡すか、いずれも渡さない(ワークスペースモード)かのいずれかです。 > 混在させるとそのままエラーになります。 +> `--resume` は範囲または単一 commit レビューのみ対応し、`--preview` とは併用できません。 ### モード @@ -125,6 +132,28 @@ ocr review -c abc123 `git show abc123` が生成する diff(すなわちその commit が導入した変更)をレビューします。 +### 中断したレビューの再開 + +すべての `ocr review` 実行は、`~/.opencodereview/sessions/` 配下にローカル +セッションログを保存します。正常終了したテキスト出力はレビュー結果に集中し、session ID +は表示しません。保存済みセッションは `ocr session list/show` で確認でき、 +`--format json` では機械可読出力に `session_id` が含まれます。範囲または単一 commit +レビューが中断された場合は、保存済みセッションを一覧表示し、同じレビュー対象に一致するセッションから再開します: + +```bash +ocr session list +ocr session show +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +再開は意図的に厳密です: + +- ワークスペースレビューは再開できません +- 範囲レビューは同じ `--from` と `--to` が必要です +- 単一 commit レビューは同じ `--commit` が必要です +- `--preview` と `--resume` は併用できません + ### 出力 #### Text(デフォルト、`--audience human`) @@ -193,6 +222,8 @@ ocr review --format json --audience agent | `summary` | 任意。実行の集計: `files_reviewed`、`comments`、`total_tokens`、`input_tokens`、`output_tokens`、`cache_read_tokens`(omitempty)、`cache_write_tokens`(omitempty)、`elapsed`。`skipped` の実行時は省略されます。 | | `comments` | 常に存在しますが、空の場合があります。各コメントのフィールドは上記の例のとおりです。 | | `warnings` | 任意。1 つ以上のサブエージェントが失敗した場合に存在します。各項目は影響を受けたファイルとエラーを記述します。 | +| `session_id` | 任意。永続化されたレビュー実行に含まれます。互換性のある範囲または単一 commit レビューを再試行する際に `ocr review --resume ` へ渡せます。 | +| `resume` | 任意。再開した実行で存在し、`resumed_from`、`reused_files`、`rerun_files`、`previous_model`、`current_model` を含みます。 | レビュー対象のファイルがない場合、JSON モードは `skipped` の外殻を発行し、呼び出し側が「変更なし」と「発見なし」を区別できるようにします: @@ -213,6 +244,48 @@ ocr review --format json --audience agent 致命的でない警告(個々のサブエージェントの失敗、あるファイルが token しきい値を超過、など)はインラインで出力されます。JSON モードでは `warnings` 配列に追加されます。 +## `ocr session` + +`~/.opencodereview/sessions/` 配下に保存されたローカルレビューセッションログを一覧表示・確認します。 +session ID の確認、ファイル単位のチェックポイント状態の確認、中断した範囲または単一 commit +レビューの再開に使用します。 + +```text +ocr session +ocr sessions (alias) + +Sub-commands: + list, ls List recent review sessions for the current repo + show Show one session's metadata and per-file items +``` + +### `ocr session list` + +```bash +ocr session list +ocr session list --limit 50 +ocr session list --json +``` + +| 引数 | デフォルト | 説明 | +|---|---|---| +| `--repo ` | カレントディレクトリ | セッションを一覧表示するリポジトリ。 | +| `--json` | `false` | セッションサマリーを JSON として出力します。 | +| `--limit ` | `20` | 一覧表示するセッション数を制限します。`0` は無制限です。 | + +### `ocr session show` + +```bash +ocr session show +ocr session show --json +ocr session show --repo /path/to/repo +``` + +| 引数 | デフォルト | 説明 | +|---|---|---| +| `--repo ` | カレントディレクトリ | セッションを確認するリポジトリ。 | +| `--json` | `false` | セッションのメタデータとファイル単位の項目を JSON として出力します。 | + ## `ocr rules` ルールの自己確認です。サブコマンドは 1 つだけです: diff --git a/pages/src/content/docs/zh/cli-reference.md b/pages/src/content/docs/zh/cli-reference.md index 34387db..39ca0fa 100644 --- a/pages/src/content/docs/zh/cli-reference.md +++ b/pages/src/content/docs/zh/cli-reference.md @@ -20,6 +20,7 @@ Commands: config Manage configuration settings llm LLM utility commands viewer Start the WebUI session viewer + session, sessions List and inspect saved review sessions version Show version information Examples: @@ -30,12 +31,14 @@ Examples: ocr config set llm.model opus-4-6 Set a config value ocr llm test Test LLM connectivity ocr llm providers List built-in providers + ocr session list List saved review sessions ocr version Show version info Use "ocr review -h" for more information about review. Use "ocr rules -h" for more information about rules. Use "ocr config" for more information about config. Use "ocr llm" for more information about LLM utilities. +Use "ocr session -h" for more information about session inspection. GitHub: https://github.com/alibaba/open-code-review ``` @@ -52,6 +55,8 @@ GitHub: https://github.com/alibaba/open-code-review | `ocr config model` | — | 交互式 model 选择 TUI。 | | `ocr llm test` | — | 发送一条简短 chat 请求以验证配置的端点。 | | `ocr llm providers` | — | 列出所有内置 LLM provider。 | +| `ocr session list` | `ocr sessions list`, `ocr session ls` | 列出已保存的评审会话。 | +| `ocr session show ` | `ocr sessions show ` | 查看单个会话及其逐文件检查点。 | | `ocr viewer` | — | 启动用于历史评审会话的本地 Web UI(`localhost:5483`)。 | | `ocr version` | — | 打印版本、commit、平台、构建日期与 GitHub URL。 | @@ -80,6 +85,7 @@ unstaged + untracked 变更。 | `--to ` | — | — | diff 结束 ref(如 `feature-branch`)。设置后 OCR 计算 `merge-base(from, to)..to`。 | | `--commit ` | `-c` | — | 评审单个 commit(相对其父)。 | | `--preview` | `-p` | `false` | 运行过滤流水线但跳过 LLM。打印文件列表与排除原因。 | +| `--resume ` | — | — | 从之前兼容的区间或单 commit 评审会话恢复。 | | `--format ` | `-f` | `text` | `text`(人类可读)或 `json`(机器可读的评论数组)。 | | `--audience ` | — | `human` | `human` 流式输出进度行;`agent` 静默 stdout,只打印最终摘要 / JSON。 | | `--background ` | `-b` | — | 注入 plan + main prompt 的可选需求 / 业务上下文。 | @@ -93,6 +99,7 @@ unstaged + untracked 变更。 > 模式参数互斥:传 `--from`/`--to`,或 `--commit`,或都不传(工作区模式)。 > 混用会直接报错。 +> `--resume` 仅支持区间或单 commit 评审,不能与 `--preview` 同时使用。 ### 模式 @@ -129,6 +136,27 @@ ocr review -c abc123 评审 `git show abc123` 产生的 diff(即该 commit 引入的变更)。 +### 恢复中断的评审 + +每次 `ocr review` 都会在 `~/.opencodereview/sessions/` 下保存本地会话日志。 +正常完成的文本输出只展示评审结果,不打印 session ID;可使用 +`ocr session list/show` 查找已保存会话,或用 `--format json` 在机器可读输出中获取 +`session_id`。如果区间或单 commit 评审被中断,先列出已保存会话,再从与当前评审目标一致的会话恢复: + +```bash +ocr session list +ocr session show +ocr review --from main --to feature-branch --resume +ocr review --commit abc123 --resume +``` + +恢复逻辑是严格的: + +- 工作区评审不能恢复 +- 区间评审必须使用相同的 `--from` 和 `--to` +- 单 commit 评审必须使用相同的 `--commit` +- `--preview` 和 `--resume` 不能同时使用 + ### 输出 #### Text(默认,`--audience human`) @@ -201,6 +229,8 @@ ocr review --format json --audience agent | `summary` | 可选。运行聚合:`files_reviewed`、`comments`、`total_tokens`、`input_tokens`、`output_tokens`、`cache_read_tokens`(omitempty)、`cache_write_tokens`(omitempty)、`elapsed`。`skipped` 运行时省略。 | | `comments` | 总是存在,可能为空。每条评论的字段如上例。 | | `warnings` | 可选。当一个或多个子 agent 失败时存在;每条描述受影响文件与错误。 | +| `session_id` | 可选。持久化的评审运行会包含该字段;重试兼容的区间或单 commit 评审时可传给 `ocr review --resume `。 | +| `resume` | 可选。恢复运行时存在,包含 `resumed_from`、`reused_files`、`rerun_files`、`previous_model` 和 `current_model`。 | 当没有文件可评审时,JSON 模式会发一个 `skipped` 外壳,以便调用方区分“无变更” 与“无发现”: @@ -223,6 +253,47 @@ ocr review --format json --audience agent 非致命警告(单个子 agent 失败、某文件超过 token 阈值等)内联打印;JSON 模式下 会加入 `warnings` 数组。 +## `ocr session` + +列出和查看保存在 `~/.opencodereview/sessions/` 下的本地评审会话日志。 +可用它查找 session ID、查看逐文件检查点状态,并恢复中断的区间或单 commit 评审。 + +```text +ocr session +ocr sessions (alias) + +Sub-commands: + list, ls List recent review sessions for the current repo + show Show one session's metadata and per-file items +``` + +### `ocr session list` + +```bash +ocr session list +ocr session list --limit 50 +ocr session list --json +``` + +| 参数 | 默认 | 说明 | +|---|---|---| +| `--repo ` | 当前目录 | 要列出会话的仓库。 | +| `--json` | `false` | 以 JSON 输出会话摘要。 | +| `--limit ` | `20` | 限制列出的会话数量。使用 `0` 表示不限制。 | + +### `ocr session show` + +```bash +ocr session show +ocr session show --json +ocr session show --repo /path/to/repo +``` + +| 参数 | 默认 | 说明 | +|---|---|---| +| `--repo ` | 当前目录 | 要查看会话的仓库。 | +| `--json` | `false` | 以 JSON 输出会话元数据和逐文件条目。 | + ## `ocr rules` 规则自查。只有一个子命令: From 07b41bad794b2b6e91f91018b954ef70e7e4605e Mon Sep 17 00:00:00 2001 From: kite Date: Thu, 9 Jul 2026 13:41:51 +0800 Subject: [PATCH 11/19] docs(roadmap): mark MCP as shipped and add delegate mode Move MCP server to the current-state list now that it is supported, and replace the MCP roadmap entry with a delegate mode that lets ocr run on the host coding agent's subscription without a standalone LLM endpoint. --- ROADMAP.md | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 4fa8ada..07d0b38 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,6 +19,9 @@ OpenCodeReview currently provides: - CI/CD integration (GitHub Actions, GitLab CI, etc.). - Multi-provider LLM support (OpenAI-compatible, Anthropic, Google Gemini, Amazon Bedrock, Azure OpenAI, etc.). +- MCP server — expose OpenCodeReview over the + [Model Context Protocol](https://modelcontextprotocol.io/) so review + capabilities can be invoked from any MCP-compatible client. - Review rules engine with per-file pattern matching. - Multi-language documentation (English, Chinese, Japanese, Korean, Russian). @@ -30,13 +33,15 @@ OpenCodeReview currently provides: PyCharm, and other JetBrains IDEs with the same capabilities as the existing VSCode extension. -### MCP Integration +### Delegate Mode -- **Standard MCP server** — Expose OpenCodeReview as a - [Model Context Protocol](https://modelcontextprotocol.io/) server, - allowing users to integrate external context tools (documentation - retrieval, issue trackers, internal knowledge bases) into the review - process through the standard MCP interface. +- **Subscription-friendly review** — An opt-in mode where `ocr` no longer + depends on a separately-configured LLM endpoint. Instead of calling an + LLM itself, `ocr` resolves the review scope, applies excludes, loads + review rules, injects background context, and collects the diffs, then + hands that off as a structured review task for the host coding agent + (e.g. Claude Code) to execute using its own agent loop and included + subscription usage — removing the need for a standalone API key. ### Ultra Mode From efe2b2e50b62437de2efad0a61484c3b88eb4b2b Mon Sep 17 00:00:00 2001 From: munsunouk <52026496+munsunouk@users.noreply.github.com> Date: Thu, 9 Jul 2026 07:45:24 +0200 Subject: [PATCH 12/19] fix(tool): reject traversal pathspecs in code_search (#303) --- internal/tool/code_search.go | 12 ++++++++ internal/tool/code_search_test.go | 48 +++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/internal/tool/code_search.go b/internal/tool/code_search.go index 15844a6..4e8ee79 100644 --- a/internal/tool/code_search.go +++ b/internal/tool/code_search.go @@ -34,6 +34,9 @@ func (p *CodeSearchProvider) Execute(ctx context.Context, args map[string]any) ( var patterns []string for _, item := range filePatternsIface { if s, ok := item.(string); ok && s != "" { + if hasTraversalPathComponent(s) { + return "Error: file_patterns must not contain ..", nil + } patterns = append(patterns, s) } } @@ -85,6 +88,15 @@ func (p *CodeSearchProvider) buildGrepArgs(searchText string, caseSensitive bool return cmdArgs } +func hasTraversalPathComponent(pathspec string) bool { + for _, part := range strings.Split(pathspec, "/") { + if part == ".." { + return true + } + } + return false +} + func (p *CodeSearchProvider) runGitGrep(parentCtx context.Context, cmdArgs []string) (string, string, error) { ctx, cancel := context.WithTimeout(parentCtx, gitGrepTimeout) defer cancel() diff --git a/internal/tool/code_search_test.go b/internal/tool/code_search_test.go index c2d0fb9..9b4045c 100644 --- a/internal/tool/code_search_test.go +++ b/internal/tool/code_search_test.go @@ -433,6 +433,54 @@ func TestCodeSearchProvider_Execute_WithFilePatterns(t *testing.T) { } } +func TestCodeSearchProvider_Execute_RejectsTraversalPattern(t *testing.T) { + dir := setupTestRepo(t) + p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace}) + tests := []struct { + name string + pattern string + want string + }{ + {name: "leading parent", pattern: "../pkg", want: "Error: file_patterns must not contain .."}, + {name: "middle parent", pattern: "pkg/../internal", want: "Error: file_patterns must not contain .."}, + {name: "trailing parent", pattern: "pkg/..", want: "Error: file_patterns must not contain .."}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := p.Execute(context.Background(), map[string]any{ + "search_text": "Hello", + "file_patterns": []any{test.pattern}, + }) + if err != nil { + t.Fatal(err) + } + if got != test.want { + t.Errorf("Execute() = %q, want %q", got, test.want) + } + }) + } +} + +func TestCodeSearchProvider_Execute_AllowsDoubleDotInFilename(t *testing.T) { + dir := setupTestRepo(t) + if err := os.WriteFile(filepath.Join(dir, "foo..bar.go"), []byte("package main\n\nfunc DoubleDotName() {}\n"), 0644); err != nil { + t.Fatal(err) + } + + p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace}) + got, err := p.Execute(context.Background(), map[string]any{ + "search_text": "DoubleDotName", + "file_patterns": []any{"foo..bar.go"}, + }) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "foo..bar.go") { + t.Errorf("expected foo..bar.go in result, got: %s", got) + } +} + func TestCodeSearchProvider_Execute_CaseSensitive(t *testing.T) { dir := setupTestRepo(t) p := NewCodeSearch(&FileReader{RepoDir: dir, Mode: ModeWorkspace}) From 44ef6d7fc7ab907b62c461778f4b2ea33cd28d20 Mon Sep 17 00:00:00 2001 From: Ayrton <120122957+AlapinEnjoyer@users.noreply.github.com> Date: Thu, 9 Jul 2026 08:35:47 +0200 Subject: [PATCH 13/19] =?UTF-8?q?feat:=20enhance=20token=20usage=20resolut?= =?UTF-8?q?ion=20for=20OpenAI=20and=20Anthropic=20compati=E2=80=A6=20(#223?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enhance token usage resolution for OpenAI and Anthropic compatibility Recognize OpenAI cached_tokens and wrapped proxy response paths, add tests for path priority and provider-specific total token fallback semantics. Co-authored-by: Cursor * style: format * chore: Add tests and improve comments * fix: Remove provider specific protocol --------- Co-authored-by: Cursor --- internal/llm/usage_resolver.go | 58 +++++++++---- internal/llm/usage_resolver_test.go | 123 ++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 15 deletions(-) create mode 100644 internal/llm/usage_resolver_test.go diff --git a/internal/llm/usage_resolver.go b/internal/llm/usage_resolver.go index 1345420..f6411e1 100644 --- a/internal/llm/usage_resolver.go +++ b/internal/llm/usage_resolver.go @@ -27,24 +27,37 @@ var completionTokensPaths = []string{ } var cacheReadTokensPaths = []string{ - "usage.cache_read_input_tokens", // Anthropic - "cache_read_input_tokens", // flat at root - "usage.prompt_tokens_details.cache_tokens_hit", // some providers - "usage.prompt_tokens_details.cache_tokens", // some providers + "usage.cache_read_input_tokens", // Anthropic + "cache_read_input_tokens", // flat at root + "data.usage.cache_read_input_tokens", // wrapped Anthropic-compatible proxy + "usage.prompt_tokens_details.cached_tokens", // OpenAI-compatible providers + "data.usage.prompt_tokens_details.cached_tokens", // wrapped OpenAI-compatible providers } var cacheWriteTokensPaths = []string{ - "usage.cache_creation_input_tokens", // Anthropic / proxy - "cache_creation_input_tokens", // flat at root + "usage.cache_creation_input_tokens", // Anthropic / proxy + "cache_creation_input_tokens", // flat at root + "data.usage.cache_creation_input_tokens", // wrapped Anthropic-compatible proxy + "usage.prompt_tokens_details.cache_creation_tokens", // ApexRoute / LLM Gateway — proxy normalization of Anthropic cache_creation_input_tokens + "data.usage.prompt_tokens_details.cache_creation_tokens", // wrapped proxy normalization } +// anthropicCacheReadPathCount is the number of Anthropic-style cache read paths +// at the start of cacheReadTokensPaths. OpenAI-style paths follow; under OpenAI +// semantics cached tokens are already included in prompt_tokens. +const anthropicCacheReadPathCount = 3 + +// anthropicCacheWritePathCount is the number of Anthropic-style cache write paths +// at the start of cacheWriteTokensPaths. +const anthropicCacheWritePathCount = 3 + // totalTokensPaths is an ordered list of JSON paths to try when extracting // total token count from a response body. Paths are dot-separated keys that // navigate through nested map[string]any objects. The first match wins. var totalTokensPaths = []string{ "usage.total_tokens", // OpenAI standard "total_tokens", // flat at root - "data.usage.total_tokens", // wrapped in data layer (some proxy APIs) + "data.usage.total_tokens", // wrapped in data layer } // resolveUsage parses raw JSON bytes into a map and extracts token usage @@ -58,8 +71,8 @@ func resolveUsage(raw []byte) *UsageInfo { total, hasAny := probePath(rawBody, totalTokensPaths) prompt, _ := probePath(rawBody, promptTokensPaths) completion, _ := probePath(rawBody, completionTokensPaths) - cacheRead, _ := probePath(rawBody, cacheReadTokensPaths) - cacheWrite, _ := probePath(rawBody, cacheWriteTokensPaths) + cacheRead, cacheReadIdx, _ := probePathIndex(rawBody, cacheReadTokensPaths) + cacheWrite, cacheWriteIdx, _ := probePathIndex(rawBody, cacheWriteTokensPaths) if !hasAny && prompt == 0 && completion == 0 { return nil @@ -74,8 +87,17 @@ func resolveUsage(raw []byte) *UsageInfo { } // If TotalTokens wasn't explicitly available but we have prompt+completion, compute it. + // Anthropic reports cache tokens separately from input_tokens, so include them in the + // fallback total. OpenAI prompt_tokens already includes cached_tokens, so only add cache + // counts when they came from Anthropic-style top-level fields. if total == 0 && (prompt > 0 || completion > 0) { - ui.TotalTokens = prompt + completion + cacheRead + cacheWrite + ui.TotalTokens = prompt + completion + if cacheReadIdx >= 0 && cacheReadIdx < anthropicCacheReadPathCount { + ui.TotalTokens += cacheRead + } + if cacheWriteIdx >= 0 && cacheWriteIdx < anthropicCacheWritePathCount { + ui.TotalTokens += cacheWrite + } } return ui @@ -84,7 +106,13 @@ func resolveUsage(raw []byte) *UsageInfo { // probePath walks through each candidate path in order, returning the first // int64 value found along with true. Returns (0, false) if none match. func probePath(root map[string]any, paths []string) (int64, bool) { - for _, p := range paths { + v, _, ok := probePathIndex(root, paths) + return v, ok +} + +// probePathIndex is like probePath but also returns the index of the matched path. +func probePathIndex(root map[string]any, paths []string) (int64, int, bool) { + for i, p := range paths { parts := strings.Split(p, ".") var current any = root @@ -101,13 +129,13 @@ func probePath(root map[string]any, paths []string) (int64, bool) { switch v := current.(type) { case float64: - return int64(v), true + return int64(v), i, true case int64: - return v, true + return v, i, true case int: - return int64(v), true + return int64(v), i, true } next: } - return 0, false + return 0, -1, false } diff --git a/internal/llm/usage_resolver_test.go b/internal/llm/usage_resolver_test.go new file mode 100644 index 0000000..0c9397a --- /dev/null +++ b/internal/llm/usage_resolver_test.go @@ -0,0 +1,123 @@ +package llm + +import "testing" + +func TestResolveUsageOpenAICompatibleCachedTokens(t *testing.T) { + usage := resolveUsage([]byte(`{ + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "total_tokens": 120, + "prompt_tokens_details": { + "cached_tokens": 75 + } + } + }`)) + + if usage == nil { + t.Fatal("resolveUsage returned nil") + } + if usage.CacheReadTokens != 75 { + t.Errorf("CacheReadTokens = %d, want 75", usage.CacheReadTokens) + } + if usage.PromptTokens != 100 { + t.Errorf("PromptTokens = %d, want 100", usage.PromptTokens) + } + if usage.CompletionTokens != 20 { + t.Errorf("CompletionTokens = %d, want 20", usage.CompletionTokens) + } +} + +func TestResolveUsageWrappedCachedTokens(t *testing.T) { + usage := resolveUsage([]byte(`{ + "data": { + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "prompt_tokens_details": { + "cached_tokens": 75, + "cache_creation_tokens": 10 + } + } + } + }`)) + + if usage == nil { + t.Fatal("resolveUsage returned nil") + } + if usage.CacheReadTokens != 75 { + t.Errorf("CacheReadTokens = %d, want 75", usage.CacheReadTokens) + } + if usage.CacheWriteTokens != 10 { + t.Errorf("CacheWriteTokens = %d, want 10", usage.CacheWriteTokens) + } + if usage.TotalTokens != 120 { + t.Errorf("TotalTokens = %d, want 120 (OpenAI cached tokens are included in prompt_tokens)", usage.TotalTokens) + } +} + +func TestResolveUsageWrappedAnthropicCompatibleCacheTokens(t *testing.T) { + usage := resolveUsage([]byte(`{ + "data": { + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "cache_read_input_tokens": 40, + "cache_creation_input_tokens": 15 + } + } + }`)) + + if usage == nil { + t.Fatal("resolveUsage returned nil") + } + if usage.CacheReadTokens != 40 { + t.Errorf("CacheReadTokens = %d, want 40", usage.CacheReadTokens) + } + if usage.CacheWriteTokens != 15 { + t.Errorf("CacheWriteTokens = %d, want 15", usage.CacheWriteTokens) + } + if usage.TotalTokens != 175 { + t.Errorf("TotalTokens = %d, want 175", usage.TotalTokens) + } +} + +func TestResolveUsageCacheReadPathPriority(t *testing.T) { + usage := resolveUsage([]byte(`{ + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "cache_read_input_tokens": 40, + "prompt_tokens_details": { + "cached_tokens": 75 + } + } + }`)) + + if usage == nil { + t.Fatal("resolveUsage returned nil") + } + if usage.CacheReadTokens != 40 { + t.Errorf("CacheReadTokens = %d, want 40 (Anthropic path should win)", usage.CacheReadTokens) + } +} + +func TestResolveUsageCacheCreationTokensPriority(t *testing.T) { + usage := resolveUsage([]byte(`{ + "usage": { + "prompt_tokens": 100, + "completion_tokens": 20, + "cache_creation_input_tokens": 30, + "prompt_tokens_details": { + "cache_creation_tokens": 15 + } + } + }`)) + + if usage == nil { + t.Fatal("resolveUsage returned nil") + } + if usage.CacheWriteTokens != 30 { + t.Errorf("CacheWriteTokens = %d, want 30 (Anthropic top-level path should win over prompt_tokens_details)", usage.CacheWriteTokens) + } +} From bf0af144fd7c5184b3ca8fe3dbcc905692b8d1f3 Mon Sep 17 00:00:00 2001 From: Nikunj Tyagi Date: Thu, 9 Jul 2026 13:39:36 +0530 Subject: [PATCH 14/19] fix(viewer): fix monospace font rendering in pre/code blocks on Windows (#328) --- internal/viewer/static/style.css | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/viewer/static/style.css b/internal/viewer/static/style.css index d2a6fb5..f5c9b00 100644 --- a/internal/viewer/static/style.css +++ b/internal/viewer/static/style.css @@ -3,7 +3,7 @@ color-scheme: light dark; /* Typography */ - --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; + --mono: "SF Mono", "Cascadia Code", Consolas, "Liberation Mono", "DejaVu Sans Mono", ui-monospace, SFMono-Regular, Menlo, "Courier New", monospace; /* Surfaces */ --bg: #f5f7fa; @@ -106,6 +106,10 @@ body { padding-bottom: 2rem; } +code, pre { + font-family: var(--mono); +} + nav.breadcrumb { background: var(--surface); border-bottom: 1px solid var(--border); From 9c1121691a7050206ca675fd6a2bf604b31db844 Mon Sep 17 00:00:00 2001 From: xyJen Date: Thu, 9 Jul 2026 17:05:12 +0800 Subject: [PATCH 15/19] fix(vscode): prevent config panel render loop and refine step status styles (#341) * feat: adjust provider configuration panel component logic and dependencies. * style: modified the display logic for step statuses in the configuration view. --- extensions/vscode/src/webview/ConfigPanelApp.tsx | 5 +++-- extensions/vscode/src/webview/views/ConfigView.tsx | 6 +++--- extensions/vscode/src/webview/views/IdleView.tsx | 2 -- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/extensions/vscode/src/webview/ConfigPanelApp.tsx b/extensions/vscode/src/webview/ConfigPanelApp.tsx index 1439a12..24e9836 100644 --- a/extensions/vscode/src/webview/ConfigPanelApp.tsx +++ b/extensions/vscode/src/webview/ConfigPanelApp.tsx @@ -1,5 +1,5 @@ import { I18nContext, resolveLocale } from './I18nProvider'; -import { useEffect, useReducer } from 'preact/hooks'; +import { useCallback, useEffect, useReducer } from 'preact/hooks'; import { bridge } from './bridge'; import { ConfigView } from './views/ConfigView'; import { configPanelInitialState, configPanelReducer } from './configStore'; @@ -12,6 +12,7 @@ function runEnvCheck(dispatch: (action: { type: 'checkingEnv' }) => void): void export function ConfigPanelApp() { const [state, dispatch] = useReducer(configPanelReducer, configPanelInitialState); + const clearConnTest = useCallback(() => dispatch({ type: 'clearConnTest' }), []); useEffect(() => { const unsub = bridge.onMessage((msg) => dispatch(msg)); @@ -59,7 +60,7 @@ export function ConfigPanelApp() { onCopy={(text) => bridge.post({ type: 'copyToClipboard', text })} onTest={(entries) => { dispatch({ type: 'testingConn' }); bridge.post({ type: 'testConnection', entries }); }} onSave={(entries) => bridge.post({ type: 'setConfigBatch', entries })} - onClearConnTest={() => dispatch({ type: 'clearConnTest' })} + onClearConnTest={clearConnTest} onDeleteCustomProvider={(name) => bridge.post({ type: 'deleteCustomProvider', name })} onActivateCustomProvider={(name) => bridge.post({ type: 'activateCustomProvider', name })} onClose={() => bridge.post({ type: 'closeConfigPanel' })} diff --git a/extensions/vscode/src/webview/views/ConfigView.tsx b/extensions/vscode/src/webview/views/ConfigView.tsx index 1e75ef1..587405b 100644 --- a/extensions/vscode/src/webview/views/ConfigView.tsx +++ b/extensions/vscode/src/webview/views/ConfigView.tsx @@ -68,17 +68,17 @@ export function ConfigView({ setTab(next.tab); setCustomView(next.customView); setCustomSelection(next.customSelection); - }, [panelFocus, config, onClearConnTest]); + }, [panelFocus, config]); const wide = layout === 'panel'; const t = useT(); const stepper = (

-
+
1 {t('view.config.step1')}
-
+
2 {t('view.config.step2')}
diff --git a/extensions/vscode/src/webview/views/IdleView.tsx b/extensions/vscode/src/webview/views/IdleView.tsx index 7de4974..dea61f7 100644 --- a/extensions/vscode/src/webview/views/IdleView.tsx +++ b/extensions/vscode/src/webview/views/IdleView.tsx @@ -113,8 +113,6 @@ export function IdleView({ gitState, modeFiles, filesLoading, configured, onMode {configured && (
- - ·
)} From d9159276af60111276f390617bdf7ac49b6f4d1e Mon Sep 17 00:00:00 2001 From: Lei Zhang Date: Thu, 9 Jul 2026 19:08:19 +0800 Subject: [PATCH 16/19] feat(action): extract reusable composite PR-review GitHub Action (#337) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(action): extract reusable OpenCodeReview PR review GitHub Action Consolidate the reusable-action work into one commit: - Add composite action (action.yml at repo root for GitHub Marketplace; helper at scripts/github-actions/post-review-comments.js) porting the sticky summary, incremental posting, and retry idempotency logic. - Add unit tests covering the ported idempotency behavior. - Switch the in-repo CI workflow to use the reusable action. - Add and refine example reusable workflows for consumers. * ci(workflow): point ocr-review at root action.yml and quote boolean inputs - Fix uses: to ./ now that action.yml lives at the repo root. - Quote sticky_summary/incremental/upload_artifacts as strings to match action.yml's input declarations (composite-action inputs are always strings) and silence actionlint. - Enable upload_artifacts for this workflow. * docs(examples): point reusable demo at root action.yml The example workflow referenced alibaba/open-code-review/action@v1, but action.yml now lives at the repo root, so the /action subpath no longer resolves. Use alibaba/open-code-review@v1 and update the stale action/README.md comment to point at the root action.yml. * docs(examples): sync README to root action.yml references The example README still pointed at the relocated/deleted locations: action.yml is now at the repo root, so update all 11 alibaba/open-code-review/action@v1 references to alibaba/open-code-review@v1, and repoint the action/ directory and action/README.md links to the root action.yml. * fix(examples): prevent unrelated PR comments from canceling ocr-review GitHub Actions evaluates concurrency before the job-level if-condition. The flat group mapped every issue_comment event on a PR into the review's group, so any comment (even a skipped conversation reply) canceled any in-progress review. Match the reusable demo's conditional group: PR events and human-authored /open-code-review/@open-code-review comments share a per-PR group, while non-matching comments fall back to a unique noop- group that can never collide with a real review. * fix(action): address code-review findings across reusable PR review - post-review-comments: parse retry delays via parseNonNegInt (0/negative fix); paginate findExistingSummaryComment through readAllPages; remove dead rangeOf and hasIssueCommentWithId (plus duplicated comment block) - action.yml: move ${{ }} interpolations into env: (resolve refs, PR_NUM, ocr_version); fail fast on PR head fetch instead of swallowing errors - workflows: add timeout-minutes: 30; gate issue_comment on author_association; tighten pr-context if to == 'issue_comment' * fix(action): harden review posting after code review - pass incremental_overlap_threshold via env to avoid github-script injection - capture ocr review exit code directly instead of &&/|| chain - drop redundant SUMMARY_MARKER prepend in postSummary (callers already add it) - align example job if-condition bot check with its concurrency group * fix(action): always upload review artifacts and capture ocr exit code * fix(action): merge posting statistics into the summary header The PR summary issue comment used to present two overlapping breakdowns: a leading "posted as inline / posted as summary" header and a trailing "📊 Posting Statistics" block. Their definitions overlapped (the header's "summary" count included failures the trailer also listed as failed), and when incremental filtering skipped comments the header counts no longer summed to the total, making the summary hard to interpret. Merge them into a single header whose four counts (inline / summary / skipped / failed) are mutually exclusive and sum to the total, and drop the trailing Posting Statistics section. buildSummaryBody now takes an options object. * fix(action): support local action resolution in container/self-hosted setups - Checkout trusted base + mark workspace safe for pull_request_target so the local `uses: ./` action can be resolved and loaded - Check for git/Node.js and install git when missing, making the composite action resilient across runner images - Move Setup Node.js earlier and make it conditional on availability - Resolve post-review-comments helper at runtime via GITHUB_ACTION_PATH falling back to GITHUB_WORKSPACE, fixing helper lookup for local actions where the action path is a host path invisible inside containers * refactor(examples): consolidate github_actions demo to reusable action Drop the inline-script full-control demo; the renamed ocr-review.yml (from ocr-review-reusable.yml) is now the single demo, invoking alibaba/open-code-review@main. Sync the README to the current implementation: - normalize action refs to @main; point self-hosted-runner users to the repo's own workflow (noting uses: ./ is internal-only) - document config via action inputs (posting modes: sticky/incremental) - update the comment-trigger if with defensive bot/author_association guards and the concurrency mirror - fix Example Output to cover the summary comment + inline comments - replace the non-existent OCR_DEBUG debugging with artifacts/outputs/ACTIONS_STEP_DEBUG - use --replace-all for safe.directory * fix(action): harden withRetry against silent undefined return withRetry's for loop had no terminal return/throw after the loop body. Although the current loop invariant (last attempt always throws, and parseNonNegInt guards against negative MAX_RETRIES) makes fall-through unreachable, an async function that falls through resolves to undefined, which would surface as a confusing downstream TypeError for the read-API callers that rely on it. Capture lastErr in the loop and add an explicit terminal throw so any future break of the invariant fails loudly instead of silently returning undefined. * docs(readme): document the reusable GitHub Action in CI/CD section * fix(action): restore language config via a language input The old inline workflow ran `ocr config set language English`, but the composite action's Configure OCR step only set llm.extra_body, with no language input. Add a language input (default English) and write it via `ocr config set language` so review output language is no longer left to the tool's default. Addresses #337 (discussion_r3550069843). * fix(action): warn when incremental comment listing hits page cap listExistingReviewComments silently dropped comments beyond its 10-page cap, unlike readAllPages which logs when truncation occurs. Add the same max-page-limit warning after the loop so a partial walk during incremental dedup is visible in the logs. Addresses #337 (discussion_r3550069871). * docs(readme): sync GitHub Action section to localized READMEs --- .github/workflows/ocr-review.yml | 864 +---------- README.ja-JP.md | 15 + README.ko-KR.md | 15 + README.md | 15 + README.ru-RU.md | 15 + README.zh-CN.md | 15 + action.yml | 305 ++++ examples/github_actions/README.md | 277 +++- examples/github_actions/ocr-review.yml | 965 +----------- .../github-actions/post-review-comments.js | 1203 +++++++++++++++ .../post-review-comments.test.js | 1330 ++++++++++++++++- 11 files changed, 3132 insertions(+), 1887 deletions(-) create mode 100644 action.yml create mode 100644 scripts/github-actions/post-review-comments.js diff --git a/.github/workflows/ocr-review.yml b/.github/workflows/ocr-review.yml index 9abcdf8..1bb8775 100644 --- a/.github/workflows/ocr-review.yml +++ b/.github/workflows/ocr-review.yml @@ -1,58 +1,19 @@ # OpenCodeReview - GitHub Actions PR Auto-Review Pipeline # -# This workflow automatically reviews pull requests using OpenCodeReview -# and posts review comments directly on the PR. +# Reviews pull requests using the reusable composite action defined in +# action.yml at the repo root. See that file for the full list of inputs, +# outputs, and implementation details (retry, idempotency, artifact upload, etc.). # # Triggers: # - PR opened (uses pull_request_target for fork secret access) -# - Comment on PR containing '/open-code-review' or '@open-code-review' # -# Required secrets: -# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions) -# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API -# -# Optional secrets: -# OCR_LLM_MODEL - Model name (default: gpt-4o) -# OCR_LLM_USE_ANTHROPIC - Set to 'true' if using Anthropic Claude models -# -# Optional variables (for retry/delay tuning): -# The retry strategy follows GitHub's documented guidance for REST API rate limits: -# https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api -# - Primary rate limit exhausted (x-ratelimit-remaining=0): wait until x-ratelimit-reset. -# - Secondary rate limit with a retry-after header: wait exactly that long. -# - Secondary rate limit with NO header: wait at least one minute, then use -# exponential backoff on continued failures. -# -# OCR_RETRY_BASE_DELAY - Base delay (ms) for exponential backoff when no retry -# header is present (default: 60000, per GitHub's -# "at least one minute" recommendation for secondary limits). -# OCR_RETRY_MAX_DELAY - Maximum delay (ms) cap applied to EVERY computed wait, -# including retry-after and x-ratelimit-reset, so a far-future -# reset cannot stall the job past its timeout (default: 300000 = 5 min). -# OCR_MAX_RETRIES - Max retry attempts per comment when rate-limited (default: 3). -# OCR_SUCCESS_DELAY - Delay (ms) between successful comment posts to pace requests (default: 2000). -# OCR_FAILURE_DELAY - Delay (ms) after a non-retryable failure to pace subsequent requests (default: 1000). -# OCR_LOW_REMAINING_THRESHOLD - When x-ratelimit-remaining is at or below this value, -# proactively increase request spacing to avoid hitting the limit -# (default: 3; GitHub best practice is to watch the header and slow down). -# OCR_LOW_REMAINING_SPACING - Request spacing (ms) used when remaining quota is low -# (default: 10000 = 10s). -# OCR_READ_SUCCESS_DELAY - Delay (ms) after a successful read API call (listReviews / -# listReviewComments / listIssueComments) used for the -# idempotency check. Reads are cheaper than writes, so the -# default is shorter (default: 500). -# OCR_READ_LOW_REMAINING_SPACING - Request spacing (ms) for read calls when remaining -# quota is low (default: 5000 = 5s). -# -# Idempotency: -# When the batch createReview fails with a 5xx, the request may still have landed on -# the server. Before retrying per-comment, the workflow queries existing reviews and -# review comments (tagged with a per-run HTML comment) and only retries the comments -# that are actually missing. This prevents duplicate review posts. +# Required secrets (mapped to action inputs): +# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions) +# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API +# OCR_LLM_MODEL - Model name +# OCR_LLM_USE_ANTHROPIC - 'true' for Anthropic Claude, 'false' for OpenAI-compatible # # Note: GITHUB_TOKEN is automatically provided by GitHub Actions. -# Note: The workflow also configures llm.extra_body to '{"thinking": {"type": "disabled"}}' -# to disable thinking mode for compatibility with various LLM providers. name: OpenCodeReview PR Review @@ -74,805 +35,30 @@ permissions: jobs: code-review: runs-on: self-hosted + timeout-minutes: 30 container: image: node:24 if: github.event_name == 'pull_request_target' steps: - - name: Checkout repository + # Materialize action.yml + scripts/ into the workspace so the local + # `uses: ./` action below can be resolved and loaded. For + # pull_request_target this checks out the trusted base branch; the + # composite action performs its own full checkout (fetch-depth: 0) later. + - name: Checkout uses: actions/checkout@v4 - with: - fetch-depth: 0 # Full history needed for merge-base diff - ref: ${{ github.event.pull_request.head.sha }} - - name: Mark repository as safe directory + - name: Trust workspace run: git config --global --replace-all safe.directory '*' - - name: Fetch PR head ref (ensures fork commits are available) - run: git fetch origin pull/${{ github.event.pull_request.number }}/head - - - name: Install OpenCodeReview - run: npm install -g @alibaba-group/open-code-review - - - name: Configure OCR - run: | - ocr config set llm.url ${{ secrets.OCR_LLM_URL }} - ocr config set llm.auth_token ${{ secrets.OCR_LLM_AUTH_TOKEN }} - ocr config set llm.model ${{ secrets.OCR_LLM_MODEL }} - ocr config set llm.use_anthropic ${{ secrets.OCR_LLM_USE_ANTHROPIC }} - ocr config set llm.extra_body '{"enable_thinking": false}' - ocr config set language English - - name: Run OpenCodeReview - id: review - run: | - BASE_REF="${{ github.event.pull_request.base.ref }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - - echo "Reviewing PR: ${HEAD_SHA} against origin/${BASE_REF}" - - # Run OCR in range mode with JSON output - ocr review \ - --from "origin/${BASE_REF}" \ - --to "${HEAD_SHA}" \ - --format json \ - > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true - - echo "OCR review completed. Output:" - cat /tmp/ocr-result.json - echo "OCR review completed. Error log:" - cat /tmp/ocr-stderr.log - - - name: Post review comments to PR - uses: actions/github-script@v7 + uses: ./ with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const crypto = require('crypto'); - const path = '/tmp/ocr-result.json'; - - // Unique tag for this workflow run + attempt. Embedded in review/comment - // bodies as an HTML comment so the idempotency check can detect whether - // a batch createReview actually landed on the server before retrying. - // context.runId / context.runAttempt are numbers from @actions/github's - // Context (parsed from GITHUB_RUN_ID / GITHUB_RUN_ATTEMPT). Use - // Number.isFinite to guard against NaN when the env vars are missing, - // falling back to safe defaults. - const runId = Number.isFinite(context.runId) ? context.runId : 0; - const runAttempt = Number.isFinite(context.runAttempt) ? context.runAttempt : 1; - const RUN_TAG = `${runId}-${runAttempt}`; - const REVIEW_TAG = ``; - const SUMMARY_TAG = ``; - - // Read OCR output - let result; - try { - const raw = fs.readFileSync(path, 'utf8'); - result = JSON.parse(raw); - } catch (e) { - console.log('Failed to parse OCR output:', e.message); - // Post a simple comment if parsing fails - const stderr = fs.readFileSync('/tmp/ocr-stderr.log', 'utf8').trim(); - if (stderr) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `⚠️ **OpenCodeReview** encountered an error:\n${fencedBlock(stderr)}` - }); - } - return; - } - - const comments = result.comments || []; - const warnings = result.warnings || []; - - // If no comments, post a summary - if (comments.length === 0) { - const message = result.message || 'No comments generated. Looks good to me.'; - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `✅ **OpenCodeReview**: ${message}` - }); - return; - } - - // Prepare PR review with inline comments - const prNumber = context.issue.number; - let commitSha = context.payload.pull_request.head.sha; - - // Build review comments array for the PR review API - // Only inline comments with line info can be posted via createReview - const reviewComments = []; - const commentsWithoutLine = []; - - for (const comment of comments) { - // Check if comment has valid line information for inline comment (line >= 1) - const hasValidLine = (comment.start_line >= 1) || (comment.end_line >= 1); - if (!hasValidLine) { - commentsWithoutLine.push({ comment }); - continue; - } - - // Each inline comment becomes an item carrying a random ID - // (assigned once) and its resolved line targeting. The body is - // built from item.id only at API-call time (see toReviewPayload), - // so retry/idempotency logic reads item.id directly instead of - // recomputing it, and distinct comments never share an ID. - reviewComments.push({ - comment, - id: newCommentId(), - lines: resolveLines(comment) - }); - } - - // Submit as a single PR review with all comments - const totalCount = comments.length; - const inlineCount = reviewComments.length; - const summaryCount = commentsWithoutLine.length; - let summaryBody = buildSummaryBody(totalCount, inlineCount, summaryCount, warnings); - - // Add comments without line info to summary body - summaryBody += formatSummaryComments(commentsWithoutLine); - - // Prepend the run tag so the idempotency check can detect whether the - // batch review actually landed on the server before retrying. - summaryBody = REVIEW_TAG + '\n' + summaryBody; - - // Statistics tracking - let successCount = 0; - let failedCount = 0; - const failedComments = []; - - // Retry/pacing configuration (shared by write and read API calls). - // parseNonNegInt guards against nonsensical env values (negative, - // NaN, non-numeric) that `parseInt(...) || default` would let - // through for negative numbers, since a negative parseInt result - // is truthy and would bypass the `|| default` fallback. - function parseNonNegInt(val, defaultVal) { - const n = parseInt(val, 10); - return Number.isFinite(n) && n >= 0 ? n : defaultVal; - } - const MAX_RETRIES = parseNonNegInt(process.env.OCR_MAX_RETRIES, 3); - const SUCCESS_DELAY = parseNonNegInt(process.env.OCR_SUCCESS_DELAY, 2000); // delay after successful write - const FAILURE_DELAY = parseNonNegInt(process.env.OCR_FAILURE_DELAY, 1000); // delay after non-retryable failure - const LOW_REMAINING_THRESHOLD = parseNonNegInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 3); - const LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_LOW_REMAINING_SPACING, 10000); - // Read APIs are cheaper and have higher thresholds; use shorter pacing. - const READ_SUCCESS_DELAY = parseNonNegInt(process.env.OCR_READ_SUCCESS_DELAY, 500); - const READ_LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_READ_LOW_REMAINING_SPACING, 5000); - - try { - const batchRes = await github.rest.pulls.createReview({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - commit_id: commitSha, - body: summaryBody, - event: 'COMMENT', - comments: reviewComments.map(toReviewPayload) - }); - successCount = reviewComments.length; - console.log(`Successfully posted review with ${successCount} inline comments (${commentsWithoutLine.length} in summary)`); - logRateLimitQuota(batchRes, 'after batch createReview'); - } catch (e) { - console.log('Failed to post review with inline comments:', e.message); - console.log('Checking whether the batch review actually landed on the server before retrying...'); - - // Idempotency check: the batch createReview may have succeeded on the - // server even though we got a 5xx. Query existing reviews to find out, - // so we only retry the comments that are actually missing. - let existingReview = null; - try { - existingReview = await findExistingBatchReview({ - owner: context.repo.owner, - repo: context.repo.repo, - prNumber, - tag: REVIEW_TAG - }); - } catch (checkErr) { - console.log(`Idempotency check failed (${checkErr.message}). ` + - `Degrading to original fallback (accepting duplicate risk).`); - } - - // Compute the list of inline comments that still need to be posted. - // If the batch review landed, only retry the missing ones; otherwise - // retry all of them. - let toRetry = reviewComments; - if (existingReview && existingReview.found) { - const postedIds = await getPostedCommentIds({ - owner: context.repo.owner, - repo: context.repo.repo, - prNumber - }); - toRetry = reviewComments.filter((item) => - !postedIds.has(item.id) - ); - successCount = reviewComments.length - toRetry.length; - console.log(`Batch review already exists (review_id=${existingReview.review.id}). ` + - `${successCount}/${reviewComments.length} inline comments already posted. ` + - `${toRetry.length} missing, will retry only those.`); - } else { - console.log('Batch review not found on server. Falling back to per-comment posting...'); - } - - // If the batch itself was rate-limited, honor its rate-limit headers - // (retry-after / x-ratelimit-reset) before retrying per-comment, - // otherwise the first per-comment call re-hits the same wall immediately. - const batchRetry = computeRetryDelayMs(e, 0); - if (batchRetry != null) { - const secs = (batchRetry.delayMs / 1000).toFixed(1); - console.log( - `Batch createReview was rate-limited (HTTP ${e.status}). ` + - `Cooling down ${secs}s via '${batchRetry.source}' (${batchRetry.detail}) before per-comment retry.` - ); - await sleep(batchRetry.delayMs); - } - - for (const item of toRetry) { - const { comment, id } = item; - let posted = false; - for (let attempt = 0; attempt <= MAX_RETRIES && !posted; attempt++) { - try { - const res = await github.rest.pulls.createReview({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - commit_id: commitSha, - body: '', - event: 'COMMENT', - comments: [toReviewPayload(item)] - }); - successCount++; - posted = true; - console.log(`Successfully posted comment for ${comment.path}`); - // Proactive throttle: if remaining quota is low, slow down to - // avoid hitting the limit (GitHub best practice: watch the header). - const remaining = logRateLimitQuota(res, `after ${comment.path}`); - const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; - if (lowQuota) { - console.log(`[rate-limit] quota low (remaining=${remaining} <= ${LOW_REMAINING_THRESHOLD}); increasing spacing to ${LOW_REMAINING_SPACING}ms.`); - await sleep(LOW_REMAINING_SPACING); - } else { - await sleep(SUCCESS_DELAY); - } - } catch (innerE) { - // Decide whether to retry and how long to wait, based on GitHub's - // rate-limit documentation (retry-after / x-ratelimit-* headers). - const retryInfo = computeRetryDelayMs(innerE, attempt); - const willRetry = retryInfo != null && attempt < MAX_RETRIES; - // Any error whose request may have reached GitHub (5xx server - // errors, 408 timeout, or network-layer errors with no status) - // can mean the comment was actually created but the response was - // lost. Before retrying (which would post a duplicate) or before - // giving up (which would wrongly list it as failed in the summary), - // we must check whether it already landed. - // - // IMPORTANT: do the check AFTER cooling down, not immediately. - // If the error is rate-limit-related (5xx under load, or a - // network blip), firing read requests right away further - // pressures the already-struggling API. Honor the computed - // retry delay first, then query. - const status = innerE.status; - const maybeReachedServer = - (typeof status === 'number' && (status >= 500 || status === 408)) || - status == null; // network errors (ECONNRESET, ETIMEDOUT, ...) - if (maybeReachedServer) { - // Cool down first: even read requests count against rate - // limits, and querying during an ongoing 5xx/rate-limit - // episode can worsen the situation. Use the retry delay when - // available; for non-retryable errors (retryInfo == null) - // there is no header-derived wait, so use a short fixed cool - // down before the read. - const coolDownMs = retryInfo != null ? retryInfo.delayMs : FAILURE_DELAY; - if (coolDownMs > 0) { - const secs = (coolDownMs / 1000).toFixed(1); - console.log( - `Cooling down ${secs}s before idempotency check for ${comment.path} ` + - `(HTTP ${innerE.status || 'n/a'}, attempt ${attempt + 1}/${MAX_RETRIES + 1}).` - ); - await sleep(coolDownMs); - } - const alreadyPosted = await isCommentAlreadyPosted({ - owner: context.repo.owner, - repo: context.repo.repo, - prNumber, - id - }); - if (alreadyPosted === true) { - successCount++; - posted = true; - console.log(`Comment for ${comment.path} already posted (id=${id}); treating as success.`); - await sleep(SUCCESS_DELAY); - continue; - } - // Unknown (null): the read API is unavailable, so we - // cannot tell whether the comment landed. To avoid a - // duplicate, do NOT retry posting; record as failed so - // the summary surfaces the uncertainty rather than - // silently risking a duplicate. - if (alreadyPosted === null) { - failedCount++; - const reason = 'idempotency check unavailable (read API failed)'; - failedComments.push({ comment, error: `${innerE.message} [${reason}]` }); - console.log(`Cannot verify whether comment for ${comment.path} was posted (${reason}, HTTP ${innerE.status || 'n/a'}); skipping retry to avoid duplicate.`); - await sleep(SUCCESS_DELAY); - break; - } - // Not found on server. If retries are exhausted or the - // error is non-retryable, this is a real failure. - if (!willRetry) { - failedCount++; - failedComments.push({ comment, error: innerE.message }); - const reason = retryInfo == null ? 'non-retryable error' : 'rate-limit retries exhausted'; - console.log(`Failed to post comment for ${comment.path} (${reason}, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`); - await sleep(SUCCESS_DELAY); - break; - } - // willRetry: cool down already consumed above, loop back. - } else if (willRetry) { - // Pure 429/403 rate-limit: the request never reached the - // server, so no duplicate is possible and the idempotency - // check can be skipped. Just honor the retry delay. - const secs = (retryInfo.delayMs / 1000).toFixed(1); - console.log( - `Rate-limited on ${comment.path} ` + - `(HTTP ${innerE.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` + - `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ` + - `Error: ${innerE.message}` - ); - await sleep(retryInfo.delayMs); - } else { - // Non-retryable error that definitely did not reach the - // server (e.g. 4xx validation error): record as failed. - failedCount++; - failedComments.push({ comment, error: innerE.message }); - console.log(`Failed to post comment for ${comment.path} (non-retryable error, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`); - await sleep(FAILURE_DELAY); - break; - } - } - } - } - - // Post summary comment with statistics - let finalBody = buildSummaryBody(totalCount, successCount, commentsWithoutLine.length + failedComments.length, warnings); - finalBody += formatSummaryComments(commentsWithoutLine); - finalBody += `\n\n---\n\n📊 **Posting Statistics:**`; - finalBody += `\n- ✅ Successfully posted: ${successCount} comment(s)`; - if (failedCount > 0) { - finalBody += `\n- ❌ Failed to post: ${failedCount} comment(s)`; - } - - // Add failed comments as summary content so review feedback is not lost. - if (failedComments.length > 0) { - finalBody += '\n\n---\n\n### ⚠️ Inline comments shown in summary'; - for (const { comment, error } of failedComments) { - finalBody += '\n\n---\n\n'; - finalBody += formatCommentMarkdown(comment, error); - } - } - - // Prepend the summary tag and post only if no summary with this tag - // already exists (idempotency: the batch review may have carried the - // same summary body, in which case we must not duplicate it). - finalBody = SUMMARY_TAG + '\n' + finalBody; - const summaryAlreadyPosted = await hasIssueCommentWithId({ - owner: context.repo.owner, - repo: context.repo.repo, - issueNumber: prNumber, - id: SUMMARY_TAG - }); - if (summaryAlreadyPosted === true) { - console.log('Summary comment with this run tag already exists; skipping.'); - } else if (summaryAlreadyPosted === null) { - // Read API unavailable: cannot tell whether the summary already - // landed. Skip posting to avoid a duplicate; the review content - // is still available via inline comments / batch review. - console.log('Cannot verify whether summary comment already exists (read API failed); skipping to avoid duplicate.'); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: finalBody - }); - } - } - - function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - // Retry wrapper shared by write and read API calls. Reuses - // computeRetryDelayMs so rate-limit headers (retry-after / - // x-ratelimit-*) are honored uniformly. Throws on final failure - // so the caller can decide how to degrade. - async function withRetry(tag, fn) { - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - try { - return await fn(); - } catch (e) { - const retryInfo = computeRetryDelayMs(e, attempt); - const willRetry = retryInfo != null && attempt < MAX_RETRIES; - if (willRetry) { - const secs = (retryInfo.delayMs / 1000).toFixed(1); - console.log( - `[${tag}] transient/rate-limited (HTTP ${e.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` + - `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ${e.message}` - ); - await sleep(retryInfo.delayMs); - } else { - console.log(`[${tag}] failed after ${attempt + 1} attempts: ${e.message}`); - throw e; - } - } - } - } - - // Read API wrapper with retry + proactive pacing. Read requests are - // cheaper than writes but still consume the primary rate limit and can - // trigger the secondary limit when issued in a tight loop. Use shorter - // delays than writes (READ_SUCCESS_DELAY / READ_LOW_REMAINING_SPACING). - async function readWithPacing(tag, fn) { - const res = await withRetry(tag, fn); - const remaining = logRateLimitQuota(res, tag); - const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; - if (lowQuota) { - console.log(`[rate-limit] quota low after read (${remaining} <= ${LOW_REMAINING_THRESHOLD}); spacing ${READ_LOW_REMAINING_SPACING}ms.`); - await sleep(READ_LOW_REMAINING_SPACING); - } else { - await sleep(READ_SUCCESS_DELAY); - } - return res; - } - - // Paginated helper that walks all pages of a list endpoint with retry - // and pacing. Returns the concatenated array of items. - async function readAllPages(tag, pageFn, maxPages = 50) { - if (!Number.isFinite(maxPages) || maxPages < 1) { - throw new Error(`readAllPages: maxPages must be a positive integer, got ${maxPages}`); - } - const all = []; - let page = 1; - const PER_PAGE = 100; - while (page <= maxPages) { - const res = await readWithPacing(`${tag} (page ${page})`, () => pageFn(page, PER_PAGE)); - const items = res.data || []; - all.push(...items); - if (items.length < PER_PAGE) break; - page++; - } - // NOTE: Truncation here is intentional and acts as a safety - // valve against unbounded loops (e.g. a bug or malicious - // activity), not as a normal operating mode. A PR accumulating - // >5000 review comments is far outside expected usage; in that - // rare case we log a warning and proceed with partial data - // rather than failing the whole review. - // - // Caveat: this is NOT the same as a read failure. When the read - // API throws (rate limit, 5xx), isCommentAlreadyPosted and - // hasIssueCommentWithId catch it and return null (unknown), so - // the caller skips retrying and creates no duplicate. A - // truncated walk does not throw; it returns a partial set - // silently, so isCommentAlreadyPosted returns false (definitively - // "not posted") for any comment beyond the cap, and the retry - // loop will repost it, producing a duplicate. This tradeoff is - // accepted because the trigger is far outside expected usage; if - // that ceiling ever needs to rise, make maxPages configurable. - if (page > maxPages) { - console.log(`[${tag}] reached max page limit (${maxPages}); results may be incomplete.`); - } - return all; - } - - // Idempotency check: find whether a batch review with this run tag - // already exists on the PR. Returns { found, review } or throws on - // final failure (caller degrades to original fallback). - async function findExistingBatchReview({ owner, repo, prNumber, tag }) { - const reviews = await readAllPages('listReviews', (page, per_page) => - github.rest.pulls.listReviews({ owner, repo, pull_number: prNumber, per_page, page }) - ); - for (const r of reviews) { - if ((r.body || '').includes(tag)) { - return { found: true, review: r }; - } - } - return { found: false }; - } - - // Collect the set of comment-level IDs already posted on the PR - // (across all reviews). Uses listReviewComments (PR-level, cross-review) - // so a single paginated walk covers everything, avoiding the O(missing) - // amplification of per-comment lookups. - async function getPostedCommentIds({ owner, repo, prNumber }) { - const comments = await readAllPages('listReviewComments', (page, per_page) => - github.rest.pulls.listReviewComments({ owner, repo, pull_number: prNumber, per_page, page }) - ); - const ids = new Set(); - // Anchor the regex to the HTML comment wrapper () - // so user-generated content or code suggestions cannot trigger - // false positives in the idempotency check. The ID format is - // `ocr--` where RUN_TAG is `-` - // and is a per-comment random hex token. Capture group 1 - // holds the bare ID (ocr--), so we can add it - // directly without stripping comment markers. - const ID_RE = //g; - for (const c of comments) { - const body = c.body || ''; - let m; - while ((m = ID_RE.exec(body)) !== null) { - ids.add(m[1]); - } - } - return ids; - } - - // Check whether a specific comment-level ID has already landed on the - // server. Used by the per-comment retry loop: when a createReview call - // fails with a transient 5xx/408, the request may have reached GitHub - // and succeeded even though the response was lost. Querying before - // retrying prevents posting a duplicate inline comment. - // Returns true/false when the check succeeds, or null when the - // read API is unavailable (rate limit, 5xx, etc.). Returning null - // (rather than defaulting to false) prevents the caller from - // assuming the comment was not posted and risking a duplicate on - // retry. - // - // Each call walks listReviewComments fresh — no cached snapshot. - // A snapshot reused across retries would go stale as comments land - // during the loop, and a stale miss for a 5xx-landed comment would - // trigger a retry that posts a duplicate. Read calls are paced via - // readAllPages/readWithPacing and degrade to null (skip retry) if the - // read API itself fails, so the extra walks cannot produce duplicates. - async function isCommentAlreadyPosted({ owner, repo, prNumber, id }) { - try { - const posted = await getPostedCommentIds({ owner, repo, prNumber }); - return posted.has(id); - } catch (e) { - console.log(`[isCommentAlreadyPosted] check failed for ${id} (${e.message}); treating as unknown to avoid duplicates.`); - return null; - } - } - - // Check whether an issue comment with the given tag already exists. - // Used to avoid posting a duplicate summary comment when the batch - // review already carried the same summary body. - // Returns true/false when the check succeeds, or null when the - // read API is unavailable. Returning null (rather than defaulting - // to false) lets the caller decide whether to skip posting or - // degrade gracefully, instead of silently risking a duplicate - // summary comment. - async function hasIssueCommentWithId({ owner, repo, issueNumber, id }) { - try { - const comments = await readAllPages('listIssueComments', (page, per_page) => - github.rest.issues.listComments({ owner, repo, issue_number: issueNumber, per_page, page }) - ); - // Match the tag anchored to its HTML comment wrapper for - // consistency with getPostedCommentIds and to defend against - // user content that happens to contain the bare tag string. - // `id` is an opaque tag like ``, - // so escape any regex metacharacters before embedding it. - const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const tagRe = new RegExp(''); - return comments.some(c => tagRe.test(c.body || '')); - } catch (e) { - console.log(`[listIssueComments] check failed (${e.message}); treating as unknown to avoid duplicates.`); - return null; - } - } - - // Case-insensitive header lookup. Octokit normalizes response headers to - // lowercase, but this defensive check also handles original casing so that - // quota logging and retry delay computation never silently miss a header. - function getHeader(headers, name) { - const v = headers[name] != null ? headers[name] : headers[name.toLowerCase()]; - return v != null ? String(v).trim() : undefined; - } - - // Decide whether an error is worth retrying and, if so, how long to wait. - // Implements GitHub's documented rate-limit retry strategy using the - // response headers (retry-after, x-ratelimit-remaining, x-ratelimit-reset). - // Returns { delayMs, source, detail } when retryable, or null otherwise. - // See: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api - function computeRetryDelayMs(error, attempt) { - if (!error) return null; - const status = error.status; - const message = String(error.message || ''); - const isRateLimit = status === 429 || (status === 403 && /rate limit|abuse|secondary/i.test(message)); - const isTransient = (status >= 500 && status < 600) || status === 408; - if (!isRateLimit && !isTransient) return null; - - const headers = ((error.response || {}).headers) || {}; - const header = (name) => getHeader(headers, name); - const nowSec = Math.floor(Date.now() / 1000); - - // The absolute maximum wait for any single retry. Header-derived waits - // (retry-after / x-ratelimit-reset) are GitHub's recommended durations, - // but capping them prevents a far-future reset from stalling the CI job - // past its timeout. When we cap, the next retry may re-hit the limit. - const cap = parseInt(process.env.OCR_RETRY_MAX_DELAY, 10) || 300000; - const base = parseInt(process.env.OCR_RETRY_BASE_DELAY, 10) || 60000; - - // { rawMs, source, detail } describing the recommended wait before cap. - let info = null; - - if (isRateLimit) { - // (1) Honor "retry-after" when present (seconds, or an HTTP-date). - const retryAfter = header('retry-after'); - if (retryAfter) { - const secs = Number(retryAfter); - if (!isNaN(secs) && secs >= 0) { - info = { rawMs: secs * 1000, source: 'retry-after', detail: `${secs}s (from header)` }; - } else { - const dateMs = Date.parse(retryAfter); - if (!isNaN(dateMs)) { - info = { rawMs: Math.max(0, dateMs - Date.now()), source: 'retry-after (HTTP-date)', detail: retryAfter }; - } - } - } - - // (2) Primary limit exhausted (x-ratelimit-remaining=0): wait until reset. - if (!info) { - const remaining = header('x-ratelimit-remaining'); - const reset = header('x-ratelimit-reset'); - if (reset != null && Number(remaining) === 0) { - const rawMs = Math.max(0, Number(reset) - nowSec) * 1000; - info = { rawMs, source: 'x-ratelimit-reset', detail: `remaining=0, reset epoch=${reset} (in ${Math.ceil(rawMs / 1000)}s)` }; - } - } - - // (3) Secondary limit with no retry hint: docs say wait at least one - // minute, then increase exponentially between retries. - if (!info) { - const backoff = Math.min(base * Math.pow(2, attempt), cap); - const jitter = Math.floor(Math.random() * 1000); - info = { rawMs: backoff + jitter, source: 'exponential-backoff', detail: `base=${base}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter` }; - } - } else { - // Transient server error (5xx / 408): back off without the 60s floor. - // Use a shorter base than the rate-limit path: server hiccups are - // typically short-lived, so a 2s initial wait (doubling per retry) - // is sufficient and avoids stalling the CI job unnecessarily. - const transientBase = 2000; - const backoff = Math.min(transientBase * Math.pow(2, attempt), cap); - const jitter = Math.floor(Math.random() * 1000); - info = { rawMs: backoff + jitter, source: 'transient-backoff', detail: `base=${transientBase}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter (HTTP ${status})` }; - } - - // Apply the universal cap to header-derived waits too. - const delayMs = Math.min(info.rawMs, cap); - if (delayMs < info.rawMs) { - info.detail += ` [CAPPED to ${cap}ms; GitHub recommended ${Math.ceil(info.rawMs / 1000)}s]`; - } - return { delayMs, source: info.source, detail: info.detail }; - } - - // Best-effort logging of remaining rate-limit quota from a successful response. - // Returns the parsed x-ratelimit-remaining value (or null) for proactive throttling. - function logRateLimitQuota(response, tag) { - try { - const h = (response && response.headers) || {}; - const header = (name) => getHeader(h, name); - const remaining = header('x-ratelimit-remaining'); - const limit = header('x-ratelimit-limit'); - const reset = header('x-ratelimit-reset'); - if (remaining != null) { - console.log( - `[rate-limit] ${tag}: remaining=${remaining}/${limit != null ? limit : '?'}` + - (reset != null ? `, reset epoch=${reset}` : '') - ); - } - return remaining != null ? Number(remaining) : null; - } catch (_) { return null; } - } - - // Random per-comment ID, assigned once when the inline-comment item - // is built and carried on the item struct. Random (rather than - // content-derived) so two distinct comments that share the same - // path/line/content still get different IDs and the idempotency - // check never mistakes one for the other (which would silently drop - // the second). Embedded in the comment body as an HTML comment so - // getPostedCommentIds can match it back on retry. - function newCommentId() { - return `ocr-${RUN_TAG}-${crypto.randomBytes(8).toString('hex')}`; - } - - // Resolve the line-targeting fields for a createReview comment - // payload (start_line/line/start_side/side) from the comment's line - // range. Returned object is spread into the payload in toReviewPayload. - function resolveLines(comment) { - const start = comment.start_line; - const end = comment.end_line; - if (start >= 1 && end >= 1 && start !== end) { - return { start_line: start, line: end, start_side: 'RIGHT', side: 'RIGHT' }; - } else if (end >= 1) { - return { line: end, side: 'RIGHT' }; - } else if (start >= 1) { - return { line: start, side: 'RIGHT' }; - } - return {}; - } - - // Build the createReview payload for an inline-comment item. The - // body is assembled here (at call time) from the item's precomputed - // ID, so retry/idempotency logic works directly off item.id instead - // of recomputing an ID each time it needs to check posting status. - function toReviewPayload(item) { - return { - path: item.comment.path, - body: buildBody(item.comment, item.id), - ...item.lines - }; - } - - // Assemble the visible comment body: the per-comment ID tag (HTML - // comment, invisible when rendered) prepended for idempotency - // matching, plus the code suggestion block if present. - function buildBody(comment, id) { - let body = `\n`; - body += comment.content || ''; - if (comment.suggestion_code && comment.existing_code) { - body += '\n\n**Suggestion:**\n'; - body += fencedBlock(comment.suggestion_code, 'suggestion'); - } - return body; - } - - function formatCommentMarkdown(comment, error) { - let md = `### 📄 \`${comment.path}\``; - if (comment.start_line && comment.end_line) { - md += ` (L${comment.start_line}-L${comment.end_line})`; - } - md += '\n\n'; - if (error) { - md += `⚠️ GitHub could not post this as an inline comment: ${error}\n\n`; - } - md += comment.content || ''; - - if (comment.suggestion_code && comment.existing_code) { - md += '\n\n
💡 Suggested Change\n\n'; - md += '**Before:**\n' + fencedBlock(comment.existing_code) + '\n\n'; - md += '**After:**\n' + fencedBlock(comment.suggestion_code) + '\n\n'; - md += '
'; - } - - return md; - } - - function buildSummaryBody(totalCount, inlineCount, summaryCount, warnings) { - let body = `🔍 **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`; - if (totalCount > 0) { - body += `\n- ✅ ${inlineCount} posted as inline comment(s)`; - body += `\n- 📝 ${summaryCount} posted as summary`; - } - if (warnings.length > 0) { - body += `\n\n⚠️ ${warnings.length} warning(s) occurred during review.`; - } - return body; - } - - function formatSummaryComments(summaryComments) { - let body = ''; - for (const { comment } of summaryComments) { - body += '\n\n---\n\n'; - body += formatCommentMarkdown(comment); - } - return body; - } - - function fencedBlock(content, language = '') { - const text = String(content || ''); - const fence = safeFence(text); - let block = fence + language + '\n' + text; - if (!text.endsWith('\n')) block += '\n'; - return block + fence; - } - - function safeFence(content) { - const matches = String(content || '').match(/`+/g) || []; - const maxTicks = matches.reduce((max, ticks) => Math.max(max, ticks.length), 0); - return '`'.repeat(Math.max(3, maxTicks + 1)); - } + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ secrets.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ secrets.OCR_LLM_USE_ANTHROPIC }} + llm_extra_body: '{"enable_thinking": false}' + github_token: ${{ secrets.GITHUB_TOKEN }} + sticky_summary: 'true' + incremental: 'false' + upload_artifacts: 'true' diff --git a/README.ja-JP.md b/README.ja-JP.md index 698fcf7..d03931c 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -434,6 +434,21 @@ JSON出力ではこの2つのフィールドは`content`や`start_line`などと - [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI統合の例 - [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI統合の例 +#### GitHub Action + +GitHub 向けに、本リポジトリはリポジトリルートにすぐ使える composite Action([`action.yml`](./action.yml))を同梱しています。自分で `ocr review` をスクリプト化する代わりに、これを直接参照するだけで、checkout、OCR のインストール、レビューの実行、インラインコメントとサマリーコメントの投稿、アーティファクトのアップロード、再試行・冪等性までの全パイプラインを処理できます: + +```yaml +- uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} +``` + +再現性を高めるため、バージョンタグまたはコミット SHA に固定してください。完全なワークフローデモ、inputs/outputs の全一覧、コメント投稿モード(スティッキーサマリー、非破壊的なインクリメンタル投稿)については [`examples/github_actions/`](./examples/github_actions/) ディレクトリを参照してください。 + ## コマンド | コマンド | エイリアス | 説明 | diff --git a/README.ko-KR.md b/README.ko-KR.md index 0733c58..d5ed2d8 100644 --- a/README.ko-KR.md +++ b/README.ko-KR.md @@ -434,6 +434,21 @@ JSON 출력에서 두 field는 `content`, `start_line` 등과 같은 수준의 s - [`gitlab_ci/`](./examples/gitlab_ci/): GitLab CI 통합 예시 - [`gitflic_ci/`](./examples/gitflic_ci/): GitFlic CI 통합 예시 +#### GitHub Action + +GitHub의 경우, 이 리포지터리는 루트에 바로 사용할 수 있는 composite Action([`action.yml`](./action.yml))을 제공합니다. 직접 `ocr review` 스크립트를 작성하는 대신 이를 참조하기만 하면 전체 파이프라인 — checkout, OCR 설치, review 실행, inline/summary comment 게시, artifact 업로드, 재시도 및 멱등성 — 을 모두 처리합니다: + +```yaml +- uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} +``` + +재현성을 위해 version tag나 commit SHA에 고정하세요. 전체 workflow 데모와 inputs/outputs, comment 게시 모드(sticky summary, incremental non-destructive posting)의 전체 목록은 [`examples/github_actions/`](./examples/github_actions/) 디렉터리를 참고하세요. + ## Commands | Command | Alias | Description | diff --git a/README.md b/README.md index 85b0a80..3ed3d78 100644 --- a/README.md +++ b/README.md @@ -436,6 +436,21 @@ See the [`examples/`](./examples/) directory for integration examples: - [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI integration example - [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI integration example +#### GitHub Action + +For GitHub, this repository also ships a ready-to-use composite Action at the repo root ([`action.yml`](./action.yml)). Instead of scripting `ocr review` yourself, reference it directly and it handles the full pipeline — checkout, OCR install, running the review, posting inline and summary comments, uploading artifacts, and retry/idempotency: + +```yaml +- uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} +``` + +Pin to a version tag or commit SHA for reproducibility. See the [`examples/github_actions/`](./examples/github_actions/) directory for a complete workflow demo and the full list of inputs, outputs, and comment-posting modes (sticky summary, incremental non-destructive posting). + ## Commands | Command | Alias | Description | diff --git a/README.ru-RU.md b/README.ru-RU.md index 936b2a5..c1310fa 100644 --- a/README.ru-RU.md +++ b/README.ru-RU.md @@ -436,6 +436,21 @@ ocr review \ - [`gitlab_ci/`](./examples/gitlab_ci/) — пример интеграции с GitLab CI - [`gitflic_ci/`](./examples/gitflic_ci/) — пример интеграции с GitFlic CI +#### GitHub Action + +Для GitHub в корне репозитория также поставляется готовая к использованию composite Action ([`action.yml`](./action.yml)). Вместо того чтобы вручную скриптовать `ocr review`, просто подключите её — она берёт на себя весь конвейер: checkout, установку OCR, запуск ревью, публикацию инлайн- и сводных комментариев, загрузку артефактов, а также повтор и идемпотентность: + +```yaml +- uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} +``` + +Для воспроизводимости зафиксируйте тег версии или SHA коммита. Полный демо-воркфлоу, а также полный список входов, выходов и режимов публикации комментариев (закреплённая сводка, инкрементальная неразрушающая публикация) см. в каталоге [`examples/github_actions/`](./examples/github_actions/). + ## Команды | Команда | Алиас | Описание | diff --git a/README.zh-CN.md b/README.zh-CN.md index e103b1e..13ae792 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -434,6 +434,21 @@ ocr review \ - [`gitlab_ci/`](./examples/gitlab_ci/) — GitLab CI 集成示例 - [`gitflic_ci/`](./examples/gitflic_ci/) — GitFlic CI 集成示例 +#### GitHub Action + +对于 GitHub,本仓库还在仓库根目录提供了一个开箱即用的 composite Action([`action.yml`](./action.yml))。你无需自己编写 `ocr review` 脚本,直接引用它即可完成完整流程——checkout、安装 OCR、执行审查、发布行内评论与汇总评论、上传 artifacts,以及重试与幂等处理: + +```yaml +- uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} +``` + +为保障可复现性,请固定到某个版本标签或 commit SHA。完整的 workflow 示例以及 inputs、outputs 与评论发布模式(置顶汇总、增量非破坏式发布)的完整列表,请参见 [`examples/github_actions/`](./examples/github_actions/) 目录。 + ## 命令 | 命令 | 别名 | 描述 | diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..adb9777 --- /dev/null +++ b/action.yml @@ -0,0 +1,305 @@ +name: OpenCodeReview PR Review +description: >- + AI-powered GitHub PR review with inline comments, sticky summary, and + incremental non-destructive posting. +author: stay-foolish-forever +branding: + icon: eye + color: green + +inputs: + llm_url: + description: LLM API endpoint URL (mapped to env OCR_LLM_URL). + required: true + llm_auth_token: + description: LLM auth token (mapped to env OCR_LLM_TOKEN). + required: true + llm_model: + description: Model name (mapped to env OCR_LLM_MODEL). + required: true + llm_use_anthropic: + description: "'true' for Anthropic Claude, 'false' for OpenAI-compatible APIs + (mapped to env OCR_USE_ANTHROPIC). Required to force an explicit choice." + required: true + llm_auth_header: + description: Custom auth header name (mapped to env OCR_LLM_AUTH_HEADER). + required: false + llm_extra_headers: + description: Extra headers "K=V,K=V" (mapped to env OCR_LLM_EXTRA_HEADERS). + required: false + llm_extra_body: + description: >- + extra_body JSON for LLM requests. No env var exists for this, so it is + written via `ocr config set llm.extra_body`. + required: false + default: '{"thinking": {"type": "disabled"}}' + language: + description: >- + Review output language, written via `ocr config set language` + (e.g. English, 中文). No env var exists for this. + required: false + default: 'English' + llm_timeout: + description: LLM request timeout in seconds (mapped to env OCR_LLM_TIMEOUT). + required: false + github_token: + description: GitHub token used to post review comments. + required: false + default: ${{ github.token }} + ocr_version: + description: npm version spec for @alibaba-group/open-code-review. + required: false + default: latest + review_concurrency: + description: Value passed to `ocr review --concurrency`. + required: false + background: + description: Value passed to `ocr review --background`. + required: false + rule: + description: Path to a custom rules JSON file passed to `ocr review --rule`. + required: false + upload_artifacts: + description: >- + Upload raw JSON result and stderr as workflow artifacts. Must be the + literal string 'true' or 'false' (quoted); the step gates on a string + comparison, so an unquoted YAML boolean will not match. + required: false + default: 'true' + sticky_summary: + description: >- + Summary dimension. true = update an existing summary comment in place + (sticky) instead of posting a new one each run. + required: false + default: 'true' + incremental: + description: >- + Incremental dimension. true = only append inline comments whose (path, + line range) does not overlap an existing bot review comment. History is + never deleted (non-destructive). + required: false + default: 'false' + incremental_overlap_threshold: + description: >- + IoU (intersection-over-union) threshold used by incremental mode to decide + whether a new multi-line comment overlaps an existing one. Two single-line + comments match when on the same line; single- vs multi-line never match. + Value in (0, 1]; ignored unless incremental is true. + required: false + default: '0.6' + base_ref: + description: >- + Override the base ref. Provide this (and head_sha) when invoking from a + non-PR event such as issue_comment. + required: false + head_sha: + description: Override the head commit SHA (use with base_ref for comment triggers). + required: false + node_version: + description: Node.js version for actions/setup-node. + required: false + default: '24' + +outputs: + comments_total: + description: Total number of review comments generated by OCR. + value: ${{ steps.post.outputs.comments_total }} + comments_inline: + description: Number of inline comments successfully posted. + value: ${{ steps.post.outputs.comments_inline }} + comments_skipped: + description: Number of inline comments skipped by incremental mode (overlap with history). + value: ${{ steps.post.outputs.comments_skipped }} + comments_failed: + description: Number of inline comments that failed to post. + value: ${{ steps.post.outputs.comments_failed }} + summary_comment_url: + description: URL of the posted/updated summary comment, if any. + value: ${{ steps.post.outputs.summary_comment_url }} + +runs: + using: composite + steps: + - name: Check git and Node.js + id: check_deps + shell: bash + run: | + if command -v git >/dev/null 2>&1; then + echo "git_installed=true" >> "$GITHUB_OUTPUT" + echo "git is already installed: $(git --version)" + else + echo "git_installed=false" >> "$GITHUB_OUTPUT" + echo "git is not installed" + fi + if command -v node >/dev/null 2>&1; then + echo "node_installed=true" >> "$GITHUB_OUTPUT" + echo "node is already installed: $(node --version)" + else + echo "node_installed=false" >> "$GITHUB_OUTPUT" + echo "node is not installed" + fi + + - name: Install git + if: steps.check_deps.outputs.git_installed != 'true' + shell: bash + run: | + if command -v apt-get >/dev/null 2>&1; then + sudo apt-get update + sudo apt-get install -y git + elif command -v brew >/dev/null 2>&1; then + brew install git + elif command -v yum >/dev/null 2>&1; then + sudo yum install -y git + elif command -v apk >/dev/null 2>&1; then + sudo apk add --no-cache git + else + echo "::error::Unable to install git: no supported package manager found" + exit 1 + fi + git --version + + - name: Setup Node.js + if: steps.check_deps.outputs.node_installed != 'true' + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node_version }} + + - name: Resolve PR refs + shell: bash + env: + INPUT_BASE_REF: ${{ inputs.base_ref }} + INPUT_HEAD_SHA: ${{ inputs.head_sha }} + EVENT_BASE_REF: ${{ github.event.pull_request.base.ref }} + EVENT_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + BASE_REF="${INPUT_BASE_REF:-$EVENT_BASE_REF}" + HEAD_SHA="${INPUT_HEAD_SHA:-$EVENT_HEAD_SHA}" + echo "BASE_REF=$BASE_REF" >> "$GITHUB_ENV" + echo "HEAD_SHA=$HEAD_SHA" >> "$GITHUB_ENV" + echo "PR base ref: $BASE_REF" + echo "PR head sha: $HEAD_SHA" + + - name: Checkout base + uses: actions/checkout@v4 + with: + # Checkout the trusted base, not the PR head. OCR reviews the + # base-to-head diff from git objects; the head commit's blobs are + # fetched separately so they are resolvable without materializing + # untrusted PR files into the working tree. + fetch-depth: 0 + + - name: Fetch PR head (fork-safe) + if: env.HEAD_SHA != '' + shell: bash + env: + PR_NUM: ${{ github.event.pull_request.number || github.event.issue.number }} + run: | + if [ -n "$PR_NUM" ]; then + git fetch origin "pull/${PR_NUM}/head" + fi + + - name: Compute merge-base + shell: bash + run: | + git fetch origin "${BASE_REF}" 2>/dev/null || true + MERGE_BASE=$(git merge-base "origin/${BASE_REF}" "${HEAD_SHA}" 2>/dev/null || echo "${HEAD_SHA}") + echo "MERGE_BASE=$MERGE_BASE" >> "$GITHUB_ENV" + echo "Reviewing ${HEAD_SHA} from merge-base ${MERGE_BASE} (base origin/${BASE_REF})" + + - name: Install OpenCodeReview + shell: bash + env: + OCR_VERSION: ${{ inputs.ocr_version }} + run: | + npm install -g "@alibaba-group/open-code-review@${OCR_VERSION}" + echo "OpenCodeReview installed:" + ocr version || true + + - name: Configure OCR + env: + OCR_EXTRA_BODY: ${{ inputs.llm_extra_body }} + OCR_LANGUAGE: ${{ inputs.language }} + shell: bash + run: | + ocr config set llm.extra_body "$OCR_EXTRA_BODY" + ocr config set language "$OCR_LANGUAGE" + + - name: Run OpenCodeReview + env: + OCR_LLM_URL: ${{ inputs.llm_url }} + OCR_LLM_TOKEN: ${{ inputs.llm_auth_token }} + OCR_LLM_MODEL: ${{ inputs.llm_model }} + OCR_USE_ANTHROPIC: ${{ inputs.llm_use_anthropic }} + OCR_LLM_AUTH_HEADER: ${{ inputs.llm_auth_header }} + OCR_LLM_EXTRA_HEADERS: ${{ inputs.llm_extra_headers }} + OCR_LLM_TIMEOUT: ${{ inputs.llm_timeout }} + OCR_REVIEW_CONCURRENCY: ${{ inputs.review_concurrency }} + OCR_BACKGROUND: ${{ inputs.background }} + OCR_RULE: ${{ inputs.rule }} + shell: bash + run: | + ARGS=(--from "${MERGE_BASE}" --to "${HEAD_SHA}" --format json) + [ -n "$OCR_REVIEW_CONCURRENCY" ] && ARGS+=(--concurrency "$OCR_REVIEW_CONCURRENCY") + [ -n "$OCR_BACKGROUND" ] && ARGS+=(--background "$OCR_BACKGROUND") + [ -n "$OCR_RULE" ] && ARGS+=(--rule "$OCR_RULE") + set +e + ocr review "${ARGS[@]}" > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log + OCR_EXIT_CODE=$? + set -e + echo "OCR_EXIT_CODE=$OCR_EXIT_CODE" >> "$GITHUB_ENV" + echo "=== OCR result ===" + cat /tmp/ocr-result.json + echo "=== OCR stderr ===" + cat /tmp/ocr-stderr.log + + - name: Upload review artifacts + if: ${{ always() && inputs.upload_artifacts == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: ocr-review-result-${{ github.run_id }}-${{ github.run_attempt }} + path: | + /tmp/ocr-result.json + /tmp/ocr-stderr.log + if-no-files-found: warn + + - name: Fail job on OCR error + if: env.OCR_EXIT_CODE != '0' + shell: bash + run: | + echo "ocr review exited with code ${OCR_EXIT_CODE}; see uploaded artifacts for details." + exit "${OCR_EXIT_CODE}" + + - name: Post review comments + if: env.OCR_EXIT_CODE == '0' + id: post + uses: actions/github-script@v7 + env: + OCR_INCREMENTAL_OVERLAP_THRESHOLD: ${{ inputs.incremental_overlap_threshold }} + with: + github-token: ${{ inputs.github_token }} + script: | + // Locate the helper shipped alongside action.yml at runtime. + // GITHUB_ACTION_PATH: correct for published (remote) actions and for + // local actions when not running in a container. + // GITHUB_WORKSPACE: correct for local `uses: ./` actions — under + // self-hosted + container setups, GITHUB_ACTION_PATH points to the + // host path (invisible inside the container), whereas GITHUB_WORKSPACE + // is correctly mapped to /__w. + const fs = require('fs'); + const path = require('path'); + const REL = 'scripts/github-actions/post-review-comments.js'; + const roots = [process.env.GITHUB_ACTION_PATH, process.env.GITHUB_WORKSPACE].filter(Boolean); + const helper = roots.map(r => path.resolve(r, REL)).find(p => fs.existsSync(p)); + if (!helper) throw new Error(`Could not locate ${REL}; searched roots: ${roots.join(', ')}`); + const { runPostReviewComments } = require(helper); + await runPostReviewComments({ + github, + context, + core, + fs, + resultPath: '/tmp/ocr-result.json', + stderrPath: '/tmp/ocr-stderr.log', + stickySummary: ${{ inputs.sticky_summary == 'true' }}, + incremental: ${{ inputs.incremental == 'true' }}, + incrementalOverlapThreshold: parseFloat(process.env.OCR_INCREMENTAL_OVERLAP_THRESHOLD), + }); diff --git a/examples/github_actions/README.md b/examples/github_actions/README.md index 468c601..0236505 100644 --- a/examples/github_actions/README.md +++ b/examples/github_actions/README.md @@ -1,6 +1,40 @@ -# OpenCodeReview - GitHub Actions Demo +# OpenCodeReview - GitHub Actions Workflow -This demo shows how to integrate OpenCodeReview into your GitHub Actions workflow to automatically review Pull Requests and post review comments. +This directory provides a ready-to-use GitHub Actions workflow demo that integrates OpenCodeReview into your repository to automatically review Pull Requests and post inline review comments. Copy it into `.github/workflows/` and configure the required secrets/vars. + +## Quick Start: `ocr-review.yml` + +The simplest adoption path: this demo delegates every step — checkout, OCR install, review, comment posting, artifact upload — to the official reusable composite action at [`action.yml`](../../action.yml) via a single `uses: alibaba/open-code-review@main` step. It covers both automatic PR review (`pull_request_target: opened/synchronize/reopened`) and on-demand re-review via comments (`/open-code-review` or `@open-code-review`). No inline scripts to maintain — `@main` always runs the latest action; pin to a version tag or commit SHA when reproducibility matters. + +```bash +mkdir -p .github/workflows +cp ocr-review.yml .github/workflows/ocr-review.yml +``` + +The core of the demo is a single action step: + +```yaml +- uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} +``` + +See [`action.yml`](../../action.yml) for the full list of inputs, outputs, security guidance, and the four comment-posting modes (sticky summary + incremental). + +## Running on a self-hosted runner + +The demo above runs on GitHub-hosted runners (`runs-on: ubuntu-latest`) and pulls the action from `alibaba/open-code-review@main`. If you prefer to run OCR on your own self-hosted runner — to reach private network resources, keep LLM traffic on-prem, or avoid runner-minute costs — the OCR project itself does exactly this in its own CI. + +See [`.github/workflows/ocr-review.yml`](../../.github/workflows/ocr-review.yml) for that workflow. It runs on `runs-on: self-hosted` inside a `node:24` container. One important caveat: it invokes the action with `uses: ./` only because `action.yml` lives in that same repository — that is an internal shortcut and will not resolve in your repo. As an external user, keep `uses: alibaba/open-code-review@main` (the runner fetches the action automatically); only the runner environment needs to change. What is worth borrowing from it: + +- `runs-on: self-hosted`, optionally with a `container:` image such as `node:24` (the action needs Node.js; git is installed automatically if missing). +- Marking the workspace as a trusted git `safe.directory` when running inside a container (e.g. `git config --global --replace-all safe.directory '*'`) to avoid "dubious ownership" errors. Use `--replace-all` (not `--add`) so repeated runs across multiple self-hosted actions replace rather than accumulate entries in the global git config. +- Pinning action inputs explicitly (`sticky_summary`, `incremental`, `upload_artifacts`, `llm_extra_body`, etc.). + +The action performs its own full `fetch-depth: 0` checkout of the PR internally, so no extra checkout step is needed for the review diff. Adapt the runner settings to your environment and secret layout. ## How It Works @@ -10,40 +44,39 @@ PR Created/Updated → GitHub Actions Triggered → OCR Reviews Diff → Comment Comment with trigger keyword ↗ ``` -1. When a PR is opened, the workflow triggers (uses `pull_request_target` for fork secret access) -2. Alternatively, when a comment containing `/open-code-review` or `@open-code-review` is posted on a PR, the workflow triggers -3. It installs OCR via `npm install -g @alibaba-group/open-code-review` -4. Runs `ocr review --from origin/ --to --format json` to analyze the diff (uses commit SHA to support fork PRs) -5. Parses the JSON output and posts inline review comments on the PR using GitHub's Pull Request Review API +1. When a PR is opened, the workflow triggers (uses `pull_request_target` for fork secret access). +2. Alternatively, when a comment containing `/open-code-review` or `@open-code-review` is posted on a PR, the workflow triggers. +3. The reusable action installs OCR, fetches the PR head blobs, computes `git merge-base`, and runs `ocr review --from --to --format json`. +4. It parses the JSON output and posts inline review comments on the PR via the Pull Request Review API, plus a summary comment (an issue comment on the PR). ## Setup -### 1. Copy the workflow file +### Configure secrets and variables -Copy `ocr-review.yml` to your repository's `.github/workflows/` directory: +Go to your repository's **Settings → Secrets and variables → Actions**. -```bash -mkdir -p .github/workflows -cp ocr-review.yml .github/workflows/ocr-review.yml -``` - -### 2. Configure secrets - -Go to your repository's **Settings → Secrets and variables → Actions** and add: +**Secrets:** | Secret | Required | Description | |--------|----------|-------------| | `OCR_LLM_URL` | Yes | LLM API endpoint URL (e.g., `https://api.openai.com/v1/chat/completions`) | -| `OCR_LLM_AUTH_TOKEN` | Yes | API authentication token | -| `OCR_LLM_MODEL` | No | Model name (defaults to `gpt-4o`) | -| `OCR_LLM_USE_ANTHROPIC` | No | Set to `true` if using Anthropic Claude models | +| `OCR_LLM_AUTH_TOKEN` | Yes | API authentication token (mapped to env `OCR_LLM_TOKEN` internally) | -> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission. -> -> The workflow also configures `llm.extra_body` to disable thinking mode for compatibility with various LLM providers. +**Variables:** + +| Variable | Required | Description | +|----------|----------|-------------| +| `OCR_LLM_MODEL` | Yes | Model name | +| `OCR_LLM_USE_ANTHROPIC` | Yes | `true` for Anthropic Claude, `false` for OpenAI-compatible | + +> **Note:** `GITHUB_TOKEN` is automatically provided by GitHub Actions with the required `pull-requests: write` permission. The action also sets `llm.extra_body` to disable thinking mode for compatibility with various LLM providers. ## Customization +> These knobs are action inputs — they apply to the demo workflow and any workflow calling `alibaba/open-code-review@main`. + +See [`action.yml`](../../action.yml) for the full input list. Workflow-level settings (triggers, keywords) are edited in the workflow file itself. + ### Change the trigger events Modify the `on.pull_request_target.types` array in the workflow file: @@ -56,92 +89,152 @@ on: ### Customize comment trigger keywords -By default, the workflow triggers when a PR comment starts with `/open-code-review` or `@open-code-review`. You can customize these keywords by modifying the `if` condition in the workflow: +By default the workflow also re-reviews on demand when a PR comment starts with `/open-code-review` or `@open-code-review`. The `if` condition is more defensive than a bare keyword check — it gates comment triggers so only authorized humans can spend LLM quota: ```yaml if: | - github.event_name == 'pull_request_target' || - (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/review')) || - (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@mybot')) + github.event_name == 'pull_request_target' + || ( + github.event_name == 'issue_comment' + && github.event.issue.pull_request + && github.event.comment.user.type != 'Bot' + && ( + github.event.comment.author_association == 'MEMBER' + || github.event.comment.author_association == 'OWNER' + || github.event.comment.author_association == 'COLLABORATOR' + ) + && ( + startsWith(github.event.comment.body, '/open-code-review') + || startsWith(github.event.comment.body, '@open-code-review') + ) + ) ``` -Or use a more flexible pattern with `contains` to trigger on any comment containing the keyword: +Each clause guards against a different abuse vector: -```yaml -if: | - github.event_name == 'pull_request_target' || - (github.event_name == 'issue_comment' && github.event.issue.pull_request && contains(github.event.comment.body, '/review')) -``` +- `github.event.issue.pull_request` — the comment must be on a PR, not a regular issue. +- `github.event.comment.user.type != 'Bot'` — ignore bot comments. `GITHUB_TOKEN` already suppresses events from comments it posted, but a PAT or GitHub App token would not, so this is a safety net against self-triggering loops. +- `author_association == 'MEMBER' | 'OWNER' | 'COLLABORATOR'` — only repository collaborators can trigger a (billable) re-review, preventing arbitrary commenters from draining LLM quota. +- The `startsWith(...)` pair — the actual trigger keywords. -> **Note:** The condition `github.event.issue.pull_request` ensures the comment is on a PR, not a regular issue. +To change the keywords, edit only that final pair (e.g. `/review` and `@mybot`), or swap `startsWith` for `contains` to match a substring anywhere in the comment body. Keep the preceding guards intact. + +The same predicate is mirrored in the workflow's `concurrency.group`: matching events share a per-PR group (`ocr-`) so a new review cancels any stale one, while non-matching comments land in a unique `noop-` group and are skipped instantly without disrupting a running review. If you change the keywords in `if`, mirror the change in `concurrency.group` too. ### Use a specific OCR version ```yaml -- name: Install OpenCodeReview - run: npm install -g @alibaba-group/open-code-review@1.0.0 +- uses: alibaba/open-code-review@main + with: + ocr_version: 1.0.0 ``` ### Add custom review rules -Use the `--rule` flag to pass a custom rules JSON file: +```yaml +- uses: alibaba/open-code-review@main + with: + rule: ./my-rules.json +``` + +> Security: do not point `rule` at a file sourced from the PR branch when secrets are in scope; use a trusted rules file from your base branch. + +### Control comment posting (sticky summary & incremental) + +The action posts a summary issue comment plus inline review comments. Two inputs select the posting mode (combined, they give the four modes referenced above); a third tunes the incremental overlap test: + +| Input | Default | Description | +|-------|---------|-------------| +| `sticky_summary` | `'true'` | Update an existing summary comment in place instead of posting a new one each run. | +| `incremental` | `'false'` | Only append inline comments whose `(path, line range)` does not overlap an existing bot review comment. History is never deleted (non-destructive). | +| `incremental_overlap_threshold` | `'0.6'` | IoU threshold `incremental` uses to decide whether a multi-line comment overlaps an existing one. Two single-line comments match on the same line; single- vs multi-line never match. Ignored unless `incremental` is `'true'`. | ```yaml -- name: Run OCR review - run: ocr review --rule ./my-rules.json --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} +- uses: alibaba/open-code-review@main + with: + sticky_summary: 'true' + incremental: 'true' + incremental_overlap_threshold: '0.75' ``` +> `sticky_summary` and `incremental` must be quoted strings (`'true'`/`'false'`); the action compares them as strings, so an unquoted YAML boolean will not match. + ### Adjust retry and delay settings -When posting review comments individually (fallback mode), the workflow includes rate-limit handling with exponential backoff. The retry strategy follows GitHub's documented guidance for REST API rate limits — see [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2026-03-10) for details on primary/secondary rate limits and recommended retry behavior: +When posting review comments individually (fallback mode), the action honors GitHub rate-limit headers (`retry-after`, `x-ratelimit-*`) with exponential backoff. The retry strategy follows GitHub's documented guidance for REST API rate limits — see [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api?apiVersion=2026-03-10) for details on primary/secondary rate limits and recommended retry behavior: - **Primary rate limit exhausted** (`x-ratelimit-remaining=0`): wait until `x-ratelimit-reset`. - **Secondary rate limit with a `retry-after` header**: wait exactly that long. - **Secondary rate limit with no header**: wait at least one minute, then use exponential backoff on continued failures. -You can configure the retry and delay behavior via **repository variables** (Settings → Secrets and variables → Actions → Variables): +These are environment variables read by the posting module with sensible defaults; set them at the **job `env:` level** to tune (they propagate into the action): | Variable | Default | Description | |----------|---------|-------------| -| `OCR_RETRY_BASE_DELAY` | `60000` | Base delay (ms) for exponential backoff when no retry header is present (per GitHub's "at least one minute" recommendation for secondary limits) | -| `OCR_RETRY_MAX_DELAY` | `300000` | Maximum delay (ms) cap applied to every computed wait, including retry-after and x-ratelimit-reset, so a far-future reset cannot stall the job past its timeout | +| `OCR_RETRY_BASE_DELAY` | `60000` | Base delay (ms) for exponential backoff when no retry header is present | +| `OCR_RETRY_MAX_DELAY` | `300000` | Maximum delay (ms) cap applied to every computed wait | | `OCR_MAX_RETRIES` | `3` | Maximum retry attempts per comment when rate-limited | -| `OCR_SUCCESS_DELAY` | `2000` | Delay (ms) after a successful comment post to pace subsequent requests | -| `OCR_FAILURE_DELAY` | `1000` | Delay (ms) after a non-rate-limit failure to pace subsequent requests | -| `OCR_LOW_REMAINING_THRESHOLD` | `3` | When x-ratelimit-remaining is at or below this value, proactively increase request spacing to avoid hitting the limit | +| `OCR_SUCCESS_DELAY` | `2000` | Delay (ms) after a successful comment post | +| `OCR_FAILURE_DELAY` | `1000` | Delay (ms) after a non-retryable failure | +| `OCR_LOW_REMAINING_THRESHOLD` | `3` | When x-ratelimit-remaining is at or below this value, proactively increase request spacing | | `OCR_LOW_REMAINING_SPACING` | `10000` | Request spacing (ms) used when remaining quota is low | | `OCR_READ_SUCCESS_DELAY` | `500` | Delay (ms) after a successful read API call (`listReviews` / `listReviewComments` / `listIssueComments`) used for the idempotency check. Reads are cheaper than writes, so the default is shorter | | `OCR_READ_LOW_REMAINING_SPACING` | `5000` | Request spacing (ms) for read calls when remaining quota is low | -These variables are optional — if not configured, sensible defaults are used. Consider increasing delays for repositories with many concurrent workflows or large PRs that generate numerous review comments. +For example, to raise the per-comment retry count to 5, set `OCR_MAX_RETRIES` on the **job's** `env:` — not on the `uses:` step. A composite action does not forward the caller's step-level `env:` into its internal steps' process environment, so a step-level value would be silently ignored; the job-level value is inherited by the action's comment-posting step and read via `process.env`: + +```yaml +jobs: + code-review: + runs-on: ubuntu-latest + env: + OCR_MAX_RETRIES: 5 + steps: + - uses: alibaba/open-code-review@main + with: + llm_url: ${{ secrets.OCR_LLM_URL }} + # ...other inputs +``` + +These variables are optional. See GitHub's [Rate limits for the REST API](https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api). #### Idempotency: avoiding duplicate review comments -When the batch `createReview` call fails with a `5xx` error, the request may still have landed on the GitHub server (the response was simply lost). Before retrying per-comment, the workflow queries existing reviews and review comments — each tagged with a per-run HTML comment (e.g. ``) — and only retries the comments that are actually missing. This prevents duplicate review posts. +When the batch `createReview` call fails with a `5xx` error, the request may still have landed on the GitHub server (the response was simply lost). Before retrying per-comment, the action queries existing reviews and review comments — each tagged with a per-run HTML comment (e.g. ``) — and only retries the comments that are actually missing. This prevents duplicate review posts. -The same idempotency check is applied to the summary comment: before posting, the workflow verifies whether a summary with the same run tag already exists, and skips posting if so. +The summary comment is deduplicated too: in sticky mode (the default) the action finds the existing summary by its persistent marker and updates it in place rather than posting a new one; in non-sticky mode it reuses this run's summary if it already exists. If the read API is unavailable, it skips posting the summary rather than risking a duplicate. -If the read API itself is unavailable (rate-limited or `5xx`), the check returns *unknown* rather than assuming the comment was not posted. In that case the workflow **skips retrying** to avoid risking a duplicate, and surfaces the uncertainty in the summary instead of silently producing duplicates. +If the read API itself is unavailable (rate-limited or `5xx`), the check returns *unknown* rather than assuming the comment was not posted. In that case the action **skips retrying** to avoid risking a duplicate, and surfaces the uncertainty in the summary instead of silently producing duplicates. -### Limit concurrency - -Adjust the `--concurrency` flag for large PRs to control the number of concurrent LLM requests: +### Limit LLM concurrency ```yaml -- name: Run OCR review - run: ocr review --concurrency 5 --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} +- uses: alibaba/open-code-review@main + with: + review_concurrency: 5 ``` ### Provide background context -Use the `--background` flag to pass additional context that helps OCR better understand the purpose of the changes: - ```yaml -- name: Run OCR review - run: ocr review --background "${{ github.event.pull_request.title }}" --from origin/${{ github.base_ref }} --to origin/${{ github.head_ref }} +- uses: alibaba/open-code-review@main + with: + background: ${{ github.event.pull_request.title }} ``` -This is particularly useful when your PR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`) that clearly summarize what the PR implements. The background information helps OCR provide more relevant and context-aware review comments. +Particularly useful when PR titles follow semantic conventions (e.g., `feat(auth): add OAuth2 support`). + +> Note: `github.event.pull_request.title` is only present on `pull_request_target` events, so it is empty for comment-triggered re-reviews. To cover both trigger types, have the pr-context step also output the title and fall back to it: +> +> ```yaml +> # inside the pr-context script (which only runs for issue_comment): +> core.setOutput('title', pullRequest.title); +> ``` +> ```yaml +> - uses: alibaba/open-code-review@main +> with: +> background: ${{ steps.pr-context.outputs.title || github.event.pull_request.title }} +> ``` ### Customize the review comment author with GitHub App @@ -184,40 +277,53 @@ Add the following secrets to your repository (**Settings → Secrets and variabl |--------|-------------| | `GITHUB_APP_ID` | Your GitHub App's ID | | `GITHUB_APP_PRIVATE_KEY` | Contents of the `.pem` file (including `-----BEGIN RSA PRIVATE KEY-----` and `-----END RSA PRIVATE KEY-----`) | -| `GITHUB_APP_INSTALLATION_ID` | The Installation ID from Step 3 | +| `GITHUB_APP_INSTALLATION_ID` | (Optional) The Installation ID from Step 3 — only needed for apps with multiple installations | -#### Step 5: Update the Workflow +#### Step 5: Pass the App token to the action -Add a step to obtain a token from the GitHub App, then use it in the "Post review comments to PR" step: +Mint a token with `actions/create-github-app-token` and pass it via the `github_token` input: ```yaml - name: Get GitHub App Token id: app-token - uses: actions/create-github-app-token@v1 + uses: actions/create-github-app-token@main with: app-id: ${{ secrets.GITHUB_APP_ID }} private-key: ${{ secrets.GITHUB_APP_PRIVATE_KEY }} -- name: Post review comments to PR - uses: actions/github-script@v7 +- uses: alibaba/open-code-review@main with: - github-token: ${{ steps.app-token.outputs.token }} - script: | - # ... existing script + github_token: ${{ steps.app-token.outputs.token }} + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} ``` Now review comments will be posted with your custom GitHub App identity (e.g., `OpenCodeReview Bot`), providing a more professional and distinguishable appearance in your PRs. ## Example Output -When a PR is reviewed, comments appear directly in the PR's "Files changed" tab: +The action posts two kinds of output on the PR: a **summary issue comment** (in the PR conversation) and **inline review comments** (in the "Files changed" tab). -- ✅ If no issues found: A comment saying "No comments generated. Looks good to me." -- 🔍 If issues found: Inline review comments with suggestions using GitHub's native suggestion syntax +### Summary comment -### Inline Comment Example +A single comment — updated in place on each run when `sticky_summary` is `'true'` (the default) — carries the review outcome and posting statistics. -The workflow uses GitHub's `suggestion` code block syntax, so reviewers can apply fixes with one click: +- ✅ No issues: `✅ **OpenCodeReview**: No comments generated. Looks good to me.` +- 🔍 Issues found: a header line plus per-outcome counts, for example: + +```markdown +🔍 **OpenCodeReview** found **3** issue(s) in this PR. +- ✅ Successfully posted inline: 2 comment(s) +- 📝 In summary (no line info): 1 comment(s) +``` + +The counts are mutually exclusive and sum to the total: `inline` (landed as review inline comments), `summary` (no line info, rendered in the summary body), `skipped` (suppressed by incremental overlap filtering), and `failed` (had line info but could not be posted). Any warnings are appended as a bulleted list. + +### Inline comments + +Comments with valid line info are posted as PR review comments in "Files changed". Each carries the review content plus, when a fix is available, a GitHub-native `suggestion` block so reviewers can apply it with one click: ````markdown **Suggestion:** @@ -226,6 +332,8 @@ The workflow uses GitHub's `suggestion` code block syntax, so reviewers can appl ``` ```` +Comments that have no line info, or that could not be posted inline (e.g. their line fell outside the current diff), are rendered in the summary body instead — each under a `### 📄 ` heading, with a collapsible `
` "💡 Suggested Change" (Before/After) when a fix is available. + ## Supported LLM Providers OCR supports both OpenAI and Anthropic API formats: @@ -234,22 +342,25 @@ OCR supports both OpenAI and Anthropic API formats: - OpenAI (GPT-4o, GPT-4, etc.) - Azure OpenAI - Self-hosted models (vLLM, Ollama, etc.) -- **Anthropic APIs** (set `OCR_LLM_USE_ANTHROPIC: true`): +- **Anthropic APIs** (set variable `OCR_LLM_USE_ANTHROPIC=true`, i.e. `llm_use_anthropic: true`): - Anthropic Claude models ## Troubleshooting ### Common Issues -1. **"Failed to parse OCR output"**: Check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` secrets are correctly set -2. **"Cannot find merge-base"**: Ensure `fetch-depth: 0` is set in the checkout step -3. **Review comments not appearing on correct lines**: This can happen when the diff has changed since the review started; the workflow handles this gracefully with a fallback to issue comments +1. **Job fails / "Failed to parse OCR output"**: When `ocr review` exits non-zero the action fails the job with that exit code (the comment-posting step is skipped); a zero exit with malformed JSON surfaces as a parse error in the summary. In both cases, check that `OCR_LLM_URL` and `OCR_LLM_AUTH_TOKEN` are set correctly, then inspect the uploaded `ocr-stderr.log` artifact (also printed in the "Run OpenCodeReview" step log) for the underlying error. +2. **"Cannot find merge-base"**: The action fetches full history (`fetch-depth: 0`) and the PR head (`git fetch origin pull//head`); if this still fails, ensure `permissions: contents: read` is set and the base branch is accessible (e.g., not deleted). +3. **Review comments not on the expected lines**: Comments are attached to the PR head commit. If a comment's line falls outside the current diff (the PR was force-pushed or updated mid-review), GitHub rejects the inline post and the comment is rendered in the summary instead. The workflow's concurrency group cancels stale runs on new pushes. +4. **No summary or comments at all**: Confirm the job's `permissions` include `pull-requests: write`, and that `github_token` (defaults to `${{ github.token }}`) is not overridden with a token lacking those scopes. ### Debugging -Enable debug logging by adding to the OCR review step: +The action does not use an `OCR_DEBUG` flag. To diagnose a run: -```yaml -env: - OCR_DEBUG: "1" -``` +- **Artifacts**: with `upload_artifacts: 'true'` (the default), the raw `ocr-result.json` and `ocr-stderr.log` are uploaded as workflow artifacts named `ocr-review-result--`. Download them from the run's **Artifacts** section. +- **Step log**: the "Run OpenCodeReview" step prints both the JSON result and stderr to the workflow log. +- **Action outputs**: the step exposes `comments_total`, `comments_inline`, `comments_skipped`, `comments_failed`, and `summary_comment_url` outputs — inspect them in the job's step outputs. +- **GitHub step debug**: for verbose Actions runner diagnostics, enable the repository secret `ACTIONS_STEP_DEBUG=true` (standard GitHub Actions mechanism). + +To stop uploading the raw artifacts, set `upload_artifacts: 'false'`. diff --git a/examples/github_actions/ocr-review.yml b/examples/github_actions/ocr-review.yml index 2073ae1..11a57a3 100644 --- a/examples/github_actions/ocr-review.yml +++ b/examples/github_actions/ocr-review.yml @@ -1,71 +1,64 @@ # OpenCodeReview - GitHub Actions PR Auto-Review Demo # -# This workflow automatically reviews pull requests using OpenCodeReview -# and posts review comments directly on the PR. +# Demonstrates invoking the reusable action for both automatic PR review +# (pull_request_target: opened/synchronize/reopened) and on-demand re-review +# via comments starting with '/open-code-review' or '@open-code-review'. # -# Triggers: -# - PR opened (uses pull_request_target for fork secret access) -# - Comment on PR containing '/open-code-review' or '@open-code-review' +# Required secrets/vars (Settings -> Secrets and variables -> Actions): +# secret OCR_LLM_URL LLM API endpoint +# secret OCR_LLM_AUTH_TOKEN LLM auth token (mapped to OCR_LLM_TOKEN) +# variable OCR_LLM_MODEL model name +# variable OCR_LLM_USE_ANTHROPIC 'true' for Anthropic, 'false' for OpenAI-compatible # -# Required secrets: -# OCR_LLM_URL - LLM API endpoint (e.g., https://api.openai.com/v1/chat/completions) -# OCR_LLM_AUTH_TOKEN - Authentication token for the LLM API -# -# Optional secrets: -# OCR_LLM_MODEL - Model name (default: gpt-4o) -# OCR_LLM_USE_ANTHROPIC - Set to 'true' if using Anthropic Claude models -# -# Optional variables (for retry/delay tuning): -# The retry strategy follows GitHub's documented guidance for REST API rate limits: -# https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api -# - Primary rate limit exhausted (x-ratelimit-remaining=0): wait until x-ratelimit-reset. -# - Secondary rate limit with a retry-after header: wait exactly that long. -# - Secondary rate limit with NO header: wait at least one minute, then use -# exponential backoff on continued failures. -# -# OCR_RETRY_BASE_DELAY - Base delay (ms) for exponential backoff when no retry -# header is present (default: 60000, per GitHub's -# "at least one minute" recommendation for secondary limits). -# OCR_RETRY_MAX_DELAY - Maximum delay (ms) cap applied to EVERY computed wait, -# including retry-after and x-ratelimit-reset, so a far-future -# reset cannot stall the job past its timeout (default: 300000 = 5 min). -# OCR_MAX_RETRIES - Max retry attempts per comment when rate-limited (default: 3). -# OCR_SUCCESS_DELAY - Delay (ms) between successful comment posts to pace requests (default: 2000). -# OCR_FAILURE_DELAY - Delay (ms) after a non-retryable failure to pace subsequent requests (default: 1000). -# OCR_LOW_REMAINING_THRESHOLD - When x-ratelimit-remaining is at or below this value, -# proactively increase request spacing to avoid hitting the limit -# (default: 3; GitHub best practice is to watch the header and slow down). -# OCR_LOW_REMAINING_SPACING - Request spacing (ms) used when remaining quota is low -# (default: 10000 = 10s). -# OCR_READ_SUCCESS_DELAY - Delay (ms) after a successful read API call (listReviews / -# listReviewComments / listIssueComments) used for the -# idempotency check. Reads are cheaper than writes, so the -# default is shorter (default: 500). -# OCR_READ_LOW_REMAINING_SPACING - Request spacing (ms) for read calls when remaining -# quota is low (default: 5000 = 5s). -# -# Idempotency: -# When the batch createReview fails with a 5xx, the request may still have landed on -# the server. Before retrying per-comment, the workflow queries existing reviews and -# review comments (tagged with a per-run HTML comment) and only retries the comments -# that are actually missing. This prevents duplicate review posts. -# -# Note: GITHUB_TOKEN is automatically provided by GitHub Actions. -# Note: The workflow also configures llm.extra_body to '{"thinking": {"type": "disabled"}}' -# to disable thinking mode for compatibility with various LLM providers. +# For the full list of action inputs/outputs and the four comment-posting modes +# (sticky / incremental), see action.yml at the repo root. name: OpenCodeReview PR Review +# Conditional concurrency group. +# +# GitHub Actions evaluates concurrency BEFORE job-level if-conditions. With a +# flat group (ocr-), every comment on the PR — even an unrelated +# conversation reply that will be skipped — enters the same group and, because +# cancel-in-progress is true, cancels any in-progress review. The result: a +# single normal comment kills a running review, and you see "two runs, one +# cancelled" in the Actions tab. +# +# Fix: matching events (PR events + /open-code-review comments) share a per-PR +# group so a new review cancels any stale one for the same PR. Non-matching +# comments land in a unique noop- group that can never collide with a +# real review, so they are skipped instantly without disrupting anything. concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: >- + ${{ + ( + github.event_name == 'pull_request_target' + || ( + github.event_name == 'issue_comment' + && github.event.issue.pull_request + && github.event.comment.user.type != 'Bot' + && ( + github.event.comment.author_association == 'MEMBER' + || github.event.comment.author_association == 'OWNER' + || github.event.comment.author_association == 'COLLABORATOR' + ) + && ( + startsWith(github.event.comment.body, '/open-code-review') + || startsWith(github.event.comment.body, '@open-code-review') + ) + ) + ) + && format('ocr-{0}', github.event.pull_request.number || github.event.issue.number) + || format('noop-{0}', github.run_id) + }} cancel-in-progress: true on: # Use pull_request_target instead of pull_request so that secrets are - # available even for PRs from forks. This is safe because OCR only reads - # the diff and does not execute any code from the PR. + # available even for PRs from forks. This is safe because the reusable + # action only reads the diff and does not execute any code from the PR. pull_request_target: - types: [opened] + types: [opened, synchronize, reopened] issue_comment: types: [created] @@ -76,19 +69,38 @@ permissions: jobs: code-review: runs-on: ubuntu-latest - # Run on PR events, or on comments starting with trigger keywords + timeout-minutes: 30 + # Run on PR events, or on human-authored comments starting with trigger + # keywords. Bot comments are excluded as a safety net: GITHUB_TOKEN already + # suppresses events from bot-posted comments, but a PAT/App token would not. + # issue_comment triggers are further gated on author_association so only + # MEMBER/OWNER/COLLABORATOR users can spend LLM quota via re-review. if: | - github.event_name == 'pull_request_target' || - (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '/open-code-review')) || - (github.event_name == 'issue_comment' && github.event.issue.pull_request && startsWith(github.event.comment.body, '@open-code-review')) + github.event_name == 'pull_request_target' + || ( + github.event_name == 'issue_comment' + && github.event.issue.pull_request + && github.event.comment.user.type != 'Bot' + && ( + github.event.comment.author_association == 'MEMBER' + || github.event.comment.author_association == 'OWNER' + || github.event.comment.author_association == 'COLLABORATOR' + ) + && ( + startsWith(github.event.comment.body, '/open-code-review') + || startsWith(github.event.comment.body, '@open-code-review') + ) + ) steps: - name: Get PR context id: pr-context - if: github.event_name != 'pull_request_target' + if: github.event_name == 'issue_comment' uses: actions/github-script@v7 with: script: | - // For issue_comment events, get PR info + // For issue_comment events, resolve PR base/head so the action + // can review the right diff (issue_comment has no top-level + // pull_request payload fields). const prNumber = context.issue.number; const { data: pullRequest } = await github.rest.pulls.get({ owner: context.repo.owner, @@ -96,829 +108,16 @@ jobs: pull_number: prNumber }); core.setOutput('base_ref', pullRequest.base.ref); - core.setOutput('head_ref', pullRequest.head.ref); core.setOutput('head_sha', pullRequest.head.sha); - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 # Full history needed for merge-base diff - ref: ${{ github.event.pull_request.head.sha || steps.pr-context.outputs.head_sha }} - - - name: Fetch PR head ref (ensures fork commits are available) - run: git fetch origin pull/${{ github.event.pull_request.number || github.event.issue.number }}/head - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '24' - - - name: Install OpenCodeReview - run: | - npm install -g @alibaba-group/open-code-review - echo "OpenCodeReview installed with version:" - ocr version || true - - - name: Configure OCR - run: | - ocr config set llm.url ${{ secrets.OCR_LLM_URL }} - ocr config set llm.auth_token ${{ secrets.OCR_LLM_AUTH_TOKEN }} - ocr config set llm.model ${{ secrets.OCR_LLM_MODEL }} - ocr config set llm.use_anthropic ${{ secrets.OCR_LLM_USE_ANTHROPIC }} - ocr config set llm.extra_body '{"thinking": {"type": "disabled"}}' - - name: Run OpenCodeReview - id: review - run: | - # Get base ref and head SHA from PR context (different for comment triggers) - # Note: We use HEAD_SHA instead of origin/${HEAD_REF} to support fork PRs, - # because fork branches don't exist on the origin remote. - if [ "${{ github.event_name }}" = "pull_request_target" ]; then - BASE_REF="${{ github.event.pull_request.base.ref }}" - HEAD_SHA="${{ github.event.pull_request.head.sha }}" - else - BASE_REF="${{ steps.pr-context.outputs.base_ref }}" - HEAD_SHA="${{ steps.pr-context.outputs.head_sha }}" - fi - - echo "Reviewing PR: ${HEAD_SHA} against origin/${BASE_REF}" - - # Run OCR in range mode with JSON output - ocr review \ - --from "origin/${BASE_REF}" \ - --to "${HEAD_SHA}" \ - --format json \ - > /tmp/ocr-result.json 2>/tmp/ocr-stderr.log || true - - echo "OCR review completed. Output:" - cat /tmp/ocr-result.json - echo "OCR review completed. Error log:" - cat /tmp/ocr-stderr.log - - - name: Post review comments to PR - uses: actions/github-script@v7 + uses: alibaba/open-code-review@main with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const fs = require('fs'); - const crypto = require('crypto'); - const path = '/tmp/ocr-result.json'; - - // Unique tag for this workflow run + attempt. Embedded in review/comment - // bodies as an HTML comment so the idempotency check can detect whether - // a batch createReview actually landed on the server before retrying. - // context.runId / context.runAttempt are numbers from @actions/github's - // Context (parsed from GITHUB_RUN_ID / GITHUB_RUN_ATTEMPT). Use - // Number.isFinite to guard against NaN when the env vars are missing, - // falling back to safe defaults. - const runId = Number.isFinite(context.runId) ? context.runId : 0; - const runAttempt = Number.isFinite(context.runAttempt) ? context.runAttempt : 1; - const RUN_TAG = `${runId}-${runAttempt}`; - const REVIEW_TAG = ``; - const SUMMARY_TAG = ``; - - // Read OCR output - let result; - try { - const raw = fs.readFileSync(path, 'utf8'); - result = JSON.parse(raw); - } catch (e) { - console.log('Failed to parse OCR output:', e.message); - // Post a simple comment if parsing fails - const stderr = fs.readFileSync('/tmp/ocr-stderr.log', 'utf8').trim(); - if (stderr) { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `⚠️ **OpenCodeReview** encountered an error:\n${fencedBlock(stderr)}` - }); - } - return; - } - - const comments = result.comments || []; - const warnings = result.warnings || []; - - // If no comments, post a summary - if (comments.length === 0) { - const message = result.message || 'No comments generated. Looks good to me.'; - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body: `✅ **OpenCodeReview**: ${message}` - }); - return; - } - - // Prepare PR review with inline comments - const prNumber = context.issue.number; - let commitSha; - - // Get commit SHA from event context - if (context.eventName === 'pull_request_target') { - commitSha = context.payload.pull_request.head.sha; - } else { - // For comment events, we need to fetch the PR - const { data: pullRequest } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber - }); - commitSha = pullRequest.head.sha; - } - - // Build review comments array for the PR review API - // Only inline comments with line info can be posted via createReview - const reviewComments = []; - const commentsWithoutLine = []; - - for (const comment of comments) { - // Check if comment has valid line information for inline comment (line >= 1) - const hasValidLine = (comment.start_line >= 1) || (comment.end_line >= 1); - if (!hasValidLine) { - commentsWithoutLine.push({ comment }); - continue; - } - - // Each inline comment becomes an item carrying a random ID - // (assigned once) and its resolved line targeting. The body is - // built from item.id only at API-call time (see toReviewPayload), - // so retry/idempotency logic reads item.id directly instead of - // recomputing it, and distinct comments never share an ID. - reviewComments.push({ - comment, - id: newCommentId(), - lines: resolveLines(comment) - }); - } - - // Submit as a single PR review with all comments - const totalCount = comments.length; - const inlineCount = reviewComments.length; - const summaryCount = commentsWithoutLine.length; - let summaryBody = buildSummaryBody(totalCount, inlineCount, summaryCount, warnings); - - // Add comments without line info to summary body - summaryBody += formatSummaryComments(commentsWithoutLine); - - // Prepend the run tag so the idempotency check can detect whether the - // batch review actually landed on the server before retrying. - summaryBody = REVIEW_TAG + '\n' + summaryBody; - - // Statistics tracking - let successCount = 0; - let failedCount = 0; - const failedComments = []; - - // Retry/pacing configuration (shared by write and read API calls). - // parseNonNegInt guards against nonsensical env values (negative, - // NaN, non-numeric) that `parseInt(...) || default` would let - // through for negative numbers, since a negative parseInt result - // is truthy and would bypass the `|| default` fallback. - function parseNonNegInt(val, defaultVal) { - const n = parseInt(val, 10); - return Number.isFinite(n) && n >= 0 ? n : defaultVal; - } - const MAX_RETRIES = parseNonNegInt(process.env.OCR_MAX_RETRIES, 3); - const SUCCESS_DELAY = parseNonNegInt(process.env.OCR_SUCCESS_DELAY, 2000); // delay after successful write - const FAILURE_DELAY = parseNonNegInt(process.env.OCR_FAILURE_DELAY, 1000); // delay after non-retryable failure - const LOW_REMAINING_THRESHOLD = parseNonNegInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 3); - const LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_LOW_REMAINING_SPACING, 10000); - // Read APIs are cheaper and have higher thresholds; use shorter pacing. - const READ_SUCCESS_DELAY = parseNonNegInt(process.env.OCR_READ_SUCCESS_DELAY, 500); - const READ_LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_READ_LOW_REMAINING_SPACING, 5000); - - try { - const batchRes = await github.rest.pulls.createReview({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - commit_id: commitSha, - body: summaryBody, - event: 'COMMENT', - comments: reviewComments.map(toReviewPayload) - }); - successCount = reviewComments.length; - console.log(`Successfully posted review with ${successCount} inline comments (${commentsWithoutLine.length} in summary)`); - logRateLimitQuota(batchRes, 'after batch createReview'); - } catch (e) { - console.log('Failed to post review with inline comments:', e.message); - console.log('Checking whether the batch review actually landed on the server before retrying...'); - - // Idempotency check: the batch createReview may have succeeded on the - // server even though we got a 5xx. Query existing reviews to find out, - // so we only retry the comments that are actually missing. - let existingReview = null; - try { - existingReview = await findExistingBatchReview({ - owner: context.repo.owner, - repo: context.repo.repo, - prNumber, - tag: REVIEW_TAG - }); - } catch (checkErr) { - console.log(`Idempotency check failed (${checkErr.message}). ` + - `Degrading to original fallback (accepting duplicate risk).`); - } - - // Compute the list of inline comments that still need to be posted. - // If the batch review landed, only retry the missing ones; otherwise - // retry all of them. - let toRetry = reviewComments; - if (existingReview && existingReview.found) { - const postedIds = await getPostedCommentIds({ - owner: context.repo.owner, - repo: context.repo.repo, - prNumber - }); - toRetry = reviewComments.filter((item) => - !postedIds.has(item.id) - ); - successCount = reviewComments.length - toRetry.length; - console.log(`Batch review already exists (review_id=${existingReview.review.id}). ` + - `${successCount}/${reviewComments.length} inline comments already posted. ` + - `${toRetry.length} missing, will retry only those.`); - } else { - console.log('Batch review not found on server. Falling back to per-comment posting...'); - } - - // If the batch itself was rate-limited, honor its rate-limit headers - // (retry-after / x-ratelimit-reset) before retrying per-comment, - // otherwise the first per-comment call re-hits the same wall immediately. - const batchRetry = computeRetryDelayMs(e, 0); - if (batchRetry != null) { - const secs = (batchRetry.delayMs / 1000).toFixed(1); - console.log( - `Batch createReview was rate-limited (HTTP ${e.status}). ` + - `Cooling down ${secs}s via '${batchRetry.source}' (${batchRetry.detail}) before per-comment retry.` - ); - await sleep(batchRetry.delayMs); - } - - for (const item of toRetry) { - const { comment, id } = item; - let posted = false; - for (let attempt = 0; attempt <= MAX_RETRIES && !posted; attempt++) { - try { - const res = await github.rest.pulls.createReview({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - commit_id: commitSha, - body: '', - event: 'COMMENT', - comments: [toReviewPayload(item)] - }); - successCount++; - posted = true; - console.log(`Successfully posted comment for ${comment.path}`); - // Proactive throttle: if remaining quota is low, slow down to - // avoid hitting the limit (GitHub best practice: watch the header). - const remaining = logRateLimitQuota(res, `after ${comment.path}`); - const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; - if (lowQuota) { - console.log(`[rate-limit] quota low (remaining=${remaining} <= ${LOW_REMAINING_THRESHOLD}); increasing spacing to ${LOW_REMAINING_SPACING}ms.`); - await sleep(LOW_REMAINING_SPACING); - } else { - await sleep(SUCCESS_DELAY); - } - } catch (innerE) { - // Decide whether to retry and how long to wait, based on GitHub's - // rate-limit documentation (retry-after / x-ratelimit-* headers). - const retryInfo = computeRetryDelayMs(innerE, attempt); - const willRetry = retryInfo != null && attempt < MAX_RETRIES; - // Any error whose request may have reached GitHub (5xx server - // errors, 408 timeout, or network-layer errors with no status) - // can mean the comment was actually created but the response was - // lost. Before retrying (which would post a duplicate) or before - // giving up (which would wrongly list it as failed in the summary), - // we must check whether it already landed. - // - // IMPORTANT: do the check AFTER cooling down, not immediately. - // If the error is rate-limit-related (5xx under load, or a - // network blip), firing read requests right away further - // pressures the already-struggling API. Honor the computed - // retry delay first, then query. - const status = innerE.status; - const maybeReachedServer = - (typeof status === 'number' && (status >= 500 || status === 408)) || - status == null; // network errors (ECONNRESET, ETIMEDOUT, ...) - if (maybeReachedServer) { - // Cool down first: even read requests count against rate - // limits, and querying during an ongoing 5xx/rate-limit - // episode can worsen the situation. Use the retry delay when - // available; for non-retryable errors (retryInfo == null) - // there is no header-derived wait, so use a short fixed cool - // down before the read. - const coolDownMs = retryInfo != null ? retryInfo.delayMs : FAILURE_DELAY; - if (coolDownMs > 0) { - const secs = (coolDownMs / 1000).toFixed(1); - console.log( - `Cooling down ${secs}s before idempotency check for ${comment.path} ` + - `(HTTP ${innerE.status || 'n/a'}, attempt ${attempt + 1}/${MAX_RETRIES + 1}).` - ); - await sleep(coolDownMs); - } - const alreadyPosted = await isCommentAlreadyPosted({ - owner: context.repo.owner, - repo: context.repo.repo, - prNumber, - id - }); - if (alreadyPosted === true) { - successCount++; - posted = true; - console.log(`Comment for ${comment.path} already posted (id=${id}); treating as success.`); - await sleep(SUCCESS_DELAY); - continue; - } - // Unknown (null): the read API is unavailable, so we - // cannot tell whether the comment landed. To avoid a - // duplicate, do NOT retry posting; record as failed so - // the summary surfaces the uncertainty rather than - // silently risking a duplicate. - if (alreadyPosted === null) { - failedCount++; - const reason = 'idempotency check unavailable (read API failed)'; - failedComments.push({ comment, error: `${innerE.message} [${reason}]` }); - console.log(`Cannot verify whether comment for ${comment.path} was posted (${reason}, HTTP ${innerE.status || 'n/a'}); skipping retry to avoid duplicate.`); - await sleep(SUCCESS_DELAY); - break; - } - // Not found on server. If retries are exhausted or the - // error is non-retryable, this is a real failure. - if (!willRetry) { - failedCount++; - failedComments.push({ comment, error: innerE.message }); - const reason = retryInfo == null ? 'non-retryable error' : 'rate-limit retries exhausted'; - console.log(`Failed to post comment for ${comment.path} (${reason}, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`); - await sleep(SUCCESS_DELAY); - break; - } - // willRetry: cool down already consumed above, loop back. - } else if (willRetry) { - // Pure 429/403 rate-limit: the request never reached the - // server, so no duplicate is possible and the idempotency - // check can be skipped. Just honor the retry delay. - const secs = (retryInfo.delayMs / 1000).toFixed(1); - console.log( - `Rate-limited on ${comment.path} ` + - `(HTTP ${innerE.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` + - `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ` + - `Error: ${innerE.message}` - ); - await sleep(retryInfo.delayMs); - } else { - // Non-retryable error that definitely did not reach the - // server (e.g. 4xx validation error): record as failed. - failedCount++; - failedComments.push({ comment, error: innerE.message }); - console.log(`Failed to post comment for ${comment.path} (non-retryable error, HTTP ${innerE.status || 'n/a'}): ${innerE.message}`); - await sleep(FAILURE_DELAY); - break; - } - } - } - } - - // Post summary comment with statistics - let finalBody = buildSummaryBody(totalCount, successCount, commentsWithoutLine.length + failedComments.length, warnings); - finalBody += formatSummaryComments(commentsWithoutLine); - finalBody += `\n\n---\n\n📊 **Posting Statistics:**`; - finalBody += `\n- ✅ Successfully posted: ${successCount} comment(s)`; - if (failedCount > 0) { - finalBody += `\n- ❌ Failed to post: ${failedCount} comment(s)`; - } - - // Add failed comments as summary content so review feedback is not lost. - if (failedComments.length > 0) { - finalBody += '\n\n---\n\n### ⚠️ Inline comments shown in summary'; - for (const { comment, error } of failedComments) { - finalBody += '\n\n---\n\n'; - finalBody += formatCommentMarkdown(comment, error); - } - } - - // Prepend the summary tag and post only if no summary with this tag - // already exists (idempotency: the batch review may have carried the - // same summary body, in which case we must not duplicate it). - finalBody = SUMMARY_TAG + '\n' + finalBody; - const summaryAlreadyPosted = await hasIssueCommentWithId({ - owner: context.repo.owner, - repo: context.repo.repo, - issueNumber: prNumber, - id: SUMMARY_TAG - }); - if (summaryAlreadyPosted === true) { - console.log('Summary comment with this run tag already exists; skipping.'); - } else if (summaryAlreadyPosted === null) { - // Read API unavailable: cannot tell whether the summary already - // landed. Skip posting to avoid a duplicate; the review content - // is still available via inline comments / batch review. - console.log('Cannot verify whether summary comment already exists (read API failed); skipping to avoid duplicate.'); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: finalBody - }); - } - } - - function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); - } - - // Retry wrapper shared by write and read API calls. Reuses - // computeRetryDelayMs so rate-limit headers (retry-after / - // x-ratelimit-*) are honored uniformly. Throws on final failure - // so the caller can decide how to degrade. - async function withRetry(tag, fn) { - for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { - try { - return await fn(); - } catch (e) { - const retryInfo = computeRetryDelayMs(e, attempt); - const willRetry = retryInfo != null && attempt < MAX_RETRIES; - if (willRetry) { - const secs = (retryInfo.delayMs / 1000).toFixed(1); - console.log( - `[${tag}] transient/rate-limited (HTTP ${e.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` + - `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ${e.message}` - ); - await sleep(retryInfo.delayMs); - } else { - console.log(`[${tag}] failed after ${attempt + 1} attempts: ${e.message}`); - throw e; - } - } - } - } - - // Read API wrapper with retry + proactive pacing. Read requests are - // cheaper than writes but still consume the primary rate limit and can - // trigger the secondary limit when issued in a tight loop. Use shorter - // delays than writes (READ_SUCCESS_DELAY / READ_LOW_REMAINING_SPACING). - async function readWithPacing(tag, fn) { - const res = await withRetry(tag, fn); - const remaining = logRateLimitQuota(res, tag); - const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; - if (lowQuota) { - console.log(`[rate-limit] quota low after read (${remaining} <= ${LOW_REMAINING_THRESHOLD}); spacing ${READ_LOW_REMAINING_SPACING}ms.`); - await sleep(READ_LOW_REMAINING_SPACING); - } else { - await sleep(READ_SUCCESS_DELAY); - } - return res; - } - - // Paginated helper that walks all pages of a list endpoint with retry - // and pacing. Returns the concatenated array of items. - async function readAllPages(tag, pageFn, maxPages = 50) { - if (!Number.isFinite(maxPages) || maxPages < 1) { - throw new Error(`readAllPages: maxPages must be a positive integer, got ${maxPages}`); - } - const all = []; - let page = 1; - const PER_PAGE = 100; - while (page <= maxPages) { - const res = await readWithPacing(`${tag} (page ${page})`, () => pageFn(page, PER_PAGE)); - const items = res.data || []; - all.push(...items); - if (items.length < PER_PAGE) break; - page++; - } - // NOTE: Truncation here is intentional and acts as a safety - // valve against unbounded loops (e.g. a bug or malicious - // activity), not as a normal operating mode. A PR accumulating - // >5000 review comments is far outside expected usage; in that - // rare case we log a warning and proceed with partial data - // rather than failing the whole review. - // - // Caveat: this is NOT the same as a read failure. When the read - // API throws (rate limit, 5xx), isCommentAlreadyPosted and - // hasIssueCommentWithId catch it and return null (unknown), so - // the caller skips retrying and creates no duplicate. A - // truncated walk does not throw; it returns a partial set - // silently, so isCommentAlreadyPosted returns false (definitively - // "not posted") for any comment beyond the cap, and the retry - // loop will repost it, producing a duplicate. This tradeoff is - // accepted because the trigger is far outside expected usage; if - // that ceiling ever needs to rise, make maxPages configurable. - if (page > maxPages) { - console.log(`[${tag}] reached max page limit (${maxPages}); results may be incomplete.`); - } - return all; - } - - // Idempotency check: find whether a batch review with this run tag - // already exists on the PR. Returns { found, review } or throws on - // final failure (caller degrades to original fallback). - async function findExistingBatchReview({ owner, repo, prNumber, tag }) { - const reviews = await readAllPages('listReviews', (page, per_page) => - github.rest.pulls.listReviews({ owner, repo, pull_number: prNumber, per_page, page }) - ); - for (const r of reviews) { - if ((r.body || '').includes(tag)) { - return { found: true, review: r }; - } - } - return { found: false }; - } - - // Collect the set of comment-level IDs already posted on the PR - // (across all reviews). Uses listReviewComments (PR-level, cross-review) - // so a single paginated walk covers everything, avoiding the O(missing) - // amplification of per-comment lookups. - async function getPostedCommentIds({ owner, repo, prNumber }) { - const comments = await readAllPages('listReviewComments', (page, per_page) => - github.rest.pulls.listReviewComments({ owner, repo, pull_number: prNumber, per_page, page }) - ); - const ids = new Set(); - // Anchor the regex to the HTML comment wrapper () - // so user-generated content or code suggestions cannot trigger - // false positives in the idempotency check. The ID format is - // `ocr--` where RUN_TAG is `-` - // and is a per-comment random hex token. Capture group 1 - // holds the bare ID (ocr--), so we can add it - // directly without stripping comment markers. - const ID_RE = //g; - for (const c of comments) { - const body = c.body || ''; - let m; - while ((m = ID_RE.exec(body)) !== null) { - ids.add(m[1]); - } - } - return ids; - } - - // Check whether a specific comment-level ID has already landed on the - // server. Used by the per-comment retry loop: when a createReview call - // fails with a transient 5xx/408, the request may have reached GitHub - // and succeeded even though the response was lost. Querying before - // retrying prevents posting a duplicate inline comment. - // Returns true/false when the check succeeds, or null when the - // read API is unavailable (rate limit, 5xx, etc.). Returning null - // (rather than defaulting to false) prevents the caller from - // assuming the comment was not posted and risking a duplicate on - // retry. - // - // Each call walks listReviewComments fresh — no cached snapshot. - // A snapshot reused across retries would go stale as comments land - // during the loop, and a stale miss for a 5xx-landed comment would - // trigger a retry that posts a duplicate. Read calls are paced via - // readAllPages/readWithPacing and degrade to null (skip retry) if the - // read API itself fails, so the extra walks cannot produce duplicates. - async function isCommentAlreadyPosted({ owner, repo, prNumber, id }) { - try { - const posted = await getPostedCommentIds({ owner, repo, prNumber }); - return posted.has(id); - } catch (e) { - console.log(`[isCommentAlreadyPosted] check failed for ${id} (${e.message}); treating as unknown to avoid duplicates.`); - return null; - } - } - - // Check whether an issue comment with the given tag already exists. - // Used to avoid posting a duplicate summary comment when the batch - // review already carried the same summary body. - // Returns true/false when the check succeeds, or null when the - // read API is unavailable. Returning null (rather than defaulting - // to false) lets the caller decide whether to skip posting or - // degrade gracefully, instead of silently risking a duplicate - // summary comment. - async function hasIssueCommentWithId({ owner, repo, issueNumber, id }) { - try { - const comments = await readAllPages('listIssueComments', (page, per_page) => - github.rest.issues.listComments({ owner, repo, issue_number: issueNumber, per_page, page }) - ); - // Match the tag anchored to its HTML comment wrapper for - // consistency with getPostedCommentIds and to defend against - // user content that happens to contain the bare tag string. - // `id` is an opaque tag like ``, - // so escape any regex metacharacters before embedding it. - const escaped = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const tagRe = new RegExp(''); - return comments.some(c => tagRe.test(c.body || '')); - } catch (e) { - console.log(`[listIssueComments] check failed (${e.message}); treating as unknown to avoid duplicates.`); - return null; - } - } - - // Case-insensitive header lookup. Octokit normalizes response headers to - // lowercase, but this defensive check also handles original casing so that - // quota logging and retry delay computation never silently miss a header. - function getHeader(headers, name) { - const v = headers[name] != null ? headers[name] : headers[name.toLowerCase()]; - return v != null ? String(v).trim() : undefined; - } - - // Decide whether an error is worth retrying and, if so, how long to wait. - // Implements GitHub's documented rate-limit retry strategy using the - // response headers (retry-after, x-ratelimit-remaining, x-ratelimit-reset). - // Returns { delayMs, source, detail } when retryable, or null otherwise. - // See: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api - function computeRetryDelayMs(error, attempt) { - if (!error) return null; - const status = error.status; - const message = String(error.message || ''); - const isRateLimit = status === 429 || (status === 403 && /rate limit|abuse|secondary/i.test(message)); - const isTransient = (status >= 500 && status < 600) || status === 408; - if (!isRateLimit && !isTransient) return null; - - const headers = ((error.response || {}).headers) || {}; - const header = (name) => getHeader(headers, name); - const nowSec = Math.floor(Date.now() / 1000); - - // The absolute maximum wait for any single retry. Header-derived waits - // (retry-after / x-ratelimit-reset) are GitHub's recommended durations, - // but capping them prevents a far-future reset from stalling the CI job - // past its timeout. When we cap, the next retry may re-hit the limit. - const cap = parseInt(process.env.OCR_RETRY_MAX_DELAY, 10) || 300000; - const base = parseInt(process.env.OCR_RETRY_BASE_DELAY, 10) || 60000; - - // { rawMs, source, detail } describing the recommended wait before cap. - let info = null; - - if (isRateLimit) { - // (1) Honor "retry-after" when present (seconds, or an HTTP-date). - const retryAfter = header('retry-after'); - if (retryAfter) { - const secs = Number(retryAfter); - if (!isNaN(secs) && secs >= 0) { - info = { rawMs: secs * 1000, source: 'retry-after', detail: `${secs}s (from header)` }; - } else { - const dateMs = Date.parse(retryAfter); - if (!isNaN(dateMs)) { - info = { rawMs: Math.max(0, dateMs - Date.now()), source: 'retry-after (HTTP-date)', detail: retryAfter }; - } - } - } - - // (2) Primary limit exhausted (x-ratelimit-remaining=0): wait until reset. - if (!info) { - const remaining = header('x-ratelimit-remaining'); - const reset = header('x-ratelimit-reset'); - if (reset != null && Number(remaining) === 0) { - const rawMs = Math.max(0, Number(reset) - nowSec) * 1000; - info = { rawMs, source: 'x-ratelimit-reset', detail: `remaining=0, reset epoch=${reset} (in ${Math.ceil(rawMs / 1000)}s)` }; - } - } - - // (3) Secondary limit with no retry hint: docs say wait at least one - // minute, then increase exponentially between retries. - if (!info) { - const backoff = Math.min(base * Math.pow(2, attempt), cap); - const jitter = Math.floor(Math.random() * 1000); - info = { rawMs: backoff + jitter, source: 'exponential-backoff', detail: `base=${base}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter` }; - } - } else { - // Transient server error (5xx / 408): back off without the 60s floor. - // Use a shorter base than the rate-limit path: server hiccups are - // typically short-lived, so a 2s initial wait (doubling per retry) - // is sufficient and avoids stalling the CI job unnecessarily. - const transientBase = 2000; - const backoff = Math.min(transientBase * Math.pow(2, attempt), cap); - const jitter = Math.floor(Math.random() * 1000); - info = { rawMs: backoff + jitter, source: 'transient-backoff', detail: `base=${transientBase}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter (HTTP ${status})` }; - } - - // Apply the universal cap to header-derived waits too. - const delayMs = Math.min(info.rawMs, cap); - if (delayMs < info.rawMs) { - info.detail += ` [CAPPED to ${cap}ms; GitHub recommended ${Math.ceil(info.rawMs / 1000)}s]`; - } - return { delayMs, source: info.source, detail: info.detail }; - } - - // Best-effort logging of remaining rate-limit quota from a successful response. - // Returns the parsed x-ratelimit-remaining value (or null) for proactive throttling. - function logRateLimitQuota(response, tag) { - try { - const h = (response && response.headers) || {}; - const header = (name) => getHeader(h, name); - const remaining = header('x-ratelimit-remaining'); - const limit = header('x-ratelimit-limit'); - const reset = header('x-ratelimit-reset'); - if (remaining != null) { - console.log( - `[rate-limit] ${tag}: remaining=${remaining}/${limit != null ? limit : '?'}` + - (reset != null ? `, reset epoch=${reset}` : '') - ); - } - return remaining != null ? Number(remaining) : null; - } catch (_) { return null; } - } - - // Random per-comment ID, assigned once when the inline-comment item - // is built and carried on the item struct. Random (rather than - // content-derived) so two distinct comments that share the same - // path/line/content still get different IDs and the idempotency - // check never mistakes one for the other (which would silently drop - // the second). Embedded in the comment body as an HTML comment so - // getPostedCommentIds can match it back on retry. - function newCommentId() { - return `ocr-${RUN_TAG}-${crypto.randomBytes(8).toString('hex')}`; - } - - // Resolve the line-targeting fields for a createReview comment - // payload (start_line/line/start_side/side) from the comment's line - // range. Returned object is spread into the payload in toReviewPayload. - function resolveLines(comment) { - const start = comment.start_line; - const end = comment.end_line; - if (start >= 1 && end >= 1 && start !== end) { - return { start_line: start, line: end, start_side: 'RIGHT', side: 'RIGHT' }; - } else if (end >= 1) { - return { line: end, side: 'RIGHT' }; - } else if (start >= 1) { - return { line: start, side: 'RIGHT' }; - } - return {}; - } - - // Build the createReview payload for an inline-comment item. The - // body is assembled here (at call time) from the item's precomputed - // ID, so retry/idempotency logic works directly off item.id instead - // of recomputing an ID each time it needs to check posting status. - function toReviewPayload(item) { - return { - path: item.comment.path, - body: buildBody(item.comment, item.id), - ...item.lines - }; - } - - // Assemble the visible comment body: the per-comment ID tag (HTML - // comment, invisible when rendered) prepended for idempotency - // matching, plus the code suggestion block if present. - function buildBody(comment, id) { - let body = `\n`; - body += comment.content || ''; - if (comment.suggestion_code && comment.existing_code) { - body += '\n\n**Suggestion:**\n'; - body += fencedBlock(comment.suggestion_code, 'suggestion'); - } - return body; - } - - function formatCommentMarkdown(comment, error) { - let md = `### 📄 \`${comment.path}\``; - if (comment.start_line && comment.end_line) { - md += ` (L${comment.start_line}-L${comment.end_line})`; - } - md += '\n\n'; - if (error) { - md += `⚠️ GitHub could not post this as an inline comment: ${error}\n\n`; - } - md += comment.content || ''; - - if (comment.suggestion_code && comment.existing_code) { - md += '\n\n
💡 Suggested Change\n\n'; - md += '**Before:**\n' + fencedBlock(comment.existing_code) + '\n\n'; - md += '**After:**\n' + fencedBlock(comment.suggestion_code) + '\n\n'; - md += '
'; - } - - return md; - } - - function buildSummaryBody(totalCount, inlineCount, summaryCount, warnings) { - let body = `🔍 **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`; - if (totalCount > 0) { - body += `\n- ✅ ${inlineCount} posted as inline comment(s)`; - body += `\n- 📝 ${summaryCount} posted as summary`; - } - if (warnings.length > 0) { - body += `\n\n⚠️ ${warnings.length} warning(s) occurred during review.`; - } - return body; - } - - function formatSummaryComments(summaryComments) { - let body = ''; - for (const { comment } of summaryComments) { - body += '\n\n---\n\n'; - body += formatCommentMarkdown(comment); - } - return body; - } - - function fencedBlock(content, language = '') { - const text = String(content || ''); - const fence = safeFence(text); - let block = fence + language + '\n' + text; - if (!text.endsWith('\n')) block += '\n'; - return block + fence; - } - - function safeFence(content) { - const matches = String(content || '').match(/`+/g) || []; - const maxTicks = matches.reduce((max, ticks) => Math.max(max, ticks.length), 0); - return '`'.repeat(Math.max(3, maxTicks + 1)); - } + llm_url: ${{ secrets.OCR_LLM_URL }} + llm_auth_token: ${{ secrets.OCR_LLM_AUTH_TOKEN }} + llm_model: ${{ vars.OCR_LLM_MODEL }} + llm_use_anthropic: ${{ vars.OCR_LLM_USE_ANTHROPIC }} + # For issue_comment triggers, pass the resolved refs; for + # pull_request_target the action resolves them from the event. + base_ref: ${{ steps.pr-context.outputs.base_ref }} + head_sha: ${{ steps.pr-context.outputs.head_sha }} diff --git a/scripts/github-actions/post-review-comments.js b/scripts/github-actions/post-review-comments.js new file mode 100644 index 0000000..fba461e --- /dev/null +++ b/scripts/github-actions/post-review-comments.js @@ -0,0 +1,1203 @@ +"use strict"; + +// OpenCodeReview PR review comment poster. +// +// Extracted from the inline actions/github-script step that used to live in +// examples/github_actions/ocr-review.yml and .github/workflows/ocr-review.yml, +// so that the reusable composite action (action/action.yml) and the in-repo +// workflows share a single source of truth. +// +// Dependencies are injected by the caller (actions/github-script provides +// `github`/`context`/`core`; `fs` is required by the caller). The module has +// no external (npm) requires — only the Node.js built-in `crypto` — which +// keeps it runnable inside actions/github-script without bundling. + +const crypto = require("crypto"); + +const SUMMARY_MARKER = ""; + +// Reason attached to comments that have no valid line range and therefore can +// never be posted as inline comments. Surfaced in the summary via the same +// `⚠️ GitHub could not post this as an inline comment: ` line as +// posting failures, so every summary-only comment explains why it is here. +const NO_LINE_REASON = "No line information provided"; + +// Default IoU threshold for the incremental multi-line overlap test. Two +// multi-line comments are considered the same when their line-range +// intersection-over-union exceeds this value. Exposed for tuning via the +// incrementalOverlapThreshold option / incremental_overlap_threshold input. +const DEFAULT_OVERLAP_THRESHOLD = 0.6; + +async function runPostReviewComments({ + github, + context, + core, + fs, + resultPath = "/tmp/ocr-result.json", + stderrPath = "/tmp/ocr-stderr.log", + stickySummary = true, + incremental = false, + incrementalOverlapThreshold = DEFAULT_OVERLAP_THRESHOLD, +}) { + const log = (msg) => { + if (core && typeof core.info === "function") core.info(msg); + else console.log(msg); + }; + const out = (name, value) => { + if (core && typeof core.setOutput === "function") core.setOutput(name, value); + }; + + const owner = context.repo.owner; + const repo = context.repo.repo; + const prNumber = context.issue.number; + + // Per-run idempotency tags. context.runId / context.runAttempt come from + // @actions/github's Context (parsed from GITHUB_RUN_ID / GITHUB_RUN_ATTEMPT). + // Number.isFinite guards against NaN when the env vars are missing, falling + // back to safe defaults. The tags are embedded in review/comment bodies as + // HTML comments so the idempotency check can detect whether a batch + // createReview actually landed on the server before retrying, which prevents + // duplicate review posts on retry. + const runId = Number.isFinite(context.runId) ? context.runId : 0; + const runAttempt = Number.isFinite(context.runAttempt) ? context.runAttempt : 1; + const RUN_TAG = `${runId}-${runAttempt}`; + const REVIEW_TAG = ``; + const SUMMARY_TAG = ``; + + const stats = { + total: 0, + inline: 0, + skipped: 0, + failed: 0, + summaryUrl: "", + }; + + // Read OCR output. + let result; + try { + const raw = fs.readFileSync(resultPath, "utf8"); + result = JSON.parse(raw); + } catch (e) { + log(`Failed to parse OCR output: ${e.message}`); + const stderr = safeRead(fs, stderrPath).trim(); + if (stderr) { + const body = `${SUMMARY_MARKER}\n⚠️ **OpenCodeReview** encountered an error:\n${fencedBlock(stderr)}`; + const posted = await postSummary({ github, owner, repo, prNumber, body, sticky: stickySummary, log }); + stats.summaryUrl = posted.url; + } + setStatsOutputs(out, stats); + return; + } + + const comments = result.comments || []; + const warnings = result.warnings || []; + stats.total = comments.length; + + // No comments: post a "looks good" summary. + if (comments.length === 0) { + const message = result.message || "No comments generated. Looks good to me."; + const body = `${SUMMARY_MARKER}\n✅ **OpenCodeReview**: ${message}`; + const posted = await postSummary({ github, owner, repo, prNumber, body, sticky: stickySummary, log }); + stats.summaryUrl = posted.url; + setStatsOutputs(out, stats); + return; + } + + // Resolve the PR head commit sha to attach the review to. + let commitSha; + if (context.eventName === "pull_request_target") { + commitSha = context.payload.pull_request.head.sha; + } else { + const { data: pullRequest } = await github.rest.pulls.get({ + owner, + repo, + pull_number: prNumber, + }); + commitSha = pullRequest.head.sha; + } + + // Partition: inline (with valid line info) vs summary (without). + // Each inline comment gets a random per-comment ID (assigned once) embedded + // in its body as an HTML comment, so the retry/idempotency logic can detect + // whether a comment already landed on the server and avoid posting a + // duplicate. Random (not content-derived) so two distinct comments that + // share path/line/content still get different IDs. + const reviewComments = []; + const commentsWithoutLine = []; + for (const comment of comments) { + const hasValidLine = comment.start_line >= 1 || comment.end_line >= 1; + if (!hasValidLine) { + commentsWithoutLine.push({ comment, body: formatComment(comment), reason: NO_LINE_REASON }); + continue; + } + const id = newCommentId(RUN_TAG); + const reviewComment = { path: comment.path, body: formatComment(comment, id) }; + if (comment.start_line >= 1 && comment.end_line >= 1 && comment.start_line !== comment.end_line) { + reviewComment.start_line = comment.start_line; + reviewComment.line = comment.end_line; + reviewComment.start_side = "RIGHT"; + reviewComment.side = "RIGHT"; + } else if (comment.end_line >= 1) { + reviewComment.line = comment.end_line; + reviewComment.side = "RIGHT"; + } else if (comment.start_line >= 1) { + reviewComment.line = comment.start_line; + reviewComment.side = "RIGHT"; + } + reviewComments.push({ comment, reviewComment, id }); + } + + // Incremental filtering (non-destructive): drop current inline comments + // whose (path, line range) overlaps an existing bot review comment, so we + // only append comments on lines not yet covered. History is never deleted. + let toSend = reviewComments; + if (incremental && reviewComments.length > 0) { + const existing = await listExistingReviewComments(github, owner, repo, prNumber, log); + const botLogin = await getAuthenticatedLogin(github, log); + const hist = existing.filter((c) => isBotComment(c, botLogin)); + toSend = reviewComments.filter( + ({ reviewComment }) => !overlapsHistory(reviewComment, hist, incrementalOverlapThreshold) + ); + stats.skipped = reviewComments.length - toSend.length; + if (stats.skipped > 0) { + log(`[incremental] skipped ${stats.skipped} overlapping comment(s); ${toSend.length} to post.`); + } + } + + // ---- Summary anchor (before the review) ---- + // Create the summary issue comment BEFORE posting the review so that on a + // cold start (the first review on this PR) the summary's timeline position is + // above the review. GitHub orders issue comments oldest-first, so creating + // the summary first pins it at the top; later runs merely update it in place + // (sticky) or post a fresh per-run comment (non-sticky), so the ordering + // stays stable and the summary is never sandwiched between review blocks. + // The anchor carries a pre-review body (issue count, warnings, and comments + // without line info — all known upfront); final posting statistics are + // written in the finalize phase below, once the review has landed. + const wrapSummary = (content) => `${SUMMARY_MARKER}\n${SUMMARY_TAG}\n${content}`; + const anchor = await ensureSummaryAnchor({ + github, + owner, + repo, + prNumber, + sticky: stickySummary, + tag: SUMMARY_TAG, + body: wrapSummary(buildPreReviewSummaryBody(stats.total, commentsWithoutLine, warnings)), + log, + }); + + // Submit inline comments (the to-send set) as a single PR review. + let successCount = 0; + let failedCount = 0; + const failedComments = []; + + if (toSend.length > 0) { + // The summary lives in its own issue comment (anchored above), so the + // review body carries only the per-run REVIEW_TAG. The tag lets the + // idempotency check locate the batch review on retry (a batch createReview + // may have landed on the server even though we received a 5xx response). + const reviewBody = REVIEW_TAG; + + try { + const batchRes = await github.rest.pulls.createReview({ + owner, + repo, + pull_number: prNumber, + commit_id: commitSha, + body: reviewBody, + event: "COMMENT", + comments: toSend.map(({ reviewComment }) => reviewComment), + }); + successCount = toSend.length; + log(`Successfully posted review with ${successCount} inline comment(s) (${commentsWithoutLine.length} in summary).`); + logRateLimitQuota(batchRes, "after batch createReview", log); + } catch (e) { + log(`Failed to post review with inline comments: ${e.message}`); + + // Retry/pacing configuration (shared by write and read API calls). + // parseNonNegInt guards against nonsensical env values (negative, NaN, + // non-numeric) that `parseInt(...) || default` would let through for + // negative numbers, since a negative parseInt result is truthy and would + // bypass the `|| default` fallback. + const MAX_RETRIES = parseNonNegInt(process.env.OCR_MAX_RETRIES, 3); + const SUCCESS_DELAY = parseNonNegInt(process.env.OCR_SUCCESS_DELAY, 2000); + const FAILURE_DELAY = parseNonNegInt(process.env.OCR_FAILURE_DELAY, 1000); + const LOW_REMAINING_THRESHOLD = parseNonNegInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 3); + const LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_LOW_REMAINING_SPACING, 10000); + // Read APIs are cheaper and have higher thresholds; use shorter pacing. + const READ_SUCCESS_DELAY = parseNonNegInt(process.env.OCR_READ_SUCCESS_DELAY, 500); + const READ_LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_READ_LOW_REMAINING_SPACING, 5000); + + // Rate-limit cooldown: honor the batch error's retry/rate-limit headers + // BEFORE any further API call — including the idempotency reads below. + // Firing reads immediately after a rate-limit/5xx would further pressure + // the already-struggling API; this is the same cool-down-before-read + // discipline the per-comment loop applies before isCommentAlreadyPosted. + const batchRetry = computeRetryDelayMs(e, 0); + if (batchRetry != null) { + const secs = (batchRetry.delayMs / 1000).toFixed(1); + log( + `Batch createReview failed (HTTP ${e.status}). ` + + `Cooling down ${secs}s via '${batchRetry.source}' (${batchRetry.detail}) before any retry or read.` + ); + await sleep(batchRetry.delayMs); + } + + // The idempotency read ("did the batch land?") is only meaningful when the + // request MAY have reached the server: 5xx, 408 timeout, or a network + // error with no status. For a pure rate-limit (429 / 403 abuse) or a + // validation error (422), the request was rejected before the review was + // created, so the batch definitely did not land — querying would be both + // pointless AND an extra read fired during a rate-limit episode. Skip it + // and retry all comments. This mirrors the per-comment maybeReachedServer + // predicate so the two layers stay consistent. + const batchStatus = e.status; + const batchMaybeReachedServer = + (typeof batchStatus === "number" && (batchStatus >= 500 || batchStatus === 408)) || + batchStatus == null; // network errors (ECONNRESET, ETIMEDOUT, ...) + + let existingReview = null; + if (batchMaybeReachedServer) { + log("Checking whether the batch review actually landed on the server before retrying..."); + try { + existingReview = await findExistingBatchReview({ github, owner, repo, prNumber, tag: REVIEW_TAG, log }); + } catch (checkErr) { + log(`Idempotency check failed (${checkErr.message}). Degrading to original fallback (accepting duplicate risk).`); + } + } else { + log(`Batch did not reach the server (HTTP ${batchStatus || "n/a"}); skipping idempotency check and retrying all comments.`); + } + + // Compute the list of inline comments that still need to be posted. If + // the batch review landed, only retry the missing ones; otherwise retry + // all of them. + let toRetry = toSend; + if (existingReview && existingReview.found) { + const postedIds = await getPostedCommentIds({ github, owner, repo, prNumber, log }); + toRetry = toSend.filter((item) => !postedIds.has(item.id)); + successCount = toSend.length - toRetry.length; + log( + `Batch review already exists (review_id=${existingReview.review.id}). ` + + `${successCount}/${toSend.length} inline comments already posted. ` + + `${toRetry.length} missing, will retry only those.` + ); + } else { + log("Batch review not found on server. Falling back to per-comment posting..."); + } + + for (const { comment, reviewComment, id } of toRetry) { + let posted = false; + for (let attempt = 0; attempt <= MAX_RETRIES && !posted; attempt++) { + try { + const res = await github.rest.pulls.createReview({ + owner, + repo, + pull_number: prNumber, + commit_id: commitSha, + body: "", + event: "COMMENT", + comments: [reviewComment], + }); + successCount++; + posted = true; + log(`Successfully posted comment for ${reviewComment.path}`); + // Proactive throttle: if remaining quota is low, slow down to + // avoid hitting the limit (GitHub best practice: watch the header). + const remaining = logRateLimitQuota(res, `after ${reviewComment.path}`, log); + const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; + if (lowQuota) { + log(`[rate-limit] quota low (remaining=${remaining} <= ${LOW_REMAINING_THRESHOLD}); increasing spacing to ${LOW_REMAINING_SPACING}ms.`); + await sleep(LOW_REMAINING_SPACING); + } else { + await sleep(SUCCESS_DELAY); + } + } catch (innerE) { + // Decide whether to retry and how long to wait, based on GitHub's + // rate-limit documentation (retry-after / x-ratelimit-* headers). + const retryInfo = computeRetryDelayMs(innerE, attempt); + const willRetry = retryInfo != null && attempt < MAX_RETRIES; + // Any error whose request may have reached GitHub (5xx server + // errors, 408 timeout, or network-layer errors with no status) can + // mean the comment was actually created but the response was lost. + // Before retrying (which would post a duplicate) or before giving + // up (which would wrongly list it as failed in the summary), check + // whether it already landed. + // + // IMPORTANT: do the check AFTER cooling down, not immediately. If + // the error is rate-limit-related (5xx under load, or a network + // blip), firing read requests right away further pressures the + // already-struggling API. Honor the computed retry delay first, + // then query. + const status = innerE.status; + const maybeReachedServer = + (typeof status === "number" && (status >= 500 || status === 408)) || + status == null; // network errors (ECONNRESET, ETIMEDOUT, ...) + if (maybeReachedServer) { + // Cool down first: even read requests count against rate limits, + // and querying during an ongoing 5xx/rate-limit episode can + // worsen the situation. Use the retry delay when available; for + // non-retryable errors (retryInfo == null) there is no + // header-derived wait, so use a short fixed cool down before the + // read. + const coolDownMs = retryInfo != null ? retryInfo.delayMs : FAILURE_DELAY; + if (coolDownMs > 0) { + const secs = (coolDownMs / 1000).toFixed(1); + log( + `Cooling down ${secs}s before idempotency check for ${reviewComment.path} ` + + `(HTTP ${innerE.status || "n/a"}, attempt ${attempt + 1}/${MAX_RETRIES + 1}).` + ); + await sleep(coolDownMs); + } + const alreadyPosted = await isCommentAlreadyPosted({ github, owner, repo, prNumber, id, log }); + if (alreadyPosted === true) { + successCount++; + posted = true; + log(`Comment for ${reviewComment.path} already posted (id=${id}); treating as success.`); + await sleep(SUCCESS_DELAY); + continue; + } + // Unknown (null): the read API is unavailable, so we cannot tell + // whether the comment landed. To avoid a duplicate, do NOT retry + // posting; record as failed so the summary surfaces the + // uncertainty rather than silently risking a duplicate. + if (alreadyPosted === null) { + failedCount++; + const reason = "idempotency check unavailable (read API failed)"; + failedComments.push({ comment, error: `${innerE.message} [${reason}]` }); + log(`Cannot verify whether comment for ${reviewComment.path} was posted (${reason}, HTTP ${innerE.status || "n/a"}); skipping retry to avoid duplicate.`); + await sleep(SUCCESS_DELAY); + break; + } + // Not found on server. If retries are exhausted or the error is + // non-retryable, this is a real failure. + if (!willRetry) { + failedCount++; + failedComments.push({ comment, error: innerE.message }); + const reason = retryInfo == null ? "non-retryable error" : "rate-limit retries exhausted"; + log(`Failed to post comment for ${reviewComment.path} (${reason}, HTTP ${innerE.status || "n/a"}): ${innerE.message}`); + await sleep(SUCCESS_DELAY); + break; + } + // willRetry: cool down already consumed above, loop back. + } else if (willRetry) { + // Pure 429/403 rate-limit: the request never reached the server, + // so no duplicate is possible and the idempotency check can be + // skipped. Just honor the retry delay. + const secs = (retryInfo.delayMs / 1000).toFixed(1); + log( + `Rate-limited on ${reviewComment.path} ` + + `(HTTP ${innerE.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` + + `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ` + + `Error: ${innerE.message}` + ); + await sleep(retryInfo.delayMs); + } else { + // Non-retryable error that definitely did not reach the server + // (e.g. 4xx validation error): record as failed. + failedCount++; + failedComments.push({ comment, error: innerE.message }); + log(`Failed to post comment for ${reviewComment.path} (non-retryable error, HTTP ${innerE.status || "n/a"}): ${innerE.message}`); + await sleep(FAILURE_DELAY); + break; + } + } + } + } + } + } else { + log("No inline comments to post after filtering (all overlapping or none had line info)."); + } + + stats.inline = successCount; + stats.failed = failedCount; + + // ---- Finalize the summary with the complete body ---- + // Now that the review has landed (or failed per-comment), write the final + // summary body. Posting statistics are merged into the leading summary + // header (see buildSummaryBody), so here we only append the per-comment + // renderings: every comment that did not go out as inline — whether because + // it had no line info or because posting failed — is rendered as one + // continuous block, each carrying the reason it ended up in the summary (so + // the reader always knows why it is here). + let summaryBody = buildSummaryBody({ + total: stats.total, + inline: successCount, + summary: commentsWithoutLine.length, + skipped: stats.skipped, + failed: failedCount, + warnings, + }); + summaryBody += formatSummaryComments(commentsWithoutLine); + for (const { comment, error } of failedComments) { + summaryBody += "\n\n---\n\n"; + summaryBody += formatCommentMarkdown(comment, error); + } + if (toSend.length === 0 && stats.skipped > 0) { + summaryBody += "\n\n---\n\nℹ️ All inline comments overlapped with existing reviews; nothing new was posted."; + } + summaryBody += formatWarnings(warnings); + + // Update the anchored comment directly when its id is known (no extra read); + // otherwise upsert (find-then-update-or-create), which also covers the case + // where the anchor phase was skipped because the read API was unavailable. + // Returns null only when the summary could not be written without risking a + // duplicate; the review content remains available via inline comments. + const finalized = await finalizeSummary({ + github, + owner, + repo, + prNumber, + anchor, + sticky: stickySummary, + tag: SUMMARY_TAG, + body: wrapSummary(summaryBody), + log, + }); + if (finalized) stats.summaryUrl = finalized.url; + + setStatsOutputs(out, stats); +} + +function setStatsOutputs(out, stats) { + out("comments_total", String(stats.total)); + out("comments_inline", String(stats.inline)); + out("comments_skipped", String(stats.skipped)); + out("comments_failed", String(stats.failed)); + out("summary_comment_url", stats.summaryUrl || ""); +} + +// ---- Summary posting (sticky vs new) ---- + +async function postSummary({ github, owner, repo, prNumber, body, sticky, log }) { + const fullBody = body; + if (sticky) { + const existing = await findExistingSummaryComment({ github, owner, repo, prNumber, log }); + if (existing) { + const { data: updated } = await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body: fullBody, + }); + return { id: updated.id, url: updated.html_url, updated: true }; + } + } + const { data: created } = await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body: fullBody, + }); + return { id: created.id, url: created.html_url, updated: false }; +} + +async function findExistingSummaryComment({ github, owner, repo, prNumber, log }) { + const comments = await readAllPages("listIssueComments", (page, per_page) => + github.rest.issues.listComments({ owner, repo, issue_number: prNumber, per_page, page }), log + ); + // Issue comments are returned oldest-first; pick the newest matching. + for (let i = comments.length - 1; i >= 0; i--) { + const body = comments[i].body; + if (typeof body === "string" && body.includes(SUMMARY_MARKER)) { + return comments[i]; + } + } + return null; +} + +// ---- Summary anchor + finalize (cold-start ordering) ---- +// +// The summary issue comment is created BEFORE the review so that on a cold +// start (first review on the PR) it lands above the review in the timeline +// (GitHub orders issue comments oldest-first). It is then updated in place +// with the final body once the review has posted. This keeps the summary from +// being sandwiched between review blocks on subsequent sticky runs. + +// Find the issue comment that should carry the summary, or null if none. +// Sticky matches the persistent cross-run marker (SUMMARY_MARKER); non-sticky +// matches this run's tag (SUMMARY_TAG) so each run gets its own comment while +// retries within a run reuse it. Throws on read failure so callers can degrade. +async function findSummaryIssueComment({ github, owner, repo, prNumber, sticky, tag, log }) { + const comments = await readAllPages("listIssueComments", (page, per_page) => + github.rest.issues.listComments({ owner, repo, issue_number: prNumber, per_page, page }), log + ); + for (let i = comments.length - 1; i >= 0; i--) { + const body = comments[i].body || ""; + if (sticky ? body.includes(SUMMARY_MARKER) : body.includes(tag)) { + return comments[i]; + } + } + return null; +} + +// Phase 1 (before review): create a summary comment only if none exists yet, so +// its timeline position is pinned above the not-yet-posted review. Returns +// { id, url } for the existing/created comment, or null when the existence +// check fails (read API unavailable) — callers then defer to finalizeSummary. +async function ensureSummaryAnchor({ github, owner, repo, prNumber, body, sticky, tag, log }) { + let existing = null; + try { + existing = await findSummaryIssueComment({ github, owner, repo, prNumber, sticky, tag, log }); + } catch (e) { + log(`[summary] cannot check for existing summary before review (${e.message}); skipping anchor.`); + return null; + } + if (existing) { + return { id: existing.id, url: existing.html_url }; + } + const { data: created } = await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + return { id: created.id, url: created.html_url }; +} + +// Phase 2 (after review): write the final summary body. When the anchor's id is +// known, update it directly (no extra read). Otherwise upsert: find then update +// or create. Returns { id, url }, or null when the read API is unavailable and +// the summary cannot be safely written without risking a duplicate. +async function finalizeSummary({ github, owner, repo, prNumber, anchor, body, sticky, tag, log }) { + if (anchor && anchor.id != null) { + const { data: updated } = await github.rest.issues.updateComment({ + owner, + repo, + comment_id: anchor.id, + body, + }); + return { id: updated.id, url: updated.html_url }; + } + let existing = null; + try { + existing = await findSummaryIssueComment({ github, owner, repo, prNumber, sticky, tag, log }); + } catch (e) { + log(`[summary] cannot check for existing summary at finalize (${e.message}); skipping to avoid duplicate.`); + return null; + } + if (existing) { + const { data: updated } = await github.rest.issues.updateComment({ + owner, + repo, + comment_id: existing.id, + body, + }); + return { id: updated.id, url: updated.html_url }; + } + const { data: created } = await github.rest.issues.createComment({ + owner, + repo, + issue_number: prNumber, + body, + }); + return { id: created.id, url: created.html_url }; +} + +// ---- Incremental helpers ---- + +async function getAuthenticatedLogin(github, log) { + try { + const { data: user } = await github.rest.users.getAuthenticated(); + return user && user.login ? user.login : null; + } catch (e) { + log(`[incremental] could not resolve authenticated user: ${e.message}`); + return null; + } +} + +async function listExistingReviewComments(github, owner, repo, prNumber, log) { + const all = []; + let page = 1; + // Cap pagination so a pathological PR cannot stall the job; 10 pages = 1000. + const MAX_PAGES = 10; + // Sort newest-first so the page cap keeps the most recent comments: the + // incremental dedup cares about the latest coverage state, and on truncation + // we'd rather drop ancient comments than the recent ones the bot just posted. + // GitHub's default is ascending (oldest-first), which would keep the oldest + // 1000 and silently drop the newest — the exact comments dedup needs most. + try { + while (page <= MAX_PAGES) { + const res = await github.rest.pulls.listReviewComments({ + owner, + repo, + pull_number: prNumber, + sort: "created", + direction: "desc", + per_page: 100, + page, + }); + const items = res.data || []; + all.push(...items); + if (items.length < 100) break; + page++; + } + } catch (e) { + log(`[incremental] listing review comments failed (${e.message}); degrading to no history.`); + return []; + } + if (page > MAX_PAGES) { + log(`[incremental] listing review comments reached max page limit (${MAX_PAGES}); results may be incomplete.`); + } + return all; +} + +function isBotComment(comment, botLogin) { + if (!comment || !comment.user) return false; + if (botLogin && comment.user.login === botLogin) return true; + // GITHUB_TOKEN posts as "github-actions[bot]"; GitHub Apps post as the app. + const login = comment.user.login || ""; + return /github-actions\[bot\]$/i.test(login) || (botLogin != null && login === botLogin); +} + +// Incremental overlap test. The current comment is considered a duplicate of +// an existing bot comment (and thus skipped) when they target the same path +// and RIGHT side AND one of these holds: +// 1. both are single-line comments on the same line; +// 2. both are multi-line comments whose line-range IoU (intersection over +// union) exceeds `threshold`. +// A single-line comment is NEVER considered the same as a multi-line one, so +// revisiting a line with a finer-grained single-line note is not suppressed by +// a prior multi-line block (and vice versa). +function overlapsHistory(reviewComment, history, threshold = DEFAULT_OVERLAP_THRESHOLD) { + const t = resolveThreshold(threshold); + const path = reviewComment.path; + const cur = lineSpan(reviewComment); + if (!cur) return false; + for (const h of history) { + if (h.path !== path) continue; + if (h.side && h.side !== "RIGHT") continue; + const other = lineSpan(h); + if (!other) continue; + if (sameCommentSpan(cur, other, t)) return true; + } + return false; +} + +// Clamp/validate the caller-provided threshold to a sane (0, 1] number, +// falling back to the default when it is missing, NaN, or out of range. This +// keeps the public overlapsHistory API robust even when the value arrives from +// an env var / action input as a malformed string. +function resolveThreshold(threshold) { + const n = Number(threshold); + return Number.isFinite(n) && n > 0 && n <= 1 ? n : DEFAULT_OVERLAP_THRESHOLD; +} + +// Resolve a comment into a line span tagged as single- or multi-line. +// Returns { start, end, multiline } or null when no line can be resolved. +// Handles both our own reviewComment shape ({start_line, line}) and GitHub's +// historical comment shape ({start_line, line}; start_line null for +// single-line). A comment is multi-line only when start_line and line are both +// present and differ; start_line === line (or a missing start_line) is treated +// as a single-line comment on that line. +function lineSpan(c) { + const start = num(c.start_line); + const end = num(c.line != null ? c.line : c.end_line); + if (start == null && end == null) return null; + if (start != null && end != null && start !== end) { + return { start: Math.min(start, end), end: Math.max(start, end), multiline: true }; + } + const single = end != null ? end : start; + return { start: single, end: single, multiline: false }; +} + +// Same-comment predicate implementing the incremental rules. The IoU +// comparison is strict (>), so a span that exactly meets the threshold is NOT +// treated as a duplicate. +function sameCommentSpan(cur, other, threshold) { + if (cur.multiline !== other.multiline) return false; + if (!cur.multiline) return cur.start === other.start; + const overlap = Math.min(cur.end, other.end) - Math.max(cur.start, other.start) + 1; + if (overlap <= 0) return false; + const union = cur.end - cur.start + 1 + (other.end - other.start + 1) - overlap; + if (union <= 0) return false; + return overlap / union > threshold; +} + +function num(v) { + if (v == null || v === "") return null; + const n = Number(v); + return Number.isFinite(n) && n >= 1 ? n : null; +} + +// ---- Rate-limit / retry helpers (ported verbatim) ---- + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +// Parse a non-negative integer env value, falling back to defaultVal when the +// value is missing, NaN, or negative. Unlike `parseInt(...) || default`, this +// guards against negative numbers: a negative parseInt result is truthy, so +// `parseInt || default` would let a nonsensical negative value bypass the +// fallback. +function parseNonNegInt(val, defaultVal) { + const n = parseInt(val, 10); + return Number.isFinite(n) && n >= 0 ? n : defaultVal; +} + +// Case-insensitive header lookup. Octokit normalizes response headers to +// lowercase, but this defensive check also handles original casing so that +// quota logging and retry delay computation never silently miss a header. +function getHeader(headers, name) { + const v = headers[name] != null ? headers[name] : headers[name.toLowerCase()]; + return v != null ? String(v).trim() : undefined; +} + +// Decide whether an error is worth retrying and, if so, how long to wait. +// Implements GitHub's documented rate-limit retry strategy using the +// response headers (retry-after, x-ratelimit-remaining, x-ratelimit-reset). +// Returns { delayMs, source, detail } when retryable, or null otherwise. +// See: https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api +function computeRetryDelayMs(error, attempt) { + if (!error) return null; + const status = error.status; + const message = String(error.message || ""); + const isRateLimit = status === 429 || (status === 403 && /rate limit|abuse|secondary/i.test(message)); + const isTransient = (status >= 500 && status < 600) || status === 408; + if (!isRateLimit && !isTransient) return null; + + const headers = ((error.response || {}).headers) || {}; + const header = (name) => getHeader(headers, name); + const nowSec = Math.floor(Date.now() / 1000); + + const cap = parseNonNegInt(process.env.OCR_RETRY_MAX_DELAY, 300000); + const base = parseNonNegInt(process.env.OCR_RETRY_BASE_DELAY, 60000); + + let info = null; + + if (isRateLimit) { + // (1) Honor "retry-after" when present (seconds, or an HTTP-date). + const retryAfter = header("retry-after"); + if (retryAfter) { + const secs = Number(retryAfter); + if (!isNaN(secs) && secs >= 0) { + info = { rawMs: secs * 1000, source: "retry-after", detail: `${secs}s (from header)` }; + } else { + const dateMs = Date.parse(retryAfter); + if (!isNaN(dateMs)) { + info = { rawMs: Math.max(0, dateMs - Date.now()), source: "retry-after (HTTP-date)", detail: retryAfter }; + } + } + } + + // (2) Primary limit exhausted (x-ratelimit-remaining=0): wait until reset. + if (!info) { + const remaining = header("x-ratelimit-remaining"); + const reset = header("x-ratelimit-reset"); + if (reset != null && Number(remaining) === 0) { + const rawMs = Math.max(0, Number(reset) - nowSec) * 1000; + info = { rawMs, source: "x-ratelimit-reset", detail: `remaining=0, reset epoch=${reset} (in ${Math.ceil(rawMs / 1000)}s)` }; + } + } + + // (3) Secondary limit with no retry hint: docs say wait at least one + // minute, then increase exponentially between retries. + if (!info) { + const backoff = Math.min(base * Math.pow(2, attempt), cap); + const jitter = Math.floor(Math.random() * 1000); + info = { rawMs: backoff + jitter, source: "exponential-backoff", detail: `base=${base}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter` }; + } + } else { + // Transient server error (5xx / 408): back off without the 60s floor. + const transientBase = 2000; + const backoff = Math.min(transientBase * Math.pow(2, attempt), cap); + const jitter = Math.floor(Math.random() * 1000); + info = { rawMs: backoff + jitter, source: "transient-backoff", detail: `base=${transientBase}ms*2^${attempt} (cap ${cap}ms) +${jitter}ms jitter (HTTP ${status})` }; + } + + const delayMs = Math.min(info.rawMs, cap); + if (delayMs < info.rawMs) { + info.detail += ` [CAPPED to ${cap}ms; GitHub recommended ${Math.ceil(info.rawMs / 1000)}s]`; + } + return { delayMs, source: info.source, detail: info.detail }; +} + +// Best-effort logging of remaining rate-limit quota from a successful response. +// Returns the parsed x-ratelimit-remaining value (or null) for proactive throttling. +function logRateLimitQuota(response, tag, log) { + try { + const h = (response && response.headers) || {}; + const header = (name) => getHeader(h, name); + const remaining = header("x-ratelimit-remaining"); + const limit = header("x-ratelimit-limit"); + const reset = header("x-ratelimit-reset"); + if (remaining != null) { + log( + `[rate-limit] ${tag}: remaining=${remaining}/${limit != null ? limit : "?"}` + + (reset != null ? `, reset epoch=${reset}` : "") + ); + } + return remaining != null ? Number(remaining) : null; + } catch (_) { + return null; + } +} + +// ---- Read API + idempotency helpers ---- +// +// The helpers below back the "prevent duplicate review posts on retry" +// strategy: when a batch createReview fails with a 5xx, the request may still +// have landed on the server. Before retrying, we query existing reviews and +// review comments (each tagged with a per-run HTML comment) and only retry the +// comments that are actually missing. Read calls are paced (shorter delays +// than writes) and degrade to "unknown" (null) when the read API itself fails, +// so the caller skips retrying rather than risking a duplicate. + +// Retry wrapper shared by write and read API calls. Reuses computeRetryDelayMs +// so rate-limit headers (retry-after / x-ratelimit-*) are honored uniformly. +// Throws on final failure so the caller can decide how to degrade. +async function withRetry(tag, fn, log) { + const MAX_RETRIES = parseNonNegInt(process.env.OCR_MAX_RETRIES, 3); + let lastErr; + for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) { + try { + return await fn(); + } catch (e) { + lastErr = e; + const retryInfo = computeRetryDelayMs(e, attempt); + const willRetry = retryInfo != null && attempt < MAX_RETRIES; + if (willRetry) { + const secs = (retryInfo.delayMs / 1000).toFixed(1); + log( + `[${tag}] transient/rate-limited (HTTP ${e.status}, attempt ${attempt + 1}/${MAX_RETRIES}). ` + + `Waiting ${secs}s via '${retryInfo.source}' (${retryInfo.detail}). ${e.message}` + ); + await sleep(retryInfo.delayMs); + } else { + log(`[${tag}] failed after ${attempt + 1} attempts: ${e.message}`); + throw e; + } + } + } + throw lastErr != null + ? lastErr + : new Error(`withRetry(${tag}): exhausted retries with no error captured`); +} + +// Read API wrapper with retry + proactive pacing. Read requests are cheaper +// than writes but still consume the primary rate limit and can trigger the +// secondary limit when issued in a tight loop. Use shorter delays than writes +// (READ_SUCCESS_DELAY / READ_LOW_REMAINING_SPACING). +async function readWithPacing(tag, fn, log) { + const res = await withRetry(tag, fn, log); + const remaining = logRateLimitQuota(res, tag, log); + const LOW_REMAINING_THRESHOLD = parseNonNegInt(process.env.OCR_LOW_REMAINING_THRESHOLD, 3); + const lowQuota = remaining != null && remaining <= LOW_REMAINING_THRESHOLD; + if (lowQuota) { + const READ_LOW_REMAINING_SPACING = parseNonNegInt(process.env.OCR_READ_LOW_REMAINING_SPACING, 5000); + log(`[rate-limit] quota low after read (${remaining} <= ${LOW_REMAINING_THRESHOLD}); spacing ${READ_LOW_REMAINING_SPACING}ms.`); + await sleep(READ_LOW_REMAINING_SPACING); + } else { + const READ_SUCCESS_DELAY = parseNonNegInt(process.env.OCR_READ_SUCCESS_DELAY, 500); + await sleep(READ_SUCCESS_DELAY); + } + return res; +} + +// Paginated helper that walks all pages of a list endpoint with retry and +// pacing. Returns the concatenated array of items. +async function readAllPages(tag, pageFn, log, maxPages = 50) { + if (!Number.isFinite(maxPages) || maxPages < 1) { + throw new Error(`readAllPages: maxPages must be a positive integer, got ${maxPages}`); + } + const all = []; + let page = 1; + const PER_PAGE = 100; + while (page <= maxPages) { + const res = await readWithPacing(`${tag} (page ${page})`, () => pageFn(page, PER_PAGE), log); + const items = res.data || []; + all.push(...items); + if (items.length < PER_PAGE) break; + page++; + } + // NOTE: Truncation here is intentional and acts as a safety valve against + // unbounded loops (e.g. a bug or malicious activity), not as a normal + // operating mode. A PR accumulating >5000 review comments is far outside + // expected usage; in that rare case we log a warning and proceed with + // partial data rather than failing the whole review. + // + // Caveat: this is NOT the same as a read failure. When the read API throws + // (rate limit, 5xx), isCommentAlreadyPosted catches it and returns null + // (unknown), so the caller skips retrying and creates no duplicate. A + // truncated walk does not throw; it returns a partial set silently, so + // isCommentAlreadyPosted returns false (definitively "not posted") for any + // comment beyond the cap, and the retry loop will repost it, producing a + // duplicate. This tradeoff is accepted because the trigger is far outside + // expected usage; if that ceiling ever needs to rise, make maxPages + // configurable. + if (page > maxPages) { + log(`[${tag}] reached max page limit (${maxPages}); results may be incomplete.`); + } + return all; +} + +// Idempotency check: find whether a batch review with this run tag already +// exists on the PR. Returns { found, review } or throws on final failure +// (caller degrades to the original fallback). +async function findExistingBatchReview({ github, owner, repo, prNumber, tag, log }) { + const reviews = await readAllPages("listReviews", (page, per_page) => + github.rest.pulls.listReviews({ owner, repo, pull_number: prNumber, per_page, page }), log + ); + for (const r of reviews) { + if ((r.body || "").includes(tag)) { + return { found: true, review: r }; + } + } + return { found: false }; +} + +// Collect the set of comment-level IDs already posted on the PR (across all +// reviews). Uses listReviewComments (PR-level, cross-review) so a single +// paginated walk covers everything, avoiding the O(missing) amplification of +// per-comment lookups. +async function getPostedCommentIds({ github, owner, repo, prNumber, log }) { + const comments = await readAllPages("listReviewComments", (page, per_page) => + github.rest.pulls.listReviewComments({ owner, repo, pull_number: prNumber, per_page, page }), log + ); + const ids = new Set(); + // Anchor the regex to the HTML comment wrapper () so + // user-generated content or code suggestions cannot trigger false positives + // in the idempotency check. The ID format is `ocr--` where + // RUN_TAG is `-` and is a per-comment random + // hex token. Capture group 1 holds the bare ID, so we can add it directly + // without stripping comment markers. + const ID_RE = //g; + for (const c of comments) { + const body = c.body || ""; + let m; + while ((m = ID_RE.exec(body)) !== null) { + ids.add(m[1]); + } + } + return ids; +} + +// Check whether a specific comment-level ID has already landed on the server. +// Used by the per-comment retry loop: when a createReview call fails with a +// transient 5xx/408, the request may have reached GitHub and succeeded even +// though the response was lost. Querying before retrying prevents posting a +// duplicate inline comment. +// Returns true/false when the check succeeds, or null when the read API is +// unavailable (rate limit, 5xx, etc.). Returning null (rather than defaulting +// to false) prevents the caller from assuming the comment was not posted and +// risking a duplicate on retry. +// +// Each call walks listReviewComments fresh — no cached snapshot. A snapshot +// reused across retries would go stale as comments land during the loop, and a +// stale miss for a 5xx-landed comment would trigger a retry that posts a +// duplicate. Read calls are paced via readAllPages/readWithPacing and degrade +// to null (skip retry) if the read API itself fails, so the extra walks cannot +// produce duplicates. +async function isCommentAlreadyPosted({ github, owner, repo, prNumber, id, log }) { + try { + const posted = await getPostedCommentIds({ github, owner, repo, prNumber, log }); + return posted.has(id); + } catch (e) { + log(`[isCommentAlreadyPosted] check failed for ${id} (${e.message}); treating as unknown to avoid duplicates.`); + return null; + } +} + +// Random per-comment ID, assigned once when the inline-comment item is built +// and carried on the item struct. Random (rather than content-derived) so two +// distinct comments that share the same path/line/content still get different +// IDs and the idempotency check never mistakes one for the other (which would +// silently drop the second). Embedded in the comment body as an HTML comment +// so getPostedCommentIds can match it back on retry. +function newCommentId(runTag) { + return `ocr-${runTag}-${crypto.randomBytes(8).toString("hex")}`; +} + +// ---- Formatting helpers (ported verbatim) ---- + +// Assemble the visible comment body. When `id` is provided (inline comments), +// the per-comment ID tag is prepended as an HTML comment (invisible when +// rendered) so getPostedCommentIds can match it back on retry for the +// idempotency check. The code suggestion block is then appended if present. +function formatComment(comment, id) { + let body = id ? `\n` : ""; + body += comment.content || ""; + if (comment.suggestion_code && comment.existing_code) { + body += "\n\n**Suggestion:**\n"; + body += fencedBlock(comment.suggestion_code, "suggestion"); + } + return body; +} + +function formatCommentMarkdown(comment, error) { + let md = `### 📄 \`${comment.path}\``; + if (comment.start_line && comment.end_line) { + md += ` (L${comment.start_line}-L${comment.end_line})`; + } + md += "\n\n"; + if (error) { + md += `⚠️ GitHub could not post this as an inline comment: ${error}\n\n`; + } + md += comment.content || ""; + + if (comment.suggestion_code && comment.existing_code) { + md += "\n\n
💡 Suggested Change\n\n"; + md += "**Before:**\n" + fencedBlock(comment.existing_code) + "\n\n"; + md += "**After:**\n" + fencedBlock(comment.suggestion_code) + "\n\n"; + md += "
"; + } + return md; +} + +// Merged summary header. All posting-outcome counts are surfaced here (and +// ONLY here) so the numbers add up to the total and the reader no longer has +// to reconcile two separately presented breakdowns (the old "posted as +// inline / posted as summary" header vs. the trailing "Posting Statistics" +// block, whose overlapping definitions made the summary hard to interpret). +// +// The four counts are mutually exclusive and, together with `inline`, sum to +// `total`: +// inline — comments that landed as review inline comments +// summary — comments without line info, rendered in the summary body below +// skipped — comments suppressed by incremental overlap filtering +// failed — comments that had line info but could not be posted (also +// rendered in the body below, each tagged with its failure reason) +function buildSummaryBody({ total, inline, summary, skipped, failed, warnings }) { + let body = `🔍 **OpenCodeReview** found **${total}** issue(s) in this PR.`; + if (total > 0) { + body += `\n- ✅ Successfully posted inline: ${inline} comment(s)`; + if (summary > 0) { + body += `\n- 📝 In summary (no line info): ${summary} comment(s)`; + } + if (skipped > 0) { + body += `\n- ⏭️ Skipped (overlap with history): ${skipped} comment(s)`; + } + if (failed > 0) { + body += `\n- ❌ Failed to post inline: ${failed} comment(s)`; + } + } + if (warnings && warnings.length > 0) { + body += `\n\n⚠️ ${warnings.length} warning(s) occurred during review.`; + } + return body; +} + +// Pre-review summary body: shown in the anchor comment while inline comments +// are being posted. Includes only what is known before the review lands (issue +// count, warnings, comments without line info) — final posting statistics are +// added by the finalize phase. Kept informative (not an empty placeholder) so +// the summary is useful even if the run is interrupted before finalize. +function buildPreReviewSummaryBody(totalCount, summaryComments, warnings) { + let body = `🔍 **OpenCodeReview** found **${totalCount}** issue(s) in this PR.`; + if (totalCount > 0) { + body += `\n- ⏳ _Posting review comments…_`; + } + if (warnings.length > 0) { + body += `\n\n⚠️ ${warnings.length} warning(s) occurred during review.`; + } + body += formatSummaryComments(summaryComments); + body += formatWarnings(warnings); + return body; +} + +function formatSummaryComments(summaryComments) { + let body = ""; + for (const { comment, reason } of summaryComments) { + body += "\n\n---\n\n"; + body += formatCommentMarkdown(comment, reason); + } + return body; +} + +// Render the warning contents as a bulleted list under a "⚠️ Warnings" heading. +// Returns "" when there are no warnings, so callers can append unconditionally. +// OCR warning objects carry `file`, `message`, and `type`; each present field is +// surfaced so the summary shows where/why the warning happened. Plain-string +// warnings (and any unknown shape) degrade gracefully to their textual form. +function formatWarnings(warnings) { + if (!warnings || warnings.length === 0) return ""; + let body = "\n\n---\n\n⚠️ **Warnings:**"; + for (const w of warnings) { + body += `\n- ${formatWarningEntry(w)}`; + } + return body; +} + +// Format a single warning as a compact bullet. Builds a `file (type): message` +// prefix from whichever of file/type are present, then appends the message. +// Missing fields are skipped so a partial warning still reads naturally. +function formatWarningEntry(w) { + if (w == null) return ""; + if (typeof w === "string") return w; + if (typeof w === "object") { + const prefixParts = []; + if (w.file != null && String(w.file) !== "") prefixParts.push(`\`${w.file}\``); + if (w.type != null && String(w.type) !== "") prefixParts.push(`(\`${w.type}\`)`); + const prefix = prefixParts.join(" "); + const msg = w.message != null ? String(w.message) : ""; + if (prefix && msg) return `${prefix}: ${msg}`; + if (msg) return msg; + if (prefix) return prefix; + try { + return JSON.stringify(w); + } catch (_) { + return String(w); + } + } + return String(w); +} + +function fencedBlock(content, language = "") { + const text = String(content || ""); + const fence = safeFence(text); + let block = fence + language + "\n" + text; + if (!text.endsWith("\n")) block += "\n"; + return block + fence; +} + +function safeFence(content) { + const matches = String(content || "").match(/`+/g) || []; + const maxTicks = matches.reduce((max, ticks) => Math.max(max, ticks.length), 0); + return "`".repeat(Math.max(3, maxTicks + 1)); +} + +function safeRead(fs, p) { + try { + return fs.readFileSync(p, "utf8"); + } catch (_) { + return ""; + } +} + +module.exports = { + runPostReviewComments, + postSummary, + findExistingSummaryComment, + findSummaryIssueComment, + ensureSummaryAnchor, + finalizeSummary, + listExistingReviewComments, + getAuthenticatedLogin, + isBotComment, + overlapsHistory, + lineSpan, + sameCommentSpan, + resolveThreshold, + DEFAULT_OVERLAP_THRESHOLD, + computeRetryDelayMs, + getHeader, + logRateLimitQuota, + parseNonNegInt, + withRetry, + readWithPacing, + readAllPages, + findExistingBatchReview, + getPostedCommentIds, + isCommentAlreadyPosted, + newCommentId, + formatComment, + formatCommentMarkdown, + buildSummaryBody, + buildPreReviewSummaryBody, + formatSummaryComments, + formatWarnings, + fencedBlock, + safeFence, + SUMMARY_MARKER, + NO_LINE_REASON, +}; diff --git a/scripts/github-actions/post-review-comments.test.js b/scripts/github-actions/post-review-comments.test.js index 9fc1dcd..78679e9 100644 --- a/scripts/github-actions/post-review-comments.test.js +++ b/scripts/github-actions/post-review-comments.test.js @@ -1,47 +1,39 @@ #!/usr/bin/env node "use strict"; +// Unit tests for scripts/github-actions/post-review-comments.js. +// +// Run via: node scripts/github-actions/post-review-comments.test.js +// (also wired as `npm run test:github-actions`). +// +// These tests drive runPostReviewComments directly with an injected mock +// github/core/fs, replacing the previous approach of regex-extracting the +// inline script from workflow YAML. + const assert = require("assert"); -const fs = require("fs"); const path = require("path"); -const vm = require("vm"); +const { runPostReviewComments, safeFence, fencedBlock, lineSpan, sameCommentSpan, overlapsHistory, resolveThreshold, DEFAULT_OVERLAP_THRESHOLD, newCommentId, getPostedCommentIds, computeRetryDelayMs, formatWarnings } = require(path.join(__dirname, "post-review-comments.js")); -const repoRoot = path.join(__dirname, "..", ".."); -const workflowFiles = [ - ".github/workflows/ocr-review.yml", - "examples/github_actions/ocr-review.yml", -]; +// Make all retry/pacing delays effectively zero so tests run fast. +// computeRetryDelayMs reads OCR_RETRY_MAX_DELAY / OCR_RETRY_BASE_DELAY via +// parseNonNegInt; "1" keeps the cap/base at 1ms so any transient/rate-limit +// backoff sleep is effectively instant. +process.env.OCR_MAX_RETRIES = "0"; +process.env.OCR_SUCCESS_DELAY = "0"; +process.env.OCR_FAILURE_DELAY = "0"; +process.env.OCR_LOW_REMAINING_SPACING = "0"; +process.env.OCR_LOW_REMAINING_THRESHOLD = "0"; +process.env.OCR_RETRY_MAX_DELAY = "1"; +process.env.OCR_RETRY_BASE_DELAY = "1"; +process.env.OCR_READ_SUCCESS_DELAY = "0"; +process.env.OCR_READ_LOW_REMAINING_SPACING = "0"; -function extractPostReviewScript(workflowPath) { - const text = fs.readFileSync(path.join(repoRoot, workflowPath), "utf8"); - const lines = text.split("\n"); - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - const marker = line.match(/^(\s*)script:\s*\|\s*$/); - if (!marker) continue; - - const blockIndent = marker[1].length + 2; - const block = []; - for (let j = i + 1; j < lines.length; j++) { - const current = lines[j]; - if (current.trim() === "") { - block.push(""); - continue; - } - const indent = current.match(/^ */)[0].length; - if (indent < blockIndent) break; - block.push(current.slice(blockIndent)); - } - - const script = block.join("\n"); - if (script.includes("/tmp/ocr-result.json")) { - return script; - } - } - - throw new Error(`post review script not found in ${workflowPath}`); -} +const context = { + repo: { owner: "owner", repo: "repo" }, + issue: { number: 123 }, + eventName: "pull_request_target", + payload: { pull_request: { head: { sha: "head-sha" } } }, +}; function mockFs(resultText, stderrText) { return { @@ -53,64 +45,241 @@ function mockFs(resultText, stderrText) { }; } -function mockGithub(options) { +function makeErr(message, status, headers) { + const e = new Error(message); + if (status != null) e.status = status; + if (headers) e.response = { headers }; + return e; +} + +// Identity key for a single inline review comment, used to drive per-comment +// error injection. Two comments on the same path but different lines get +// different keys, so one can fail (e.g. 422 line-unresolvable) while another on +// the same file succeeds. Mirrors the (path, line range) identity the bot uses +// for incremental dedup and the idempotency check. +function commentKey(rc) { + if (!rc) return "?"; + return `${rc.path}|${rc.start_line != null ? rc.start_line : "-"}|${rc.line != null ? rc.line : "-"}`; +} + +// Temporarily override env vars for a single (sync or async) test body, always +// restoring originals afterwards. Used for retry/quota tests that need a +// different OCR_MAX_RETRIES / OCR_LOW_REMAINING_THRESHOLD than the fast default. +async function withEnv(env, fn) { + const saved = {}; + for (const k of Object.keys(env)) { + saved[k] = process.env[k]; + process.env[k] = env[k]; + } + try { + return await fn(); + } finally { + for (const k of Object.keys(env)) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + } +} + +function makeGithub(opts = {}) { const createReviewCalls = []; const issueComments = []; + const updatedComments = []; + const listCommentsCalls = []; + const listReviewCommentsCalls = []; + const listReviewsCalls = []; + // Interleaved log of write operations (createReview / createComment / + // updateComment) in call order, so tests can assert positioning invariants + // such as "summary created before review" without timing the calls. + const ops = []; + // Per-comment attempt counter, keyed by commentKey, so perCommentError can be + // attempt-aware (e.g. "429 on attempt 0, succeed on attempt 1"). + const perCommentAttempts = new Map(); + + function successRemaining() { + return opts.successRemaining != null ? String(opts.successRemaining) : "5000"; + } + + // Inline comment objects recorded in the BATCH createReview call (index 0) + // only, so tests can simulate "this comment already landed on the server" + // without predicting the random IDs from newCommentId(). Scoped to the batch + // call so batch-level landing (echoPosted) stays disjoint from per-comment + // landing (landedKeys, which reads per-comment calls at index >= 1). + function batchPostedComments() { + const out = []; + const call = createReviewCalls[0]; + if (call) { + for (const c of call.comments || []) { + const m = //.exec(c.body || ""); + if (m) { + out.push({ + path: c.path, + body: c.body, + side: c.side || "RIGHT", + start_line: c.start_line, + line: c.line, + }); + } + } + } + return out; + } return { createReviewCalls, issueComments, + updatedComments, + listCommentsCalls, + listReviewCommentsCalls, + listReviewsCalls, + ops, rest: { + users: { + getAuthenticated: async () => ({ data: { login: "github-actions[bot]" } }), + }, pulls: { get: async () => ({ data: { head: { sha: "head-sha" } } }), createReview: async (params) => { createReviewCalls.push(params); - if (createReviewCalls.length === 1 && options.bulkError) { - throw new Error(options.bulkError); + ops.push({ type: "createReview", params }); + const callIdx = createReviewCalls.length - 1; + const successRes = () => ({ data: {}, headers: { "x-ratelimit-remaining": successRemaining() } }); + if (callIdx === 0) { + // Batch call. bulkErrorSpec (rich: with headers) takes precedence + // over the legacy bulkError/bulkErrorStatus pair. + if (opts.bulkErrorSpec) { + throw makeErr(opts.bulkErrorSpec.message, opts.bulkErrorSpec.status, opts.bulkErrorSpec.headers); + } + if (opts.bulkError) { + throw makeErr(opts.bulkError, opts.bulkErrorStatus, opts.bulkHeaders); + } + return successRes(); } - if (createReviewCalls.length > 1 && options.individualError) { - throw new Error(options.individualError); + // Per-comment call (index >= 1). perCommentError(rc, attempt) lets a + // test fail some comments and not others (partial failure), and be + // attempt-aware (retry-then-succeed). Falls back to the legacy + // individualError (applies to all per-comment calls) for older tests. + if (typeof opts.perCommentError === "function") { + const rc = params.comments && params.comments[0]; + const key = commentKey(rc); + const attempt = perCommentAttempts.get(key) || 0; + perCommentAttempts.set(key, attempt + 1); + const spec = opts.perCommentError(rc, attempt); + if (spec) throw makeErr(spec.message, spec.status, spec.headers); + return successRes(); } - return { data: {} }; + if (opts.individualError) { + throw makeErr(opts.individualError, opts.individualErrorStatus, opts.individualHeaders); + } + return successRes(); + }, + listReviews: async (params) => { + listReviewsCalls.push(params); + // Consume a queued sequence of read errors (e.g. a transient 429 on + // the read itself) before falling through to the normal response, so + // withRetry's rate-limit backoff on reads can be exercised. + if (opts.listReviewsErrorSeq && opts.listReviewsErrorSeq.length) { + const spec = opts.listReviewsErrorSeq.shift(); + throw makeErr(spec.message, spec.status, spec.headers); + } + if (opts.listReviewsThrow) { + throw makeErr("listReviews unavailable", 503); + } + // Simulate the batch review having landed on the server even though + // createReview threw: echo the batch call's body (which carries the + // REVIEW_TAG) as an existing review's body so findExistingBatchReview + // matches it. + if (opts.batchLanded && createReviewCalls[0]) { + return { data: [{ id: 999, body: createReviewCalls[0].body || "" }] }; + } + return { data: opts.reviews || [] }; + }, + listReviewComments: async (params) => { + listReviewCommentsCalls.push(params); + if (opts.listReviewCommentsThrow) { + throw makeErr(opts.listReviewCommentsError || "read api unavailable", 503); + } + // Build the visible comment set from two disjoint, deduped sources: + // - echoPosted: comments carried by the BATCH call (index 0) that + // "already landed" — drives the batch-level getPostedCommentIds. + // - landedKeys: per-comment calls (index >= 1) that landed despite + // a 5xx/network error — drives per-comment isCommentAlreadyPosted. + // Deduping by embedded comment id keeps them composable. + if (opts.echoPosted || opts.landedKeys) { + const byId = new Map(); + const add = (c) => { + const m = //.exec(c.body || ""); + const k = m ? m[1] : `${c.path}|${c.start_line != null ? c.start_line : "-"}|${c.line != null ? c.line : "-"}|${c.body}`; + if (!byId.has(k)) byId.set(k, c); + }; + if (opts.echoPosted) { + const posted = batchPostedComments(); + const n = opts.postedCount != null ? opts.postedCount : posted.length; + for (const c of posted.slice(0, n)) add(c); + } + if (opts.landedKeys) { + for (let i = 1; i < createReviewCalls.length; i++) { + const rc = createReviewCalls[i].comments && createReviewCalls[i].comments[0]; + if (rc && opts.landedKeys.has(commentKey(rc))) { + add({ path: rc.path, body: rc.body, side: rc.side || "RIGHT", start_line: rc.start_line, line: rc.line }); + } + } + } + return { data: [...byId.values()] }; + } + return { data: opts.history || [] }; }, }, issues: { + listComments: async (params) => { + listCommentsCalls.push(params); + return { data: opts.existingSummary || [] }; + }, createComment: async (params) => { issueComments.push(params); - return { data: {} }; + ops.push({ type: "createComment", params }); + return { data: { id: 1000 + issueComments.length, html_url: `http://ex/c${issueComments.length}` } }; + }, + updateComment: async (params) => { + updatedComments.push(params); + ops.push({ type: "updateComment", params }); + return { data: { id: params.comment_id, html_url: `http://ex/u${updatedComments.length}` } }; }, }, }, }; } -async function runPostReviewScript(workflowPath, options) { - const script = extractPostReviewScript(workflowPath); - const github = mockGithub(options); - const context = { - repo: { owner: "owner", repo: "repo" }, - issue: { number: 123 }, - eventName: "pull_request_target", - payload: { pull_request: { head: { sha: "head-sha" } } }, +function mockCore() { + const outputs = {}; + return { + outputs, + setOutput(name, value) { outputs[name] = value; }, + info() {}, }; - const sandbox = { - github, - context, - console: { log() {} }, - require(name) { - if (name === "fs") return options.fs; - throw new Error(`unexpected require: ${name}`); - }, - }; - - await vm.runInNewContext(`(async () => {\n${script}\n})()`, sandbox, { - timeout: 1000, - }); - - return github; } -async function testFailedInlineCommentsAreSummarized(workflowPath) { +async function run({ result, stderr = "", opts = {}, githubOpts = {} }) { + const resultText = typeof result === "string" ? result : JSON.stringify(result); + const fs = mockFs(resultText, stderr); + const github = makeGithub(githubOpts); + const core = mockCore(); + const options = Object.assign({ stickySummary: true, incremental: false }, opts); + await runPostReviewComments({ + github, + context, + core, + fs, + resultPath: "/tmp/ocr-result.json", + stderrPath: "/tmp/ocr-stderr.log", + ...options, + }); + return { github, core, outputs: core.outputs }; +} + +// ---- Test cases (mirror PLAN §7) ---- + +async function testFailedInlineCommentsAreSummarized() { const result = { comments: [ { @@ -134,37 +303,1034 @@ async function testFailedInlineCommentsAreSummarized(workflowPath) { warnings: [], }; - const github = await runPostReviewScript(workflowPath, { - fs: mockFs(JSON.stringify(result), ""), - bulkError: 'Unprocessable Entity: "Line could not be resolved"', - individualError: 'Unprocessable Entity: "Line could not be resolved"', + const { github } = await run({ + result, + githubOpts: { + bulkError: 'Unprocessable Entity: "Line could not be resolved"', + individualError: 'Unprocessable Entity: "Line could not be resolved"', + }, + opts: { stickySummary: true }, }); - assert.strictEqual(github.createReviewCalls.length, 2); - assert.strictEqual(github.issueComments.length, 1); - const body = github.issueComments[0].body; + assert.strictEqual(github.createReviewCalls.length, 2, "bulk + one per-comment attempt"); + assert.strictEqual(github.issueComments.length, 1, "summary anchor created (no existing)"); + assert.strictEqual(github.updatedComments.length, 1, "anchor finalized with the full body"); + const body = github.updatedComments[0].body; assert.match(body, /No-line content with a fenced block/); assert.match(body, /Failed inline content must remain visible/); assert.match(body, /Line could not be resolved/); + // The no-line comment now carries the same reason line as a posting failure. + assert.match(body, /GitHub could not post this as an inline comment: No line information provided/); + // Posting statistics are merged into the leading summary header (the trailing + // "📊 Posting Statistics" section is gone), so the merged stats must appear + // BEFORE the per-comment renderings. + assert.doesNotMatch(body, /Inline comments shown in summary/); + assert.doesNotMatch(body, /📊 \*\*Posting Statistics:\*\*/); + const statsIdx = body.indexOf("❌ Failed to post inline"); + const noLineIdx = body.indexOf("No-line content with a fenced block"); + const failedIdx = body.indexOf("Failed inline content must remain visible"); + assert.ok(statsIdx !== -1, "merged stats present in the header"); + assert.ok(statsIdx < noLineIdx, "merged stats rendered before no-line comment"); + assert.ok(statsIdx < failedIdx, "merged stats rendered before failed comment"); } -async function testErrorCommentUsesSafeFence(workflowPath) { - const github = await runPostReviewScript(workflowPath, { - fs: mockFs("not json", "stderr includes a fence\n```js\nbroken();\n```"), +async function testWarningsListedAfterSummaryComments() { + const result = { + comments: [ + { path: "src/a.js", content: "Inline comment content.", start_line: 1, end_line: 1 }, + { path: "docs/no-line.md", content: "No-line comment content.", start_line: 0, end_line: 0 }, + ], + warnings: [ + "file too large to review fully", + { file: "assets/logo.png", message: "skipped binary asset", type: "binary_asset" }, + ], + }; + + const { github } = await run({ result, opts: { stickySummary: true } }); + + assert.strictEqual(github.updatedComments.length, 1, "anchor finalized with the full body"); + const body = github.updatedComments[0].body; + // Both the count line and the detailed list must be present... + assert.match(body, /2 warning\(s\) occurred during review/); + assert.match(body, /⚠️ \*\*Warnings:\*\*/); + assert.match(body, /file too large to review fully/); + // Object warnings surface file, type, and message. + assert.match(body, /`assets\/logo\.png` \(`binary_asset`\): skipped binary asset/); + // ...and the list must come AFTER the non-inline (no-line) review comment. + const noLineIdx = body.indexOf("No-line comment content."); + const warningsIdx = body.indexOf("⚠️ **Warnings:**"); + assert.ok(noLineIdx !== -1 && warningsIdx > noLineIdx, "warnings list placed after summary comments"); + // The pre-review anchor body must also surface the warning contents. + assert.match(github.issueComments[0].body, /`assets\/logo\.png` \(`binary_asset`\): skipped binary asset/); +} + +function testFormatWarnings() { + assert.strictEqual(formatWarnings([]), ""); + assert.strictEqual(formatWarnings(null), ""); + assert.strictEqual(formatWarnings(undefined), ""); + // Plain string warnings. + assert.match(formatWarnings(["a", "b"]), /⚠️ \*\*Warnings:\*\*/); + assert.match(formatWarnings(["a", "b"]), /\n- a\n- b/); + // Object warnings surface file, type, and message together. + assert.match( + formatWarnings([{ file: "internal/llm/resolver.go", message: "context deadline exceeded", type: "subtask_error" }]), + /\n- `internal\/llm\/resolver\.go` \(`subtask_error`\): context deadline exceeded/ + ); + // Partial objects: only message. + assert.match(formatWarnings([{ message: "boom" }]), /\n- boom/); + // Partial objects: file + message, no type. + assert.match(formatWarnings([{ file: "a.go", message: "m" }]), /\n- `a\.go`: m/); + // Unknown object shapes degrade to a stable JSON stringification. + assert.match(formatWarnings([{ code: 42 }]), /\n- \{"code":42\}/); +} + +async function testErrorCommentUsesSafeFence() { + const { github } = await run({ + result: "not json", + stderr: "stderr includes a fence\n```js\nbroken();\n```", + opts: { stickySummary: true }, }); assert.strictEqual(github.issueComments.length, 1); const body = github.issueComments[0].body; + // stderr contains a 3-backtick fence, so safeFence must use 4 backticks. assert.match(body, /\n````\nstderr includes a fence/); } -async function main() { - for (const workflowPath of workflowFiles) { - await testFailedInlineCommentsAreSummarized(workflowPath); - await testErrorCommentUsesSafeFence(workflowPath); +async function testStickyUpdatesExistingSummary() { + const existing = [{ id: 42, body: "\nold summary", user: { login: "github-actions[bot]" } }]; + const result = { comments: [{ path: "src/a.js", content: "x", start_line: 1, end_line: 1 }], warnings: [] }; + + const { github, outputs } = await run({ + result, + githubOpts: { existingSummary: existing }, + opts: { stickySummary: true }, + }); + + assert.strictEqual(github.updatedComments.length, 1, "existing summary updated"); + assert.strictEqual(github.issueComments.length, 0, "no new comment created"); + assert.strictEqual(github.updatedComments[0].comment_id, 42); + assert.strictEqual(outputs.comments_inline, "1"); + assert.strictEqual(outputs.comments_skipped, "0"); + assert.strictEqual(outputs.summary_comment_url, "http://ex/u1"); +} + +// Non-sticky + batch fails (e.g. rate-limit) but the per-comment fallback then +// succeeds for every comment. The summary must still be posted as its own issue +// comment (the summary never rides in the review body anymore) and finalized +// with the success statistics. +async function testNonStickyFallbackAllSuccessStillPostsSummary() { + const result = { comments: [{ path: "src/a.js", content: "comment A", start_line: 1, end_line: 1 }], warnings: [] }; + + const { github, outputs } = await run({ + result, + githubOpts: { + // Batch fails (rate-limit)... + bulkError: "rate limited", + bulkErrorStatus: 429, + // ...but the per-comment fallback succeeds (no individualError). + }, + opts: { stickySummary: false }, + }); + + // batch (call #1, failed) + one per-comment retry (call #2, succeeded). + assert.strictEqual(github.createReviewCalls.length, 2, "batch + per-comment fallback"); + assert.strictEqual(github.issueComments.length, 1, "summary anchor posted as issue comment"); + assert.strictEqual(github.updatedComments.length, 1, "anchor finalized with the success stats"); + assert.strictEqual(outputs.comments_inline, "1"); + assert.strictEqual(outputs.comments_failed, "0"); +} + +async function testNonStickyCreatesNewCommentOnFallback() { + const result = { comments: [{ path: "src/a.js", content: "Failed inline content.", start_line: 10, end_line: 10 }], warnings: [] }; + + const { github } = await run({ + result, + githubOpts: { + bulkError: 'Unprocessable Entity: "Line could not be resolved"', + individualError: 'Unprocessable Entity: "Line could not be resolved"', + }, + opts: { stickySummary: false }, + }); + + assert.strictEqual(github.issueComments.length, 1, "anchor summary comment created"); + assert.strictEqual(github.updatedComments.length, 1, "anchor finalized with full body"); + assert.match(github.updatedComments[0].body, /Failed inline content/); +} + +async function testNoCommentsStickyUpdate() { + const existing = [{ id: 7, body: "\nold good", user: { login: "github-actions[bot]" } }]; + const result = { comments: [], message: "All clear." }; + + const { github } = await run({ + result, + githubOpts: { existingSummary: existing }, + opts: { stickySummary: true }, + }); + + assert.strictEqual(github.updatedComments.length, 1); + assert.strictEqual(github.issueComments.length, 0); + assert.match(github.updatedComments[0].body, /All clear\./); +} + +async function testIncrementalSkipsOverlapping() { + const history = [{ path: "src/a.js", line: 10, start_line: 10, side: "RIGHT", user: { login: "github-actions[bot]" } }]; + const result = { + comments: [ + { path: "src/a.js", content: "overlap", start_line: 10, end_line: 10 }, + { path: "src/b.js", content: "new", start_line: 5, end_line: 5 }, + ], + warnings: [], + }; + + const { github, outputs } = await run({ + result, + githubOpts: { history }, + opts: { stickySummary: true, incremental: true }, + }); + + assert.strictEqual(github.createReviewCalls.length, 1, "one batch review"); + const sent = github.createReviewCalls[0].comments; + assert.strictEqual(sent.length, 1, "only non-overlapping comment sent"); + assert.strictEqual(sent[0].path, "src/b.js"); + assert.strictEqual(outputs.comments_skipped, "1"); + assert.strictEqual(outputs.comments_inline, "1"); +} + +async function testIncrementalAllOverlapPostsNoReview() { + const history = [{ path: "src/a.js", line: 10, start_line: 10, side: "RIGHT", user: { login: "github-actions[bot]" } }]; + const result = { comments: [{ path: "src/a.js", content: "overlap", start_line: 10, end_line: 10 }], warnings: [] }; + + const { github, outputs } = await run({ + result, + githubOpts: { history }, + opts: { stickySummary: true, incremental: true }, + }); + + assert.strictEqual(github.createReviewCalls.length, 0, "no review posted"); + assert.strictEqual(github.issueComments.length, 1, "summary anchor created"); + assert.strictEqual(github.updatedComments.length, 1, "anchor finalized with status body"); + assert.match(github.updatedComments[0].body, /nothing new was posted/); + assert.strictEqual(outputs.comments_skipped, "1"); + assert.strictEqual(outputs.comments_inline, "0"); +} + +// Multi-line IoU dedup end-to-end at the default threshold (0.6). History +// covers [8,10]; of the three new multi-line comments, the identical span +// (IoU 1.0) is skipped while the low-IoU one (0.5) and a different file are +// posted. Also verifies a single-line comment is NOT suppressed by a prior +// multi-line block on an overlapping line. +async function testIncrementalMultiLineIoUDefaultThreshold() { + const history = [{ path: "src/a.js", line: 10, start_line: 8, side: "RIGHT", user: { login: "github-actions[bot]" } }]; + const result = { + comments: [ + { path: "src/a.js", content: "identical", start_line: 8, end_line: 10 }, // IoU 1.0 -> skipped + { path: "src/a.js", content: "low-iou", start_line: 9, end_line: 11 }, // IoU 0.5 -> posted + { path: "src/a.js", content: "single", start_line: 9, end_line: 9 }, // single vs multi -> posted + { path: "src/b.js", content: "new", start_line: 1, end_line: 3 }, // other file -> posted + ], + warnings: [], + }; + + const { github, outputs } = await run({ + result, + githubOpts: { history }, + opts: { stickySummary: true, incremental: true }, + }); + + assert.strictEqual(github.createReviewCalls.length, 1, "one batch review"); + const sent = github.createReviewCalls[0].comments; + assert.strictEqual(sent.length, 3, "identical multi-line span skipped, rest posted"); + const aJsLow = sent.find((c) => c.path === "src/a.js" && c.start_line === 9 && c.line === 11); + const aJsSingle = sent.find((c) => c.path === "src/a.js" && c.line === 9 && c.start_line == null); + assert.ok(aJsLow, "low-IoU multi-line comment was posted"); + assert.ok(aJsSingle, "single-line comment was not suppressed by multi-line history"); + assert.strictEqual(outputs.comments_skipped, "1"); + assert.strictEqual(outputs.comments_inline, "3"); +} + +// Threshold propagation: lowering incrementalOverlapThreshold to 0.4 makes the +// previously low-IoU span (0.5) now overlap, so it is skipped. Exercises the +// runPostReviewComments -> overlapsHistory wiring end-to-end. +async function testIncrementalOverlapThresholdPropagated() { + const history = [{ path: "src/a.js", line: 10, start_line: 8, side: "RIGHT", user: { login: "github-actions[bot]" } }]; + const result = { + comments: [{ path: "src/a.js", content: "low-iou", start_line: 9, end_line: 11 }], // IoU 0.5 + warnings: [], + }; + + const { github, outputs } = await run({ + result, + githubOpts: { history }, + opts: { stickySummary: true, incremental: true, incrementalOverlapThreshold: 0.4 }, + }); + + assert.strictEqual(github.createReviewCalls.length, 0, "no review posted (0.5 > 0.4 now overlaps)"); + assert.strictEqual(outputs.comments_skipped, "1"); + assert.strictEqual(outputs.comments_inline, "0"); +} + +// ---- Idempotency tests (prevent duplicate review posts on retry) ---- + +// Batch createReview fails with 5xx but the batch actually landed on the +// server. The retry must post ONLY the comments that are missing, not all of +// them (which would create duplicates). +async function testBatchLandedRetriesOnlyMissingComments() { + const result = { + comments: [ + { path: "src/a.js", content: "comment A", start_line: 1, end_line: 1 }, + { path: "src/b.js", content: "comment B", start_line: 2, end_line: 2 }, + { path: "src/c.js", content: "comment C", start_line: 3, end_line: 3 }, + ], + warnings: [], + }; + + const { github, outputs } = await run({ + result, + githubOpts: { + // Batch createReview fails with 5xx ... + bulkError: "Bad Gateway", + bulkErrorStatus: 502, + // ... but the batch actually landed on the server (listReviews echoes + // the batch call's REVIEW_TAG-tagged body back as an existing review). + batchLanded: true, + // 2 of the 3 inline comments are already posted (echoed from the batch + // call's comment bodies via listReviewComments). + echoPosted: true, + postedCount: 2, + }, + opts: { stickySummary: true }, + }); + + // batch (call #1) + only the 1 missing comment retried (call #2). NOT 3 + // per-comment calls -> no duplicates. + assert.strictEqual(github.createReviewCalls.length, 2, "batch + only the missing comment retried"); + assert.strictEqual(github.createReviewCalls[1].comments.length, 1, "exactly one comment retried"); + assert.strictEqual(github.createReviewCalls[1].comments[0].path, "src/c.js", "the missing comment is retried"); + assert.strictEqual(outputs.comments_inline, "3", "2 already-posted + 1 retried = 3 successes"); + assert.strictEqual(outputs.comments_failed, "0"); +} + +// Per-comment createReview fails with 5xx but the comment already landed on +// the server. It must be treated as a success (no retry, no duplicate). +async function testPerComment5xxAlreadyPostedTreatedAsSuccess() { + const result = { comments: [{ path: "src/a.js", content: "comment A", start_line: 1, end_line: 1 }], warnings: [] }; + + const { github, outputs } = await run({ + result, + githubOpts: { + bulkError: "Bad Gateway", + bulkErrorStatus: 502, + individualError: "Bad Gateway", + individualErrorStatus: 502, + // The comment is already on the server (echoed from the batch call's + // comment body via listReviewComments). + echoPosted: true, + }, + opts: { stickySummary: true }, + }); + + // batch (call #1) + one per-comment attempt (call #2) that 5xx'd. The + // idempotency check finds the comment already posted -> no retry. + assert.strictEqual(github.createReviewCalls.length, 2, "no retry after already-posted detection"); + assert.strictEqual(outputs.comments_inline, "1", "already-posted counted as success"); + assert.strictEqual(outputs.comments_failed, "0"); +} + +// Per-comment createReview fails with 5xx and the read API is unavailable, so +// the idempotency check cannot tell whether the comment landed. The retry must +// be SKIPPED (to avoid a duplicate) and the comment recorded as failed. +async function testPerComment5xxIdempotencyUnavailableSkipsRetry() { + const result = { comments: [{ path: "src/a.js", content: "comment A", start_line: 1, end_line: 1 }], warnings: [] }; + + const { github, outputs } = await run({ + result, + githubOpts: { + bulkError: "Bad Gateway", + bulkErrorStatus: 502, + individualError: "Bad Gateway", + individualErrorStatus: 502, + // Read API unavailable -> isCommentAlreadyPosted returns null (unknown). + listReviewCommentsThrow: true, + }, + opts: { stickySummary: true }, + }); + + // batch (call #1) + one per-comment attempt (call #2). No retry despite 5xx + // (unknown -> skip to avoid duplicate). + assert.strictEqual(github.createReviewCalls.length, 2, "no retry when idempotency check is unavailable"); + assert.strictEqual(outputs.comments_failed, "1", "recorded as failed, not retried"); + // The uncertainty is surfaced in the finalized summary. + assert.strictEqual(github.issueComments.length, 1, "anchor created"); + assert.strictEqual(github.updatedComments.length, 1, "anchor finalized"); + assert.match(github.updatedComments[0].body, /idempotency check unavailable/); +} + +// A summary comment already exists (e.g. a previous attempt within the run +// posted it). The anchor phase must reuse it (no duplicate created) and the +// finalize phase must refresh it in place with the final body. +async function testSummaryDoesNotDuplicateWhenAlreadyPosted() { + // context.runId/runAttempt are unset -> RUN_TAG = "0-1" -> SUMMARY_TAG = + // "". A real summary carries both the persistent + // SUMMARY_MARKER and the per-run SUMMARY_TAG. + const existing = [ + { id: 5, body: "\n\nold summary", user: { login: "github-actions[bot]" } }, + ]; + const result = { comments: [{ path: "src/a.js", content: "x", start_line: 1, end_line: 1 }], warnings: [] }; + + const { github, outputs } = await run({ + result, + githubOpts: { existingSummary: existing }, + opts: { stickySummary: true }, + }); + + // Batch review posted normally; the existing summary is reused and refreshed, + // never duplicated. + assert.strictEqual(github.createReviewCalls.length, 1, "batch review posted"); + assert.strictEqual(github.issueComments.length, 0, "no duplicate summary created"); + assert.strictEqual(github.updatedComments.length, 1, "existing summary refreshed in place"); + assert.strictEqual(github.updatedComments[0].comment_id, 5, "the existing comment is the one updated"); + assert.strictEqual(outputs.comments_inline, "1"); + assert.strictEqual(outputs.summary_comment_url, "http://ex/u1"); + assert.match(github.updatedComments[0].body, /Successfully posted inline: 1 comment/, "final body reflects the run outcome"); +} + +// Cold-start ordering: on the first review on a PR, the summary issue comment +// must be created BEFORE the batch review so its timeline position is above the +// review (GitHub orders issue comments oldest-first). It is then finalized +// (updated in place) after the review lands. This is the core fix for the +// "summary sandwiched between review blocks" defect on sticky PRs. +async function testSummaryAnchorCreatedBeforeReviewColdStart() { + const result = { comments: [{ path: "src/a.js", content: "x", start_line: 1, end_line: 1 }], warnings: [] }; + + const { github } = await run({ + result, + githubOpts: { existingSummary: [] }, // cold start: no existing summary + opts: { stickySummary: true }, + }); + + const types = github.ops.map((o) => o.type); + const anchorIdx = types.indexOf("createComment"); + const reviewIdx = types.indexOf("createReview"); + const finalizeIdx = types.lastIndexOf("updateComment"); + assert.notStrictEqual(anchorIdx, -1, "summary anchor created"); + assert.notStrictEqual(reviewIdx, -1, "batch review posted"); + assert.notStrictEqual(finalizeIdx, -1, "summary finalized"); + assert.ok(anchorIdx < reviewIdx, "summary anchor created BEFORE the review (cold-start positioning)"); + assert.ok(reviewIdx < finalizeIdx, "summary finalized AFTER the review"); + // The anchor body is a pre-review placeholder; the final body carries stats. + assert.match(github.issueComments[0].body, /Posting review comments/); + assert.match(github.updatedComments[0].body, /Successfully posted inline: 1 comment/); +} + +// Cold start + non-sticky: the per-run summary is also anchored before the +// review (non-sticky still creates a fresh comment each run, but within the run +// it must lead the review for a natural reading order). +async function testSummaryAnchorCreatedBeforeReviewNonSticky() { + const result = { comments: [{ path: "src/a.js", content: "x", start_line: 1, end_line: 1 }], warnings: [] }; + + const { github } = await run({ + result, + githubOpts: { existingSummary: [] }, + opts: { stickySummary: false }, + }); + + const types = github.ops.map((o) => o.type); + assert.ok(types.indexOf("createComment") < types.indexOf("createReview"), "anchor before review"); + assert.ok(types.indexOf("createReview") < types.lastIndexOf("updateComment"), "finalize after review"); +} + +function testNewCommentIdFormat() { + const id = newCommentId("12-3"); + // Format: ocr---<16 hex chars> (crypto.randomBytes(8)). + assert.match(id, /^ocr-12-3-[a-f0-9]{16}$/, "id format is ocr--"); + // Random -> two calls produce distinct IDs (so two comments that share + // path/line/content still get different IDs and the check never mistakes + // one for the other). + assert.notStrictEqual(newCommentId("1-1"), newCommentId("1-1"), "IDs are random per call"); +} + +async function testGetPostedCommentIdsExtractsEmbeddedIds() { + const github = { + rest: { + pulls: { + listReviewComments: async () => ({ + data: [ + { body: "\ncontent a" }, + { body: "no id here" }, + { body: "\ncontent c" }, + // User content that mentions the bare id string must NOT match: + // the regex is anchored to wrappers, defending against + // false positives in the idempotency check. + { body: "see ocr-0-1-aaaa0000bbbb1111 somewhere" }, + ], + headers: {}, + }), + }, + }, + }; + const ids = await getPostedCommentIds({ github, owner: "o", repo: "r", prNumber: 1, log: () => {} }); + assert.strictEqual(ids.size, 2, "only IDs inside HTML comment wrappers are extracted"); + assert.ok(ids.has("ocr-0-1-aaaa0000bbbb1111")); + assert.ok(ids.has("ocr-0-1-cccc2222dddd3333")); + assert.ok(!ids.has("ocr-0-1-zzzz0000"), "non-hex tokens do not match"); +} + +// ---- computeRetryDelayMs unit tests ---- +// +// The rate-limit retry strategy is a pure function of the error (status + +// response headers) and attempt number. The integration tests below cap every +// delay to ~1ms via OCR_RETRY_MAX_DELAY=1, so they cannot assert that specific +// headers are honored; these unit tests pin down each branch of the strategy +// directly. They run under realistic cap/base values (overridden locally) so +// the returned delayMs is meaningful. + +function testComputeRetryDelayMs() { + // Use realistic cap/base so delayMs reflects the strategy rather than the + // 1ms test-harness cap. Restored at the end. + const realCap = process.env.OCR_RETRY_MAX_DELAY; + const realBase = process.env.OCR_RETRY_BASE_DELAY; + process.env.OCR_RETRY_MAX_DELAY = "300000"; + process.env.OCR_RETRY_BASE_DELAY = "60000"; + try { + // Non-error / non-retryable -> null (no retry). + assert.strictEqual(computeRetryDelayMs(null, 0), null); + assert.strictEqual(computeRetryDelayMs(makeErr("validation", 422), 0), null); + + // 429 honoring retry-after (seconds form): delay = secs * 1000. + let r = computeRetryDelayMs(makeErr("rate", 429, { "retry-after": "5" }), 0); + assert.strictEqual(r.source, "retry-after"); + assert.strictEqual(r.delayMs, 5000); + + // 429 honoring retry-after (HTTP-date form): source tagged accordingly, + // delay ~ the time until the given date. + const dateMs = Date.now() + 5000; + r = computeRetryDelayMs(makeErr("rate", 429, { "retry-after": new Date(dateMs).toUTCString() }), 0); + assert.strictEqual(r.source, "retry-after (HTTP-date)"); + assert.ok(r.delayMs > 0 && r.delayMs <= 5000, "HTTP-date retry-after within 5s window"); + + // 429 with primary limit exhausted (remaining=0): wait until reset epoch. + const reset = Math.floor(Date.now() / 1000) + 10; + r = computeRetryDelayMs(makeErr("rate", 429, { "x-ratelimit-remaining": "0", "x-ratelimit-reset": String(reset) }), 0); + assert.strictEqual(r.source, "x-ratelimit-reset"); + assert.strictEqual(r.delayMs, 10000); + + // remaining > 0 must NOT trigger the reset branch even with a reset header. + r = computeRetryDelayMs(makeErr("rate", 429, { "x-ratelimit-remaining": "1", "x-ratelimit-reset": String(reset) }), 0); + assert.strictEqual(r.source, "exponential-backoff"); + + // 429 with no hint: exponential backoff, base*2^attempt + 0..999 jitter. + r = computeRetryDelayMs(makeErr("rate", 429), 0); + assert.strictEqual(r.source, "exponential-backoff"); + assert.ok(r.delayMs >= 60000 && r.delayMs <= 60999, "attempt 0 backoff = 60000 + jitter"); + r = computeRetryDelayMs(makeErr("rate", 429), 2); + assert.ok(r.delayMs >= 240000 && r.delayMs <= 240999, "attempt 2 backoff = 240000 + jitter"); + + // 403 is a rate-limit ONLY when the message mentions rate limit/abuse/secondary. + assert.ok(computeRetryDelayMs(makeErr("rate limit exceeded", 403), 0) != null, "403 + 'rate limit' retryable"); + assert.ok(computeRetryDelayMs(makeErr("abuse detection", 403), 0) != null, "403 + 'abuse' retryable"); + assert.ok(computeRetryDelayMs(makeErr("secondary rate", 403), 0) != null, "403 + 'secondary' retryable"); + assert.strictEqual(computeRetryDelayMs(makeErr("forbidden", 403), 0), null, "plain 403 not retryable"); + + // 5xx transient: shorter base (2000ms) than rate-limit, grows with attempt. + r = computeRetryDelayMs(makeErr("Bad Gateway", 502), 0); + assert.strictEqual(r.source, "transient-backoff"); + assert.ok(r.delayMs >= 2000 && r.delayMs <= 2999, "502 attempt 0 = 2000 + jitter"); + // 408 timeout is also treated as transient. + assert.strictEqual(computeRetryDelayMs(makeErr("timeout", 408), 0).source, "transient-backoff"); + + // Cap: a huge retry-after is clamped to OCR_RETRY_MAX_DELAY. + r = computeRetryDelayMs(makeErr("rate", 429, { "retry-after": "1000000" }), 0); + assert.strictEqual(r.delayMs, 300000, "capped to 300000ms"); + assert.match(r.detail, /CAPPED/, "capping is surfaced in detail"); + } finally { + if (realCap === undefined) delete process.env.OCR_RETRY_MAX_DELAY; + else process.env.OCR_RETRY_MAX_DELAY = realCap; + if (realBase === undefined) delete process.env.OCR_RETRY_BASE_DELAY; + else process.env.OCR_RETRY_BASE_DELAY = realBase; } } +// ---- Cross-scenario integration tests ---- +// +// rate-limit × partial-invalid-content × landed-on-server intersect on the +// per-comment fallback loop, where EACH comment can independently succeed, +// fail with a non-retryable 4xx, retry on 429, or be recovered (or not) via +// the idempotency check after a 5xx/network error. The mock's perCommentError +// (comment-keyed, attempt-aware) + landedKeys/echoPosted drive these. + +// P0-1: batch rate-limit (429) triggers the per-comment fallback, where SOME +// comments succeed and SOME fail with 422 (invalid content, e.g. line gone). +// Verifies success/failed counts split correctly and ONLY the failed comment +// is surfaced in the summary (successful inline comments are not duplicated +// into the summary). +async function testBatchRateLimitWithPartialInvalidContent() { + const result = { + comments: [ + { path: "src/a.js", content: "valid A", start_line: 1, end_line: 1 }, + { path: "src/b.js", content: "invalid B (line gone)", start_line: 99, end_line: 99 }, + { path: "src/c.js", content: "valid C", start_line: 3, end_line: 3 }, + ], + warnings: [], + }; + + const { github, outputs } = await run({ + result, + githubOpts: { + bulkErrorSpec: { message: "rate limited", status: 429, headers: { "retry-after": "1" } }, + perCommentError: (rc) => { + // b.js is invalid (422); a.js and c.js succeed. + if (commentKey(rc) === "src/b.js|-|99") { + return { status: 422, message: 'Unprocessable Entity: "Line could not be resolved"' }; + } + return null; + }, + }, + opts: { stickySummary: true }, + }); + + // batch (429) + 3 per-comment calls (a ok, b 422, c ok). + assert.strictEqual(github.createReviewCalls.length, 4, "batch + 3 per-comment attempts"); + assert.strictEqual(outputs.comments_inline, "2", "a and c posted"); + assert.strictEqual(outputs.comments_failed, "1", "b failed (invalid content)"); + // Fix B: a pure 429 never reached the server, so the idempotency reads must + // be skipped entirely (no listReviews / listReviewComments). + assert.strictEqual(github.listReviewsCalls.length, 0, "429 batch skips listReviews idempotency read"); + assert.strictEqual(github.listReviewCommentsCalls.length, 0, "no per-comment idempotency reads (422 non-retryable, successes need none)"); + // Summary surfaces ONLY the failed comment (in the finalized body). + assert.strictEqual(github.issueComments.length, 1, "anchor created"); + assert.strictEqual(github.updatedComments.length, 1, "anchor finalized"); + const body = github.updatedComments[0].body; + assert.match(body, /invalid B/, "failed comment content appears in summary"); + assert.doesNotMatch(body, /valid A/, "successful comment not duplicated into summary"); + assert.doesNotMatch(body, /valid C/, "successful comment not duplicated into summary"); +} + +// P0-2: per-comment rate-limit with retries. One comment recovers after a +// retry (429 then success); another stays rate-limited until retries are +// exhausted. Requires OCR_MAX_RETRIES >= 1 (overridden locally). +async function testPerCommentRateLimitRetryThenSuccessAndExhausted() { + const result = { + comments: [ + { path: "src/a.js", content: "recovers after retry", start_line: 1, end_line: 1 }, + { path: "src/b.js", content: "always rate limited", start_line: 2, end_line: 2 }, + ], + warnings: [], + }; + + return withEnv({ OCR_MAX_RETRIES: "1" }, async () => { + const { github, outputs } = await run({ + result, + githubOpts: { + bulkErrorSpec: { message: "rate limited", status: 429 }, + perCommentError: (rc, attempt) => { + if (commentKey(rc) === "src/a.js|-|1") { + // a.js: 429 on attempt 0, success on attempt 1. + return attempt === 0 ? { status: 429, message: "rate limited" } : null; + } + // b.js: always 429 -> retries exhausted -> failed. + return { status: 429, message: "rate limited" }; + }, + }, + opts: { stickySummary: true }, + }); + + // batch + a(2 attempts: 429 then ok) + b(2 attempts: 429, 429 exhausted). + assert.strictEqual(github.createReviewCalls.length, 5, "batch + a(2) + b(2)"); + assert.strictEqual(outputs.comments_inline, "1", "a recovered via retry"); + assert.strictEqual(outputs.comments_failed, "1", "b exhausted all retries"); + }); +} + +// P0-3: batch 5xx but the batch LANDED on the server. The batch-level +// idempotency check finds some comments already posted; the MISSING ones are +// retried per-comment, where one fails with 422 (invalid content). Verifies +// batch-level dedup and per-comment failure compose without double-counting. +async function testBatchLandedWithPerCommentPartialInvalid() { + const result = { + comments: [ + { path: "src/a.js", content: "already landed A", start_line: 1, end_line: 1 }, + { path: "src/b.js", content: "already landed B", start_line: 2, end_line: 2 }, + { path: "src/c.js", content: "invalid C", start_line: 99, end_line: 99 }, + ], + warnings: [], + }; + + const { github, outputs } = await run({ + result, + githubOpts: { + bulkError: "Bad Gateway", + bulkErrorStatus: 502, + batchLanded: true, + echoPosted: true, + postedCount: 2, // a and b already on the server + perCommentError: (rc) => { + if (commentKey(rc) === "src/c.js|-|99") { + return { status: 422, message: 'Unprocessable Entity: "Line could not be resolved"' }; + } + return null; + }, + }, + opts: { stickySummary: true }, + }); + + // batch (502, landed) + only the 1 missing comment (c) retried, which 422s. + assert.strictEqual(github.createReviewCalls.length, 2, "batch + only missing c retried"); + assert.strictEqual(outputs.comments_inline, "2", "a,b recovered via batch-landing; c failed"); + assert.strictEqual(outputs.comments_failed, "1", "c invalid content"); +} + +// P0-4: the full four-state mix under a landed batch. Combines batch-level +// landing with per-comment: success, 422-invalid, 5xx-landed (recovered via +// idempotency), and 5xx-NOT-landed (failed). This is the most entangled +// intersection of all three scenarios. +async function testBatchLandedWithPerCommentMixedStates() { + const result = { + comments: [ + { path: "src/a.js", content: "batch-landed A", start_line: 1, end_line: 1 }, + { path: "src/b.js", content: "success B", start_line: 2, end_line: 2 }, + { path: "src/c.js", content: "invalid C", start_line: 99, end_line: 99 }, + { path: "src/d.js", content: "5xx landed D", start_line: 4, end_line: 4 }, + { path: "src/e.js", content: "5xx not landed E", start_line: 5, end_line: 5 }, + ], + warnings: [], + }; + + const landedKeys = new Set(["src/d.js|-|4"]); + const { outputs } = await run({ + result, + githubOpts: { + bulkError: "Bad Gateway", + bulkErrorStatus: 502, + batchLanded: true, + echoPosted: true, + postedCount: 1, // only a batch-landed + landedKeys, // d lands despite its per-comment 502 + perCommentError: (rc) => { + const key = commentKey(rc); + if (key === "src/c.js|-|99") return { status: 422, message: "Line could not be resolved" }; + if (key === "src/d.js|-|4") return { status: 502, message: "Bad Gateway" }; + if (key === "src/e.js|-|5") return { status: 502, message: "Bad Gateway" }; + return null; // b succeeds + }, + }, + opts: { stickySummary: true }, + }); + + // a(batch-landed) + b(success) + d(5xx-landed) = 3 successes; + // c(422) + e(5xx-not-landed) = 2 failures. + assert.strictEqual(outputs.comments_inline, "3", "a+b+d succeed across three different recovery paths"); + assert.strictEqual(outputs.comments_failed, "2", "c(422) + e(5xx not landed) fail"); +} + +// P1: a network-layer error (no HTTP status) is treated as "maybe reached the +// server", so the idempotency check runs. A comment that landed is recovered; +// one that did not is recorded as failed (no blind retry that would duplicate). +async function testNetworkErrorLandedRecoveredAndNotLandedFailed() { + const result = { + comments: [ + { path: "src/a.js", content: "net landed", start_line: 1, end_line: 1 }, + { path: "src/b.js", content: "net not landed", start_line: 2, end_line: 2 }, + ], + warnings: [], + }; + + const landedKeys = new Set(["src/a.js|-|1"]); + const { outputs } = await run({ + result, + githubOpts: { + bulkError: "Bad Gateway", + bulkErrorStatus: 502, + landedKeys, + // status omitted -> typeof status !== "number" && status == null -> + // maybeReachedServer=true -> idempotency check decides. + perCommentError: () => ({ message: "ECONNRESET" }), + }, + opts: { stickySummary: true }, + }); + + assert.strictEqual(outputs.comments_inline, "1", "a recovered (landed) via idempotency check"); + assert.strictEqual(outputs.comments_failed, "1", "b not landed -> failed, no blind retry"); +} + +// P1: the batch-level idempotency check itself throws (listReviews +// unavailable). The code degrades to the original fallback (retry ALL +// comments, accepting duplicate risk) rather than aborting. +async function testBatchIdempotencyCheckFailureDegradesToFullRetry() { + const result = { + comments: [ + { path: "src/a.js", content: "A", start_line: 1, end_line: 1 }, + { path: "src/b.js", content: "B", start_line: 2, end_line: 2 }, + ], + warnings: [], + }; + + const { github, outputs } = await run({ + result, + githubOpts: { + bulkError: "Bad Gateway", + bulkErrorStatus: 502, + listReviewsThrow: true, // findExistingBatchReview fails -> degrade + perCommentError: () => null, // all per-comment succeed + }, + opts: { stickySummary: true }, + }); + + // Degrade retries ALL (no filtering) -> batch + 2 per-comment. + assert.strictEqual(github.createReviewCalls.length, 3, "degraded to full retry of all comments"); + assert.strictEqual(outputs.comments_inline, "2"); + assert.strictEqual(outputs.comments_failed, "0"); +} + +// P1 (smoke): low remaining quota on a per-comment success triggers the +// proactive throttle branch. We cannot spy on the internal sleep, so this +// only verifies the branch executes without breaking the flow or counts. +async function testLowQuotaProactiveThrottleDoesNotBreakFlow() { + return withEnv({ OCR_LOW_REMAINING_THRESHOLD: "3" }, async () => { + const result = { comments: [{ path: "src/a.js", content: "A", start_line: 1, end_line: 1 }], warnings: [] }; + const { outputs } = await run({ + result, + githubOpts: { + bulkError: "rate limited", + bulkErrorStatus: 429, // force the per-comment fallback path + successRemaining: 2, // <= threshold -> low-quota branch + perCommentError: () => null, + }, + opts: { stickySummary: true }, + }); + assert.strictEqual(outputs.comments_inline, "1", "low-quota throttle does not impede success"); + }); +} + +// Fix B (focused): a pure rate-limit (429) on the batch means the request never +// reached the server, so the batch did not land. The idempotency reads +// (listReviews / listReviewComments) must be SKIPPED entirely — querying would +// be pointless and would pressure the API during an ongoing rate-limit episode. +// The batch rate-limit cooldown still runs before the per-comment retry. +async function testBatchRateLimitSkipsIdempotencyReads() { + const result = { + comments: [{ path: "src/a.js", content: "A", start_line: 1, end_line: 1 }], + warnings: [], + }; + + const { github, outputs } = await run({ + result, + githubOpts: { + bulkErrorSpec: { message: "rate limited", status: 429, headers: { "retry-after": "1" } }, + perCommentError: () => null, // per-comment succeeds + }, + opts: { stickySummary: true }, + }); + + assert.strictEqual(github.listReviewsCalls.length, 0, "listReviews not called (429 never reached server)"); + assert.strictEqual(github.listReviewCommentsCalls.length, 0, "listReviewComments not called"); + assert.strictEqual(github.createReviewCalls.length, 2, "batch + 1 per-comment"); + assert.strictEqual(outputs.comments_inline, "1"); +} + +// Fix A + read self-protection: a 5xx batch MAY have landed, so the idempotency +// read runs — but only AFTER cooling down. The read itself can also hit a +// rate-limit; withRetry (wrapping readWithPacing) must back off and recover so +// the batch-landing detection still works. Requires OCR_MAX_RETRIES >= 1. +async function testBatchReadRateLimitRetriedViaWithRetry() { + const result = { + comments: [ + { path: "src/a.js", content: "A", start_line: 1, end_line: 1 }, + { path: "src/b.js", content: "B", start_line: 2, end_line: 2 }, + ], + warnings: [], + }; + + return withEnv({ OCR_MAX_RETRIES: "1" }, async () => { + const { github, outputs } = await run({ + result, + githubOpts: { + bulkError: "Bad Gateway", + bulkErrorStatus: 502, + batchLanded: true, + echoPosted: true, + postedCount: 2, // both comments already on the server + // The idempotency read (listReviews) itself is rate-limited once, then + // succeeds: withRetry must honor retry-after and recover. + listReviewsErrorSeq: [ + { status: 429, message: "rate limited", headers: { "retry-after": "1" } }, + ], + }, + opts: { stickySummary: true }, + }); + + // listReviews: 1st call 429, 2nd call success -> read recovered via retry. + assert.strictEqual(github.listReviewsCalls.length, 2, "read retried after its own 429"); + assert.strictEqual(outputs.comments_inline, "2", "both recovered as already-posted"); + assert.strictEqual(outputs.comments_failed, "0"); + assert.strictEqual(github.createReviewCalls.length, 1, "no per-comment retry (all already posted)"); + }); +} + +// ---- Pure helper unit tests ---- + +function testSafeFenceAndFencedBlock() { + assert.strictEqual(safeFence("plain"), "```"); + // single backticks -> maxTicks=1 -> max(3, 2) = 3 + assert.strictEqual(safeFence("a `backtick` here"), "```"); + // 5 backticks -> maxTicks=5 -> 6 + assert.strictEqual(safeFence("`````"), "``````"); + const block = fencedBlock("```js\nx\n```"); + assert.ok(block.startsWith("````")); + assert.ok(block.endsWith("````")); +} + +function testLineSpan() { + assert.deepStrictEqual(lineSpan({ line: 10, start_line: 5 }), { start: 5, end: 10, multiline: true }); + assert.deepStrictEqual(lineSpan({ line: 7 }), { start: 7, end: 7, multiline: false }); + assert.deepStrictEqual(lineSpan({ start_line: 3 }), { start: 3, end: 3, multiline: false }); + // start_line === line collapses to a single-line span. + assert.deepStrictEqual(lineSpan({ line: 9, start_line: 9 }), { start: 9, end: 9, multiline: false }); + assert.strictEqual(lineSpan({}), null); + // Invalid line numbers (0, negative, NaN) are dropped by num(); a span with + // no usable line resolves to null. + assert.strictEqual(lineSpan({ line: 0 }), null); + assert.strictEqual(lineSpan({ line: -3 }), null); + assert.strictEqual(lineSpan({ line: NaN }), null); + // An invalid start_line but valid line degrades to a single-line span. + assert.deepStrictEqual(lineSpan({ line: 5, start_line: 0 }), { start: 5, end: 5, multiline: false }); + assert.deepStrictEqual(lineSpan({ line: 5, start_line: -1 }), { start: 5, end: 5, multiline: false }); + // Reversed order (start_line > line) is normalized via min/max. + assert.deepStrictEqual(lineSpan({ line: 3, start_line: 8 }), { start: 3, end: 8, multiline: true }); +} + +function testSameCommentSpan() { + const sl = (n) => ({ start: n, end: n, multiline: false }); + const ml = (a, b) => ({ start: a, end: b, multiline: true }); + // Rule 1: single vs multi never match. + assert.strictEqual(sameCommentSpan(sl(9), ml(8, 10), 0.6), false); + assert.strictEqual(sameCommentSpan(ml(8, 10), sl(9), 0.6), false); + // Rule 2: single-line, same line matches; different line does not. + assert.strictEqual(sameCommentSpan(sl(9), sl(9), 0.6), true); + assert.strictEqual(sameCommentSpan(sl(9), sl(10), 0.6), false); + // Rule 3: multi-line IoU. [8,10] vs [9,11] => overlap 2 / union 4 = 0.5. + assert.strictEqual(sameCommentSpan(ml(8, 10), ml(9, 11), 0.6), false); + assert.strictEqual(sameCommentSpan(ml(8, 10), ml(9, 11), 0.4), true); + // [8,10] vs [8,9] => overlap 2 / union 3 ~= 0.67. + assert.strictEqual(sameCommentSpan(ml(8, 10), ml(8, 9), 0.6), true); + // Identical spans => IoU 1. + assert.strictEqual(sameCommentSpan(ml(8, 10), ml(8, 10), 0.6), true); + // Disjoint multi-line spans never match. + assert.strictEqual(sameCommentSpan(ml(1, 3), ml(8, 10), 0.6), false); + // IoU comparison is strict: exactly at the threshold is NOT a match. + // [8,10] vs [9,11] => IoU 0.5; threshold 0.5 => 0.5 > 0.5 is false. + assert.strictEqual(sameCommentSpan(ml(8, 10), ml(9, 11), 0.5), false); + // Single-line matching (rule 2) ignores threshold entirely: same line still + // matches even at threshold = 1. + assert.strictEqual(sameCommentSpan(sl(9), sl(9), 1), true); + // threshold = 1 is unreachable for multi-line under strict >: even identical + // spans (IoU 1) do not satisfy 1 > 1, so nothing ever matches. Locks the + // strict-> semantics. + assert.strictEqual(sameCommentSpan(ml(8, 10), ml(8, 10), 1), false); +} + +function testResolveThreshold() { + // Valid values in (0, 1] pass through unchanged. + assert.strictEqual(resolveThreshold(0.6), 0.6); + assert.strictEqual(resolveThreshold(0.5), 0.5); + assert.strictEqual(resolveThreshold(1), 1); + // Numeric strings are accepted (mirrors parseFloat(action input)). + assert.strictEqual(resolveThreshold("0.4"), 0.4); + // Out-of-range values fall back to the default. + assert.strictEqual(resolveThreshold(0), DEFAULT_OVERLAP_THRESHOLD); + assert.strictEqual(resolveThreshold(-0.5), DEFAULT_OVERLAP_THRESHOLD); + assert.strictEqual(resolveThreshold(1.5), DEFAULT_OVERLAP_THRESHOLD); + // Non-numeric / missing values fall back to the default. + assert.strictEqual(resolveThreshold(NaN), DEFAULT_OVERLAP_THRESHOLD); + assert.strictEqual(resolveThreshold("abc"), DEFAULT_OVERLAP_THRESHOLD); + assert.strictEqual(resolveThreshold(undefined), DEFAULT_OVERLAP_THRESHOLD); + assert.strictEqual(resolveThreshold(null), DEFAULT_OVERLAP_THRESHOLD); +} + +function testOverlapsHistory() { + // Rule 2: single-line, same line => overlap; different line => no overlap. + const sl = [{ path: "a.js", line: 9, side: "RIGHT" }]; + assert.strictEqual(overlapsHistory({ path: "a.js", line: 9, start_line: 9, side: "RIGHT" }, sl), true); + assert.strictEqual(overlapsHistory({ path: "a.js", line: 20, start_line: 20, side: "RIGHT" }, sl), false); + // Rule 1: single-line vs multi-line never overlap. + const ml = [{ path: "a.js", line: 10, start_line: 8, side: "RIGHT" }]; + assert.strictEqual(overlapsHistory({ path: "a.js", line: 9, start_line: 9, side: "RIGHT" }, ml), false); + // Rule 3: multi-line IoU vs default threshold 0.6. + assert.strictEqual(overlapsHistory({ path: "a.js", line: 10, start_line: 8, side: "RIGHT" }, ml), true); + assert.strictEqual(overlapsHistory({ path: "a.js", line: 11, start_line: 9, side: "RIGHT" }, ml), false); + assert.strictEqual(overlapsHistory({ path: "a.js", line: 9, start_line: 8, side: "RIGHT" }, ml), true); + // Threshold argument lowers the bar (IoU 0.5 > 0.4). + assert.strictEqual(overlapsHistory({ path: "a.js", line: 11, start_line: 9, side: "RIGHT" }, ml, 0.4), true); + // Different path and LEFT-side history are still ignored. + assert.strictEqual(overlapsHistory({ path: "b.js", line: 10, start_line: 8, side: "RIGHT" }, ml), false); + const leftHist = [{ path: "a.js", line: 10, start_line: 8, side: "LEFT" }]; + assert.strictEqual(overlapsHistory({ path: "a.js", line: 10, start_line: 8, side: "RIGHT" }, leftHist), false); + // An unresolvable current comment (no usable line) never overlaps. + assert.strictEqual(overlapsHistory({ path: "a.js", side: "RIGHT" }, ml), false); + // Unresolvable history entries are skipped, not fatal: a later valid entry + // on the same path can still match. + const mixedHist = [ + { path: "a.js", side: "RIGHT" }, // no line info -> lineSpan null + { path: "a.js", line: 9, side: "RIGHT" }, // single-line 9 + ]; + assert.strictEqual(overlapsHistory({ path: "a.js", line: 9, start_line: 9, side: "RIGHT" }, mixedHist), true); + // Any-of semantics: multiple history entries, a match on any one wins. + const multiHist = [ + { path: "a.js", line: 5, start_line: 5, side: "RIGHT" }, // no match + { path: "a.js", line: 10, start_line: 8, side: "RIGHT" }, // matches [8,10] + ]; + assert.strictEqual(overlapsHistory({ path: "a.js", line: 10, start_line: 8, side: "RIGHT" }, multiHist), true); + // A history entry with no side field still participates (falsy side bypasses + // the RIGHT-only guard). + const noSideHist = [{ path: "a.js", line: 9 }]; + assert.strictEqual(overlapsHistory({ path: "a.js", line: 9, start_line: 9, side: "RIGHT" }, noSideHist), true); + // An invalid threshold falls back to the default (IoU 0.5 < 0.6 -> no match). + assert.strictEqual(overlapsHistory({ path: "a.js", line: 11, start_line: 9, side: "RIGHT" }, ml, "garbage"), false); +} + +async function main() { + await testFailedInlineCommentsAreSummarized(); + await testWarningsListedAfterSummaryComments(); + await testErrorCommentUsesSafeFence(); + await testStickyUpdatesExistingSummary(); + await testNonStickyCreatesNewCommentOnFallback(); + await testNonStickyFallbackAllSuccessStillPostsSummary(); + await testNoCommentsStickyUpdate(); + await testIncrementalSkipsOverlapping(); + await testIncrementalAllOverlapPostsNoReview(); + await testIncrementalMultiLineIoUDefaultThreshold(); + await testIncrementalOverlapThresholdPropagated(); + // Idempotency + await testBatchLandedRetriesOnlyMissingComments(); + await testPerComment5xxAlreadyPostedTreatedAsSuccess(); + await testPerComment5xxIdempotencyUnavailableSkipsRetry(); + await testSummaryDoesNotDuplicateWhenAlreadyPosted(); + await testSummaryAnchorCreatedBeforeReviewColdStart(); + await testSummaryAnchorCreatedBeforeReviewNonSticky(); + await testGetPostedCommentIdsExtractsEmbeddedIds(); + // Rate-limit strategy (pure function) + testComputeRetryDelayMs(); + // Cross-scenario: rate-limit x partial-invalid x landed + await testBatchRateLimitWithPartialInvalidContent(); + await testPerCommentRateLimitRetryThenSuccessAndExhausted(); + await testBatchLandedWithPerCommentPartialInvalid(); + await testBatchLandedWithPerCommentMixedStates(); + await testNetworkErrorLandedRecoveredAndNotLandedFailed(); + await testBatchIdempotencyCheckFailureDegradesToFullRetry(); + await testLowQuotaProactiveThrottleDoesNotBreakFlow(); + await testBatchRateLimitSkipsIdempotencyReads(); + await testBatchReadRateLimitRetriedViaWithRetry(); + // Pure helpers + testSafeFenceAndFencedBlock(); + testFormatWarnings(); + testLineSpan(); + testSameCommentSpan(); + testResolveThreshold(); + testOverlapsHistory(); + testNewCommentIdFormat(); + console.log("All post-review-comments tests passed."); +} + main().catch((err) => { console.error(err); process.exit(1); From daf6365cd33a54cb9202aa2c45e96e71a3cc044f Mon Sep 17 00:00:00 2001 From: Lei Zhang Date: Thu, 9 Jul 2026 19:53:23 +0800 Subject: [PATCH 17/19] chore(action): set author to alibaba (#344) --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index adb9777..203b2e2 100644 --- a/action.yml +++ b/action.yml @@ -2,7 +2,7 @@ name: OpenCodeReview PR Review description: >- AI-powered GitHub PR review with inline comments, sticky summary, and incremental non-destructive posting. -author: stay-foolish-forever +author: alibaba branding: icon: eye color: green From 8ef497c619caabebef5c2e3222e6c7647e7b5799 Mon Sep 17 00:00:00 2001 From: oo0-0-0oo <117795446+oo0-0-0oo@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:04:18 +0800 Subject: [PATCH 18/19] feat: add Python code review rules (#343) * feat: add Python code review rules Add python.md rule doc for reviewing .py files and wire "**/*.py" into the system rule map so Python files no longer fall back to default.md. Rules follow the precision-first house style: security/correctness are blocking, style is not, and noise-prone sections carry explicit "do not report" guards. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: align identity-comparison rule with precision-first principle Split the identity/equality section so severity is explicit: `is` against literals and `== True/False` are real correctness risks, while `== None` vs `is None` is a style preference reported as minor. This removes the section's conflict with the doc's own "style is non-blocking" header. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- internal/config/rules/rule_docs/python.md | 76 ++++++++++++++++++++++ internal/config/rules/system_rules.json | 3 +- internal/config/rules/system_rules_test.go | 3 +- 3 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 internal/config/rules/rule_docs/python.md diff --git a/internal/config/rules/rule_docs/python.md b/internal/config/rules/rule_docs/python.md new file mode 100644 index 0000000..644d2a0 --- /dev/null +++ b/internal/config/rules/rule_docs/python.md @@ -0,0 +1,76 @@ +> Favor precision over recall: only raise an issue when you are confident it is a real defect, and stay silent when the surrounding context is unclear — a false alarm costs more reviewer trust than a missed minor issue. Treat security and correctness findings as blocking, and style or idiom suggestions as non-blocking. + +#### Obvious Typos or Spelling Errors +- Spelling errors in variable, function, class, or module names at their declaration sites; do not report spelling errors at reference sites, as these are determined by the declaration +- Strings in log messages or exception messages containing spelling errors that affect readability + +#### Dead Code +- Code blocks that can never be reached (e.g., branches where the condition is always false, code after a `return`, `raise`, `break`, or `continue`) +- Variables, imports, or function parameters that are declared but never read or referenced +- Large blocks of commented-out code with no apparent intent to preserve + +#### Mutable Default Arguments and Shared State +- Mutable default arguments such as `def f(x=[])` or `def f(x={})`; the default is created once and shared across every call. Default to `None` and build the value inside the body +- Class-level mutable attributes shared unintentionally across instances when a per-instance value was intended +- Module-level mutable globals (lists, dicts, caches) mutated across requests or threads, retaining state in ways that surprise the caller +- Closures that capture a loop variable by reference and all end up seeing its final value +- Do not report when the function never mutates the argument, or when the shared default is a deliberate, documented cache or sentinel + +#### Boundary and Edge-Case Handling +- Empty inputs assumed to be non-empty: indexing `xs[0]`, `max()`/`min()`, or slicing without first handling the empty `list`, `str`, `dict`, or iterator +- Off-by-one and out-of-range access on indices, ranges, or slices, especially at the first/last element +- `None` reaching code that assumes a value, when an upstream call or default can legitimately return `None` (confirm the data source with `file_read` before flagging) +- Comparing floats for exact equality with `==`; use `math.isclose` or an explicit tolerance, since floating-point results are not exact +- Integer/float and division assumptions: unintended truncation with `//`, or `ZeroDivisionError` when a divisor can be zero +- Heterogeneous or unexpected element types in a collection that the code assumes are uniform (e.g., mixing `None`, numbers, and strings) +- Dictionary access by key without handling the missing-key case (`d[k]` vs `d.get(k)`), or set/dict operations that assume a key is present +- Do not report edge cases that a caller or type contract has already ruled out, or inputs that cannot occur given validated boundaries upstream + +#### Error Handling and Exceptions +- Bare `except:` swallows everything, including `KeyboardInterrupt` and `SystemExit`; catch `except Exception` at minimum, and prefer the specific exception types you expect +- `except Exception` that is still broader than the failure being handled; narrow it to the exceptions actually raised by the guarded call +- Exceptions caught and silently discarded (`pass`) without logging or re-raising +- Original traceback lost when re-raising; prefer `raise NewError(...) from err` to preserve the cause +- Broad `try` blocks that wrap far more than the line that can actually fail, hiding where the error originates +- `assert` used for runtime validation of external input — assertions are stripped under `python -O` + +#### Identity and Equality Comparisons +- Using `is`/`is not` to compare against literals such as strings, numbers, or tuples; this relies on implementation-specific interning rather than value equality — use `==` (a real correctness risk) +- Comparing against `True`/`False` with `==`, where a truthy-but-not-`True` value (e.g. `1`, a non-empty container) would compare unequal; prefer a plain truthiness check +- Reserve `is` for identity checks against singletons and sentinels +- Comparing against `None` with `==`/`!=` rather than `is`/`is not` is a style preference; report as minor, not blocking + +#### Resource Management +- Files, sockets, locks, or database connections opened without a `with` statement, risking leaks on early return or exception +- Context managers available but bypassed in favor of manual `open()`/`close()` pairs +- Resources acquired in a `try` whose `finally` cleanup is missing or incomplete on the error path +- Iterators or generators holding resources open longer than necessary +- Do not report short-lived scripts, or handles already managed by an enclosing `with` or framework-managed lifecycle (confirm the surrounding scope with `file_read` before flagging) + +#### Performance +Confirm data scale and that the code is on a hot path before flagging: +- Building strings with `+=` in a loop instead of accumulating in a list and `"".join(...)`, or using an f-string +- Repeated membership tests against a `list` where a `set` or `dict` would turn O(n) lookups into O(1) +- Building a full list when a generator would avoid holding everything in memory +- Recomputing inside a loop a value that is invariant across iterations (e.g., compiling a regex, attribute lookups in hot paths) +- Passing an eagerly formatted f-string to `logging` (e.g., `logging.info(f"...")`) instead of `logging.info("%s", value)`, which defeats lazy formatting when the level is disabled + +#### Concurrency and Async +Only flag concurrency issues when there is evidence of multi-threaded, multi-process, or async invocation (confirm the call context before reporting): +- CPU-bound work parallelized with `threading` under the GIL where `multiprocessing` or a process pool is the right tool (traditional CPython; free-threaded builds excepted); I/O-bound work is the case threads actually help +- Check-then-act races on shared state without a `Lock`, or non-atomic compound updates assumed to be atomic +- Blocking calls (synchronous I/O, `time.sleep`, `requests`, CPU-heavy work) inside `async def`, stalling the event loop; use the async equivalent or run them in an executor +- `asyncio` tasks created and never awaited, so exceptions are swallowed and the work may be garbage-collected before it finishes +- Shared mutable state across threads or tasks without synchronization or a thread-safe structure + +Do not report local variables (each thread has its own), read-only access to shared data, or code with no evidence of concurrent use. + +#### Security-Sensitive Code +Validate the data source before flagging; confirm the input is actually attacker-controlled rather than a trusted constant: +- `eval`, `exec`, or `compile` on untrusted input; this is arbitrary code execution +- `subprocess` with `shell=True` built from unsanitized input; pass an argument list and avoid the shell +- `pickle`, `marshal`, or `yaml.load` (without `SafeLoader`) on untrusted data; deserialization can execute arbitrary code +- SQL built by string concatenation or f-strings instead of parameterized queries +- Secrets, tokens, passwords, or PII written to logs or committed in source +- Weak or misused cryptography (`hashlib.md5`/`sha1` for passwords, `random` for security tokens); use `secrets` and vetted libraries +- Untrusted file paths joined without validation, allowing path traversal diff --git a/internal/config/rules/system_rules.json b/internal/config/rules/system_rules.json index 1f6bc4b..b16518f 100644 --- a/internal/config/rules/system_rules.json +++ b/internal/config/rules/system_rules.json @@ -18,6 +18,7 @@ "**/*.{kt}": "kotlin.md", "**/*.rs": "rust.md", "**/*.{cpp,cc,hpp}": "cpp.md", - "**/*.c": "c.md" + "**/*.c": "c.md", + "**/*.py": "python.md" } } diff --git a/internal/config/rules/system_rules_test.go b/internal/config/rules/system_rules_test.go index 5412382..cf54f3a 100644 --- a/internal/config/rules/system_rules_test.go +++ b/internal/config/rules/system_rules_test.go @@ -80,6 +80,8 @@ func TestResolve_DefaultRules(t *testing.T) { {"src/lib.rs", "Ownership and Lifetime Correctness"}, {"crates/service/src/main.rs", "Unsafe Code Boundaries"}, {"crates/service/Cargo.toml", "Cargo Manifest Hygiene"}, + {"scripts/deploy.py", "Mutable Default Arguments"}, + {"src/app/main.py", "Mutable Default Arguments"}, } for _, tt := range tests { @@ -104,7 +106,6 @@ func TestResolve_FallbackToDefault(t *testing.T) { "docs/architecture.txt", "Makefile", "internal/agent/agent.go", - "scripts/deploy.py", "ios/ViewController.m", } From fbc11045bde7e37fee6268119cb9949a9a3474d1 Mon Sep 17 00:00:00 2001 From: kite Date: Thu, 9 Jul 2026 20:30:08 +0800 Subject: [PATCH 19/19] docs(pages): split install and version commands into separate code blocks (#345) Separate `npm install` and `ocr version` into individual code blocks in quickstart guides so each command can be copied independently. --- pages/src/content/docs/en/quickstart.md | 3 +++ pages/src/content/docs/ja/quickstart.md | 3 +++ pages/src/content/docs/zh/quickstart.md | 3 +++ 3 files changed, 9 insertions(+) diff --git a/pages/src/content/docs/en/quickstart.md b/pages/src/content/docs/en/quickstart.md index 7ab60e3..d2a97d0 100644 --- a/pages/src/content/docs/en/quickstart.md +++ b/pages/src/content/docs/en/quickstart.md @@ -16,6 +16,9 @@ Get your first code review running in a few minutes. ```bash npm install -g @alibaba-group/open-code-review +``` + +```bash ocr version ``` diff --git a/pages/src/content/docs/ja/quickstart.md b/pages/src/content/docs/ja/quickstart.md index a29e594..00c9778 100644 --- a/pages/src/content/docs/ja/quickstart.md +++ b/pages/src/content/docs/ja/quickstart.md @@ -16,6 +16,9 @@ sidebar: ```bash npm install -g @alibaba-group/open-code-review +``` + +```bash ocr version ``` diff --git a/pages/src/content/docs/zh/quickstart.md b/pages/src/content/docs/zh/quickstart.md index 10f8753..a7954b3 100644 --- a/pages/src/content/docs/zh/quickstart.md +++ b/pages/src/content/docs/zh/quickstart.md @@ -16,6 +16,9 @@ sidebar: ```bash npm install -g @alibaba-group/open-code-review +``` + +```bash ocr version ```