From ab69188c9c5960e6fbc7d9d7694451262a96f444 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 15:59:52 +0900 Subject: [PATCH 01/34] fix(review): cite trusted path:line in GitHub 422 inline fallback When GitHub refuses inline review comments, the PR-level fallback now lists each sanitized current-head finding location instead of a generic sentence. Suggested diffs stay out of the body. --- .../workflows/opencode-review-dispatch.yml | 12 +- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 54 ++++++ .../ci/opencode_inline_comment_fallback.py | 133 ++++++++++++++ scripts/ci/test_strix_quick_gate.sh | 2 + tests/test_opencode_agent_contract.py | 5 + .../test_opencode_inline_comment_fallback.py | 171 ++++++++++++++++++ 7 files changed, 372 insertions(+), 6 deletions(-) create mode 100644 docs/doctoring/review-inline-comment-422-fallback.md create mode 100644 scripts/ci/opencode_inline_comment_fallback.py create mode 100644 tests/test_opencode_inline_comment_fallback.py diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 83f6830d5..7a72d4a94 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -5766,12 +5766,12 @@ jobs: build_inline_comment_failure_body() { local body_file="$1" local output_file="$2" + local control_json="$3" - { - cat "$body_file" - printf '\n## Inline comment publishing failed\n\n' - printf 'GitHub did not accept the inline review comments for the cited finding lines, so OpenCode did not copy suggested diffs into this PR-level body. Re-run the review after the findings are anchored to changed diff lines, or inspect the workflow log/control JSON and apply the changes manually.\n' - } >"$output_file" + python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ + --control "$control_json" \ + --body "$body_file" \ + --output "$output_file" } publish_request_changes_from_control() { @@ -5785,7 +5785,7 @@ jobs: fallback_body_file="$(mktemp)" format_request_changes_body "$control_json" "$body_file" build_request_changes_review_payload "$control_json" "$body_file" "$payload_file" - build_inline_comment_failure_body "$body_file" "$fallback_body_file" + build_inline_comment_failure_body "$body_file" "$fallback_body_file" "$control_json" create_pull_review_with_payload "REQUEST_CHANGES" "$(cat "$body_file")" "$payload_file" "$fallback_body_file" rm -f "$body_file" "$payload_file" "$fallback_body_file" } diff --git a/CHANGELOG.md b/CHANGELOG.md index bf30091dd..dac72d56d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Named each trusted `path:line` in the OpenCode GitHub 422 inline-comment fallback so a refused attach still tells the author the exact current-head location instead of a generic “cited finding lines” sentence. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. - Bound each review-agent invocation key to the wrapper's complete canonical payload, including the base branch and requesting actor; altered fields with a valid-format key now fail before durable-leader election or forwarding, and wrapper write permission is job-scoped. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md new file mode 100644 index 000000000..6375bf801 --- /dev/null +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -0,0 +1,54 @@ +# GitHub 422 inline-comment fallback cites trusted path:line + +검토 기준일: **2026-08-13** + +## Incident + +When GitHub rejects an OpenCode `REQUEST_CHANGES` review because one or more +inline comments cannot attach, the publisher already falls back to a PR-level +body and does not copy suggested diffs into that body. The fallback sentence +said only “the cited finding lines.” Authors then had to open the workflow log +or control JSON to learn *which* `path:line` GitHub refused (GitHub, n.d.-a, +n.d.-b). That is weaker than the line-anchored review artifact modern code +review expects (Bacchelli & Bird, 2013). + +## Decision + +`scripts/ci/opencode_inline_comment_fallback.py` reads the trusted control +JSON, keeps first-seen safe relative `path` plus positive integer `line` +pairs, and appends them to the fallback body as `` `path:line` `` list +items. Unsafe paths (`..`, absolute, drive, backslash) and non-positive +lines are omitted. An empty location set is stated explicitly. + +The publisher calls this helper from `build_inline_comment_failure_body` +with the same control object used to build the inline `comments` array. +Suggested diffs stay out of the PR-level body. + +## Verification contract + +- `tests/test_opencode_inline_comment_fallback.py` pins safe-pair extraction, + the exact location list, the empty-set sentence, CLI success, and fail-closed + unreadable control input. +- `tests/test_opencode_agent_contract.py` and + `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with + `$control_json`. + +## Rollback + +If GitHub later accepts off-diff comments, keep citing the attempted +`path:line` in the fallback. Do not restore a location-free sentence. + +## References (APA 7th) + +Bacchelli, A., & Bird, C. (2013). Expectations, outcomes, and challenges of +modern code review. In *Proceedings of the 35th International Conference on +Software Engineering* (pp. 712–721). IEEE. +https://doi.org/10.1109/ICSE.2013.6606617 + +GitHub. (n.d.-a). *Create a review for a pull request*. GitHub Docs. Retrieved +August 13, 2026, from +https://docs.github.com/en/rest/pulls/reviews#create-a-review-for-a-pull-request + +GitHub. (n.d.-b). *Create a review comment for a pull request*. GitHub Docs. +Retrieved August 13, 2026, from +https://docs.github.com/en/rest/pulls/comments#create-a-review-comment-for-a-pull-request diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py new file mode 100644 index 000000000..c9ab8f328 --- /dev/null +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Render a GitHub 422 inline-comment fallback that cites trusted path:line.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path, PurePosixPath, PureWindowsPath +from typing import Any + + +def safe_finding_path(raw_path: object) -> str | None: + """Return a repository-relative finding path, or None when it is unsafe.""" + if not isinstance(raw_path, str): + return None + path = raw_path.strip() + posix_path = PurePosixPath(path) + windows_path = PureWindowsPath(path) + if ( + not path + or "\\" in path + or path.startswith(("/", "//")) + or posix_path.is_absolute() + or windows_path.is_absolute() + or bool(windows_path.drive) + or ".." in posix_path.parts + or path != posix_path.as_posix() + ): + return None + return path + + +def safe_finding_line(raw_line: object) -> int | None: + """Return a positive integer finding line, or None when it is not one.""" + if isinstance(raw_line, bool) or not isinstance(raw_line, int) or raw_line <= 0: + return None + return raw_line + + +def trusted_finding_locations(control: dict[str, Any]) -> list[tuple[str, int]]: + """Return unique sanitized finding path:line pairs in first-seen order.""" + findings = control.get("findings") + if not isinstance(findings, list): + return [] + locations: list[tuple[str, int]] = [] + seen: set[tuple[str, int]] = set() + for finding in findings: + if not isinstance(finding, dict): + continue + path = safe_finding_path(finding.get("path")) + line = safe_finding_line(finding.get("line")) + if path is None or line is None: + continue + location = (path, line) + if location in seen: + continue + seen.add(location) + locations.append(location) + return locations + + +def render_inline_comment_failure_suffix(locations: list[tuple[str, int]]) -> str: + """Return the PR-body suffix used when GitHub rejects inline comments.""" + lines = [ + "", + "## Inline comment publishing failed", + "", + ] + if locations: + lines.append( + "GitHub did not accept the inline review comments for these " + "trusted current-head finding locations:" + ) + lines.append("") + lines.extend(f"- `{path}:{line}`" for path, line in locations) + lines.append("") + lines.append( + "OpenCode did not copy suggested diffs into this PR-level body. " + "Re-run the review after those exact path:line anchors sit on " + "current-head changed hunks, or inspect the workflow log/control " + "JSON and apply the changes manually." + ) + else: + lines.append( + "GitHub did not accept the inline review comments, and the " + "control JSON had no trusted path:line findings. Inspect the " + "workflow log and apply any remaining blockers from the review " + "body manually." + ) + lines.append("") + return "\n".join(lines) + + +def render_inline_comment_failure_body(body: str, control: dict[str, Any]) -> str: + """Append the 422 fallback suffix to an existing REQUEST_CHANGES body.""" + return body.rstrip("\n") + render_inline_comment_failure_suffix( + trusted_finding_locations(control) + ) + + +def load_control(path: Path) -> dict[str, Any]: + """Load one trusted review-control JSON object.""" + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError(f"control JSON could not be read: {exc}") from exc + if not isinstance(value, dict): + raise ValueError("control JSON must be an object") + return value + + +def main(argv: list[str] | None = None) -> int: + """Write a REQUEST_CHANGES body plus the exact path:line 422 suffix.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--control", required=True, type=Path) + parser.add_argument("--body", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args(argv) + try: + control = load_control(args.control) + body = args.body.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError, ValueError) as exc: + print(exc, file=sys.stderr) + return 2 + args.output.write_text( + render_inline_comment_failure_body(body, control), encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover - exercised through runpy CLI test + raise SystemExit(main()) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 7343c06ac..0507fafde 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1475,6 +1475,8 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$workflow_file" "comments: [" "opencode review payload includes inline review comments" assert_file_contains "$workflow_file" '#### Suggested diff\n```diff\n' "opencode review puts suggested diffs inside inline review comments" assert_file_contains "$workflow_file" "GitHub did not accept the inline review comments" "opencode review explains anchor failures instead of copying diffs to the PR body" + assert_file_contains "$workflow_file" "opencode_inline_comment_fallback.py" "opencode 422 fallback cites trusted path:line via the dedicated helper" + assert_file_contains "$workflow_file" 'build_inline_comment_failure_body "$body_file" "$fallback_body_file" "$control_json"' "opencode 422 fallback receives the trusted control JSON" assert_file_contains "$workflow_file" "publish_request_changes_from_control" "opencode review REQUEST_CHANGES path publishes findings from the control JSON" if awk '/format_request_changes_body\(\)/,/build_request_changes_review_payload\(\)/ { print }' "$workflow_file" | diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index daeaa37a2..c3d9978ff 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1609,6 +1609,11 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): 'post_pull_review_with_retry "inline review" "$review_write_token"' in publish_step ) + assert "opencode_inline_comment_fallback.py" in workflow + assert ( + 'build_inline_comment_failure_body "$body_file" "$fallback_body_file" "$control_json"' + in workflow + ) assert "OPENCODE_EXHAUSTED_REKICK_" not in publish_step assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "10800"' not in publish_step assert "steps.opencode_review_model_pool.outcome == 'success'" not in workflow diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py new file mode 100644 index 000000000..6ee60354d --- /dev/null +++ b/tests/test_opencode_inline_comment_fallback.py @@ -0,0 +1,171 @@ +import json +import runpy +import sys + +import pytest + +from scripts.ci.opencode_inline_comment_fallback import ( + main, + render_inline_comment_failure_body, + trusted_finding_locations, +) + + +def control(*findings: dict[str, object]) -> dict[str, object]: + """Return a REQUEST_CHANGES control object for fallback tests.""" + return { + "result": "REQUEST_CHANGES", + "findings": list(findings), + } + + +def test_trusted_finding_locations_keeps_first_safe_path_line_pairs(): + locations = trusted_finding_locations( + control( + {"path": "scripts/ci/example.py", "line": 7}, + {"path": 12, "line": 1}, + {"path": "../escape.py", "line": 1}, + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "/abs.py", "line": 3}, + {"path": "scripts/ci/other.py", "line": 0}, + {"path": "scripts/ci/other.py", "line": True}, + {"path": "scripts/ci/other.py", "line": 12}, + "not-an-object", + ) + ) + + assert locations == [ + ("scripts/ci/example.py", 7), + ("scripts/ci/other.py", 12), + ] + assert trusted_finding_locations({"findings": None}) == [] + assert trusted_finding_locations({}) == [] + + +def test_fallback_body_cites_each_trusted_path_line(): + body = render_inline_comment_failure_body( + "## Findings\n\nexisting body\n", + control( + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "README.md", "line": 3}, + ), + ) + + assert body.startswith("## Findings\n\nexisting body") + assert "GitHub did not accept the inline review comments" in body + assert "- `scripts/ci/example.py:7`" in body + assert "- `README.md:3`" in body + assert "did not copy suggested diffs into this PR-level body" in body + + +def test_fallback_body_explains_missing_trusted_locations(): + body = render_inline_comment_failure_body("overview\n", control()) + + assert "GitHub did not accept the inline review comments" in body + assert "no trusted path:line findings" in body + assert "- `" not in body + + +def test_cli_writes_fallback_and_rejects_unreadable_control(tmp_path, monkeypatch): + control_path = tmp_path / "control.json" + body_path = tmp_path / "body.md" + output_path = tmp_path / "fallback.md" + control_path.write_text( + json.dumps( + control({"path": "scripts/ci/example.py", "line": 7}), + ), + encoding="utf-8", + ) + body_path.write_text("## Findings\n", encoding="utf-8") + + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(output_path), + ] + ) + == 0 + ) + written = output_path.read_text(encoding="utf-8") + assert "- `scripts/ci/example.py:7`" in written + + assert ( + main( + [ + "--control", + str(tmp_path / "missing.json"), + "--body", + str(body_path), + "--output", + str(output_path), + ] + ) + == 2 + ) + bad_json = tmp_path / "list.json" + bad_json.write_text("[]", encoding="utf-8") + assert ( + main( + [ + "--control", + str(bad_json), + "--body", + str(body_path), + "--output", + str(output_path), + ] + ) + == 2 + ) + broken = tmp_path / "broken.json" + broken.write_text("{", encoding="utf-8") + assert ( + main( + [ + "--control", + str(broken), + "--body", + str(body_path), + "--output", + str(output_path), + ] + ) + == 2 + ) + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(tmp_path / "missing-body.md"), + "--output", + str(output_path), + ] + ) + == 2 + ) + + monkeypatch.setattr( + sys, + "argv", + [ + "opencode_inline_comment_fallback.py", + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(output_path), + ], + ) + with pytest.raises(SystemExit) as excinfo: + runpy.run_path( + "scripts/ci/opencode_inline_comment_fallback.py", run_name="__main__" + ) + assert excinfo.value.code == 0 From e099c28cf2d371df376c2f38dee05c309decf90b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 16:07:34 +0900 Subject: [PATCH 02/34] fix(review): persist 422 inline failures as overview receipts Rebuild the fallback from gh api stderr after a refused attach so the OpenCode overview keeps each trusted path:line next to the GitHub 422 phrase instead of a location-only list. --- .../workflows/opencode-review-dispatch.yml | 22 +++- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 13 +- .../ci/opencode_inline_comment_fallback.py | 100 ++++++++++++++- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 5 + .../test_opencode_inline_comment_fallback.py | 120 ++++++++++++++++++ 7 files changed, 249 insertions(+), 13 deletions(-) diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 7a72d4a94..9f7355a66 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -5625,6 +5625,8 @@ jobs: create_pull_review_with_payload() { local event="$1" body="$2" review_payload_file="$3" fallback_body_file="$4" + local source_body_file="${5:-}" + local control_json="${6:-}" local gh_error_file local rewritten_payload_file local review_response_file @@ -5640,6 +5642,10 @@ jobs: emit_review_body_to_action_log "$event" "$body" "$review_payload_file" if ! post_pull_review_with_retry "inline review" "$review_write_token" "$review_payload_file" "$gh_error_file" "$review_response_file"; then warn_gh_publication_failure "pull review inline comments" "$gh_error_file" + if [ -n "$source_body_file" ] && [ -n "$control_json" ]; then + build_inline_comment_failure_body \ + "$source_body_file" "$fallback_body_file" "$control_json" "$gh_error_file" || true + fi rm -f "$gh_error_file" "$review_response_file" if [ "${REVIEW_PUBLICATION_STALE_HEAD:-}" = "1" ]; then printf '::error::OpenCode inline review publication stopped because PR head advanced beyond %s.\n' "$HEAD_SHA" @@ -5767,11 +5773,19 @@ jobs: local body_file="$1" local output_file="$2" local control_json="$3" + local error_file="${4:-}" + local -a fallback_args - python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ - --control "$control_json" \ - --body "$body_file" \ + fallback_args=( + python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" + --control "$control_json" + --body "$body_file" --output "$output_file" + ) + if [ -n "$error_file" ]; then + fallback_args+=(--error-file "$error_file") + fi + "${fallback_args[@]}" } publish_request_changes_from_control() { @@ -5786,7 +5800,7 @@ jobs: format_request_changes_body "$control_json" "$body_file" build_request_changes_review_payload "$control_json" "$body_file" "$payload_file" build_inline_comment_failure_body "$body_file" "$fallback_body_file" "$control_json" - create_pull_review_with_payload "REQUEST_CHANGES" "$(cat "$body_file")" "$payload_file" "$fallback_body_file" + create_pull_review_with_payload "REQUEST_CHANGES" "$(cat "$body_file")" "$payload_file" "$fallback_body_file" "$body_file" "$control_json" rm -f "$body_file" "$payload_file" "$fallback_body_file" } diff --git a/CHANGELOG.md b/CHANGELOG.md index dac72d56d..a175254c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Stored each refused OpenCode inline comment as a durable overview receipt that pairs the trusted `path:line` with the GitHub 422 error phrase from `gh api` stderr or JSON `errors[].message`. - Named each trusted `path:line` in the OpenCode GitHub 422 inline-comment fallback so a refused attach still tells the author the exact current-head location instead of a generic “cited finding lines” sentence. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. - Allowed commas and ASCII parentheses in the bounded Strix changed-file path policy so legal tracked Packrat fixtures can receive exact-head security analysis, while rejecting raw `..` components before normalization and keeping controls, backslashes, whitespace ambiguity, and shell punctuation fail-closed. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 6375bf801..f683d6540 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -20,6 +20,14 @@ pairs, and appends them to the fallback body as `` `path:line` `` list items. Unsafe paths (`..`, absolute, drive, backslash) and non-positive lines are omitted. An empty location set is stated explicitly. +After a refused attach, the publisher rebuilds the fallback from the +`gh api` error file and writes durable receipts into the OpenCode +overview comment (``). Each receipt is +`` `path:line` — GitHub HTTP 422: ``. The phrase prefers JSON +`errors[].message` (for example `pull_request_review_thread.path is +invalid`) and otherwise the first `HTTP 422` line. URLs are stripped and +the phrase is bounded to 240 characters. + The publisher calls this helper from `build_inline_comment_failure_body` with the same control object used to build the inline `comments` array. Suggested diffs stay out of the PR-level body. @@ -27,8 +35,9 @@ Suggested diffs stay out of the PR-level body. ## Verification contract - `tests/test_opencode_inline_comment_fallback.py` pins safe-pair extraction, - the exact location list, the empty-set sentence, CLI success, and fail-closed - unreadable control input. + the exact location list, GitHub JSON `errors[].message` phrases, HTTP 422 + line fallback, empty-set sentence, CLI success with `--error-file`, and + fail-closed unreadable control or error input. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index c9ab8f328..6eaa9411a 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -5,10 +5,14 @@ import argparse import json +import re import sys from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Any +ERROR_PHRASE_MAX_CHARS = 240 +HTTP_422_LINE_RE = re.compile(r"(?im)^(?:gh:\s*)?(.*HTTP 422.*)$") + def safe_finding_path(raw_path: object) -> str | None: """Return a repository-relative finding path, or None when it is unsafe.""" @@ -60,11 +64,78 @@ def trusted_finding_locations(control: dict[str, Any]) -> list[tuple[str, int]]: return locations -def render_inline_comment_failure_suffix(locations: list[tuple[str, int]]) -> str: +def _collapse_error_text(text: str) -> str: + """Return one-line error text without URLs or extra whitespace.""" + without_urls = re.sub(r"https?://\S+", "", text) + return " ".join(without_urls.split()) + + +def github_publication_error_phrase(text: str) -> str: + """Return a bounded GitHub 422 phrase from ``gh api`` stderr or JSON.""" + raw = text or "" + messages: list[str] = [] + seen: set[str] = set() + decoder = json.JSONDecoder() + index = 0 + while index < len(raw): + start = raw.find("{", index) + if start < 0: + break + try: + value, consumed = decoder.raw_decode(raw[start:]) + except json.JSONDecodeError: + index = start + 1 + continue + index = start + consumed + errors = value.get("errors") + if not isinstance(errors, list): + continue + for item in errors: + if not isinstance(item, dict) or not isinstance(item.get("message"), str): + continue + message = _collapse_error_text(item["message"]) + if not message or message in seen: + continue + seen.add(message) + messages.append(message) + if messages: + return f"GitHub HTTP 422: {'; '.join(messages)}"[:ERROR_PHRASE_MAX_CHARS] + match = HTTP_422_LINE_RE.search(raw) + if match: + line = _collapse_error_text(match.group(1)) + if line.casefold().startswith("github http 422"): + return line[:ERROR_PHRASE_MAX_CHARS] + return f"GitHub HTTP 422: {line}".rstrip(": ")[:ERROR_PHRASE_MAX_CHARS] + if "422" in raw: + return "GitHub HTTP 422" + return "GitHub review write failed" + + +def render_inline_comment_receipts( + locations: list[tuple[str, int]], error_phrase: str +) -> list[str]: + """Return durable overview receipt lines for refused inline comments.""" + if not locations: + return [] + if error_phrase: + return [f"- `{path}:{line}` — {error_phrase}" for path, line in locations] + return [f"- `{path}:{line}`" for path, line in locations] + + +def render_inline_comment_failure_suffix( + locations: list[tuple[str, int]], + *, + error_phrase: str = "", +) -> str: """Return the PR-body suffix used when GitHub rejects inline comments.""" + heading = ( + "## Inline comment publication receipts" + if error_phrase + else "## Inline comment publishing failed" + ) lines = [ "", - "## Inline comment publishing failed", + heading, "", ] if locations: @@ -73,7 +144,7 @@ def render_inline_comment_failure_suffix(locations: list[tuple[str, int]]) -> st "trusted current-head finding locations:" ) lines.append("") - lines.extend(f"- `{path}:{line}`" for path, line in locations) + lines.extend(render_inline_comment_receipts(locations, error_phrase)) lines.append("") lines.append( "OpenCode did not copy suggested diffs into this PR-level body. " @@ -88,14 +159,24 @@ def render_inline_comment_failure_suffix(locations: list[tuple[str, int]]) -> st "workflow log and apply any remaining blockers from the review " "body manually." ) + if error_phrase: + lines.append("") + lines.append(f"- GitHub error: {error_phrase}") lines.append("") return "\n".join(lines) -def render_inline_comment_failure_body(body: str, control: dict[str, Any]) -> str: +def render_inline_comment_failure_body( + body: str, + control: dict[str, Any], + *, + error_text: str = "", +) -> str: """Append the 422 fallback suffix to an existing REQUEST_CHANGES body.""" + error_phrase = github_publication_error_phrase(error_text) if error_text else "" return body.rstrip("\n") + render_inline_comment_failure_suffix( - trusted_finding_locations(control) + trusted_finding_locations(control), + error_phrase=error_phrase, ) @@ -111,20 +192,25 @@ def load_control(path: Path) -> dict[str, Any]: def main(argv: list[str] | None = None) -> int: - """Write a REQUEST_CHANGES body plus the exact path:line 422 suffix.""" + """Write a REQUEST_CHANGES body plus path:line receipts and optional 422 phrase.""" parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--control", required=True, type=Path) parser.add_argument("--body", required=True, type=Path) parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--error-file", type=Path) args = parser.parse_args(argv) try: control = load_control(args.control) body = args.body.read_text(encoding="utf-8") + error_text = ( + args.error_file.read_text(encoding="utf-8") if args.error_file else "" + ) except (OSError, UnicodeDecodeError, ValueError) as exc: print(exc, file=sys.stderr) return 2 args.output.write_text( - render_inline_comment_failure_body(body, control), encoding="utf-8" + render_inline_comment_failure_body(body, control, error_text=error_text), + encoding="utf-8", ) return 0 diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 0507fafde..9e8039879 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1477,6 +1477,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$workflow_file" "GitHub did not accept the inline review comments" "opencode review explains anchor failures instead of copying diffs to the PR body" assert_file_contains "$workflow_file" "opencode_inline_comment_fallback.py" "opencode 422 fallback cites trusted path:line via the dedicated helper" assert_file_contains "$workflow_file" 'build_inline_comment_failure_body "$body_file" "$fallback_body_file" "$control_json"' "opencode 422 fallback receives the trusted control JSON" + assert_file_contains "$workflow_file" 'fallback_args+=(--error-file "$error_file")' "opencode 422 overview receipt includes the GitHub error file" assert_file_contains "$workflow_file" "publish_request_changes_from_control" "opencode review REQUEST_CHANGES path publishes findings from the control JSON" if awk '/format_request_changes_body\(\)/,/build_request_changes_review_payload\(\)/ { print }' "$workflow_file" | diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index c3d9978ff..23f82bf05 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1614,6 +1614,11 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): 'build_inline_comment_failure_body "$body_file" "$fallback_body_file" "$control_json"' in workflow ) + assert ( + 'create_pull_review_with_payload "REQUEST_CHANGES" "$(cat "$body_file")" "$payload_file" "$fallback_body_file" "$body_file" "$control_json"' + in workflow + ) + assert 'fallback_args+=(--error-file "$error_file")' in workflow assert "OPENCODE_EXHAUSTED_REKICK_" not in publish_step assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "10800"' not in publish_step assert "steps.opencode_review_model_pool.outcome == 'success'" not in workflow diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 6ee60354d..c08ee840b 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -5,8 +5,10 @@ import pytest from scripts.ci.opencode_inline_comment_fallback import ( + github_publication_error_phrase, main, render_inline_comment_failure_body, + render_inline_comment_receipts, trusted_finding_locations, ) @@ -58,12 +60,88 @@ def test_fallback_body_cites_each_trusted_path_line(): assert "did not copy suggested diffs into this PR-level body" in body +def test_github_publication_error_phrase_prefers_json_error_messages(): + phrase = github_publication_error_phrase( + "gh: HTTP 422: Unprocessable Entity " + "(https://api.github.com/repos/org/repo/pulls/1/reviews)\n" + '{"message":"Validation Failed","errors":[' + '{"resource":"PullRequestReview","field":"comments","code":"custom",' + '"message":"pull_request_review_thread.path is invalid"},' + '{"message":"Review comments is invalid"}' + "]}\n" + ) + + assert phrase.startswith("GitHub HTTP 422:") + assert "pull_request_review_thread.path is invalid" in phrase + assert "Review comments is invalid" in phrase + assert "https://api.github.com" not in phrase + + +def test_github_publication_error_phrase_falls_back_to_http_line(): + assert ( + github_publication_error_phrase( + "post failed\ngh: Validation Failed (HTTP 422)\n" + ) + == "GitHub HTTP 422: Validation Failed (HTTP 422)" + ) + assert ( + github_publication_error_phrase("GitHub HTTP 422: already normalized\n") + == "GitHub HTTP 422: already normalized" + ) + assert github_publication_error_phrase("status code 422 only") == "GitHub HTTP 422" + assert ( + github_publication_error_phrase("https://api.github.example/HTTP 422") + == "GitHub HTTP 422: 422" + ) + assert render_inline_comment_receipts([], "GitHub HTTP 422") == [] + assert github_publication_error_phrase("") == "GitHub review write failed" + assert ( + github_publication_error_phrase("secondary rate limit") + == "GitHub review write failed" + ) + assert github_publication_error_phrase("{") == "GitHub review write failed" + assert ( + github_publication_error_phrase('{"errors":"not-a-list","message":"x"}') + == "GitHub review write failed" + ) + assert ( + github_publication_error_phrase('{"errors":[{"code":"custom"}]}') + == "GitHub review write failed" + ) + assert ( + github_publication_error_phrase('{"errors":[1,{"message":""}]}') + == "GitHub review write failed" + ) + + +def test_fallback_body_attaches_error_phrase_to_each_receipt(): + body = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/example.py", "line": 7}), + error_text=( + '{"errors":[{"message":"Line could not be resolved"}]}' + ), + ) + + assert "## Inline comment publication receipts" in body + assert ( + "- `scripts/ci/example.py:7` — GitHub HTTP 422: Line could not be resolved" + in body + ) + + def test_fallback_body_explains_missing_trusted_locations(): body = render_inline_comment_failure_body("overview\n", control()) assert "GitHub did not accept the inline review comments" in body assert "no trusted path:line findings" in body assert "- `" not in body + with_error = render_inline_comment_failure_body( + "overview\n", + control(), + error_text='{"errors":[{"message":"Review comments is invalid"}]}', + ) + assert "GitHub error: GitHub HTTP 422: Review comments is invalid" in with_error def test_cli_writes_fallback_and_rejects_unreadable_control(tmp_path, monkeypatch): @@ -94,6 +172,33 @@ def test_cli_writes_fallback_and_rejects_unreadable_control(tmp_path, monkeypatc written = output_path.read_text(encoding="utf-8") assert "- `scripts/ci/example.py:7`" in written + error_path = tmp_path / "gh-error.txt" + error_path.write_text( + '{"errors":[{"message":"pull_request_review_thread.path is invalid"}]}\n', + encoding="utf-8", + ) + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(output_path), + "--error-file", + str(error_path), + ] + ) + == 0 + ) + written = output_path.read_text(encoding="utf-8") + assert ( + "- `scripts/ci/example.py:7` — GitHub HTTP 422: " + "pull_request_review_thread.path is invalid" + in written + ) + assert ( main( [ @@ -150,6 +255,21 @@ def test_cli_writes_fallback_and_rejects_unreadable_control(tmp_path, monkeypatc ) == 2 ) + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(output_path), + "--error-file", + str(tmp_path / "missing-error.txt"), + ] + ) + == 2 + ) monkeypatch.setattr( sys, From d37885d88c8854faaf2edf706d30e20b015eec28 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 16:13:42 +0900 Subject: [PATCH 03/34] fix(review): retry inline comments one at a time after batch 422 A single invalid path:line 422s the whole comments array. After that failure, split the payload and retry each comment so surviving hunks still attach; remaining failures keep the overview receipts. --- .../workflows/opencode-review-dispatch.yml | 55 ++++++++ CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 16 ++- .../ci/opencode_inline_comment_fallback.py | 102 +++++++++++++- scripts/ci/test_strix_quick_gate.sh | 2 + tests/test_opencode_agent_contract.py | 3 + .../test_opencode_inline_comment_fallback.py | 128 ++++++++++++++++++ 7 files changed, 298 insertions(+), 9 deletions(-) diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 9f7355a66..59017c88f 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -5623,6 +5623,52 @@ jobs: exit 0 } + retry_inline_comments_one_at_a_time() { + local batch_payload_file="$1" review_body="$2" + local split_dir comment_file wrapped_file error_file response_file + local attached=0 + local found=0 + + split_dir="$(mktemp -d)" + if ! python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ + --split-payload "$batch_payload_file" \ + --output-dir "$split_dir"; then + rm -rf "$split_dir" + return 1 + fi + for comment_file in "$split_dir"/comment-*.json; do + [ -f "$comment_file" ] || continue + found=1 + wrapped_file="$(mktemp)" + error_file="$(mktemp)" + response_file="$(mktemp)" + if [ "$attached" -eq 0 ]; then + jq --arg body "$review_body" --arg event "REQUEST_CHANGES" \ + '.event = $event | .body = $body' "$comment_file" >"$wrapped_file" + else + cp "$comment_file" "$wrapped_file" + fi + if post_pull_review_with_retry \ + "inline review one-at-a-time" \ + "$review_write_token" \ + "$wrapped_file" \ + "$error_file" \ + "$response_file"; then + attached=1 + fi + rm -f "$wrapped_file" "$error_file" "$response_file" + if [ "${REVIEW_PUBLICATION_STALE_HEAD:-}" = "1" ]; then + rm -rf "$split_dir" + return 1 + fi + done + rm -rf "$split_dir" + if [ "$found" -eq 0 ] || [ "$attached" -eq 0 ]; then + return 1 + fi + return 0 + } + create_pull_review_with_payload() { local event="$1" body="$2" review_payload_file="$3" fallback_body_file="$4" local source_body_file="${5:-}" @@ -5642,6 +5688,15 @@ jobs: emit_review_body_to_action_log "$event" "$body" "$review_payload_file" if ! post_pull_review_with_retry "inline review" "$review_write_token" "$review_payload_file" "$gh_error_file" "$review_response_file"; then warn_gh_publication_failure "pull review inline comments" "$gh_error_file" + if [ "${REVIEW_PUBLICATION_STALE_HEAD:-}" != "1" ] \ + && python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ + --is-unprocessable --error-file "$gh_error_file"; then + if retry_inline_comments_one_at_a_time "$review_payload_file" "$body"; then + rm -f "$gh_error_file" "$review_response_file" + update_review_overview "$event" "$body" + return 0 + fi + fi if [ -n "$source_body_file" ] && [ -n "$control_json" ]; then build_inline_comment_failure_body \ "$source_body_file" "$fallback_body_file" "$control_json" "$gh_error_file" || true diff --git a/CHANGELOG.md b/CHANGELOG.md index a175254c6..c3f91113b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- After a batch GitHub 422, retried OpenCode inline comments one at a time so comments on surviving hunks still attach instead of dropping the entire review thread. - Stored each refused OpenCode inline comment as a durable overview receipt that pairs the trusted `path:line` with the GitHub 422 error phrase from `gh api` stderr or JSON `errors[].message`. - Named each trusted `path:line` in the OpenCode GitHub 422 inline-comment fallback so a refused attach still tells the author the exact current-head location instead of a generic “cited finding lines” sentence. - Bounded the Strix quality self-test's deterministic timeout fixtures to 3-second process and 5-second fake-sleep budgets so exact-head policy evidence completes inside the existing job limit without changing production Strix scanner timeouts, providers, credentials, or review semantics. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index f683d6540..35a3e1d21 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -20,9 +20,14 @@ pairs, and appends them to the fallback body as `` `path:line` `` list items. Unsafe paths (`..`, absolute, drive, backslash) and non-positive lines are omitted. An empty location set is stated explicitly. -After a refused attach, the publisher rebuilds the fallback from the -`gh api` error file and writes durable receipts into the OpenCode -overview comment (``). Each receipt is +After a refused attach, the publisher first checks that the failure is +HTTP 422, splits the batch `comments` array into single-comment review +payloads, and retries each with the same write helper. The first success +uses `REQUEST_CHANGES` plus the review body; later successes use +`COMMENT`. Survivors therefore still appear on Files changed. Remaining +failures still rebuild the fallback from the `gh api` error file and +write durable receipts into the OpenCode overview comment +(``). Each receipt is `` `path:line` — GitHub HTTP 422: ``. The phrase prefers JSON `errors[].message` (for example `pull_request_review_thread.path is invalid`) and otherwise the first `HTTP 422` line. URLs are stripped and @@ -36,8 +41,9 @@ Suggested diffs stay out of the PR-level body. - `tests/test_opencode_inline_comment_fallback.py` pins safe-pair extraction, the exact location list, GitHub JSON `errors[].message` phrases, HTTP 422 - line fallback, empty-set sentence, CLI success with `--error-file`, and - fail-closed unreadable control or error input. + line fallback, empty-set sentence, CLI success with `--error-file`, + fail-closed unreadable control or error input, batch-to-single comment + splitting, and `--is-unprocessable` classification. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 6eaa9411a..143cf39ad 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -111,6 +111,84 @@ def github_publication_error_phrase(text: str) -> str: return "GitHub review write failed" +def github_error_is_unprocessable(text: str) -> bool: + """Return whether GitHub rejected the review write as HTTP 422.""" + raw = text or "" + if "422" in raw or "Unprocessable Entity" in raw: + return True + return "422" in github_publication_error_phrase(raw) + + +def iter_single_comment_payloads(payload: dict[str, Any]) -> list[dict[str, Any]]: + """Return safe single-comment slices from a batch review payload.""" + comments = payload.get("comments") + commit_id = payload.get("commit_id") + if not isinstance(comments, list) or not isinstance(commit_id, str): + return [] + commit_id = commit_id.strip() + if not commit_id: + return [] + singles: list[dict[str, Any]] = [] + for comment in comments: + if not isinstance(comment, dict): + continue + path = safe_finding_path(comment.get("path")) + line = safe_finding_line(comment.get("line")) + body = comment.get("body") + if path is None or line is None or not isinstance(body, str) or not body.strip(): + continue + side = comment.get("side") + singles.append( + { + "path": path, + "line": line, + "side": side if side in {"LEFT", "RIGHT"} else "RIGHT", + "body": body, + "commit_id": commit_id, + } + ) + return singles + + +def render_single_comment_review( + item: dict[str, Any], + *, + event: str, + review_body: str, +) -> dict[str, Any]: + """Return one GitHub review payload that carries a single inline comment.""" + return { + "event": event, + "body": review_body, + "commit_id": item["commit_id"], + "comments": [ + { + "path": item["path"], + "line": item["line"], + "side": item["side"], + "body": item["body"], + } + ], + } + + +def write_single_comment_payloads(payload: dict[str, Any], output_dir: Path) -> int: + """Write COMMENT-event single-comment payloads and return the file count.""" + output_dir.mkdir(parents=True, exist_ok=True) + count = 0 + for index, item in enumerate(iter_single_comment_payloads(payload)): + path = output_dir / f"comment-{index:03d}.json" + path.write_text( + json.dumps( + render_single_comment_review(item, event="COMMENT", review_body=""), + ensure_ascii=True, + ), + encoding="utf-8", + ) + count += 1 + return count + + def render_inline_comment_receipts( locations: list[tuple[str, int]], error_phrase: str ) -> list[str]: @@ -192,14 +270,30 @@ def load_control(path: Path) -> dict[str, Any]: def main(argv: list[str] | None = None) -> int: - """Write a REQUEST_CHANGES body plus path:line receipts and optional 422 phrase.""" + """Write 422 fallback text or split a batch review into single comments.""" parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--control", required=True, type=Path) - parser.add_argument("--body", required=True, type=Path) - parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--control", type=Path) + parser.add_argument("--body", type=Path) + parser.add_argument("--output", type=Path) parser.add_argument("--error-file", type=Path) + parser.add_argument("--split-payload", type=Path) + parser.add_argument("--output-dir", type=Path) + parser.add_argument("--is-unprocessable", action="store_true") args = parser.parse_args(argv) try: + if args.is_unprocessable: + if args.error_file is None: + raise ValueError("--error-file is required with --is-unprocessable") + error_text = args.error_file.read_text(encoding="utf-8") + return 0 if github_error_is_unprocessable(error_text) else 1 + if args.split_payload is not None: + if args.output_dir is None: + raise ValueError("--output-dir is required with --split-payload") + payload = load_control(args.split_payload) + write_single_comment_payloads(payload, args.output_dir) + return 0 + if args.control is None or args.body is None or args.output is None: + raise ValueError("--control, --body, and --output are required") control = load_control(args.control) body = args.body.read_text(encoding="utf-8") error_text = ( diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 9e8039879..74c26fe88 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1478,6 +1478,8 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$workflow_file" "opencode_inline_comment_fallback.py" "opencode 422 fallback cites trusted path:line via the dedicated helper" assert_file_contains "$workflow_file" 'build_inline_comment_failure_body "$body_file" "$fallback_body_file" "$control_json"' "opencode 422 fallback receives the trusted control JSON" assert_file_contains "$workflow_file" 'fallback_args+=(--error-file "$error_file")' "opencode 422 overview receipt includes the GitHub error file" + assert_file_contains "$workflow_file" "retry_inline_comments_one_at_a_time" "opencode retries inline comments one at a time after batch 422" + assert_file_contains "$workflow_file" "inline review one-at-a-time" "opencode one-at-a-time retries use the bounded review-write helper" assert_file_contains "$workflow_file" "publish_request_changes_from_control" "opencode review REQUEST_CHANGES path publishes findings from the control JSON" if awk '/format_request_changes_body\(\)/,/build_request_changes_review_payload\(\)/ { print }' "$workflow_file" | diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 23f82bf05..0d7a6881a 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1619,6 +1619,9 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): in workflow ) assert 'fallback_args+=(--error-file "$error_file")' in workflow + assert "retry_inline_comments_one_at_a_time" in workflow + assert "--is-unprocessable" in workflow + assert "inline review one-at-a-time" in workflow assert "OPENCODE_EXHAUSTED_REKICK_" not in publish_step assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "10800"' not in publish_step assert "steps.opencode_review_model_pool.outcome == 'success'" not in workflow diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index c08ee840b..f32b1fc2c 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -5,10 +5,13 @@ import pytest from scripts.ci.opencode_inline_comment_fallback import ( + github_error_is_unprocessable, github_publication_error_phrase, + iter_single_comment_payloads, main, render_inline_comment_failure_body, render_inline_comment_receipts, + render_single_comment_review, trusted_finding_locations, ) @@ -289,3 +292,128 @@ def test_cli_writes_fallback_and_rejects_unreadable_control(tmp_path, monkeypatc "scripts/ci/opencode_inline_comment_fallback.py", run_name="__main__" ) assert excinfo.value.code == 0 + + +def test_github_error_is_unprocessable_detects_real_422_bodies(): + assert github_error_is_unprocessable( + '{"message":"Validation Failed","errors":[' + '{"message":"pull_request_review_thread.path is invalid"}]}' + ) + assert github_error_is_unprocessable("gh: HTTP 422: Unprocessable Entity") + assert not github_error_is_unprocessable("Resource not accessible by integration") + assert not github_error_is_unprocessable("") + + +def test_iter_single_comment_payloads_keeps_only_safe_comments(): + payload = { + "event": "REQUEST_CHANGES", + "body": "review body", + "commit_id": "a" * 40, + "comments": [ + { + "path": "scripts/ci/example.py", + "line": 7, + "side": "RIGHT", + "body": "first", + }, + {"path": "../escape.py", "line": 1, "body": "bad"}, + {"path": "scripts/ci/other.py", "line": 12, "body": "second"}, + {"path": "scripts/ci/plain.py", "line": 4, "body": "no-side"}, + "not-an-object", + {"path": "scripts/ci/empty.py", "line": 3, "body": " "}, + ], + } + + singles = iter_single_comment_payloads(payload) + assert [(item["path"], item["line"], item["side"]) for item in singles] == [ + ("scripts/ci/example.py", 7, "RIGHT"), + ("scripts/ci/other.py", 12, "RIGHT"), + ("scripts/ci/plain.py", 4, "RIGHT"), + ] + first = render_single_comment_review( + singles[0], event="REQUEST_CHANGES", review_body="review body" + ) + assert first["event"] == "REQUEST_CHANGES" + assert first["body"] == "review body" + assert first["comments"] == [ + { + "path": "scripts/ci/example.py", + "line": 7, + "side": "RIGHT", + "body": "first", + } + ] + later = render_single_comment_review( + singles[1], event="COMMENT", review_body="" + ) + assert later["event"] == "COMMENT" + assert later["body"] == "" + assert later["comments"][0]["path"] == "scripts/ci/other.py" + assert iter_single_comment_payloads({"comments": []}) == [] + assert iter_single_comment_payloads({"comments": "bad"}) == [] + assert iter_single_comment_payloads({"commit_id": "", "comments": [{}]}) == [] + + +def test_cli_splits_batch_payload_into_single_comment_files(tmp_path): + payload = tmp_path / "batch.json" + payload.write_text( + json.dumps( + { + "event": "REQUEST_CHANGES", + "body": "review body", + "commit_id": "b" * 40, + "comments": [ + { + "path": "scripts/ci/example.py", + "line": 7, + "side": "RIGHT", + "body": "first", + }, + { + "path": "scripts/ci/other.py", + "line": 12, + "side": "LEFT", + "body": "second", + }, + ], + } + ), + encoding="utf-8", + ) + output_dir = tmp_path / "singles" + + assert ( + main( + [ + "--split-payload", + str(payload), + "--output-dir", + str(output_dir), + ] + ) + == 0 + ) + files = sorted(output_dir.glob("comment-*.json")) + assert [path.name for path in files] == ["comment-000.json", "comment-001.json"] + first = json.loads(files[0].read_text(encoding="utf-8")) + assert first["event"] == "COMMENT" + assert first["comments"][0]["line"] == 7 + assert ( + main( + [ + "--split-payload", + str(tmp_path / "missing-batch.json"), + "--output-dir", + str(output_dir), + ] + ) + == 2 + ) + assert main(["--split-payload", str(payload)]) == 2 + error_path = tmp_path / "422.txt" + error_path.write_text("gh: HTTP 422: Unprocessable Entity\n", encoding="utf-8") + assert main(["--is-unprocessable", "--error-file", str(error_path)]) == 0 + error_path.write_text("Resource not accessible by integration\n", encoding="utf-8") + assert main(["--is-unprocessable", "--error-file", str(error_path)]) == 1 + assert main(["--is-unprocessable"]) == 2 + assert main([]) == 2 From 154a33d092e0ce23f5299981bef5fe12cc8cab41 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 16:18:33 +0900 Subject: [PATCH 04/34] test(review): pin 422 fallback sentence in the Python helper The publisher moved that phrase out of the workflow YAML, so the exact-head path-policy harness failed looking in the old file. --- scripts/ci/test_strix_quick_gate.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 74c26fe88..801e16584 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1474,7 +1474,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$workflow_file" "create_pull_review_with_payload" "opencode review can post custom review payloads" assert_file_contains "$workflow_file" "comments: [" "opencode review payload includes inline review comments" assert_file_contains "$workflow_file" '#### Suggested diff\n```diff\n' "opencode review puts suggested diffs inside inline review comments" - assert_file_contains "$workflow_file" "GitHub did not accept the inline review comments" "opencode review explains anchor failures instead of copying diffs to the PR body" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "GitHub did not accept the inline review comments" "opencode review explains anchor failures instead of copying diffs to the PR body" assert_file_contains "$workflow_file" "opencode_inline_comment_fallback.py" "opencode 422 fallback cites trusted path:line via the dedicated helper" assert_file_contains "$workflow_file" 'build_inline_comment_failure_body "$body_file" "$fallback_body_file" "$control_json"' "opencode 422 fallback receives the trusted control JSON" assert_file_contains "$workflow_file" 'fallback_args+=(--error-file "$error_file")' "opencode 422 overview receipt includes the GitHub error file" From dc261ff834a81f1fbd45e16bde1c86fe6c870c40 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 16:27:51 +0900 Subject: [PATCH 05/34] fix(review): receipt only refused path:line after mixed 422 retry When some one-at-a-time inline comments attach and others 422, the overview must list only the refused locations so attached hunks are not reported as failed. --- .../workflows/opencode-review-dispatch.yml | 34 +++++++- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 7 +- .../ci/opencode_inline_comment_fallback.py | 68 +++++++++++++-- scripts/ci/test_strix_quick_gate.sh | 2 + tests/test_opencode_agent_contract.py | 2 + .../test_opencode_inline_comment_fallback.py | 83 +++++++++++++++++++ 7 files changed, 184 insertions(+), 13 deletions(-) diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 59017c88f..83b4273eb 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -5624,11 +5624,12 @@ jobs: } retry_inline_comments_one_at_a_time() { - local batch_payload_file="$1" review_body="$2" + local batch_payload_file="$1" review_body="$2" refused_locations_file="$3" local split_dir comment_file wrapped_file error_file response_file local attached=0 local found=0 + : >"$refused_locations_file" split_dir="$(mktemp -d)" if ! python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ --split-payload "$batch_payload_file" \ @@ -5655,6 +5656,12 @@ jobs: "$error_file" \ "$response_file"; then attached=1 + else + jq -r '.comments[0] | "\(.path):\(.line)"' "$comment_file" \ + >>"$refused_locations_file" || true + if [ -s "$error_file" ]; then + cat "$error_file" >>"${refused_locations_file}.errors" + fi fi rm -f "$wrapped_file" "$error_file" "$response_file" if [ "${REVIEW_PUBLICATION_STALE_HEAD:-}" = "1" ]; then @@ -5691,11 +5698,26 @@ jobs: if [ "${REVIEW_PUBLICATION_STALE_HEAD:-}" != "1" ] \ && python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ --is-unprocessable --error-file "$gh_error_file"; then - if retry_inline_comments_one_at_a_time "$review_payload_file" "$body"; then - rm -f "$gh_error_file" "$review_response_file" - update_review_overview "$event" "$body" + refused_locations_file="$(mktemp)" + if retry_inline_comments_one_at_a_time \ + "$review_payload_file" "$body" "$refused_locations_file"; then + if [ -s "$refused_locations_file" ] && [ -n "$source_body_file" ] && [ -n "$control_json" ]; then + mixed_error_file="$gh_error_file" + if [ -s "${refused_locations_file}.errors" ]; then + mixed_error_file="${refused_locations_file}.errors" + fi + build_inline_comment_failure_body \ + "$source_body_file" "$fallback_body_file" "$control_json" \ + "$mixed_error_file" "$refused_locations_file" || true + update_review_overview "$event" "$(cat "$fallback_body_file")" + else + update_review_overview "$event" "$body" + fi + rm -f "$gh_error_file" "$review_response_file" \ + "$refused_locations_file" "${refused_locations_file}.errors" return 0 fi + rm -f "$refused_locations_file" "${refused_locations_file}.errors" fi if [ -n "$source_body_file" ] && [ -n "$control_json" ]; then build_inline_comment_failure_body \ @@ -5829,6 +5851,7 @@ jobs: local output_file="$2" local control_json="$3" local error_file="${4:-}" + local refused_locations_file="${5:-}" local -a fallback_args fallback_args=( @@ -5840,6 +5863,9 @@ jobs: if [ -n "$error_file" ]; then fallback_args+=(--error-file "$error_file") fi + if [ -n "$refused_locations_file" ]; then + fallback_args+=(--refused-locations "$refused_locations_file") + fi "${fallback_args[@]}" } diff --git a/CHANGELOG.md b/CHANGELOG.md index c3f91113b..0509adc41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- After a mixed one-at-a-time inline retry, listed only the refused `path:line` rows in the overview receipts so attached hunks are not reported as failed. - After a batch GitHub 422, retried OpenCode inline comments one at a time so comments on surviving hunks still attach instead of dropping the entire review thread. - Stored each refused OpenCode inline comment as a durable overview receipt that pairs the trusted `path:line` with the GitHub 422 error phrase from `gh api` stderr or JSON `errors[].message`. - Named each trusted `path:line` in the OpenCode GitHub 422 inline-comment fallback so a refused attach still tells the author the exact current-head location instead of a generic “cited finding lines” sentence. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 35a3e1d21..237b906a2 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -27,7 +27,9 @@ uses `REQUEST_CHANGES` plus the review body; later successes use `COMMENT`. Survivors therefore still appear on Files changed. Remaining failures still rebuild the fallback from the `gh api` error file and write durable receipts into the OpenCode overview comment -(``). Each receipt is +(``). On mixed success the receipt list +contains only refused `path:line` rows, not the comments that already +attached. Each receipt is `` `path:line` — GitHub HTTP 422: ``. The phrase prefers JSON `errors[].message` (for example `pull_request_review_thread.path is invalid`) and otherwise the first `HTTP 422` line. URLs are stripped and @@ -43,7 +45,8 @@ Suggested diffs stay out of the PR-level body. the exact location list, GitHub JSON `errors[].message` phrases, HTTP 422 line fallback, empty-set sentence, CLI success with `--error-file`, fail-closed unreadable control or error input, batch-to-single comment - splitting, and `--is-unprocessable` classification. + splitting, `--is-unprocessable` classification, and mixed-success + receipts that omit attached path:line rows. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 143cf39ad..96a4fac2c 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -64,6 +64,31 @@ def trusted_finding_locations(control: dict[str, Any]) -> list[tuple[str, int]]: return locations +def parse_refused_locations(text: str) -> list[tuple[str, int]]: + """Parse ``path:line`` rows from one-at-a-time retry failures.""" + locations: list[tuple[str, int]] = [] + seen: set[tuple[str, int]] = set() + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#") or ":" not in line: + continue + path_text, _, line_text = line.rpartition(":") + path = safe_finding_path(path_text) + try: + parsed_line = int(line_text) + except ValueError: + parsed_line = 0 + line_number = safe_finding_line(parsed_line) + if path is None or line_number is None: + continue + location = (path, line_number) + if location in seen: + continue + seen.add(location) + locations.append(location) + return locations + + def _collapse_error_text(text: str) -> str: """Return one-line error text without URLs or extra whitespace.""" without_urls = re.sub(r"https?://\S+", "", text) @@ -204,11 +229,12 @@ def render_inline_comment_failure_suffix( locations: list[tuple[str, int]], *, error_phrase: str = "", + mixed_success: bool = False, ) -> str: """Return the PR-body suffix used when GitHub rejects inline comments.""" heading = ( "## Inline comment publication receipts" - if error_phrase + if error_phrase or mixed_success else "## Inline comment publishing failed" ) lines = [ @@ -217,10 +243,16 @@ def render_inline_comment_failure_suffix( "", ] if locations: - lines.append( - "GitHub did not accept the inline review comments for these " - "trusted current-head finding locations:" - ) + if mixed_success: + lines.append( + "GitHub accepted some inline comments. These trusted " + "current-head finding locations were still refused:" + ) + else: + lines.append( + "GitHub did not accept the inline review comments for these " + "trusted current-head finding locations:" + ) lines.append("") lines.extend(render_inline_comment_receipts(locations, error_phrase)) lines.append("") @@ -249,12 +281,23 @@ def render_inline_comment_failure_body( control: dict[str, Any], *, error_text: str = "", + refused_locations: list[tuple[str, int]] | None = None, ) -> str: """Append the 422 fallback suffix to an existing REQUEST_CHANGES body.""" error_phrase = github_publication_error_phrase(error_text) if error_text else "" + if refused_locations is None: + locations = trusted_finding_locations(control) + mixed_success = False + else: + allowed = set(trusted_finding_locations(control)) + locations = [item for item in refused_locations if item in allowed] + mixed_success = True + if not locations: + return body.rstrip("\n") + "\n" return body.rstrip("\n") + render_inline_comment_failure_suffix( - trusted_finding_locations(control), + locations, error_phrase=error_phrase, + mixed_success=mixed_success, ) @@ -279,6 +322,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--split-payload", type=Path) parser.add_argument("--output-dir", type=Path) parser.add_argument("--is-unprocessable", action="store_true") + parser.add_argument("--refused-locations", type=Path) args = parser.parse_args(argv) try: if args.is_unprocessable: @@ -299,11 +343,21 @@ def main(argv: list[str] | None = None) -> int: error_text = ( args.error_file.read_text(encoding="utf-8") if args.error_file else "" ) + refused_locations = ( + parse_refused_locations(args.refused_locations.read_text(encoding="utf-8")) + if args.refused_locations is not None + else None + ) except (OSError, UnicodeDecodeError, ValueError) as exc: print(exc, file=sys.stderr) return 2 args.output.write_text( - render_inline_comment_failure_body(body, control, error_text=error_text), + render_inline_comment_failure_body( + body, + control, + error_text=error_text, + refused_locations=refused_locations, + ), encoding="utf-8", ) return 0 diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 801e16584..7d555c84c 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1475,11 +1475,13 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$workflow_file" "comments: [" "opencode review payload includes inline review comments" assert_file_contains "$workflow_file" '#### Suggested diff\n```diff\n' "opencode review puts suggested diffs inside inline review comments" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "GitHub did not accept the inline review comments" "opencode review explains anchor failures instead of copying diffs to the PR body" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "accepted some inline comments" "opencode mixed-success receipts distinguish attached and refused comments" assert_file_contains "$workflow_file" "opencode_inline_comment_fallback.py" "opencode 422 fallback cites trusted path:line via the dedicated helper" assert_file_contains "$workflow_file" 'build_inline_comment_failure_body "$body_file" "$fallback_body_file" "$control_json"' "opencode 422 fallback receives the trusted control JSON" assert_file_contains "$workflow_file" 'fallback_args+=(--error-file "$error_file")' "opencode 422 overview receipt includes the GitHub error file" assert_file_contains "$workflow_file" "retry_inline_comments_one_at_a_time" "opencode retries inline comments one at a time after batch 422" assert_file_contains "$workflow_file" "inline review one-at-a-time" "opencode one-at-a-time retries use the bounded review-write helper" + assert_file_contains "$workflow_file" '--refused-locations "$refused_locations_file"' "opencode mixed-success receipts pass only refused path:line rows" assert_file_contains "$workflow_file" "publish_request_changes_from_control" "opencode review REQUEST_CHANGES path publishes findings from the control JSON" if awk '/format_request_changes_body\(\)/,/build_request_changes_review_payload\(\)/ { print }' "$workflow_file" | diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 0d7a6881a..dcc06546d 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1622,6 +1622,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "retry_inline_comments_one_at_a_time" in workflow assert "--is-unprocessable" in workflow assert "inline review one-at-a-time" in workflow + assert '--refused-locations "$refused_locations_file"' in workflow + assert "accepted some inline comments" not in workflow assert "OPENCODE_EXHAUSTED_REKICK_" not in publish_step assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "10800"' not in publish_step assert "steps.opencode_review_model_pool.outcome == 'success'" not in workflow diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index f32b1fc2c..9c240fef5 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -9,6 +9,7 @@ github_publication_error_phrase, iter_single_comment_payloads, main, + parse_refused_locations, render_inline_comment_failure_body, render_inline_comment_receipts, render_single_comment_review, @@ -133,6 +134,40 @@ def test_fallback_body_attaches_error_phrase_to_each_receipt(): ) +def test_mixed_success_receipts_list_only_refused_path_lines(): + refused = parse_refused_locations( + "scripts/ci/other.py:12\n# note\n../escape.py:1\n" + "scripts/ci/example.py:0\nbadline\nscripts/ci/other.py:12\n" + "scripts/ci/skip.py:x\n" + ) + assert refused == [("scripts/ci/other.py", 12)] + + body = render_inline_comment_failure_body( + "## Findings\nattached example.py:7\n", + control( + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "scripts/ci/other.py", "line": 12}, + ), + error_text='{"errors":[{"message":"Line could not be resolved"}]}', + refused_locations=refused, + ) + + assert "accepted some inline comments" in body + assert ( + "- `scripts/ci/other.py:12` — GitHub HTTP 422: Line could not be resolved" + in body + ) + assert "scripts/ci/example.py:7`" not in body + assert parse_refused_locations("") == [] + all_attached = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/example.py", "line": 7}), + refused_locations=[], + ) + assert "were still refused" not in all_attached + assert "did not accept the inline review comments" not in all_attached + + def test_fallback_body_explains_missing_trusted_locations(): body = render_inline_comment_failure_body("overview\n", control()) @@ -201,6 +236,54 @@ def test_cli_writes_fallback_and_rejects_unreadable_control(tmp_path, monkeypatc "pull_request_review_thread.path is invalid" in written ) + two_findings = tmp_path / "two.json" + two_findings.write_text( + json.dumps( + control( + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "scripts/ci/other.py", "line": 12}, + ) + ), + encoding="utf-8", + ) + refused_path = tmp_path / "refused.txt" + refused_path.write_text("scripts/ci/other.py:12\n", encoding="utf-8") + assert ( + main( + [ + "--control", + str(two_findings), + "--body", + str(body_path), + "--output", + str(output_path), + "--error-file", + str(error_path), + "--refused-locations", + str(refused_path), + ] + ) + == 0 + ) + mixed = output_path.read_text(encoding="utf-8") + assert "accepted some inline comments" in mixed + assert "`scripts/ci/other.py:12`" in mixed + assert "`scripts/ci/example.py:7`" not in mixed + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(output_path), + "--refused-locations", + str(tmp_path / "missing-refused.txt"), + ] + ) + == 2 + ) assert ( main( From 94ee03d507b014ba73840f9fca9052d60c75d0ee Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 16:34:11 +0900 Subject: [PATCH 06/34] fix(review): keep each refused comment's own GitHub 422 phrase Mixed one-at-a-time retries can fail for different reasons. Record path:line plus that comment's gh api error so the overview does not reuse one shared sentence for every refused hunk. --- .../workflows/opencode-review-dispatch.yml | 7 +- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 14 +- .../ci/opencode_inline_comment_fallback.py | 128 +++++++++++-- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 1 + .../test_opencode_inline_comment_fallback.py | 177 ++++++++++++++++++ 7 files changed, 303 insertions(+), 26 deletions(-) diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 83b4273eb..e09f88fdc 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -5657,8 +5657,11 @@ jobs: "$response_file"; then attached=1 else - jq -r '.comments[0] | "\(.path):\(.line)"' "$comment_file" \ - >>"$refused_locations_file" || true + python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ + --record-refusal \ + --refused-locations "$refused_locations_file" \ + --comment-file "$comment_file" \ + --error-file "$error_file" || true if [ -s "$error_file" ]; then cat "$error_file" >>"${refused_locations_file}.errors" fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 0509adc41..7050a3e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Kept each refused OpenCode inline comment's own GitHub 422 phrase next to its `path:line` so mixed retries do not collapse every failure into one shared error sentence. - After a mixed one-at-a-time inline retry, listed only the refused `path:line` rows in the overview receipts so attached hunks are not reported as failed. - After a batch GitHub 422, retried OpenCode inline comments one at a time so comments on surviving hunks still attach instead of dropping the entire review thread. - Stored each refused OpenCode inline comment as a durable overview receipt that pairs the trusted `path:line` with the GitHub 422 error phrase from `gh api` stderr or JSON `errors[].message`. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 237b906a2..5a6a747e0 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -29,11 +29,12 @@ failures still rebuild the fallback from the `gh api` error file and write durable receipts into the OpenCode overview comment (``). On mixed success the receipt list contains only refused `path:line` rows, not the comments that already -attached. Each receipt is -`` `path:line` — GitHub HTTP 422: ``. The phrase prefers JSON -`errors[].message` (for example `pull_request_review_thread.path is -invalid`) and otherwise the first `HTTP 422` line. URLs are stripped and -the phrase is bounded to 240 characters. +attached. Each refused row keeps the 422 phrase from that comment's own +`gh api` stderr (JSON `errors[].message` such as +`pull_request_review_thread.path is invalid`, or the first `HTTP 422` +line). A later comment's different GitHub error does not overwrite an +earlier one. URLs are stripped and each phrase is bounded to 240 +characters. The publisher calls this helper from `build_inline_comment_failure_body` with the same control object used to build the inline `comments` array. @@ -46,7 +47,8 @@ Suggested diffs stay out of the PR-level body. line fallback, empty-set sentence, CLI success with `--error-file`, fail-closed unreadable control or error input, batch-to-single comment splitting, `--is-unprocessable` classification, and mixed-success - receipts that omit attached path:line rows. + receipts that omit attached path:line rows, and per-comment 422 + phrases recorded beside each refused location. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 96a4fac2c..15b857ae1 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -64,15 +64,18 @@ def trusted_finding_locations(control: dict[str, Any]) -> list[tuple[str, int]]: return locations -def parse_refused_locations(text: str) -> list[tuple[str, int]]: - """Parse ``path:line`` rows from one-at-a-time retry failures.""" - locations: list[tuple[str, int]] = [] +def parse_refused_receipts(text: str) -> list[tuple[str, int, str]]: + """Parse ``path:line`` or ``path:linephrase`` retry-failure rows.""" + receipts: list[tuple[str, int, str]] = [] seen: set[tuple[str, int]] = set() for raw_line in text.splitlines(): line = raw_line.strip() - if not line or line.startswith("#") or ":" not in line: + if not line or line.startswith("#"): + continue + loc_text, _sep, phrase = line.partition("\t") + if ":" not in loc_text: continue - path_text, _, line_text = line.rpartition(":") + path_text, _, line_text = loc_text.rpartition(":") path = safe_finding_path(path_text) try: parsed_line = int(line_text) @@ -85,8 +88,26 @@ def parse_refused_locations(text: str) -> list[tuple[str, int]]: if location in seen: continue seen.add(location) - locations.append(location) - return locations + receipts.append((path, line_number, phrase.strip())) + return receipts + + +def parse_refused_locations(text: str) -> list[tuple[str, int]]: + """Parse ``path:line`` rows from one-at-a-time retry failures.""" + return [(path, line) for path, line, _phrase in parse_refused_receipts(text)] + + +def record_refused_receipt( + dest: Path, path: str, line: int, error_text: str +) -> None: + """Append one refused ``path:line`` and its GitHub 422 phrase.""" + safe_path = safe_finding_path(path) + safe_line = safe_finding_line(line) + if safe_path is None or safe_line is None: + return + phrase = github_publication_error_phrase(error_text) + with dest.open("a", encoding="utf-8") as handle: + handle.write(f"{safe_path}:{safe_line}\t{phrase}\n") def _collapse_error_text(text: str) -> str: @@ -215,14 +236,25 @@ def write_single_comment_payloads(payload: dict[str, Any], output_dir: Path) -> def render_inline_comment_receipts( - locations: list[tuple[str, int]], error_phrase: str + locations: list[tuple[str, int]], + error_phrase: str = "", + phrases: dict[tuple[str, int], str] | None = None, ) -> list[str]: """Return durable overview receipt lines for refused inline comments.""" if not locations: return [] - if error_phrase: - return [f"- `{path}:{line}` — {error_phrase}" for path, line in locations] - return [f"- `{path}:{line}`" for path, line in locations] + lines: list[str] = [] + for path, line in locations: + phrase = "" + if phrases is not None: + phrase = phrases.get((path, line), "") + if not phrase: + phrase = error_phrase + if phrase: + lines.append(f"- `{path}:{line}` — {phrase}") + else: + lines.append(f"- `{path}:{line}`") + return lines def render_inline_comment_failure_suffix( @@ -230,6 +262,7 @@ def render_inline_comment_failure_suffix( *, error_phrase: str = "", mixed_success: bool = False, + phrases: dict[tuple[str, int], str] | None = None, ) -> str: """Return the PR-body suffix used when GitHub rejects inline comments.""" heading = ( @@ -254,7 +287,11 @@ def render_inline_comment_failure_suffix( "trusted current-head finding locations:" ) lines.append("") - lines.extend(render_inline_comment_receipts(locations, error_phrase)) + lines.extend( + render_inline_comment_receipts( + locations, error_phrase, phrases=phrases + ) + ) lines.append("") lines.append( "OpenCode did not copy suggested diffs into this PR-level body. " @@ -282,10 +319,27 @@ def render_inline_comment_failure_body( *, error_text: str = "", refused_locations: list[tuple[str, int]] | None = None, + refused_receipts: list[tuple[str, int, str]] | None = None, ) -> str: """Append the 422 fallback suffix to an existing REQUEST_CHANGES body.""" error_phrase = github_publication_error_phrase(error_text) if error_text else "" - if refused_locations is None: + phrases: dict[tuple[str, int], str] | None = None + if refused_receipts is not None: + allowed = set(trusted_finding_locations(control)) + locations = [ + (path, line) + for path, line, _phrase in refused_receipts + if (path, line) in allowed + ] + phrases = { + (path, line): phrase + for path, line, phrase in refused_receipts + if phrase and (path, line) in allowed + } + mixed_success = True + if not locations: + return body.rstrip("\n") + "\n" + elif refused_locations is None: locations = trusted_finding_locations(control) mixed_success = False else: @@ -298,6 +352,7 @@ def render_inline_comment_failure_body( locations, error_phrase=error_phrase, mixed_success=mixed_success, + phrases=phrases, ) @@ -323,8 +378,37 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--output-dir", type=Path) parser.add_argument("--is-unprocessable", action="store_true") parser.add_argument("--refused-locations", type=Path) + parser.add_argument("--record-refusal", action="store_true") + parser.add_argument("--comment-file", type=Path) args = parser.parse_args(argv) try: + if args.record_refusal: + if ( + args.refused_locations is None + or args.comment_file is None + or args.error_file is None + ): + raise ValueError( + "--refused-locations, --comment-file, and --error-file " + "are required with --record-refusal" + ) + payload = load_control(args.comment_file) + comments = payload.get("comments") + if ( + not isinstance(comments, list) + or not comments + or not isinstance(comments[0], dict) + ): + raise ValueError("comment file must contain comments[0]") + first = comments[0] + line = first.get("line") + record_refused_receipt( + args.refused_locations, + str(first.get("path") or ""), + line if isinstance(line, int) and not isinstance(line, bool) else 0, + args.error_file.read_text(encoding="utf-8"), + ) + return 0 if args.is_unprocessable: if args.error_file is None: raise ValueError("--error-file is required with --is-unprocessable") @@ -343,11 +427,18 @@ def main(argv: list[str] | None = None) -> int: error_text = ( args.error_file.read_text(encoding="utf-8") if args.error_file else "" ) - refused_locations = ( - parse_refused_locations(args.refused_locations.read_text(encoding="utf-8")) - if args.refused_locations is not None - else None - ) + refused_locations = None + refused_receipts = None + if args.refused_locations is not None: + parsed_receipts = parse_refused_receipts( + args.refused_locations.read_text(encoding="utf-8") + ) + if any(phrase for _path, _line, phrase in parsed_receipts): + refused_receipts = parsed_receipts + else: + refused_locations = [ + (path, line) for path, line, _phrase in parsed_receipts + ] except (OSError, UnicodeDecodeError, ValueError) as exc: print(exc, file=sys.stderr) return 2 @@ -357,6 +448,7 @@ def main(argv: list[str] | None = None) -> int: control, error_text=error_text, refused_locations=refused_locations, + refused_receipts=refused_receipts, ), encoding="utf-8", ) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 7d555c84c..788096d40 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1482,6 +1482,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$workflow_file" "retry_inline_comments_one_at_a_time" "opencode retries inline comments one at a time after batch 422" assert_file_contains "$workflow_file" "inline review one-at-a-time" "opencode one-at-a-time retries use the bounded review-write helper" assert_file_contains "$workflow_file" '--refused-locations "$refused_locations_file"' "opencode mixed-success receipts pass only refused path:line rows" + assert_file_contains "$workflow_file" "--record-refusal" "opencode records per-comment 422 phrases on refused path:line rows" assert_file_contains "$workflow_file" "publish_request_changes_from_control" "opencode review REQUEST_CHANGES path publishes findings from the control JSON" if awk '/format_request_changes_body\(\)/,/build_request_changes_review_payload\(\)/ { print }' "$workflow_file" | diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index dcc06546d..05c87c0d1 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1623,6 +1623,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "--is-unprocessable" in workflow assert "inline review one-at-a-time" in workflow assert '--refused-locations "$refused_locations_file"' in workflow + assert "--record-refusal" in workflow assert "accepted some inline comments" not in workflow assert "OPENCODE_EXHAUSTED_REKICK_" not in publish_step assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "10800"' not in publish_step diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 9c240fef5..b8bc2ec51 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -10,6 +10,8 @@ iter_single_comment_payloads, main, parse_refused_locations, + parse_refused_receipts, + record_refused_receipt, render_inline_comment_failure_body, render_inline_comment_receipts, render_single_comment_review, @@ -159,6 +161,13 @@ def test_mixed_success_receipts_list_only_refused_path_lines(): ) assert "scripts/ci/example.py:7`" not in body assert parse_refused_locations("") == [] + assert parse_refused_receipts( + "scripts/ci/a.py:3\tGitHub HTTP 422: path is invalid\n" + "scripts/ci/b.py:9\tGitHub HTTP 422: Line could not be resolved\n" + ) == [ + ("scripts/ci/a.py", 3, "GitHub HTTP 422: path is invalid"), + ("scripts/ci/b.py", 9, "GitHub HTTP 422: Line could not be resolved"), + ] all_attached = render_inline_comment_failure_body( "## Findings\n", control({"path": "scripts/ci/example.py", "line": 7}), @@ -168,6 +177,174 @@ def test_mixed_success_receipts_list_only_refused_path_lines(): assert "did not accept the inline review comments" not in all_attached +def test_mixed_success_receipts_keep_per_comment_422_phrases(tmp_path): + receipts = [ + ( + "scripts/ci/example.py", + 7, + "GitHub HTTP 422: pull_request_review_thread.path is invalid", + ), + ( + "scripts/ci/other.py", + 12, + "GitHub HTTP 422: Line could not be resolved", + ), + ] + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "scripts/ci/other.py", "line": 12}, + {"path": "scripts/ci/ok.py", "line": 4}, + ), + refused_receipts=receipts, + ) + assert "accepted some inline comments" in body + assert ( + "- `scripts/ci/example.py:7` — GitHub HTTP 422: " + "pull_request_review_thread.path is invalid" + in body + ) + assert ( + "- `scripts/ci/other.py:12` — GitHub HTTP 422: Line could not be resolved" + in body + ) + assert "scripts/ci/ok.py:4" not in body + unmatched = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/example.py", "line": 7}), + refused_receipts=[("scripts/ci/missing.py", 1, "GitHub HTTP 422")], + ) + assert "were still refused" not in unmatched + + dest = tmp_path / "refused.txt" + record_refused_receipt( + dest, + "scripts/ci/example.py", + 7, + '{"errors":[{"message":"pull_request_review_thread.path is invalid"}]}', + ) + record_refused_receipt( + dest, + "scripts/ci/other.py", + 12, + '{"errors":[{"message":"Line could not be resolved"}]}', + ) + assert parse_refused_receipts(dest.read_text(encoding="utf-8")) == receipts + + comment = tmp_path / "comment.json" + comment.write_text( + json.dumps( + { + "comments": [ + {"path": "scripts/ci/example.py", "line": 7, "body": "x"} + ] + } + ), + encoding="utf-8", + ) + error = tmp_path / "err.txt" + error.write_text( + '{"errors":[{"message":"pull_request_review_thread.path is invalid"}]}\n', + encoding="utf-8", + ) + dest2 = tmp_path / "cli-refused.txt" + assert ( + main( + [ + "--record-refusal", + "--refused-locations", + str(dest2), + "--comment-file", + str(comment), + "--error-file", + str(error), + ] + ) + == 0 + ) + assert "example.py:7\tGitHub HTTP 422: pull_request_review_thread.path is invalid" in dest2.read_text( + encoding="utf-8" + ) + assert main(["--record-refusal"]) == 2 + loc_only = tmp_path / "loc-only.txt" + loc_only.write_text("scripts/ci/example.py:7\n", encoding="utf-8") + control_only = tmp_path / "control-only.json" + body_only = tmp_path / "body-only.md" + out_only = tmp_path / "out-loc.md" + control_only.write_text( + json.dumps(control({"path": "scripts/ci/example.py", "line": 7})), + encoding="utf-8", + ) + body_only.write_text("## Findings\n", encoding="utf-8") + assert ( + main( + [ + "--control", + str(control_only), + "--body", + str(body_only), + "--output", + str(out_only), + "--refused-locations", + str(loc_only), + ] + ) + == 0 + ) + assert "`scripts/ci/example.py:7`" in out_only.read_text(encoding="utf-8") + two_control = tmp_path / "two-control.json" + two_out = tmp_path / "two-out.md" + two_control.write_text( + json.dumps( + control( + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "scripts/ci/other.py", "line": 12}, + ) + ), + encoding="utf-8", + ) + assert ( + main( + [ + "--control", + str(two_control), + "--body", + str(body_only), + "--output", + str(two_out), + "--refused-locations", + str(dest), + ] + ) + == 0 + ) + two_text = two_out.read_text(encoding="utf-8") + assert "pull_request_review_thread.path is invalid" in two_text + assert "Line could not be resolved" in two_text + dest3 = tmp_path / "skip.txt" + record_refused_receipt(dest3, "../escape.py", 1, "HTTP 422") + assert dest3.read_text(encoding="utf-8") == "" if dest3.exists() else True + if dest3.exists(): + assert dest3.read_text(encoding="utf-8") == "" + bad_comment = tmp_path / "bad-comment.json" + bad_comment.write_text("{}", encoding="utf-8") + assert ( + main( + [ + "--record-refusal", + "--refused-locations", + str(dest2), + "--comment-file", + str(bad_comment), + "--error-file", + str(error), + ] + ) + == 2 + ) + + def test_fallback_body_explains_missing_trusted_locations(): body = render_inline_comment_failure_body("overview\n", control()) From 3f06fb8cbeae3e9941b891669a9ac22ff41ab71b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 16:47:37 +0900 Subject: [PATCH 07/34] fix(review): cap inline retry at 20 and list attached path:line Unbounded one-at-a-time retry after a batch 422 can thrash GitHub, and mixed receipts listed only refused locations. Cap retries at 20, persist attached path:line beside refused ones, and record leftovers the cap left untried so the overview shows every outcome. --- .../workflows/opencode-review-dispatch.yml | 52 ++- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 35 +- .../ci/opencode_inline_comment_fallback.py | 184 ++++++++++- scripts/ci/test_strix_quick_gate.sh | 4 + tests/test_opencode_agent_contract.py | 5 + .../test_opencode_inline_comment_fallback.py | 299 ++++++++++++++++++ 7 files changed, 541 insertions(+), 39 deletions(-) diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index e09f88fdc..587c73cd7 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -5625,15 +5625,29 @@ jobs: retry_inline_comments_one_at_a_time() { local batch_payload_file="$1" review_body="$2" refused_locations_file="$3" + local attached_locations_file="${4:-}" + local deferred_locations_file="${5:-}" local split_dir comment_file wrapped_file error_file response_file local attached=0 local found=0 + local -a split_args : >"$refused_locations_file" + if [ -n "$attached_locations_file" ]; then + : >"$attached_locations_file" + fi split_dir="$(mktemp -d)" - if ! python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ - --split-payload "$batch_payload_file" \ - --output-dir "$split_dir"; then + split_args=( + python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" + --split-payload "$batch_payload_file" + --output-dir "$split_dir" + --retry-limit "${OPENCODE_INLINE_COMMENT_RETRY_LIMIT:-20}" + ) + if [ -n "$deferred_locations_file" ]; then + : >"$deferred_locations_file" + split_args+=(--deferred-locations "$deferred_locations_file") + fi + if ! "${split_args[@]}"; then rm -rf "$split_dir" return 1 fi @@ -5656,6 +5670,12 @@ jobs: "$error_file" \ "$response_file"; then attached=1 + if [ -n "$attached_locations_file" ]; then + python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ + --record-attach \ + --attached-locations "$attached_locations_file" \ + --comment-file "$comment_file" || true + fi else python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ --record-refusal \ @@ -5702,25 +5722,32 @@ jobs: && python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ --is-unprocessable --error-file "$gh_error_file"; then refused_locations_file="$(mktemp)" + attached_locations_file="$(mktemp)" + deferred_locations_file="$(mktemp)" if retry_inline_comments_one_at_a_time \ - "$review_payload_file" "$body" "$refused_locations_file"; then - if [ -s "$refused_locations_file" ] && [ -n "$source_body_file" ] && [ -n "$control_json" ]; then + "$review_payload_file" "$body" "$refused_locations_file" \ + "$attached_locations_file" "$deferred_locations_file"; then + if { [ -s "$refused_locations_file" ] || [ -s "$deferred_locations_file" ]; } \ + && [ -n "$source_body_file" ] && [ -n "$control_json" ]; then mixed_error_file="$gh_error_file" if [ -s "${refused_locations_file}.errors" ]; then mixed_error_file="${refused_locations_file}.errors" fi build_inline_comment_failure_body \ "$source_body_file" "$fallback_body_file" "$control_json" \ - "$mixed_error_file" "$refused_locations_file" || true + "$mixed_error_file" "$refused_locations_file" \ + "$attached_locations_file" "$deferred_locations_file" || true update_review_overview "$event" "$(cat "$fallback_body_file")" else update_review_overview "$event" "$body" fi rm -f "$gh_error_file" "$review_response_file" \ - "$refused_locations_file" "${refused_locations_file}.errors" + "$refused_locations_file" "${refused_locations_file}.errors" \ + "$attached_locations_file" "$deferred_locations_file" return 0 fi - rm -f "$refused_locations_file" "${refused_locations_file}.errors" + rm -f "$refused_locations_file" "${refused_locations_file}.errors" \ + "$attached_locations_file" "$deferred_locations_file" fi if [ -n "$source_body_file" ] && [ -n "$control_json" ]; then build_inline_comment_failure_body \ @@ -5855,6 +5882,8 @@ jobs: local control_json="$3" local error_file="${4:-}" local refused_locations_file="${5:-}" + local attached_locations_file="${6:-}" + local deferred_locations_file="${7:-}" local -a fallback_args fallback_args=( @@ -5862,6 +5891,7 @@ jobs: --control "$control_json" --body "$body_file" --output "$output_file" + --retry-limit "${OPENCODE_INLINE_COMMENT_RETRY_LIMIT:-20}" ) if [ -n "$error_file" ]; then fallback_args+=(--error-file "$error_file") @@ -5869,6 +5899,12 @@ jobs: if [ -n "$refused_locations_file" ]; then fallback_args+=(--refused-locations "$refused_locations_file") fi + if [ -n "$attached_locations_file" ]; then + fallback_args+=(--attached-locations "$attached_locations_file") + fi + if [ -n "$deferred_locations_file" ]; then + fallback_args+=(--deferred-locations "$deferred_locations_file") + fi "${fallback_args[@]}" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7050a3e23..555842d88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Capped one-at-a-time OpenCode inline retries at 20 comments and listed attached `path:line` beside refused receipts so the overview shows both outcomes, plus any locations left untried by the cap. - Kept each refused OpenCode inline comment's own GitHub 422 phrase next to its `path:line` so mixed retries do not collapse every failure into one shared error sentence. - After a mixed one-at-a-time inline retry, listed only the refused `path:line` rows in the overview receipts so attached hunks are not reported as failed. - After a batch GitHub 422, retried OpenCode inline comments one at a time so comments on surviving hunks still attach instead of dropping the entire review thread. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 5a6a747e0..3f3d0bae7 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -21,20 +21,22 @@ items. Unsafe paths (`..`, absolute, drive, backslash) and non-positive lines are omitted. An empty location set is stated explicitly. After a refused attach, the publisher first checks that the failure is -HTTP 422, splits the batch `comments` array into single-comment review -payloads, and retries each with the same write helper. The first success -uses `REQUEST_CHANGES` plus the review body; later successes use -`COMMENT`. Survivors therefore still appear on Files changed. Remaining -failures still rebuild the fallback from the `gh api` error file and -write durable receipts into the OpenCode overview comment -(``). On mixed success the receipt list -contains only refused `path:line` rows, not the comments that already -attached. Each refused row keeps the 422 phrase from that comment's own -`gh api` stderr (JSON `errors[].message` such as +HTTP 422, splits the batch `comments` array into at most 20 +single-comment review payloads (`OPENCODE_INLINE_COMMENT_RETRY_LIMIT`, +default 20), and retries each with the same write helper. Comments past +that cap are recorded as not retried instead of opening unbounded `gh +api` writes. The first success uses `REQUEST_CHANGES` plus the review +body; later successes use `COMMENT`. Survivors therefore still appear on +Files changed. Remaining failures still rebuild the fallback from the +`gh api` error file and write durable receipts into the OpenCode +overview comment (``). On mixed success +the overview lists attached `path:line` rows beside refused `path:line` +rows (each refused row keeps that comment's own 422 phrase) and any +locations left untried by the retry cap. JSON `errors[].message` such as `pull_request_review_thread.path is invalid`, or the first `HTTP 422` -line). A later comment's different GitHub error does not overwrite an -earlier one. URLs are stripped and each phrase is bounded to 240 -characters. +line, is the phrase source. A later comment's different GitHub error +does not overwrite an earlier one. URLs are stripped and each phrase is +bounded to 240 characters. The publisher calls this helper from `build_inline_comment_failure_body` with the same control object used to build the inline `comments` array. @@ -46,9 +48,10 @@ Suggested diffs stay out of the PR-level body. the exact location list, GitHub JSON `errors[].message` phrases, HTTP 422 line fallback, empty-set sentence, CLI success with `--error-file`, fail-closed unreadable control or error input, batch-to-single comment - splitting, `--is-unprocessable` classification, and mixed-success - receipts that omit attached path:line rows, and per-comment 422 - phrases recorded beside each refused location. + splitting, `--is-unprocessable` classification, mixed-success + receipts that list attached path:line beside refused path:line, + per-comment 422 phrases, the 20-comment one-at-a-time retry cap, and + leftover path:line rows that were not retried. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 15b857ae1..15ef5fd42 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -5,11 +5,13 @@ import argparse import json +import os import re import sys from pathlib import Path, PurePosixPath, PureWindowsPath from typing import Any +DEFAULT_SINGLE_COMMENT_RETRY_LIMIT = 20 ERROR_PHRASE_MAX_CHARS = 240 HTTP_422_LINE_RE = re.compile(r"(?im)^(?:gh:\s*)?(.*HTTP 422.*)$") @@ -97,6 +99,23 @@ def parse_refused_locations(text: str) -> list[tuple[str, int]]: return [(path, line) for path, line, _phrase in parse_refused_receipts(text)] +def single_comment_retry_limit(raw: object | None = None) -> int: + """Return a positive one-at-a-time retry cap, defaulting to 20.""" + if raw is None: + raw = os.environ.get("OPENCODE_INLINE_COMMENT_RETRY_LIMIT") + if isinstance(raw, bool): + return DEFAULT_SINGLE_COMMENT_RETRY_LIMIT + if isinstance(raw, int) and raw > 0: + return raw + if isinstance(raw, str): + text = raw.strip() + if text.isdigit(): + value = int(text) + if value > 0: + return value + return DEFAULT_SINGLE_COMMENT_RETRY_LIMIT + + def record_refused_receipt( dest: Path, path: str, line: int, error_text: str ) -> None: @@ -110,6 +129,16 @@ def record_refused_receipt( handle.write(f"{safe_path}:{safe_line}\t{phrase}\n") +def record_attached_receipt(dest: Path, path: str, line: int) -> None: + """Append one attached ``path:line`` row.""" + safe_path = safe_finding_path(path) + safe_line = safe_finding_line(line) + if safe_path is None or safe_line is None: + return + with dest.open("a", encoding="utf-8") as handle: + handle.write(f"{safe_path}:{safe_line}\n") + + def _collapse_error_text(text: str) -> str: """Return one-line error text without URLs or extra whitespace.""" without_urls = re.sub(r"https?://\S+", "", text) @@ -218,11 +247,18 @@ def render_single_comment_review( } -def write_single_comment_payloads(payload: dict[str, Any], output_dir: Path) -> int: - """Write COMMENT-event single-comment payloads and return the file count.""" +def write_single_comment_payloads( + payload: dict[str, Any], + output_dir: Path, + limit: int | None = None, + deferred_path: Path | None = None, +) -> int: + """Write at most ``limit`` COMMENT payloads and leftover ``path:line`` rows.""" + items = iter_single_comment_payloads(payload) + cap = single_comment_retry_limit(limit) output_dir.mkdir(parents=True, exist_ok=True) count = 0 - for index, item in enumerate(iter_single_comment_payloads(payload)): + for index, item in enumerate(items[:cap]): path = output_dir / f"comment-{index:03d}.json" path.write_text( json.dumps( @@ -232,6 +268,11 @@ def write_single_comment_payloads(payload: dict[str, Any], output_dir: Path) -> encoding="utf-8", ) count += 1 + if deferred_path is not None: + deferred_path.write_text( + "".join(f"{item['path']}:{item['line']}\n" for item in items[cap:]), + encoding="utf-8", + ) return count @@ -263,11 +304,16 @@ def render_inline_comment_failure_suffix( error_phrase: str = "", mixed_success: bool = False, phrases: dict[tuple[str, int], str] | None = None, + attached_locations: list[tuple[str, int]] | None = None, + deferred_locations: list[tuple[str, int]] | None = None, + retry_limit: int | None = None, ) -> str: """Return the PR-body suffix used when GitHub rejects inline comments.""" + attached = attached_locations or [] + deferred = deferred_locations or [] heading = ( "## Inline comment publication receipts" - if error_phrase or mixed_success + if error_phrase or mixed_success or attached or deferred else "## Inline comment publishing failed" ) lines = [ @@ -275,12 +321,22 @@ def render_inline_comment_failure_suffix( heading, "", ] + if attached: + lines.append("GitHub accepted these trusted current-head finding locations:") + lines.append("") + lines.extend(render_inline_comment_receipts(attached)) + lines.append("") if locations: if mixed_success: - lines.append( - "GitHub accepted some inline comments. These trusted " - "current-head finding locations were still refused:" - ) + if attached: + lines.append( + "These trusted current-head finding locations were still refused:" + ) + else: + lines.append( + "GitHub accepted some inline comments. These trusted " + "current-head finding locations were still refused:" + ) else: lines.append( "GitHub did not accept the inline review comments for these " @@ -299,7 +355,7 @@ def render_inline_comment_failure_suffix( "current-head changed hunks, or inspect the workflow log/control " "JSON and apply the changes manually." ) - else: + elif not attached and not deferred: lines.append( "GitHub did not accept the inline review comments, and the " "control JSON had no trusted path:line findings. Inspect the " @@ -309,10 +365,44 @@ def render_inline_comment_failure_suffix( if error_phrase: lines.append("") lines.append(f"- GitHub error: {error_phrase}") + if deferred: + if locations or attached: + lines.append("") + lines.append( + "These trusted current-head finding locations were not retried " + f"(retry limit {single_comment_retry_limit(retry_limit)}):" + ) + lines.append("") + lines.extend(render_inline_comment_receipts(deferred)) + if not locations: + lines.append("") + lines.append( + "OpenCode did not copy suggested diffs into this PR-level body. " + "Re-run the review after those exact path:line anchors sit on " + "current-head changed hunks, or inspect the workflow log/control " + "JSON and apply the changes manually." + ) lines.append("") return "\n".join(lines) +def _trusted_location_subset( + items: list[tuple[str, int]] | None, + allowed: set[tuple[str, int]], +) -> list[tuple[str, int]]: + """Return first-seen locations that remain in the trusted control set.""" + if not items: + return [] + kept: list[tuple[str, int]] = [] + seen: set[tuple[str, int]] = set() + for item in items: + if item not in allowed or item in seen: + continue + seen.add(item) + kept.append(item) + return kept + + def render_inline_comment_failure_body( body: str, control: dict[str, Any], @@ -320,12 +410,25 @@ def render_inline_comment_failure_body( error_text: str = "", refused_locations: list[tuple[str, int]] | None = None, refused_receipts: list[tuple[str, int, str]] | None = None, + attached_locations: list[tuple[str, int]] | None = None, + deferred_locations: list[tuple[str, int]] | None = None, + retry_limit: int | None = None, ) -> str: """Append the 422 fallback suffix to an existing REQUEST_CHANGES body.""" error_phrase = github_publication_error_phrase(error_text) if error_text else "" + allowed = set(trusted_finding_locations(control)) + attached = ( + _trusted_location_subset(attached_locations, allowed) + if attached_locations is not None + else [] + ) + deferred = ( + _trusted_location_subset(deferred_locations, allowed) + if deferred_locations is not None + else [] + ) phrases: dict[tuple[str, int], str] | None = None if refused_receipts is not None: - allowed = set(trusted_finding_locations(control)) locations = [ (path, line) for path, line, _phrase in refused_receipts @@ -337,22 +440,29 @@ def render_inline_comment_failure_body( if phrase and (path, line) in allowed } mixed_success = True - if not locations: + if not locations and not attached and not deferred: return body.rstrip("\n") + "\n" - elif refused_locations is None: + elif refused_locations is None and attached_locations is None and deferred_locations is None: locations = trusted_finding_locations(control) mixed_success = False + elif refused_locations is None: + locations = [] + mixed_success = True + if not attached and not deferred: + return body.rstrip("\n") + "\n" else: - allowed = set(trusted_finding_locations(control)) locations = [item for item in refused_locations if item in allowed] mixed_success = True - if not locations: + if not locations and not attached and not deferred: return body.rstrip("\n") + "\n" return body.rstrip("\n") + render_inline_comment_failure_suffix( locations, error_phrase=error_phrase, mixed_success=mixed_success, phrases=phrases, + attached_locations=attached or None, + deferred_locations=deferred or None, + retry_limit=retry_limit, ) @@ -378,10 +488,36 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--output-dir", type=Path) parser.add_argument("--is-unprocessable", action="store_true") parser.add_argument("--refused-locations", type=Path) + parser.add_argument("--attached-locations", type=Path) + parser.add_argument("--deferred-locations", type=Path) + parser.add_argument("--retry-limit", type=int) parser.add_argument("--record-refusal", action="store_true") + parser.add_argument("--record-attach", action="store_true") parser.add_argument("--comment-file", type=Path) args = parser.parse_args(argv) try: + if args.record_attach: + if args.attached_locations is None or args.comment_file is None: + raise ValueError( + "--attached-locations and --comment-file are required " + "with --record-attach" + ) + payload = load_control(args.comment_file) + comments = payload.get("comments") + if ( + not isinstance(comments, list) + or not comments + or not isinstance(comments[0], dict) + ): + raise ValueError("comment file must contain comments[0]") + first = comments[0] + line = first.get("line") + record_attached_receipt( + args.attached_locations, + str(first.get("path") or ""), + line if isinstance(line, int) and not isinstance(line, bool) else 0, + ) + return 0 if args.record_refusal: if ( args.refused_locations is None @@ -418,7 +554,12 @@ def main(argv: list[str] | None = None) -> int: if args.output_dir is None: raise ValueError("--output-dir is required with --split-payload") payload = load_control(args.split_payload) - write_single_comment_payloads(payload, args.output_dir) + write_single_comment_payloads( + payload, + args.output_dir, + limit=args.retry_limit, + deferred_path=args.deferred_locations, + ) return 0 if args.control is None or args.body is None or args.output is None: raise ValueError("--control, --body, and --output are required") @@ -429,6 +570,8 @@ def main(argv: list[str] | None = None) -> int: ) refused_locations = None refused_receipts = None + attached_locations = None + deferred_locations = None if args.refused_locations is not None: parsed_receipts = parse_refused_receipts( args.refused_locations.read_text(encoding="utf-8") @@ -439,6 +582,14 @@ def main(argv: list[str] | None = None) -> int: refused_locations = [ (path, line) for path, line, _phrase in parsed_receipts ] + if args.attached_locations is not None: + attached_locations = parse_refused_locations( + args.attached_locations.read_text(encoding="utf-8") + ) + if args.deferred_locations is not None: + deferred_locations = parse_refused_locations( + args.deferred_locations.read_text(encoding="utf-8") + ) except (OSError, UnicodeDecodeError, ValueError) as exc: print(exc, file=sys.stderr) return 2 @@ -449,6 +600,9 @@ def main(argv: list[str] | None = None) -> int: error_text=error_text, refused_locations=refused_locations, refused_receipts=refused_receipts, + attached_locations=attached_locations, + deferred_locations=deferred_locations, + retry_limit=args.retry_limit, ), encoding="utf-8", ) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 788096d40..bc4222f7f 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1483,6 +1483,10 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$workflow_file" "inline review one-at-a-time" "opencode one-at-a-time retries use the bounded review-write helper" assert_file_contains "$workflow_file" '--refused-locations "$refused_locations_file"' "opencode mixed-success receipts pass only refused path:line rows" assert_file_contains "$workflow_file" "--record-refusal" "opencode records per-comment 422 phrases on refused path:line rows" + assert_file_contains "$workflow_file" "--record-attach" "opencode records attached path:line rows beside refused receipts" + assert_file_contains "$workflow_file" '--attached-locations "$attached_locations_file"' "opencode mixed-success receipts persist attached path:line rows" + assert_file_contains "$workflow_file" "--retry-limit" "opencode bounds one-at-a-time inline comment retries" + assert_file_contains "$workflow_file" '${OPENCODE_INLINE_COMMENT_RETRY_LIMIT:-20}' "opencode default one-at-a-time retry cap is 20" assert_file_contains "$workflow_file" "publish_request_changes_from_control" "opencode review REQUEST_CHANGES path publishes findings from the control JSON" if awk '/format_request_changes_body\(\)/,/build_request_changes_review_payload\(\)/ { print }' "$workflow_file" | diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 05c87c0d1..f6cc8329e 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1624,6 +1624,11 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "inline review one-at-a-time" in workflow assert '--refused-locations "$refused_locations_file"' in workflow assert "--record-refusal" in workflow + assert "--record-attach" in workflow + assert '--attached-locations "$attached_locations_file"' in workflow + assert "--deferred-locations" in workflow + assert "--retry-limit" in workflow + assert "${OPENCODE_INLINE_COMMENT_RETRY_LIMIT:-20}" in workflow assert "accepted some inline comments" not in workflow assert "OPENCODE_EXHAUSTED_REKICK_" not in publish_step assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "10800"' not in publish_step diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index b8bc2ec51..9da4d38af 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -5,17 +5,21 @@ import pytest from scripts.ci.opencode_inline_comment_fallback import ( + DEFAULT_SINGLE_COMMENT_RETRY_LIMIT, github_error_is_unprocessable, github_publication_error_phrase, iter_single_comment_payloads, main, parse_refused_locations, parse_refused_receipts, + record_attached_receipt, record_refused_receipt, render_inline_comment_failure_body, render_inline_comment_receipts, render_single_comment_review, + single_comment_retry_limit, trusted_finding_locations, + write_single_comment_payloads, ) @@ -677,3 +681,298 @@ def test_cli_splits_batch_payload_into_single_comment_files(tmp_path): assert main(["--is-unprocessable", "--error-file", str(error_path)]) == 1 assert main(["--is-unprocessable"]) == 2 assert main([]) == 2 + + +def _batch_payload(*comments: dict[str, object]) -> dict[str, object]: + """Return a batch review payload for retry-limit tests.""" + return { + "event": "REQUEST_CHANGES", + "body": "review body", + "commit_id": "c" * 40, + "comments": list(comments), + } + + +def test_single_comment_retry_limit_defaults_and_rejects_invalid(monkeypatch): + monkeypatch.delenv("OPENCODE_INLINE_COMMENT_RETRY_LIMIT", raising=False) + assert single_comment_retry_limit() == DEFAULT_SINGLE_COMMENT_RETRY_LIMIT + assert single_comment_retry_limit(5) == 5 + assert single_comment_retry_limit("3") == 3 + assert single_comment_retry_limit(" 8 ") == 8 + assert single_comment_retry_limit(0) == DEFAULT_SINGLE_COMMENT_RETRY_LIMIT + assert single_comment_retry_limit(-1) == DEFAULT_SINGLE_COMMENT_RETRY_LIMIT + assert single_comment_retry_limit(True) == DEFAULT_SINGLE_COMMENT_RETRY_LIMIT + assert single_comment_retry_limit("abc") == DEFAULT_SINGLE_COMMENT_RETRY_LIMIT + assert single_comment_retry_limit("0") == DEFAULT_SINGLE_COMMENT_RETRY_LIMIT + monkeypatch.setenv("OPENCODE_INLINE_COMMENT_RETRY_LIMIT", "4") + assert single_comment_retry_limit() == 4 + monkeypatch.setenv("OPENCODE_INLINE_COMMENT_RETRY_LIMIT", "nope") + assert single_comment_retry_limit() == DEFAULT_SINGLE_COMMENT_RETRY_LIMIT + + +def test_write_single_comment_payloads_caps_retry_and_records_deferred(tmp_path): + payload = _batch_payload( + {"path": "scripts/ci/a.py", "line": 1, "body": "one"}, + {"path": "scripts/ci/b.py", "line": 2, "body": "two"}, + {"path": "scripts/ci/c.py", "line": 3, "body": "three"}, + ) + output_dir = tmp_path / "singles" + deferred = tmp_path / "deferred.txt" + assert write_single_comment_payloads(payload, output_dir, limit=1, deferred_path=deferred) == 1 + files = sorted(output_dir.glob("comment-*.json")) + assert [path.name for path in files] == ["comment-000.json"] + assert parse_refused_locations(deferred.read_text(encoding="utf-8")) == [ + ("scripts/ci/b.py", 2), + ("scripts/ci/c.py", 3), + ] + empty_deferred = tmp_path / "none.txt" + assert write_single_comment_payloads(payload, tmp_path / "all", limit=20, deferred_path=empty_deferred) == 3 + assert empty_deferred.read_text(encoding="utf-8") == "" + + +def test_mixed_success_receipts_list_attached_beside_refused(): + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/ok.py", "line": 4}, + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "scripts/ci/later.py", "line": 20}, + {"path": "scripts/ci/skip.py", "line": 9}, + ), + refused_receipts=[ + ( + "scripts/ci/example.py", + 7, + "GitHub HTTP 422: pull_request_review_thread.path is invalid", + ) + ], + attached_locations=[("scripts/ci/ok.py", 4), ("scripts/ci/missing.py", 1)], + deferred_locations=[("scripts/ci/later.py", 20), ("scripts/ci/later.py", 20)], + retry_limit=1, + ) + assert "GitHub accepted these trusted current-head finding locations:" in body + assert "- `scripts/ci/ok.py:4`" in body + assert "These trusted current-head finding locations were still refused:" in body + assert ( + "- `scripts/ci/example.py:7` — GitHub HTTP 422: " + "pull_request_review_thread.path is invalid" + in body + ) + assert "were not retried (retry limit 1):" in body + assert "- `scripts/ci/later.py:20`" in body + assert "scripts/ci/skip.py:9" not in body + assert "scripts/ci/missing.py:1" not in body + deferred_only = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/later.py", "line": 20}), + refused_locations=[], + deferred_locations=[("scripts/ci/later.py", 20)], + retry_limit=1, + ) + assert "were not retried (retry limit 1):" in deferred_only + assert "- `scripts/ci/later.py:20`" in deferred_only + assert "did not copy suggested diffs" in deferred_only + attached_only = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/ok.py", "line": 4}), + refused_receipts=[], + attached_locations=[("scripts/ci/ok.py", 4)], + ) + assert "- `scripts/ci/ok.py:4`" in attached_only + assert "were still refused" not in attached_only + attached_without_refused_kw = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/ok.py", "line": 4}, + {"path": "scripts/ci/later.py", "line": 20}, + ), + attached_locations=[("scripts/ci/ok.py", 4)], + deferred_locations=[("scripts/ci/later.py", 20)], + retry_limit=1, + ) + assert "- `scripts/ci/ok.py:4`" in attached_without_refused_kw + assert "were not retried (retry limit 1):" in attached_without_refused_kw + assert "were still refused" not in attached_without_refused_kw + empty_outcome_files = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/ok.py", "line": 4}), + attached_locations=[], + deferred_locations=[], + ) + assert "were still refused" not in empty_outcome_files + assert "did not accept the inline review comments" not in empty_outcome_files + + +def test_cli_records_attached_and_bounded_split(tmp_path): + payload = tmp_path / "batch.json" + payload.write_text( + json.dumps( + _batch_payload( + {"path": "scripts/ci/a.py", "line": 1, "side": "RIGHT", "body": "one"}, + {"path": "scripts/ci/b.py", "line": 2, "side": "RIGHT", "body": "two"}, + ) + ), + encoding="utf-8", + ) + output_dir = tmp_path / "singles" + deferred = tmp_path / "deferred.txt" + assert ( + main( + [ + "--split-payload", + str(payload), + "--output-dir", + str(output_dir), + "--retry-limit", + "1", + "--deferred-locations", + str(deferred), + ] + ) + == 0 + ) + assert [path.name for path in sorted(output_dir.glob("comment-*.json"))] == [ + "comment-000.json" + ] + assert "scripts/ci/b.py:2" in deferred.read_text(encoding="utf-8") + + comment = tmp_path / "comment.json" + comment.write_text( + json.dumps({"comments": [{"path": "scripts/ci/a.py", "line": 1, "body": "x"}]}), + encoding="utf-8", + ) + attached = tmp_path / "attached.txt" + assert ( + main( + [ + "--record-attach", + "--attached-locations", + str(attached), + "--comment-file", + str(comment), + ] + ) + == 0 + ) + assert attached.read_text(encoding="utf-8") == "scripts/ci/a.py:1\n" + record_attached_receipt(tmp_path / "skip.txt", "../escape.py", 1) + record_attached_receipt(tmp_path / "skip.txt", "scripts/ci/a.py", 0) + assert not (tmp_path / "skip.txt").exists() + assert main(["--record-attach"]) == 2 + string_line = tmp_path / "string-line.json" + string_line.write_text( + json.dumps({"comments": [{"path": "scripts/ci/a.py", "line": "1"}]}), + encoding="utf-8", + ) + dest_empty = tmp_path / "empty-attach.txt" + assert ( + main( + [ + "--record-attach", + "--attached-locations", + str(dest_empty), + "--comment-file", + str(string_line), + ] + ) + == 0 + ) + assert not dest_empty.exists() or dest_empty.read_text(encoding="utf-8") == "" + assert ( + main( + [ + "--record-attach", + "--attached-locations", + str(attached), + "--comment-file", + str(tmp_path / "missing-comment.json"), + ] + ) + == 2 + ) + bad_comment = tmp_path / "bad-comment.json" + bad_comment.write_text("{}", encoding="utf-8") + assert ( + main( + [ + "--record-attach", + "--attached-locations", + str(attached), + "--comment-file", + str(bad_comment), + ] + ) + == 2 + ) + + control_path = tmp_path / "control.json" + body_path = tmp_path / "body.md" + output_path = tmp_path / "out.md" + refused = tmp_path / "refused.txt" + control_path.write_text( + json.dumps( + control( + {"path": "scripts/ci/a.py", "line": 1}, + {"path": "scripts/ci/b.py", "line": 2}, + {"path": "scripts/ci/c.py", "line": 3}, + ) + ), + encoding="utf-8", + ) + body_path.write_text("## Findings\n", encoding="utf-8") + refused.write_text( + "scripts/ci/b.py:2\tGitHub HTTP 422: Line could not be resolved\n", + encoding="utf-8", + ) + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(output_path), + "--refused-locations", + str(refused), + "--attached-locations", + str(attached), + "--deferred-locations", + str(deferred), + "--retry-limit", + "1", + ] + ) + == 0 + ) + written = output_path.read_text(encoding="utf-8") + assert "- `scripts/ci/a.py:1`" in written + assert "- `scripts/ci/b.py:2` — GitHub HTTP 422: Line could not be resolved" in written + assert "- `scripts/ci/c.py:3`" not in written + assert "scripts/ci/b.py:2`" in written or "were still refused" in written + assert "were not retried (retry limit 1):" in written + assert "- `scripts/ci/b.py:2`" in written or "scripts/ci/b.py:2" in written + assert main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(output_path), + "--attached-locations", + str(tmp_path / "missing-attached.txt"), + ] + ) == 2 + assert main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(output_path), + "--deferred-locations", + str(tmp_path / "missing-deferred.txt"), + ] + ) == 2 From 68264f1f00d12a0ed41bfc19a2afdcfc5acdfd2a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 17:04:56 +0900 Subject: [PATCH 08/34] fix(review): drop off-hunk inline comments before GitHub POST GitHub 422s review comments that sit outside every current-head @@ hunk. Filter the payload against git diff --unified=3 first, post only the on-hunk comments, and persist skipped path:line as overview receipts. --- .../workflows/opencode-review-dispatch.yml | 83 +++++- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 19 +- .../ci/opencode_inline_comment_fallback.py | 186 ++++++++++++- scripts/ci/test_strix_quick_gate.sh | 3 + tests/test_opencode_agent_contract.py | 5 + .../test_opencode_inline_comment_fallback.py | 260 ++++++++++++++++++ 7 files changed, 543 insertions(+), 14 deletions(-) diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 587c73cd7..c7941a2eb 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -5699,6 +5699,41 @@ jobs: return 0 } + prefilter_inline_comments_to_hunks() { + local payload_file="$1" + local skipped_file="$2" + local hunks_diff_file + local filtered_file + local merge_base + hunks_diff_file="$(mktemp)" + filtered_file="$(mktemp)" + : >"$skipped_file" + if [ -z "${OPENCODE_SOURCE_WORKDIR:-}" ] || [ -z "${PR_BASE_SHA:-}" ] || [ -z "${PR_HEAD_SHA:-}" ]; then + rm -f "$hunks_diff_file" "$filtered_file" + return 0 + fi + merge_base="$(git -C "$OPENCODE_SOURCE_WORKDIR" merge-base "$PR_BASE_SHA" "$PR_HEAD_SHA" 2>/dev/null || true)" + if [ -z "$merge_base" ]; then + merge_base="$PR_BASE_SHA" + fi + if ! git -C "$OPENCODE_SOURCE_WORKDIR" diff --unified=3 --find-renames --no-color --no-ext-diff \ + "$merge_base" "$PR_HEAD_SHA" >"$hunks_diff_file"; then + rm -f "$hunks_diff_file" "$filtered_file" + return 0 + fi + if python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ + --filter-hunks \ + --payload "$payload_file" \ + --hunks-diff "$hunks_diff_file" \ + --output "$filtered_file" \ + --skipped-locations "$skipped_file"; then + mv "$filtered_file" "$payload_file" + else + rm -f "$filtered_file" + fi + rm -f "$hunks_diff_file" + } + create_pull_review_with_payload() { local event="$1" body="$2" review_payload_file="$3" fallback_body_file="$4" local source_body_file="${5:-}" @@ -5706,15 +5741,35 @@ jobs: local gh_error_file local rewritten_payload_file local review_response_file + local skipped_locations_file + local comment_count gh_error_file="$(mktemp)" rewritten_payload_file="$(mktemp)" review_response_file="$(mktemp)" + skipped_locations_file="$(mktemp)" body="$(ensure_review_body_has_change_graph "$body")" if jq --arg body "$body" '.body = $body' "$review_payload_file" >"$rewritten_payload_file"; then mv "$rewritten_payload_file" "$review_payload_file" else rm -f "$rewritten_payload_file" fi + prefilter_inline_comments_to_hunks "$review_payload_file" "$skipped_locations_file" + comment_count="$(jq '.comments | length' "$review_payload_file" 2>/dev/null || printf '0')" + if [ "$comment_count" = "0" ] && [ -s "$skipped_locations_file" ] \ + && [ -n "$source_body_file" ] && [ -n "$control_json" ]; then + build_inline_comment_failure_body \ + "$source_body_file" "$fallback_body_file" "$control_json" \ + "" "" "" "" "$skipped_locations_file" || true + if [ -s "$fallback_body_file" ]; then + body="$(cat "$fallback_body_file")" + if jq --arg body "$body" '.body = $body | del(.comments)' \ + "$review_payload_file" >"$rewritten_payload_file"; then + mv "$rewritten_payload_file" "$review_payload_file" + else + rm -f "$rewritten_payload_file" + fi + fi + fi emit_review_body_to_action_log "$event" "$body" "$review_payload_file" if ! post_pull_review_with_retry "inline review" "$review_write_token" "$review_payload_file" "$gh_error_file" "$review_response_file"; then warn_gh_publication_failure "pull review inline comments" "$gh_error_file" @@ -5727,7 +5782,8 @@ jobs: if retry_inline_comments_one_at_a_time \ "$review_payload_file" "$body" "$refused_locations_file" \ "$attached_locations_file" "$deferred_locations_file"; then - if { [ -s "$refused_locations_file" ] || [ -s "$deferred_locations_file" ]; } \ + if { [ -s "$refused_locations_file" ] || [ -s "$deferred_locations_file" ] \ + || [ -s "$skipped_locations_file" ]; } \ && [ -n "$source_body_file" ] && [ -n "$control_json" ]; then mixed_error_file="$gh_error_file" if [ -s "${refused_locations_file}.errors" ]; then @@ -5736,14 +5792,16 @@ jobs: build_inline_comment_failure_body \ "$source_body_file" "$fallback_body_file" "$control_json" \ "$mixed_error_file" "$refused_locations_file" \ - "$attached_locations_file" "$deferred_locations_file" || true + "$attached_locations_file" "$deferred_locations_file" \ + "$skipped_locations_file" || true update_review_overview "$event" "$(cat "$fallback_body_file")" else update_review_overview "$event" "$body" fi rm -f "$gh_error_file" "$review_response_file" \ "$refused_locations_file" "${refused_locations_file}.errors" \ - "$attached_locations_file" "$deferred_locations_file" + "$attached_locations_file" "$deferred_locations_file" \ + "$skipped_locations_file" return 0 fi rm -f "$refused_locations_file" "${refused_locations_file}.errors" \ @@ -5751,9 +5809,10 @@ jobs: fi if [ -n "$source_body_file" ] && [ -n "$control_json" ]; then build_inline_comment_failure_body \ - "$source_body_file" "$fallback_body_file" "$control_json" "$gh_error_file" || true + "$source_body_file" "$fallback_body_file" "$control_json" \ + "$gh_error_file" "" "" "" "$skipped_locations_file" || true fi - rm -f "$gh_error_file" "$review_response_file" + rm -f "$gh_error_file" "$review_response_file" "$skipped_locations_file" if [ "${REVIEW_PUBLICATION_STALE_HEAD:-}" = "1" ]; then printf '::error::OpenCode inline review publication stopped because PR head advanced beyond %s.\n' "$HEAD_SHA" return 1 @@ -5765,7 +5824,15 @@ jobs: fi return 1 fi - rm -f "$gh_error_file" "$review_response_file" + if [ -s "$skipped_locations_file" ] && [ -n "$source_body_file" ] && [ -n "$control_json" ]; then + build_inline_comment_failure_body \ + "$source_body_file" "$fallback_body_file" "$control_json" \ + "" "" "" "" "$skipped_locations_file" || true + if [ -s "$fallback_body_file" ]; then + body="$(cat "$fallback_body_file")" + fi + fi + rm -f "$gh_error_file" "$review_response_file" "$skipped_locations_file" update_review_overview "$event" "$body" } @@ -5884,6 +5951,7 @@ jobs: local refused_locations_file="${5:-}" local attached_locations_file="${6:-}" local deferred_locations_file="${7:-}" + local skipped_locations_file="${8:-}" local -a fallback_args fallback_args=( @@ -5905,6 +5973,9 @@ jobs: if [ -n "$deferred_locations_file" ]; then fallback_args+=(--deferred-locations "$deferred_locations_file") fi + if [ -n "$skipped_locations_file" ]; then + fallback_args+=(--skipped-locations "$skipped_locations_file") + fi "${fallback_args[@]}" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 555842d88..fa8778d50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Dropped OpenCode inline comments that sit outside every current-head changed hunk before the GitHub POST so those comments become overview receipts instead of a 422 that wipes the batch. - Capped one-at-a-time OpenCode inline retries at 20 comments and listed attached `path:line` beside refused receipts so the overview shows both outcomes, plus any locations left untried by the cap. - Kept each refused OpenCode inline comment's own GitHub 422 phrase next to its `path:line` so mixed retries do not collapse every failure into one shared error sentence. - After a mixed one-at-a-time inline retry, listed only the refused `path:line` rows in the overview receipts so attached hunks are not reported as failed. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 3f3d0bae7..22b0cc41e 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -38,6 +38,17 @@ line, is the phrase source. A later comment's different GitHub error does not overwrite an earlier one. URLs are stripped and each phrase is bounded to 240 characters. +Before the first GitHub POST, the publisher runs +`git diff --unified=3` from the merge base to the current head and keeps +only comments whose `path:line` sits inside a parsed hunk range, including +hunk context lines. GitHub accepts review comments only on those diff +hunks (GitHub, n.d.-b); off-hunk comments are recorded as skipped +`path:line` receipts instead of being sent. An empty hunk map leaves the +payload unchanged so a failed diff collection cannot drop every comment. +This matches the modern-review expectation that discussion belongs on the +changed hunk rather than elsewhere in the file (Bacchelli & Bird, 2013; +Sadowski et al., 2018). + The publisher calls this helper from `build_inline_comment_failure_body` with the same control object used to build the inline `comments` array. Suggested diffs stay out of the PR-level body. @@ -51,7 +62,8 @@ Suggested diffs stay out of the PR-level body. splitting, `--is-unprocessable` classification, mixed-success receipts that list attached path:line beside refused path:line, per-comment 422 phrases, the 20-comment one-at-a-time retry cap, and - leftover path:line rows that were not retried. + leftover path:line rows that were not retried, unified-diff hunk + parsing, and the pre-POST filter that drops off-hunk comments. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. @@ -75,3 +87,8 @@ https://docs.github.com/en/rest/pulls/reviews#create-a-review-for-a-pull-request GitHub. (n.d.-b). *Create a review comment for a pull request*. GitHub Docs. Retrieved August 13, 2026, from https://docs.github.com/en/rest/pulls/comments#create-a-review-comment-for-a-pull-request + +Sadowski, C., Söderberg, E., Church, L., Sipko, M., & Bacchelli, A. (2018). +Modern code review: A case study at Google. In *Proceedings of the 40th +International Conference on Software Engineering: Software Engineering in +Practice* (pp. 181–190). ACM. https://doi.org/10.1145/3183519.3183525 diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 15ef5fd42..57e90f891 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Render a GitHub 422 inline-comment fallback that cites trusted path:line.""" +"""Filter inline comments to current-head hunks and cite refused path:line.""" from __future__ import annotations @@ -14,6 +14,9 @@ DEFAULT_SINGLE_COMMENT_RETRY_LIMIT = 20 ERROR_PHRASE_MAX_CHARS = 240 HTTP_422_LINE_RE = re.compile(r"(?im)^(?:gh:\s*)?(.*HTTP 422.*)$") +HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") +PLUS_PATH_RE = re.compile(r"^\+\+\+ b/(.+?)(?:\t.*)?$") +MINUS_PATH_RE = re.compile(r"^--- a/(.+?)(?:\t.*)?$") def safe_finding_path(raw_path: object) -> str | None: @@ -129,6 +132,117 @@ def record_refused_receipt( handle.write(f"{safe_path}:{safe_line}\t{phrase}\n") +def _hunk_side_lines(start_text: str, count_text: str | None) -> set[int]: + """Return inclusive new- or old-file line numbers for one unified hunk side.""" + start = int(start_text) + count = int(count_text) if count_text is not None else 1 + if count < 1: + return set() + return set(range(start, start + count)) + + +def _diff_path(raw_path: str) -> str | None: + """Return a safe repository path from a unified-diff a/ or b/ suffix.""" + return safe_finding_path(raw_path.strip().strip('"')) + + +def parse_unified_diff_hunk_lines( + diff_text: str, +) -> dict[str, dict[str, set[int]]]: + """Return LEFT/RIGHT commentable lines for each path in a unified diff.""" + hunks: dict[str, dict[str, set[int]]] = {} + current_left: str | None = None + current_right: str | None = None + for raw in (diff_text or "").splitlines(): + if raw.startswith("+++ "): + match = PLUS_PATH_RE.match(raw) + current_right = _diff_path(match.group(1)) if match else None + continue + if raw.startswith("--- "): + match = MINUS_PATH_RE.match(raw) + current_left = _diff_path(match.group(1)) if match else None + continue + header = HUNK_HEADER_RE.match(raw) + if header is None: + continue + left_lines = _hunk_side_lines(header.group(1), header.group(2)) + right_lines = _hunk_side_lines(header.group(3), header.group(4)) + if current_left and left_lines: + bucket = hunks.setdefault(current_left, {"LEFT": set(), "RIGHT": set()}) + bucket["LEFT"].update(left_lines) + if current_right and right_lines: + bucket = hunks.setdefault(current_right, {"LEFT": set(), "RIGHT": set()}) + bucket["RIGHT"].update(right_lines) + return hunks + + +def comment_on_changed_hunk( + path: str, + line: int, + hunks: dict[str, dict[str, set[int]]], + *, + side: str = "RIGHT", +) -> bool: + """Return whether ``path:line`` sits on a current-head changed hunk.""" + safe_path = safe_finding_path(path) + safe_line = safe_finding_line(line) + if safe_path is None or safe_line is None: + return False + side_key = side if side in {"LEFT", "RIGHT"} else "RIGHT" + return safe_line in hunks.get(safe_path, {}).get(side_key, set()) + + +def filter_payload_comments_to_hunks( + payload: dict[str, Any], + hunks: dict[str, dict[str, set[int]]], +) -> tuple[dict[str, Any], list[tuple[str, int]]]: + """Keep only comments whose path:line sits on a current-head changed hunk.""" + comments = payload.get("comments") + if not hunks or not isinstance(comments, list): + return payload, [] + kept: list[Any] = [] + skipped: list[tuple[str, int]] = [] + seen: set[tuple[str, int]] = set() + for comment in comments: + if not isinstance(comment, dict): + continue + path = safe_finding_path(comment.get("path")) + line = safe_finding_line(comment.get("line")) + if path is None or line is None: + continue + side = comment.get("side") + side_key = side if side in {"LEFT", "RIGHT"} else "RIGHT" + if comment_on_changed_hunk(path, line, hunks, side=side_key): + kept.append(comment) + continue + location = (path, line) + if location in seen: + continue + seen.add(location) + skipped.append(location) + filtered = dict(payload) + filtered["comments"] = kept + return filtered, skipped + + +def write_hunk_filtered_payload( + payload: dict[str, Any], + hunks: dict[str, dict[str, set[int]]], + output: Path, + skipped_path: Path | None = None, +) -> int: + """Write a hunk-filtered review payload and optional skipped ``path:line`` rows.""" + filtered, skipped = filter_payload_comments_to_hunks(payload, hunks) + comments = filtered.get("comments") + output.write_text(json.dumps(filtered, ensure_ascii=True), encoding="utf-8") + if skipped_path is not None: + skipped_path.write_text( + "".join(f"{path}:{line}\n" for path, line in skipped), + encoding="utf-8", + ) + return len(comments) if isinstance(comments, list) else 0 + + def record_attached_receipt(dest: Path, path: str, line: int) -> None: """Append one attached ``path:line`` row.""" safe_path = safe_finding_path(path) @@ -306,14 +420,16 @@ def render_inline_comment_failure_suffix( phrases: dict[tuple[str, int], str] | None = None, attached_locations: list[tuple[str, int]] | None = None, deferred_locations: list[tuple[str, int]] | None = None, + skipped_locations: list[tuple[str, int]] | None = None, retry_limit: int | None = None, ) -> str: """Return the PR-body suffix used when GitHub rejects inline comments.""" attached = attached_locations or [] deferred = deferred_locations or [] + skipped = skipped_locations or [] heading = ( "## Inline comment publication receipts" - if error_phrase or mixed_success or attached or deferred + if error_phrase or mixed_success or attached or deferred or skipped else "## Inline comment publishing failed" ) lines = [ @@ -355,7 +471,7 @@ def render_inline_comment_failure_suffix( "current-head changed hunks, or inspect the workflow log/control " "JSON and apply the changes manually." ) - elif not attached and not deferred: + elif not attached and not deferred and not skipped: lines.append( "GitHub did not accept the inline review comments, and the " "control JSON had no trusted path:line findings. Inspect the " @@ -382,6 +498,23 @@ def render_inline_comment_failure_suffix( "current-head changed hunks, or inspect the workflow log/control " "JSON and apply the changes manually." ) + if skipped: + if locations or attached or deferred: + lines.append("") + lines.append( + "These trusted current-head finding locations were not posted " + "because they sit outside every current-head changed hunk:" + ) + lines.append("") + lines.extend(render_inline_comment_receipts(skipped)) + if not locations and not deferred: + lines.append("") + lines.append( + "OpenCode did not copy suggested diffs into this PR-level body. " + "Re-run the review after those exact path:line anchors sit on " + "current-head changed hunks, or inspect the workflow log/control " + "JSON and apply the changes manually." + ) lines.append("") return "\n".join(lines) @@ -412,6 +545,7 @@ def render_inline_comment_failure_body( refused_receipts: list[tuple[str, int, str]] | None = None, attached_locations: list[tuple[str, int]] | None = None, deferred_locations: list[tuple[str, int]] | None = None, + skipped_locations: list[tuple[str, int]] | None = None, retry_limit: int | None = None, ) -> str: """Append the 422 fallback suffix to an existing REQUEST_CHANGES body.""" @@ -427,6 +561,11 @@ def render_inline_comment_failure_body( if deferred_locations is not None else [] ) + skipped = ( + _trusted_location_subset(skipped_locations, allowed) + if skipped_locations is not None + else [] + ) phrases: dict[tuple[str, int], str] | None = None if refused_receipts is not None: locations = [ @@ -440,20 +579,25 @@ def render_inline_comment_failure_body( if phrase and (path, line) in allowed } mixed_success = True - if not locations and not attached and not deferred: + if not locations and not attached and not deferred and not skipped: return body.rstrip("\n") + "\n" - elif refused_locations is None and attached_locations is None and deferred_locations is None: + elif ( + refused_locations is None + and attached_locations is None + and deferred_locations is None + and skipped_locations is None + ): locations = trusted_finding_locations(control) mixed_success = False elif refused_locations is None: locations = [] mixed_success = True - if not attached and not deferred: + if not attached and not deferred and not skipped: return body.rstrip("\n") + "\n" else: locations = [item for item in refused_locations if item in allowed] mixed_success = True - if not locations and not attached and not deferred: + if not locations and not attached and not deferred and not skipped: return body.rstrip("\n") + "\n" return body.rstrip("\n") + render_inline_comment_failure_suffix( locations, @@ -462,6 +606,7 @@ def render_inline_comment_failure_body( phrases=phrases, attached_locations=attached or None, deferred_locations=deferred or None, + skipped_locations=skipped or None, retry_limit=retry_limit, ) @@ -490,12 +635,33 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--refused-locations", type=Path) parser.add_argument("--attached-locations", type=Path) parser.add_argument("--deferred-locations", type=Path) + parser.add_argument("--skipped-locations", type=Path) parser.add_argument("--retry-limit", type=int) parser.add_argument("--record-refusal", action="store_true") parser.add_argument("--record-attach", action="store_true") parser.add_argument("--comment-file", type=Path) + parser.add_argument("--filter-hunks", action="store_true") + parser.add_argument("--payload", type=Path) + parser.add_argument("--hunks-diff", type=Path) args = parser.parse_args(argv) try: + if args.filter_hunks: + if args.payload is None or args.hunks_diff is None or args.output is None: + raise ValueError( + "--payload, --hunks-diff, and --output are required " + "with --filter-hunks" + ) + payload = load_control(args.payload) + hunks = parse_unified_diff_hunk_lines( + args.hunks_diff.read_text(encoding="utf-8") + ) + write_hunk_filtered_payload( + payload, + hunks, + args.output, + skipped_path=args.skipped_locations, + ) + return 0 if args.record_attach: if args.attached_locations is None or args.comment_file is None: raise ValueError( @@ -572,6 +738,7 @@ def main(argv: list[str] | None = None) -> int: refused_receipts = None attached_locations = None deferred_locations = None + skipped_locations = None if args.refused_locations is not None: parsed_receipts = parse_refused_receipts( args.refused_locations.read_text(encoding="utf-8") @@ -590,6 +757,10 @@ def main(argv: list[str] | None = None) -> int: deferred_locations = parse_refused_locations( args.deferred_locations.read_text(encoding="utf-8") ) + if args.skipped_locations is not None: + skipped_locations = parse_refused_locations( + args.skipped_locations.read_text(encoding="utf-8") + ) except (OSError, UnicodeDecodeError, ValueError) as exc: print(exc, file=sys.stderr) return 2 @@ -602,6 +773,7 @@ def main(argv: list[str] | None = None) -> int: refused_receipts=refused_receipts, attached_locations=attached_locations, deferred_locations=deferred_locations, + skipped_locations=skipped_locations, retry_limit=args.retry_limit, ), encoding="utf-8", diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index bc4222f7f..a52de34a5 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1487,6 +1487,9 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$workflow_file" '--attached-locations "$attached_locations_file"' "opencode mixed-success receipts persist attached path:line rows" assert_file_contains "$workflow_file" "--retry-limit" "opencode bounds one-at-a-time inline comment retries" assert_file_contains "$workflow_file" '${OPENCODE_INLINE_COMMENT_RETRY_LIMIT:-20}' "opencode default one-at-a-time retry cap is 20" + assert_file_contains "$workflow_file" "--filter-hunks" "opencode drops off-hunk inline comments before GitHub POST" + assert_file_contains "$workflow_file" "prefilter_inline_comments_to_hunks" "opencode prefilters inline comments against current-head hunks" + assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" assert_file_contains "$workflow_file" "publish_request_changes_from_control" "opencode review REQUEST_CHANGES path publishes findings from the control JSON" if awk '/format_request_changes_body\(\)/,/build_request_changes_review_payload\(\)/ { print }' "$workflow_file" | diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index f6cc8329e..150280297 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1629,6 +1629,11 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "--deferred-locations" in workflow assert "--retry-limit" in workflow assert "${OPENCODE_INLINE_COMMENT_RETRY_LIMIT:-20}" in workflow + assert "--filter-hunks" in workflow + assert "--hunks-diff" in workflow + assert "prefilter_inline_comments_to_hunks" in workflow + assert '--skipped-locations "$skipped_locations_file"' in workflow + assert "--unified=3" in workflow assert "accepted some inline comments" not in workflow assert "OPENCODE_EXHAUSTED_REKICK_" not in publish_step assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "10800"' not in publish_step diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 9da4d38af..906dd9d51 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -6,12 +6,15 @@ from scripts.ci.opencode_inline_comment_fallback import ( DEFAULT_SINGLE_COMMENT_RETRY_LIMIT, + comment_on_changed_hunk, + filter_payload_comments_to_hunks, github_error_is_unprocessable, github_publication_error_phrase, iter_single_comment_payloads, main, parse_refused_locations, parse_refused_receipts, + parse_unified_diff_hunk_lines, record_attached_receipt, record_refused_receipt, render_inline_comment_failure_body, @@ -19,6 +22,7 @@ render_single_comment_review, single_comment_retry_limit, trusted_finding_locations, + write_hunk_filtered_payload, write_single_comment_payloads, ) @@ -976,3 +980,259 @@ def test_cli_records_attached_and_bounded_split(tmp_path): str(tmp_path / "missing-deferred.txt"), ] ) == 2 + + +EXAMPLE_UNIFIED_DIFF = """\ +diff --git a/scripts/ci/example.py b/scripts/ci/example.py +index 1111111..2222222 100644 +--- a/scripts/ci/example.py ++++ b/scripts/ci/example.py +@@ -5,7 +5,8 @@ def run(): + keep + keep + keep +- old ++ new + keep + keep + keep +diff --git a/scripts/ci/removed.py b/scripts/ci/removed.py +index 3333333..0000000 100644 +--- a/scripts/ci/removed.py ++++ /dev/null +@@ -10,3 +0,0 @@ leftover +-gone +-gone +-gone +diff --git a/scripts/ci/added.py b/scripts/ci/added.py +new file mode 100644 +index 0000000..4444444 +--- /dev/null ++++ b/scripts/ci/added.py +@@ -0,0 +1 @@ ++created +diff --git a/old/name.py b/scripts/ci/renamed.py +similarity index 90% +rename from old/name.py +rename to scripts/ci/renamed.py +index 5555555..6666666 100644 +--- a/old/name.py ++++ b/scripts/ci/renamed.py +@@ -2 +2 @@ +-old ++new +""" + + +def test_parse_unified_diff_hunk_lines_covers_github_commentable_ranges(): + hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF) + + assert hunks["scripts/ci/example.py"]["RIGHT"] == set(range(5, 13)) + assert hunks["scripts/ci/example.py"]["LEFT"] == set(range(5, 12)) + assert hunks["scripts/ci/removed.py"]["LEFT"] == {10, 11, 12} + assert hunks["scripts/ci/removed.py"]["RIGHT"] == set() + assert hunks["scripts/ci/added.py"]["RIGHT"] == {1} + assert hunks["scripts/ci/added.py"]["LEFT"] == set() + assert hunks["scripts/ci/renamed.py"]["RIGHT"] == {2} + assert hunks["old/name.py"]["LEFT"] == {2} + assert parse_unified_diff_hunk_lines("") == {} + assert parse_unified_diff_hunk_lines("+++ not-a-path\n--- also-bad\n") == {} + assert comment_on_changed_hunk("scripts/ci/example.py", 5, hunks) + assert comment_on_changed_hunk("scripts/ci/example.py", 12, hunks) + assert not comment_on_changed_hunk("scripts/ci/example.py", 20, hunks) + assert comment_on_changed_hunk( + "scripts/ci/removed.py", 11, hunks, side="LEFT" + ) + assert not comment_on_changed_hunk( + "scripts/ci/removed.py", 11, hunks, side="RIGHT" + ) + assert not comment_on_changed_hunk("../escape.py", 1, hunks) + assert not comment_on_changed_hunk("scripts/ci/example.py", 0, hunks) + assert comment_on_changed_hunk( + "scripts/ci/example.py", 7, hunks, side="NOPE" + ) + + +def test_filter_payload_comments_to_hunks_drops_off_hunk_before_post(): + hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF) + payload = _batch_payload( + {"path": "scripts/ci/example.py", "line": 7, "side": "RIGHT", "body": "on hunk"}, + {"path": "scripts/ci/example.py", "line": 20, "side": "RIGHT", "body": "past hunk"}, + {"path": "scripts/ci/example.py", "line": 20, "side": "RIGHT", "body": "duplicate skip"}, + {"path": "scripts/ci/removed.py", "line": 11, "side": "LEFT", "body": "deleted"}, + {"path": "scripts/ci/missing.py", "line": 3, "body": "unchanged path"}, + {"path": "../escape.py", "line": 1, "body": "unsafe"}, + "not-an-object", + ) + + filtered, skipped = filter_payload_comments_to_hunks(payload, hunks) + assert [item["line"] for item in filtered["comments"]] == [7, 11] + assert skipped == [ + ("scripts/ci/example.py", 20), + ("scripts/ci/missing.py", 3), + ] + unchanged, no_skip = filter_payload_comments_to_hunks(payload, {}) + assert unchanged["comments"] == payload["comments"] + assert no_skip == [] + no_comments, empty_skip = filter_payload_comments_to_hunks( + {"event": "COMMENT", "comments": "bad"}, hunks + ) + assert no_comments["comments"] == "bad" + assert empty_skip == [] + + +def test_skipped_receipts_list_off_hunk_locations(): + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "scripts/ci/example.py", "line": 20}, + {"path": "scripts/ci/ok.py", "line": 4}, + ), + attached_locations=[("scripts/ci/ok.py", 4)], + skipped_locations=[ + ("scripts/ci/example.py", 20), + ("scripts/ci/foreign.py", 1), + ], + ) + assert "GitHub accepted these trusted current-head finding locations:" in body + assert "- `scripts/ci/ok.py:4`" in body + assert ( + "were not posted because they sit outside every current-head changed hunk:" + in body + ) + assert "- `scripts/ci/example.py:20`" in body + assert "scripts/ci/foreign.py" not in body + skipped_only = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/example.py", "line": 20}), + skipped_locations=[("scripts/ci/example.py", 20)], + ) + assert ( + "were not posted because they sit outside every current-head changed hunk:" + in skipped_only + ) + assert "did not copy suggested diffs" in skipped_only + assert "did not accept the inline review comments" not in skipped_only + skipped_with_deferred = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/later.py", "line": 20}, + {"path": "scripts/ci/example.py", "line": 20}, + ), + deferred_locations=[("scripts/ci/later.py", 20)], + skipped_locations=[("scripts/ci/example.py", 20)], + retry_limit=1, + ) + assert "were not retried (retry limit 1):" in skipped_with_deferred + assert ( + "were not posted because they sit outside every current-head changed hunk:" + in skipped_with_deferred + ) + + +def test_cli_filters_payload_to_current_head_hunks(tmp_path): + payload = tmp_path / "batch.json" + payload.write_text( + json.dumps( + _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 7, + "side": "RIGHT", + "body": "keep", + }, + { + "path": "scripts/ci/example.py", + "line": 20, + "side": "RIGHT", + "body": "drop", + }, + ) + ), + encoding="utf-8", + ) + hunks_diff = tmp_path / "hunks.diff" + hunks_diff.write_text(EXAMPLE_UNIFIED_DIFF, encoding="utf-8") + output = tmp_path / "filtered.json" + skipped = tmp_path / "skipped.txt" + + assert ( + main( + [ + "--filter-hunks", + "--payload", + str(payload), + "--hunks-diff", + str(hunks_diff), + "--output", + str(output), + "--skipped-locations", + str(skipped), + ] + ) + == 0 + ) + filtered = json.loads(output.read_text(encoding="utf-8")) + assert [item["line"] for item in filtered["comments"]] == [7] + assert skipped.read_text(encoding="utf-8") == "scripts/ci/example.py:20\n" + assert write_hunk_filtered_payload( + json.loads(payload.read_text(encoding="utf-8")), + parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF), + tmp_path / "again.json", + ) == 1 + assert main(["--filter-hunks"]) == 2 + assert ( + main( + [ + "--filter-hunks", + "--payload", + str(tmp_path / "missing.json"), + "--hunks-diff", + str(hunks_diff), + "--output", + str(output), + ] + ) + == 2 + ) + + control_path = tmp_path / "control.json" + body_path = tmp_path / "body.md" + receipt = tmp_path / "receipt.md" + control_path.write_text( + json.dumps(control({"path": "scripts/ci/example.py", "line": 20})), + encoding="utf-8", + ) + body_path.write_text("## Findings\n", encoding="utf-8") + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--skipped-locations", + str(skipped), + ] + ) + == 0 + ) + assert "scripts/ci/example.py:20" in receipt.read_text(encoding="utf-8") + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--skipped-locations", + str(tmp_path / "missing-skipped.txt"), + ] + ) + == 2 + ) From 050b2c922cb148718116426741049426b126c9d1 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 17:13:01 +0900 Subject: [PATCH 09/34] fix(review): turn surviving suggested diffs into GitHub suggestions Authors could not one-click apply OpenCode inline repairs because the payload only posted ```diff fences. Convert + lines from those diffs into ```suggestion blocks on surviving RIGHT-side hunk comments. --- .../workflows/opencode-review-dispatch.yml | 20 ++- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 16 ++- .../ci/opencode_inline_comment_fallback.py | 73 ++++++++++ scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 6 + .../test_opencode_inline_comment_fallback.py | 129 ++++++++++++++++++ 7 files changed, 232 insertions(+), 14 deletions(-) diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index c7941a2eb..9b3f8110b 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -5708,18 +5708,14 @@ jobs: hunks_diff_file="$(mktemp)" filtered_file="$(mktemp)" : >"$skipped_file" - if [ -z "${OPENCODE_SOURCE_WORKDIR:-}" ] || [ -z "${PR_BASE_SHA:-}" ] || [ -z "${PR_HEAD_SHA:-}" ]; then - rm -f "$hunks_diff_file" "$filtered_file" - return 0 - fi - merge_base="$(git -C "$OPENCODE_SOURCE_WORKDIR" merge-base "$PR_BASE_SHA" "$PR_HEAD_SHA" 2>/dev/null || true)" - if [ -z "$merge_base" ]; then - merge_base="$PR_BASE_SHA" - fi - if ! git -C "$OPENCODE_SOURCE_WORKDIR" diff --unified=3 --find-renames --no-color --no-ext-diff \ - "$merge_base" "$PR_HEAD_SHA" >"$hunks_diff_file"; then - rm -f "$hunks_diff_file" "$filtered_file" - return 0 + : >"$hunks_diff_file" + if [ -n "${OPENCODE_SOURCE_WORKDIR:-}" ] && [ -n "${PR_BASE_SHA:-}" ] && [ -n "${PR_HEAD_SHA:-}" ]; then + merge_base="$(git -C "$OPENCODE_SOURCE_WORKDIR" merge-base "$PR_BASE_SHA" "$PR_HEAD_SHA" 2>/dev/null || true)" + if [ -z "$merge_base" ]; then + merge_base="$PR_BASE_SHA" + fi + git -C "$OPENCODE_SOURCE_WORKDIR" diff --unified=3 --find-renames --no-color --no-ext-diff \ + "$merge_base" "$PR_HEAD_SHA" >"$hunks_diff_file" || : >"$hunks_diff_file" fi if python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ --filter-hunks \ diff --git a/CHANGELOG.md b/CHANGELOG.md index fa8778d50..7f2ce9b65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Converted surviving OpenCode inline suggested diffs into GitHub `suggestion` blocks so authors can apply the replacement on the current-head hunk in one click. - Dropped OpenCode inline comments that sit outside every current-head changed hunk before the GitHub POST so those comments become overview receipts instead of a 422 that wipes the batch. - Capped one-at-a-time OpenCode inline retries at 20 comments and listed attached `path:line` beside refused receipts so the overview shows both outcomes, plus any locations left untried by the cap. - Kept each refused OpenCode inline comment's own GitHub 422 phrase next to its `path:line` so mixed retries do not collapse every failure into one shared error sentence. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 22b0cc41e..45d556e93 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -49,9 +49,16 @@ This matches the modern-review expectation that discussion belongs on the changed hunk rather than elsewhere in the file (Bacchelli & Bird, 2013; Sadowski et al., 2018). +After that filter, surviving RIGHT-side comments convert their +`` ```diff `` suggested-diff fence into a GitHub `` ```suggestion `` +block so the author can apply the replacement in one click (GitHub, +n.d.-c). Only `+` lines become the replacement; `n/a`, “cannot provide”, +LEFT-side comments, and replacements that would break the fence stay as +the original `` ```diff `` context. Suggested diffs still stay out of the +PR-level body. + The publisher calls this helper from `build_inline_comment_failure_body` with the same control object used to build the inline `comments` array. -Suggested diffs stay out of the PR-level body. ## Verification contract @@ -63,7 +70,8 @@ Suggested diffs stay out of the PR-level body. receipts that list attached path:line beside refused path:line, per-comment 422 phrases, the 20-comment one-at-a-time retry cap, and leftover path:line rows that were not retried, unified-diff hunk - parsing, and the pre-POST filter that drops off-hunk comments. + parsing, the pre-POST filter that drops off-hunk comments, and + conversion of surviving suggested diffs into GitHub suggestion blocks. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. @@ -88,6 +96,10 @@ GitHub. (n.d.-b). *Create a review comment for a pull request*. GitHub Docs. Retrieved August 13, 2026, from https://docs.github.com/en/rest/pulls/comments#create-a-review-comment-for-a-pull-request +GitHub. (n.d.-c). *Commenting on a pull request*. GitHub Docs. Retrieved +August 13, 2026, from +https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/reviewing-changes-in-pull-requests/commenting-on-a-pull-request#suggesting-changes-to-a-file + Sadowski, C., Söderberg, E., Church, L., Sipko, M., & Bacchelli, A. (2018). Modern code review: A case study at Google. In *Proceedings of the 40th International Conference on Software Engineering: Software Engineering in diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 57e90f891..0575482c9 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -17,6 +17,7 @@ HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") PLUS_PATH_RE = re.compile(r"^\+\+\+ b/(.+?)(?:\t.*)?$") MINUS_PATH_RE = re.compile(r"^--- a/(.+?)(?:\t.*)?$") +DIFF_FENCE_RE = re.compile(r"```diff\r?\n(.*?)```", re.DOTALL) def safe_finding_path(raw_path: object) -> str | None: @@ -225,6 +226,77 @@ def filter_payload_comments_to_hunks( return filtered, skipped +def extract_suggestion_replacement(diff_text: str) -> str | None: + """Return GitHub suggestion replacement lines from a unified suggested_diff.""" + raw = (diff_text or "").replace("\r\n", "\n") + stripped = raw.strip() + if not stripped: + return None + lowered = stripped.casefold() + if lowered.startswith("n/a") or lowered.startswith("cannot provide"): + return None + plus_lines: list[str] = [] + saw_diff_marker = False + for line in raw.splitlines(): + if line.startswith(("diff ", "index ", "---", "+++", "@@")): + saw_diff_marker = True + continue + if line.startswith("+"): + plus_lines.append(line[1:]) + saw_diff_marker = True + continue + if line.startswith("-"): + saw_diff_marker = True + if plus_lines: + replacement = "\n".join(plus_lines) + if "```" in replacement: + return None + return replacement + if saw_diff_marker: + return None + if "```" in stripped: + return None + return stripped.strip("\n") + + +def render_github_suggestion_block(replacement: str) -> str: + """Return one GitHub apply-suggestion fence for replacement lines.""" + return f"```suggestion\n{replacement}\n```" + + +def apply_github_suggestion_blocks(payload: dict[str, Any]) -> dict[str, Any]: + """Append GitHub suggestion fences to surviving RIGHT-side inline comments.""" + comments = payload.get("comments") + if not isinstance(comments, list): + return payload + updated: list[Any] = [] + for comment in comments: + if not isinstance(comment, dict): + updated.append(comment) + continue + body = comment.get("body") + side = comment.get("side") + if not isinstance(body, str) or side == "LEFT" or "```suggestion" in body: + updated.append(comment) + continue + replacement: str | None = None + for match in DIFF_FENCE_RE.finditer(body): + replacement = extract_suggestion_replacement(match.group(1)) + if replacement is not None: + break + if replacement is None: + updated.append(comment) + continue + new_comment = dict(comment) + new_comment["body"] = ( + f"{body.rstrip()}\n\n{render_github_suggestion_block(replacement)}\n" + ) + updated.append(new_comment) + rewritten = dict(payload) + rewritten["comments"] = updated + return rewritten + + def write_hunk_filtered_payload( payload: dict[str, Any], hunks: dict[str, dict[str, set[int]]], @@ -233,6 +305,7 @@ def write_hunk_filtered_payload( ) -> int: """Write a hunk-filtered review payload and optional skipped ``path:line`` rows.""" filtered, skipped = filter_payload_comments_to_hunks(payload, hunks) + filtered = apply_github_suggestion_blocks(filtered) comments = filtered.get("comments") output.write_text(json.dumps(filtered, ensure_ascii=True), encoding="utf-8") if skipped_path is not None: diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index a52de34a5..aaf119705 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1489,6 +1489,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$workflow_file" '${OPENCODE_INLINE_COMMENT_RETRY_LIMIT:-20}' "opencode default one-at-a-time retry cap is 20" assert_file_contains "$workflow_file" "--filter-hunks" "opencode drops off-hunk inline comments before GitHub POST" assert_file_contains "$workflow_file" "prefilter_inline_comments_to_hunks" "opencode prefilters inline comments against current-head hunks" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" '```suggestion' "opencode surviving hunk comments become GitHub suggestion blocks" assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" assert_file_contains "$workflow_file" "publish_request_changes_from_control" "opencode review REQUEST_CHANGES path publishes findings from the control JSON" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 150280297..cb990fb79 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1634,6 +1634,12 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "prefilter_inline_comments_to_hunks" in workflow assert '--skipped-locations "$skipped_locations_file"' in workflow assert "--unified=3" in workflow + assert "```suggestion" in Path("scripts/ci/opencode_inline_comment_fallback.py").read_text( + encoding="utf-8" + ) + assert "apply_github_suggestion_blocks" in Path( + "scripts/ci/opencode_inline_comment_fallback.py" + ).read_text(encoding="utf-8") assert "accepted some inline comments" not in workflow assert "OPENCODE_EXHAUSTED_REKICK_" not in publish_step assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "10800"' not in publish_step diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 906dd9d51..5fe58a5d4 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -6,7 +6,9 @@ from scripts.ci.opencode_inline_comment_fallback import ( DEFAULT_SINGLE_COMMENT_RETRY_LIMIT, + apply_github_suggestion_blocks, comment_on_changed_hunk, + extract_suggestion_replacement, filter_payload_comments_to_hunks, github_error_is_unprocessable, github_publication_error_phrase, @@ -17,6 +19,7 @@ parse_unified_diff_hunk_lines, record_attached_receipt, record_refused_receipt, + render_github_suggestion_block, render_inline_comment_failure_body, render_inline_comment_receipts, render_single_comment_review, @@ -1236,3 +1239,129 @@ def test_cli_filters_payload_to_current_head_hunks(tmp_path): ) == 2 ) + + +SUGGESTED_DIFF_BODY = """\ +### HIGH replace old line + +- Location: `scripts/ci/example.py:7` +- Problem: The old line is wrong. +- Root cause: The review found the current-head hunk. +- Fix: Replace the old line. +- Regression test: Keep the hunk prefilter. + +#### Suggested diff +```diff +@@ -7 +7 @@ +- old ++ new +``` +""" + + +def test_extract_suggestion_replacement_from_unified_and_plain_diffs(): + assert ( + extract_suggestion_replacement("@@ -7 +7 @@\n- old\n+ new\n") + == " new" + ) + assert extract_suggestion_replacement( + "diff --git a/a.py b/a.py\n--- a/a.py\n+++ b/a.py\n@@ -1,2 +1,3 @@\n keep\n-old\n+new1\n+new2\n" + ) == "new1\nnew2" + assert extract_suggestion_replacement("plain replacement") == "plain replacement" + assert extract_suggestion_replacement("Cannot provide diff - inaccessible") is None + assert extract_suggestion_replacement("n/a") is None + assert extract_suggestion_replacement("") is None + assert extract_suggestion_replacement("- only removed\n") is None + assert extract_suggestion_replacement("+has ``` fence") is None + assert extract_suggestion_replacement("plain ``` no") is None + assert render_github_suggestion_block(" new") == "```suggestion\n new\n```" + + +def test_apply_github_suggestion_blocks_on_surviving_right_comments(): + payload = _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 7, + "side": "RIGHT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/removed.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/plain.py", + "line": 4, + "side": "RIGHT", + "body": "no suggested diff here", + }, + { + "path": "scripts/ci/done.py", + "line": 2, + "side": "RIGHT", + "body": "already\n\n```suggestion\nkept\n```\n", + }, + "not-an-object", + ) + updated = apply_github_suggestion_blocks(payload) + bodies = [item["body"] if isinstance(item, dict) else item for item in updated["comments"]] + assert "```suggestion\n new\n```" in bodies[0] + assert "```diff" in bodies[0] + assert "```suggestion" not in bodies[1] + assert bodies[2] == "no suggested diff here" + assert bodies[3].count("```suggestion") == 1 + assert bodies[4] == "not-an-object" + unchanged = apply_github_suggestion_blocks({"event": "COMMENT", "comments": "bad"}) + assert unchanged["comments"] == "bad" + second_fence = apply_github_suggestion_blocks( + _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 7, + "side": "RIGHT", + "body": ( + "#### Suggested diff\n```diff\nCannot provide diff\n```\n\n" + "#### Suggested diff\n```diff\n+fixed\n```\n" + ), + } + ) + ) + assert "```suggestion\nfixed\n```" in second_fence["comments"][0]["body"] + + +def test_write_hunk_filtered_payload_adds_suggestion_on_surviving_hunk(tmp_path): + payload = _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 7, + "side": "RIGHT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/example.py", + "line": 20, + "side": "RIGHT", + "body": SUGGESTED_DIFF_BODY, + }, + ) + output = tmp_path / "filtered.json" + skipped = tmp_path / "skipped.txt" + assert ( + write_hunk_filtered_payload( + payload, + parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF), + output, + skipped_path=skipped, + ) + == 1 + ) + filtered = json.loads(output.read_text(encoding="utf-8")) + assert filtered["comments"][0]["line"] == 7 + assert "```suggestion\n new\n```" in filtered["comments"][0]["body"] + assert skipped.read_text(encoding="utf-8") == "scripts/ci/example.py:20\n" + empty_hunks_out = tmp_path / "unfiltered.json" + assert write_hunk_filtered_payload(payload, {}, empty_hunks_out) == 2 + unfiltered = json.loads(empty_hunks_out.read_text(encoding="utf-8")) + assert "```suggestion\n new\n```" in unfiltered["comments"][0]["body"] From e8d6966b50789648dbd0f983a600ab4e9a35c476 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 17:19:07 +0900 Subject: [PATCH 10/34] fix(review): set start_line on multi-line GitHub suggestions A surviving suggested_diff that removes more than one current-head line still posted as a single-line comment, so Apply suggestion only replaced the first line. Set start_line, line, and start_side when the full span sits on the same hunk; leave off-hunk spans single-line to avoid 422. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 12 +- .../ci/opencode_inline_comment_fallback.py | 76 +++++++++-- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 6 + .../test_opencode_inline_comment_fallback.py | 118 ++++++++++++++++++ 6 files changed, 199 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f2ce9b65..553dcb405 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Set `start_line`/`line` on surviving multi-line OpenCode GitHub suggestions so a replacement that spans more than one current-head hunk line applies as one range. - Converted surviving OpenCode inline suggested diffs into GitHub `suggestion` blocks so authors can apply the replacement on the current-head hunk in one click. - Dropped OpenCode inline comments that sit outside every current-head changed hunk before the GitHub POST so those comments become overview receipts instead of a 422 that wipes the batch. - Capped one-at-a-time OpenCode inline retries at 20 comments and listed attached `path:line` beside refused receipts so the overview shows both outcomes, plus any locations left untried by the cap. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 45d556e93..bdfc5c695 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -54,8 +54,12 @@ After that filter, surviving RIGHT-side comments convert their block so the author can apply the replacement in one click (GitHub, n.d.-c). Only `+` lines become the replacement; `n/a`, “cannot provide”, LEFT-side comments, and replacements that would break the fence stay as -the original `` ```diff `` context. Suggested diffs still stay out of the -PR-level body. +the original `` ```diff `` context. When the suggested_diff removes more +than one current-head line and every line from the finding through that +span sits on the same hunk, the comment also sets `start_line`, `line`, +and `start_side` so GitHub applies one multi-line suggestion range +(GitHub, n.d.-b). A range that would leave the hunk stays single-line. +Suggested diffs still stay out of the PR-level body. The publisher calls this helper from `build_inline_comment_failure_body` with the same control object used to build the inline `comments` array. @@ -71,7 +75,9 @@ with the same control object used to build the inline `comments` array. per-comment 422 phrases, the 20-comment one-at-a-time retry cap, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and - conversion of surviving suggested diffs into GitHub suggestion blocks. + conversion of surviving suggested diffs into GitHub suggestion blocks, + and `start_line`/`line` ranges when a multi-line replacement sits on + one current-head hunk. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 0575482c9..1c019b937 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -264,8 +264,47 @@ def render_github_suggestion_block(replacement: str) -> str: return f"```suggestion\n{replacement}\n```" -def apply_github_suggestion_blocks(payload: dict[str, Any]) -> dict[str, Any]: - """Append GitHub suggestion fences to surviving RIGHT-side inline comments.""" +def count_removed_suggestion_lines(diff_text: str) -> int: + """Return how many current-file lines a unified suggested_diff removes.""" + count = 0 + for line in (diff_text or "").splitlines(): + if line.startswith(("diff ", "index ", "---", "+++", "@@")): + continue + if line.startswith("-"): + count += 1 + return count + + +def suggestion_comment_range( + path: str, + line: int, + diff_text: str, + hunks: dict[str, dict[str, set[int]]] | None, + *, + side: str = "RIGHT", +) -> tuple[int | None, int]: + """Return ``(start_line, end_line)`` when a multi-line hunk range is safe.""" + safe_path = safe_finding_path(path) + safe_line = safe_finding_line(line) + if safe_path is None or safe_line is None: + fallback = line if isinstance(line, int) and not isinstance(line, bool) and line > 0 else 1 + return None, fallback + removed = count_removed_suggestion_lines(diff_text) + if removed <= 1 or not hunks: + return None, safe_line + end = safe_line + removed - 1 + side_key = side if side in {"LEFT", "RIGHT"} else "RIGHT" + hunk_lines = hunks.get(safe_path, {}).get(side_key, set()) + if all(candidate in hunk_lines for candidate in range(safe_line, end + 1)): + return safe_line, end + return None, safe_line + + +def apply_github_suggestion_blocks( + payload: dict[str, Any], + hunks: dict[str, dict[str, set[int]]] | None = None, +) -> dict[str, Any]: + """Append GitHub suggestion fences and multi-line ranges on surviving comments.""" comments = payload.get("comments") if not isinstance(comments, list): return payload @@ -276,21 +315,34 @@ def apply_github_suggestion_blocks(payload: dict[str, Any]) -> dict[str, Any]: continue body = comment.get("body") side = comment.get("side") - if not isinstance(body, str) or side == "LEFT" or "```suggestion" in body: + if not isinstance(body, str) or side == "LEFT": updated.append(comment) continue replacement: str | None = None + diff_text: str | None = None for match in DIFF_FENCE_RE.finditer(body): - replacement = extract_suggestion_replacement(match.group(1)) - if replacement is not None: + candidate = extract_suggestion_replacement(match.group(1)) + if candidate is not None: + replacement = candidate + diff_text = match.group(1) break - if replacement is None: - updated.append(comment) - continue new_comment = dict(comment) - new_comment["body"] = ( - f"{body.rstrip()}\n\n{render_github_suggestion_block(replacement)}\n" - ) + if replacement is not None: + if "```suggestion" not in body: + new_comment["body"] = ( + f"{body.rstrip()}\n\n{render_github_suggestion_block(replacement)}\n" + ) + path = safe_finding_path(comment.get("path")) + line = safe_finding_line(comment.get("line")) + side_key = side if side in {"LEFT", "RIGHT"} else "RIGHT" + if path is not None and line is not None: + start, end = suggestion_comment_range( + path, line, diff_text or "", hunks, side=side_key + ) + if start is not None: + new_comment["start_line"] = start + new_comment["line"] = end + new_comment["start_side"] = side_key updated.append(new_comment) rewritten = dict(payload) rewritten["comments"] = updated @@ -305,7 +357,7 @@ def write_hunk_filtered_payload( ) -> int: """Write a hunk-filtered review payload and optional skipped ``path:line`` rows.""" filtered, skipped = filter_payload_comments_to_hunks(payload, hunks) - filtered = apply_github_suggestion_blocks(filtered) + filtered = apply_github_suggestion_blocks(filtered, hunks) comments = filtered.get("comments") output.write_text(json.dumps(filtered, ensure_ascii=True), encoding="utf-8") if skipped_path is not None: diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index aaf119705..7eaf3b84b 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1490,6 +1490,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$workflow_file" "--filter-hunks" "opencode drops off-hunk inline comments before GitHub POST" assert_file_contains "$workflow_file" "prefilter_inline_comments_to_hunks" "opencode prefilters inline comments against current-head hunks" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" '```suggestion' "opencode surviving hunk comments become GitHub suggestion blocks" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "start_line" "opencode multi-line suggestions set GitHub start_line" assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" assert_file_contains "$workflow_file" "publish_request_changes_from_control" "opencode review REQUEST_CHANGES path publishes findings from the control JSON" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index cb990fb79..0f0f02d53 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1640,6 +1640,12 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "apply_github_suggestion_blocks" in Path( "scripts/ci/opencode_inline_comment_fallback.py" ).read_text(encoding="utf-8") + assert "suggestion_comment_range" in Path( + "scripts/ci/opencode_inline_comment_fallback.py" + ).read_text(encoding="utf-8") + assert "start_side" in Path( + "scripts/ci/opencode_inline_comment_fallback.py" + ).read_text(encoding="utf-8") assert "accepted some inline comments" not in workflow assert "OPENCODE_EXHAUSTED_REKICK_" not in publish_step assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "10800"' not in publish_step diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 5fe58a5d4..775db8ab4 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -8,6 +8,7 @@ DEFAULT_SINGLE_COMMENT_RETRY_LIMIT, apply_github_suggestion_blocks, comment_on_changed_hunk, + count_removed_suggestion_lines, extract_suggestion_replacement, filter_payload_comments_to_hunks, github_error_is_unprocessable, @@ -20,6 +21,7 @@ record_attached_receipt, record_refused_receipt, render_github_suggestion_block, + suggestion_comment_range, render_inline_comment_failure_body, render_inline_comment_receipts, render_single_comment_review, @@ -1309,6 +1311,7 @@ def test_apply_github_suggestion_blocks_on_surviving_right_comments(): bodies = [item["body"] if isinstance(item, dict) else item for item in updated["comments"]] assert "```suggestion\n new\n```" in bodies[0] assert "```diff" in bodies[0] + assert "start_line" not in updated["comments"][0] assert "```suggestion" not in bodies[1] assert bodies[2] == "no suggested diff here" assert bodies[3].count("```suggestion") == 1 @@ -1359,9 +1362,124 @@ def test_write_hunk_filtered_payload_adds_suggestion_on_surviving_hunk(tmp_path) ) filtered = json.loads(output.read_text(encoding="utf-8")) assert filtered["comments"][0]["line"] == 7 + assert "start_line" not in filtered["comments"][0] assert "```suggestion\n new\n```" in filtered["comments"][0]["body"] assert skipped.read_text(encoding="utf-8") == "scripts/ci/example.py:20\n" empty_hunks_out = tmp_path / "unfiltered.json" assert write_hunk_filtered_payload(payload, {}, empty_hunks_out) == 2 unfiltered = json.loads(empty_hunks_out.read_text(encoding="utf-8")) assert "```suggestion\n new\n```" in unfiltered["comments"][0]["body"] + assert "start_line" not in unfiltered["comments"][0] + + +MULTILINE_DIFF_BODY = """\ +### HIGH replace three lines + +- Location: `scripts/ci/example.py:5` + +#### Suggested diff +```diff +@@ -5,3 +5,2 @@ +- keep +- keep +- keep ++ first ++ second +``` +""" + + +def test_suggestion_comment_range_spans_on_hunk_removed_lines(): + hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF) + diff = ( + "@@ -5,3 +5,2 @@\n" + "- keep\n" + "- keep\n" + "- keep\n" + "+ first\n" + "+ second\n" + ) + assert count_removed_suggestion_lines(diff) == 3 + assert count_removed_suggestion_lines( + "diff --git a/a.py b/a.py\n--- a/a.py\n+++ b/a.py\n-old\n+new\n" + ) == 1 + assert suggestion_comment_range( + "scripts/ci/example.py", 5, diff, hunks + ) == (5, 7) + assert suggestion_comment_range( + "scripts/ci/example.py", 11, diff, hunks + ) == (None, 11) + assert suggestion_comment_range( + "scripts/ci/example.py", 5, diff, {} + ) == (None, 5) + assert suggestion_comment_range( + "scripts/ci/example.py", 7, "-old\n+new\n", hunks + ) == (None, 7) + assert suggestion_comment_range("../escape.py", 5, diff, hunks)[0] is None + assert suggestion_comment_range("scripts/ci/example.py", 0, diff, hunks) == ( + None, + 1, + ) + assert suggestion_comment_range( + "scripts/ci/example.py", 5, diff, hunks, side="NOPE" + ) == (5, 7) + assert suggestion_comment_range( + "scripts/ci/example.py", "5", diff, hunks + ) == (None, 1) + + +def test_apply_github_suggestion_blocks_sets_multiline_range(tmp_path): + hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF) + payload = _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 5, + "side": "RIGHT", + "body": MULTILINE_DIFF_BODY, + }, + { + "path": "scripts/ci/example.py", + "line": 11, + "side": "RIGHT", + "body": MULTILINE_DIFF_BODY, + }, + ) + updated = apply_github_suggestion_blocks(payload, hunks) + first, second = updated["comments"] + assert first["start_line"] == 5 + assert first["line"] == 7 + assert first["start_side"] == "RIGHT" + assert "```suggestion\n first\n second\n```" in first["body"] + assert "start_line" not in second + assert second["line"] == 11 + already = apply_github_suggestion_blocks( + _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 5, + "side": "RIGHT", + "body": MULTILINE_DIFF_BODY + "\n```suggestion\nkept\n```\n", + } + ), + hunks, + ) + assert already["comments"][0]["start_line"] == 5 + assert already["comments"][0]["body"].count("```suggestion") == 1 + no_line = apply_github_suggestion_blocks( + _batch_payload( + { + "path": "scripts/ci/example.py", + "line": True, + "side": "RIGHT", + "body": MULTILINE_DIFF_BODY, + } + ), + hunks, + ) + assert "start_line" not in no_line["comments"][0] + assert "```suggestion" in no_line["comments"][0]["body"] + output = tmp_path / "ranged.json" + assert write_hunk_filtered_payload(payload, hunks, output) == 2 + written = json.loads(output.read_text(encoding="utf-8")) + assert written["comments"][0]["start_line"] == 5 + assert written["comments"][0]["line"] == 7 From 7009465f64d3cae2896bdf0ee7565ba185d8a01a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 17:27:30 +0900 Subject: [PATCH 11/34] fix(review): list applyable suggestion ranges in overview receipts Authors could see refused and skipped path:line after a 422, but not which surviving hunks shipped as one-click GitHub suggestions. Persist path:line or path:start-end for comments that carry a suggestion fence. --- .../workflows/opencode-review-dispatch.yml | 53 +++-- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 10 +- .../ci/opencode_inline_comment_fallback.py | 143 +++++++++++- scripts/ci/test_strix_quick_gate.sh | 2 + tests/test_opencode_agent_contract.py | 4 + .../test_opencode_inline_comment_fallback.py | 218 ++++++++++++++++++ 7 files changed, 407 insertions(+), 24 deletions(-) diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 9b3f8110b..326862626 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -5702,13 +5702,18 @@ jobs: prefilter_inline_comments_to_hunks() { local payload_file="$1" local skipped_file="$2" + local applyable_file="${3:-}" local hunks_diff_file local filtered_file local merge_base + local -a filter_args hunks_diff_file="$(mktemp)" filtered_file="$(mktemp)" : >"$skipped_file" : >"$hunks_diff_file" + if [ -n "$applyable_file" ]; then + : >"$applyable_file" + fi if [ -n "${OPENCODE_SOURCE_WORKDIR:-}" ] && [ -n "${PR_BASE_SHA:-}" ] && [ -n "${PR_HEAD_SHA:-}" ]; then merge_base="$(git -C "$OPENCODE_SOURCE_WORKDIR" merge-base "$PR_BASE_SHA" "$PR_HEAD_SHA" 2>/dev/null || true)" if [ -z "$merge_base" ]; then @@ -5717,12 +5722,18 @@ jobs: git -C "$OPENCODE_SOURCE_WORKDIR" diff --unified=3 --find-renames --no-color --no-ext-diff \ "$merge_base" "$PR_HEAD_SHA" >"$hunks_diff_file" || : >"$hunks_diff_file" fi - if python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" \ - --filter-hunks \ - --payload "$payload_file" \ - --hunks-diff "$hunks_diff_file" \ - --output "$filtered_file" \ - --skipped-locations "$skipped_file"; then + filter_args=( + python3 "$GITHUB_WORKSPACE/scripts/ci/opencode_inline_comment_fallback.py" + --filter-hunks + --payload "$payload_file" + --hunks-diff "$hunks_diff_file" + --output "$filtered_file" + --skipped-locations "$skipped_file" + ) + if [ -n "$applyable_file" ]; then + filter_args+=(--applyable-locations "$applyable_file") + fi + if "${filter_args[@]}"; then mv "$filtered_file" "$payload_file" else rm -f "$filtered_file" @@ -5738,24 +5749,26 @@ jobs: local rewritten_payload_file local review_response_file local skipped_locations_file + local applyable_locations_file local comment_count gh_error_file="$(mktemp)" rewritten_payload_file="$(mktemp)" review_response_file="$(mktemp)" skipped_locations_file="$(mktemp)" + applyable_locations_file="$(mktemp)" body="$(ensure_review_body_has_change_graph "$body")" if jq --arg body "$body" '.body = $body' "$review_payload_file" >"$rewritten_payload_file"; then mv "$rewritten_payload_file" "$review_payload_file" else rm -f "$rewritten_payload_file" fi - prefilter_inline_comments_to_hunks "$review_payload_file" "$skipped_locations_file" + prefilter_inline_comments_to_hunks "$review_payload_file" "$skipped_locations_file" "$applyable_locations_file" comment_count="$(jq '.comments | length' "$review_payload_file" 2>/dev/null || printf '0')" if [ "$comment_count" = "0" ] && [ -s "$skipped_locations_file" ] \ && [ -n "$source_body_file" ] && [ -n "$control_json" ]; then build_inline_comment_failure_body \ "$source_body_file" "$fallback_body_file" "$control_json" \ - "" "" "" "" "$skipped_locations_file" || true + "" "" "" "" "$skipped_locations_file" "$applyable_locations_file" || true if [ -s "$fallback_body_file" ]; then body="$(cat "$fallback_body_file")" if jq --arg body "$body" '.body = $body | del(.comments)' \ @@ -5779,7 +5792,7 @@ jobs: "$review_payload_file" "$body" "$refused_locations_file" \ "$attached_locations_file" "$deferred_locations_file"; then if { [ -s "$refused_locations_file" ] || [ -s "$deferred_locations_file" ] \ - || [ -s "$skipped_locations_file" ]; } \ + || [ -s "$skipped_locations_file" ] || [ -s "$applyable_locations_file" ]; } \ && [ -n "$source_body_file" ] && [ -n "$control_json" ]; then mixed_error_file="$gh_error_file" if [ -s "${refused_locations_file}.errors" ]; then @@ -5789,7 +5802,7 @@ jobs: "$source_body_file" "$fallback_body_file" "$control_json" \ "$mixed_error_file" "$refused_locations_file" \ "$attached_locations_file" "$deferred_locations_file" \ - "$skipped_locations_file" || true + "$skipped_locations_file" "$applyable_locations_file" || true update_review_overview "$event" "$(cat "$fallback_body_file")" else update_review_overview "$event" "$body" @@ -5797,7 +5810,7 @@ jobs: rm -f "$gh_error_file" "$review_response_file" \ "$refused_locations_file" "${refused_locations_file}.errors" \ "$attached_locations_file" "$deferred_locations_file" \ - "$skipped_locations_file" + "$skipped_locations_file" "$applyable_locations_file" return 0 fi rm -f "$refused_locations_file" "${refused_locations_file}.errors" \ @@ -5806,9 +5819,11 @@ jobs: if [ -n "$source_body_file" ] && [ -n "$control_json" ]; then build_inline_comment_failure_body \ "$source_body_file" "$fallback_body_file" "$control_json" \ - "$gh_error_file" "" "" "" "$skipped_locations_file" || true + "$gh_error_file" "" "" "" "$skipped_locations_file" \ + "$applyable_locations_file" || true fi - rm -f "$gh_error_file" "$review_response_file" "$skipped_locations_file" + rm -f "$gh_error_file" "$review_response_file" \ + "$skipped_locations_file" "$applyable_locations_file" if [ "${REVIEW_PUBLICATION_STALE_HEAD:-}" = "1" ]; then printf '::error::OpenCode inline review publication stopped because PR head advanced beyond %s.\n' "$HEAD_SHA" return 1 @@ -5820,15 +5835,17 @@ jobs: fi return 1 fi - if [ -s "$skipped_locations_file" ] && [ -n "$source_body_file" ] && [ -n "$control_json" ]; then + if { [ -s "$skipped_locations_file" ] || [ -s "$applyable_locations_file" ]; } \ + && [ -n "$source_body_file" ] && [ -n "$control_json" ]; then build_inline_comment_failure_body \ "$source_body_file" "$fallback_body_file" "$control_json" \ - "" "" "" "" "$skipped_locations_file" || true + "" "" "" "" "$skipped_locations_file" "$applyable_locations_file" || true if [ -s "$fallback_body_file" ]; then body="$(cat "$fallback_body_file")" fi fi - rm -f "$gh_error_file" "$review_response_file" "$skipped_locations_file" + rm -f "$gh_error_file" "$review_response_file" \ + "$skipped_locations_file" "$applyable_locations_file" update_review_overview "$event" "$body" } @@ -5948,6 +5965,7 @@ jobs: local attached_locations_file="${6:-}" local deferred_locations_file="${7:-}" local skipped_locations_file="${8:-}" + local applyable_locations_file="${9:-}" local -a fallback_args fallback_args=( @@ -5972,6 +5990,9 @@ jobs: if [ -n "$skipped_locations_file" ]; then fallback_args+=(--skipped-locations "$skipped_locations_file") fi + if [ -n "$applyable_locations_file" ]; then + fallback_args+=(--applyable-locations "$applyable_locations_file") + fi "${fallback_args[@]}" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 553dcb405..449db9fa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Listed applyable OpenCode GitHub suggestion ranges (`path:line` or `path:start-end`) in the overview receipts so authors can see which surviving hunks shipped as one-click applies. - Set `start_line`/`line` on surviving multi-line OpenCode GitHub suggestions so a replacement that spans more than one current-head hunk line applies as one range. - Converted surviving OpenCode inline suggested diffs into GitHub `suggestion` blocks so authors can apply the replacement on the current-head hunk in one click. - Dropped OpenCode inline comments that sit outside every current-head changed hunk before the GitHub POST so those comments become overview receipts instead of a 422 that wipes the batch. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index bdfc5c695..b4b7a0fec 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -59,7 +59,10 @@ than one current-head line and every line from the finding through that span sits on the same hunk, the comment also sets `start_line`, `line`, and `start_side` so GitHub applies one multi-line suggestion range (GitHub, n.d.-b). A range that would leave the hunk stays single-line. -Suggested diffs still stay out of the PR-level body. +The publisher then persists those applyable ranges as overview receipts +(``path:line`` or ``path:start-end``) so the author can see which hunks +shipped as one-click GitHub suggestions (GitHub, n.d.-c). Suggested diffs +still stay out of the PR-level body. The publisher calls this helper from `build_inline_comment_failure_body` with the same control object used to build the inline `comments` array. @@ -76,8 +79,9 @@ with the same control object used to build the inline `comments` array. leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, - and `start_line`/`line` ranges when a multi-line replacement sits on - one current-head hunk. + `start_line`/`line` ranges when a multi-line replacement sits on one + current-head hunk, and overview receipts that list applyable + ``path:start-end`` suggestion ranges. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 1c019b937..21ddf19a0 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -349,11 +349,94 @@ def apply_github_suggestion_blocks( return rewritten +def format_applyable_range(path: str, start: int, end: int) -> str: + """Return ``path:line`` or ``path:start-end`` for one applyable suggestion.""" + if start == end: + return f"{path}:{start}" + return f"{path}:{start}-{end}" + + +def parse_applyable_ranges(text: str) -> list[tuple[str, int, int]]: + """Parse ``path:line`` or ``path:start-end`` applyable-suggestion rows.""" + ranges: list[tuple[str, int, int]] = [] + seen: set[tuple[str, int, int]] = set() + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + loc_text, _sep, _phrase = line.partition("\t") + if ":" not in loc_text: + continue + path_text, _, rest = loc_text.rpartition(":") + path = safe_finding_path(path_text) + if path is None: + continue + if "-" in rest: + start_text, _, end_text = rest.partition("-") + try: + start_value = int(start_text) + end_value = int(end_text) + except ValueError: + continue + else: + try: + start_value = end_value = int(rest) + except ValueError: + continue + start = safe_finding_line(start_value) + end = safe_finding_line(end_value) + if start is None or end is None or end < start: + continue + key = (path, start, end) + if key in seen: + continue + seen.add(key) + ranges.append(key) + return ranges + + +def applyable_suggestion_ranges( + payload: dict[str, Any], +) -> list[tuple[str, int, int]]: + """Return ``(path, start, end)`` for comments that carry a suggestion fence.""" + comments = payload.get("comments") + if not isinstance(comments, list): + return [] + ranges: list[tuple[str, int, int]] = [] + seen: set[tuple[str, int, int]] = set() + for comment in comments: + if not isinstance(comment, dict): + continue + body = comment.get("body") + if not isinstance(body, str) or "```suggestion" not in body: + continue + path = safe_finding_path(comment.get("path")) + end = safe_finding_line(comment.get("line")) + start_raw = comment.get("start_line") + start = safe_finding_line(start_raw) if start_raw is not None else end + if path is None or end is None or start is None: + continue + if start > end: + start, end = end, start + key = (path, start, end) + if key in seen: + continue + seen.add(key) + ranges.append(key) + return ranges + + +def render_applyable_receipts(ranges: list[tuple[str, int, int]]) -> list[str]: + """Return overview receipt lines for applyable suggestion ranges.""" + return [f"- `{format_applyable_range(path, start, end)}`" for path, start, end in ranges] + + def write_hunk_filtered_payload( payload: dict[str, Any], hunks: dict[str, dict[str, set[int]]], output: Path, skipped_path: Path | None = None, + applyable_path: Path | None = None, ) -> int: """Write a hunk-filtered review payload and optional skipped ``path:line`` rows.""" filtered, skipped = filter_payload_comments_to_hunks(payload, hunks) @@ -365,6 +448,14 @@ def write_hunk_filtered_payload( "".join(f"{path}:{line}\n" for path, line in skipped), encoding="utf-8", ) + if applyable_path is not None: + applyable_path.write_text( + "".join( + f"{format_applyable_range(path, start, end)}\n" + for path, start, end in applyable_suggestion_ranges(filtered) + ), + encoding="utf-8", + ) return len(comments) if isinstance(comments, list) else 0 @@ -546,15 +637,17 @@ def render_inline_comment_failure_suffix( attached_locations: list[tuple[str, int]] | None = None, deferred_locations: list[tuple[str, int]] | None = None, skipped_locations: list[tuple[str, int]] | None = None, + applyable_locations: list[tuple[str, int, int]] | None = None, retry_limit: int | None = None, ) -> str: """Return the PR-body suffix used when GitHub rejects inline comments.""" attached = attached_locations or [] deferred = deferred_locations or [] skipped = skipped_locations or [] + applyable = applyable_locations or [] heading = ( "## Inline comment publication receipts" - if error_phrase or mixed_success or attached or deferred or skipped + if error_phrase or mixed_success or attached or deferred or skipped or applyable else "## Inline comment publishing failed" ) lines = [ @@ -596,7 +689,7 @@ def render_inline_comment_failure_suffix( "current-head changed hunks, or inspect the workflow log/control " "JSON and apply the changes manually." ) - elif not attached and not deferred and not skipped: + elif not attached and not deferred and not skipped and not applyable: lines.append( "GitHub did not accept the inline review comments, and the " "control JSON had no trusted path:line findings. Inspect the " @@ -640,6 +733,12 @@ def render_inline_comment_failure_suffix( "current-head changed hunks, or inspect the workflow log/control " "JSON and apply the changes manually." ) + if applyable: + if locations or attached or deferred or skipped: + lines.append("") + lines.append("GitHub can apply these suggested replacements:") + lines.append("") + lines.extend(render_applyable_receipts(applyable)) lines.append("") return "\n".join(lines) @@ -661,6 +760,24 @@ def _trusted_location_subset( return kept +def _trusted_range_subset( + items: list[tuple[str, int, int]] | None, + allowed: set[tuple[str, int]], +) -> list[tuple[str, int, int]]: + """Return first-seen applyable ranges whose start is a trusted finding.""" + if not items: + return [] + kept: list[tuple[str, int, int]] = [] + seen: set[tuple[str, int, int]] = set() + for item in items: + path, start, _end = item + if (path, start) not in allowed or item in seen: + continue + seen.add(item) + kept.append(item) + return kept + + def render_inline_comment_failure_body( body: str, control: dict[str, Any], @@ -671,6 +788,7 @@ def render_inline_comment_failure_body( attached_locations: list[tuple[str, int]] | None = None, deferred_locations: list[tuple[str, int]] | None = None, skipped_locations: list[tuple[str, int]] | None = None, + applyable_locations: list[tuple[str, int, int]] | None = None, retry_limit: int | None = None, ) -> str: """Append the 422 fallback suffix to an existing REQUEST_CHANGES body.""" @@ -691,6 +809,11 @@ def render_inline_comment_failure_body( if skipped_locations is not None else [] ) + applyable = ( + _trusted_range_subset(applyable_locations, allowed) + if applyable_locations is not None + else [] + ) phrases: dict[tuple[str, int], str] | None = None if refused_receipts is not None: locations = [ @@ -704,25 +827,26 @@ def render_inline_comment_failure_body( if phrase and (path, line) in allowed } mixed_success = True - if not locations and not attached and not deferred and not skipped: + if not locations and not attached and not deferred and not skipped and not applyable: return body.rstrip("\n") + "\n" elif ( refused_locations is None and attached_locations is None and deferred_locations is None and skipped_locations is None + and applyable_locations is None ): locations = trusted_finding_locations(control) mixed_success = False elif refused_locations is None: locations = [] mixed_success = True - if not attached and not deferred and not skipped: + if not attached and not deferred and not skipped and not applyable: return body.rstrip("\n") + "\n" else: locations = [item for item in refused_locations if item in allowed] mixed_success = True - if not locations and not attached and not deferred and not skipped: + if not locations and not attached and not deferred and not skipped and not applyable: return body.rstrip("\n") + "\n" return body.rstrip("\n") + render_inline_comment_failure_suffix( locations, @@ -732,6 +856,7 @@ def render_inline_comment_failure_body( attached_locations=attached or None, deferred_locations=deferred or None, skipped_locations=skipped or None, + applyable_locations=applyable or None, retry_limit=retry_limit, ) @@ -761,6 +886,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--attached-locations", type=Path) parser.add_argument("--deferred-locations", type=Path) parser.add_argument("--skipped-locations", type=Path) + parser.add_argument("--applyable-locations", type=Path) parser.add_argument("--retry-limit", type=int) parser.add_argument("--record-refusal", action="store_true") parser.add_argument("--record-attach", action="store_true") @@ -785,6 +911,7 @@ def main(argv: list[str] | None = None) -> int: hunks, args.output, skipped_path=args.skipped_locations, + applyable_path=args.applyable_locations, ) return 0 if args.record_attach: @@ -864,6 +991,7 @@ def main(argv: list[str] | None = None) -> int: attached_locations = None deferred_locations = None skipped_locations = None + applyable_locations = None if args.refused_locations is not None: parsed_receipts = parse_refused_receipts( args.refused_locations.read_text(encoding="utf-8") @@ -886,6 +1014,10 @@ def main(argv: list[str] | None = None) -> int: skipped_locations = parse_refused_locations( args.skipped_locations.read_text(encoding="utf-8") ) + if args.applyable_locations is not None: + applyable_locations = parse_applyable_ranges( + args.applyable_locations.read_text(encoding="utf-8") + ) except (OSError, UnicodeDecodeError, ValueError) as exc: print(exc, file=sys.stderr) return 2 @@ -899,6 +1031,7 @@ def main(argv: list[str] | None = None) -> int: attached_locations=attached_locations, deferred_locations=deferred_locations, skipped_locations=skipped_locations, + applyable_locations=applyable_locations, retry_limit=args.retry_limit, ), encoding="utf-8", diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 7eaf3b84b..e76d25544 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1491,6 +1491,8 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$workflow_file" "prefilter_inline_comments_to_hunks" "opencode prefilters inline comments against current-head hunks" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" '```suggestion' "opencode surviving hunk comments become GitHub suggestion blocks" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "start_line" "opencode multi-line suggestions set GitHub start_line" + assert_file_contains "$workflow_file" "--applyable-locations" "opencode persists applyable suggestion ranges in overview receipts" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "GitHub can apply these suggested replacements:" "opencode overview lists applyable suggestion ranges" assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" assert_file_contains "$workflow_file" "publish_request_changes_from_control" "opencode review REQUEST_CHANGES path publishes findings from the control JSON" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 0f0f02d53..ae3658e91 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1646,6 +1646,10 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "start_side" in Path( "scripts/ci/opencode_inline_comment_fallback.py" ).read_text(encoding="utf-8") + assert "--applyable-locations" in workflow + assert "GitHub can apply these suggested replacements:" in Path( + "scripts/ci/opencode_inline_comment_fallback.py" + ).read_text(encoding="utf-8") assert "accepted some inline comments" not in workflow assert "OPENCODE_EXHAUSTED_REKICK_" not in publish_step assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "10800"' not in publish_step diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 775db8ab4..164c5df70 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -7,9 +7,11 @@ from scripts.ci.opencode_inline_comment_fallback import ( DEFAULT_SINGLE_COMMENT_RETRY_LIMIT, apply_github_suggestion_blocks, + applyable_suggestion_ranges, comment_on_changed_hunk, count_removed_suggestion_lines, extract_suggestion_replacement, + format_applyable_range, filter_payload_comments_to_hunks, github_error_is_unprocessable, github_publication_error_phrase, @@ -20,6 +22,8 @@ parse_unified_diff_hunk_lines, record_attached_receipt, record_refused_receipt, + parse_applyable_ranges, + render_applyable_receipts, render_github_suggestion_block, suggestion_comment_range, render_inline_comment_failure_body, @@ -1483,3 +1487,217 @@ def test_apply_github_suggestion_blocks_sets_multiline_range(tmp_path): written = json.loads(output.read_text(encoding="utf-8")) assert written["comments"][0]["start_line"] == 5 assert written["comments"][0]["line"] == 7 + + +def test_applyable_ranges_parse_and_render_path_start_end(): + assert format_applyable_range("scripts/ci/example.py", 5, 5) == ( + "scripts/ci/example.py:5" + ) + assert format_applyable_range("scripts/ci/example.py", 5, 7) == ( + "scripts/ci/example.py:5-7" + ) + parsed = parse_applyable_ranges( + "\n".join( + [ + "scripts/ci/example.py:5-7", + "scripts/ci/ok.py:4", + "scripts/ci/example.py:5-7", + "../escape.py:1-2", + "scripts/ci/bad.py:7-3", + "scripts/ci/nope.py:abc", + "scripts/ci/nope.py:1-x", + "# comment", + "", + "not-a-location", + ] + ) + ) + assert parsed == [ + ("scripts/ci/example.py", 5, 7), + ("scripts/ci/ok.py", 4, 4), + ] + assert render_applyable_receipts(parsed) == [ + "- `scripts/ci/example.py:5-7`", + "- `scripts/ci/ok.py:4`", + ] + assert applyable_suggestion_ranges({"comments": "bad"}) == [] + payload = apply_github_suggestion_blocks( + _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 5, + "side": "RIGHT", + "body": MULTILINE_DIFF_BODY, + }, + { + "path": "scripts/ci/example.py", + "line": 7, + "side": "RIGHT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/plain.py", + "line": 4, + "side": "RIGHT", + "body": "no suggestion", + }, + ), + parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF), + ) + assert applyable_suggestion_ranges(payload) == [ + ("scripts/ci/example.py", 5, 7), + ("scripts/ci/example.py", 7, 7), + ] + swapped = applyable_suggestion_ranges( + { + "comments": [ + { + "path": "scripts/ci/example.py", + "line": 5, + "start_line": 7, + "body": "```suggestion\nx\n```", + }, + { + "path": "scripts/ci/example.py", + "line": 7, + "start_line": 5, + "body": "```suggestion\ny\n```", + }, + "not-an-object", + ] + } + ) + assert swapped == [("scripts/ci/example.py", 5, 7)] + assert applyable_suggestion_ranges( + { + "comments": [ + { + "path": "../escape.py", + "line": 1, + "body": "```suggestion\nx\n```", + }, + { + "path": "scripts/ci/example.py", + "line": True, + "body": "```suggestion\nx\n```", + }, + { + "path": "scripts/ci/example.py", + "line": 5, + "start_line": 0, + "body": "```suggestion\nx\n```", + }, + ] + } + ) == [] + + +def test_overview_receipts_list_applyable_suggestion_ranges(tmp_path): + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/example.py", "line": 5}, + {"path": "scripts/ci/ok.py", "line": 4}, + {"path": "scripts/ci/skip.py", "line": 9}, + ), + skipped_locations=[("scripts/ci/skip.py", 9)], + applyable_locations=[ + ("scripts/ci/example.py", 5, 7), + ("scripts/ci/ok.py", 4, 4), + ("scripts/ci/foreign.py", 1, 2), + ], + ) + assert "GitHub can apply these suggested replacements:" in body + assert "- `scripts/ci/example.py:5-7`" in body + assert "- `scripts/ci/ok.py:4`" in body + assert "scripts/ci/foreign.py" not in body + assert "sit outside every current-head changed hunk" in body + applyable_only = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/example.py", "line": 5}), + applyable_locations=[("scripts/ci/example.py", 5, 7)], + ) + assert "GitHub can apply these suggested replacements:" in applyable_only + assert "- `scripts/ci/example.py:5-7`" in applyable_only + assert "did not accept the inline review comments" not in applyable_only + empty_applyable = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/ok.py", "line": 4}), + applyable_locations=[], + ) + assert "GitHub can apply these suggested replacements:" not in empty_applyable + + payload = tmp_path / "batch.json" + payload.write_text( + json.dumps( + _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 5, + "side": "RIGHT", + "body": MULTILINE_DIFF_BODY, + } + ) + ), + encoding="utf-8", + ) + hunks_diff = tmp_path / "hunks.diff" + hunks_diff.write_text(EXAMPLE_UNIFIED_DIFF, encoding="utf-8") + output = tmp_path / "filtered.json" + applyable = tmp_path / "applyable.txt" + assert ( + main( + [ + "--filter-hunks", + "--payload", + str(payload), + "--hunks-diff", + str(hunks_diff), + "--output", + str(output), + "--applyable-locations", + str(applyable), + ] + ) + == 0 + ) + assert applyable.read_text(encoding="utf-8") == "scripts/ci/example.py:5-7\n" + control_path = tmp_path / "control.json" + body_path = tmp_path / "body.md" + receipt = tmp_path / "receipt.md" + control_path.write_text( + json.dumps(control({"path": "scripts/ci/example.py", "line": 5})), + encoding="utf-8", + ) + body_path.write_text("## Findings\n", encoding="utf-8") + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--applyable-locations", + str(applyable), + ] + ) + == 0 + ) + assert "scripts/ci/example.py:5-7" in receipt.read_text(encoding="utf-8") + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--applyable-locations", + str(tmp_path / "missing-applyable.txt"), + ] + ) + == 2 + ) From 93c0afdfcbf30afaf92961b1af74d1b860ae7fe4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 17:42:26 +0900 Subject: [PATCH 12/34] fix(review): distinguish leftover diff fences from applyable suggestions Overview receipts listed applyable path:start-end ranges, but authors could not tell leftover ```diff fences (cannot-provide / LEFT) from one-click GitHub suggestions. Persist those leftover path:line reasons in a separate overview section. --- .../workflows/opencode-review-dispatch.yml | 41 +- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 15 +- .../ci/opencode_inline_comment_fallback.py | 144 ++++- scripts/ci/test_strix_quick_gate.sh | 4 + tests/test_opencode_agent_contract.py | 14 + .../test_opencode_inline_comment_fallback.py | 558 ++++++++++++++++++ 7 files changed, 758 insertions(+), 19 deletions(-) diff --git a/.github/workflows/opencode-review-dispatch.yml b/.github/workflows/opencode-review-dispatch.yml index 326862626..f7324eae9 100644 --- a/.github/workflows/opencode-review-dispatch.yml +++ b/.github/workflows/opencode-review-dispatch.yml @@ -5703,6 +5703,7 @@ jobs: local payload_file="$1" local skipped_file="$2" local applyable_file="${3:-}" + local leftover_file="${4:-}" local hunks_diff_file local filtered_file local merge_base @@ -5714,6 +5715,9 @@ jobs: if [ -n "$applyable_file" ]; then : >"$applyable_file" fi + if [ -n "$leftover_file" ]; then + : >"$leftover_file" + fi if [ -n "${OPENCODE_SOURCE_WORKDIR:-}" ] && [ -n "${PR_BASE_SHA:-}" ] && [ -n "${PR_HEAD_SHA:-}" ]; then merge_base="$(git -C "$OPENCODE_SOURCE_WORKDIR" merge-base "$PR_BASE_SHA" "$PR_HEAD_SHA" 2>/dev/null || true)" if [ -z "$merge_base" ]; then @@ -5733,6 +5737,9 @@ jobs: if [ -n "$applyable_file" ]; then filter_args+=(--applyable-locations "$applyable_file") fi + if [ -n "$leftover_file" ]; then + filter_args+=(--leftover-diff-locations "$leftover_file") + fi if "${filter_args[@]}"; then mv "$filtered_file" "$payload_file" else @@ -5750,25 +5757,28 @@ jobs: local review_response_file local skipped_locations_file local applyable_locations_file + local leftover_diff_locations_file local comment_count gh_error_file="$(mktemp)" rewritten_payload_file="$(mktemp)" review_response_file="$(mktemp)" skipped_locations_file="$(mktemp)" applyable_locations_file="$(mktemp)" + leftover_diff_locations_file="$(mktemp)" body="$(ensure_review_body_has_change_graph "$body")" if jq --arg body "$body" '.body = $body' "$review_payload_file" >"$rewritten_payload_file"; then mv "$rewritten_payload_file" "$review_payload_file" else rm -f "$rewritten_payload_file" fi - prefilter_inline_comments_to_hunks "$review_payload_file" "$skipped_locations_file" "$applyable_locations_file" + prefilter_inline_comments_to_hunks "$review_payload_file" "$skipped_locations_file" "$applyable_locations_file" "$leftover_diff_locations_file" comment_count="$(jq '.comments | length' "$review_payload_file" 2>/dev/null || printf '0')" if [ "$comment_count" = "0" ] && [ -s "$skipped_locations_file" ] \ && [ -n "$source_body_file" ] && [ -n "$control_json" ]; then build_inline_comment_failure_body \ "$source_body_file" "$fallback_body_file" "$control_json" \ - "" "" "" "" "$skipped_locations_file" "$applyable_locations_file" || true + "" "" "" "" "$skipped_locations_file" "$applyable_locations_file" \ + "$leftover_diff_locations_file" || true if [ -s "$fallback_body_file" ]; then body="$(cat "$fallback_body_file")" if jq --arg body "$body" '.body = $body | del(.comments)' \ @@ -5792,7 +5802,8 @@ jobs: "$review_payload_file" "$body" "$refused_locations_file" \ "$attached_locations_file" "$deferred_locations_file"; then if { [ -s "$refused_locations_file" ] || [ -s "$deferred_locations_file" ] \ - || [ -s "$skipped_locations_file" ] || [ -s "$applyable_locations_file" ]; } \ + || [ -s "$skipped_locations_file" ] || [ -s "$applyable_locations_file" ] \ + || [ -s "$leftover_diff_locations_file" ]; } \ && [ -n "$source_body_file" ] && [ -n "$control_json" ]; then mixed_error_file="$gh_error_file" if [ -s "${refused_locations_file}.errors" ]; then @@ -5802,7 +5813,8 @@ jobs: "$source_body_file" "$fallback_body_file" "$control_json" \ "$mixed_error_file" "$refused_locations_file" \ "$attached_locations_file" "$deferred_locations_file" \ - "$skipped_locations_file" "$applyable_locations_file" || true + "$skipped_locations_file" "$applyable_locations_file" \ + "$leftover_diff_locations_file" || true update_review_overview "$event" "$(cat "$fallback_body_file")" else update_review_overview "$event" "$body" @@ -5810,7 +5822,8 @@ jobs: rm -f "$gh_error_file" "$review_response_file" \ "$refused_locations_file" "${refused_locations_file}.errors" \ "$attached_locations_file" "$deferred_locations_file" \ - "$skipped_locations_file" "$applyable_locations_file" + "$skipped_locations_file" "$applyable_locations_file" \ + "$leftover_diff_locations_file" return 0 fi rm -f "$refused_locations_file" "${refused_locations_file}.errors" \ @@ -5820,10 +5833,11 @@ jobs: build_inline_comment_failure_body \ "$source_body_file" "$fallback_body_file" "$control_json" \ "$gh_error_file" "" "" "" "$skipped_locations_file" \ - "$applyable_locations_file" || true + "$applyable_locations_file" "$leftover_diff_locations_file" || true fi rm -f "$gh_error_file" "$review_response_file" \ - "$skipped_locations_file" "$applyable_locations_file" + "$skipped_locations_file" "$applyable_locations_file" \ + "$leftover_diff_locations_file" if [ "${REVIEW_PUBLICATION_STALE_HEAD:-}" = "1" ]; then printf '::error::OpenCode inline review publication stopped because PR head advanced beyond %s.\n' "$HEAD_SHA" return 1 @@ -5835,17 +5849,20 @@ jobs: fi return 1 fi - if { [ -s "$skipped_locations_file" ] || [ -s "$applyable_locations_file" ]; } \ + if { [ -s "$skipped_locations_file" ] || [ -s "$applyable_locations_file" ] \ + || [ -s "$leftover_diff_locations_file" ]; } \ && [ -n "$source_body_file" ] && [ -n "$control_json" ]; then build_inline_comment_failure_body \ "$source_body_file" "$fallback_body_file" "$control_json" \ - "" "" "" "" "$skipped_locations_file" "$applyable_locations_file" || true + "" "" "" "" "$skipped_locations_file" "$applyable_locations_file" \ + "$leftover_diff_locations_file" || true if [ -s "$fallback_body_file" ]; then body="$(cat "$fallback_body_file")" fi fi rm -f "$gh_error_file" "$review_response_file" \ - "$skipped_locations_file" "$applyable_locations_file" + "$skipped_locations_file" "$applyable_locations_file" \ + "$leftover_diff_locations_file" update_review_overview "$event" "$body" } @@ -5966,6 +5983,7 @@ jobs: local deferred_locations_file="${7:-}" local skipped_locations_file="${8:-}" local applyable_locations_file="${9:-}" + local leftover_diff_locations_file="${10:-}" local -a fallback_args fallback_args=( @@ -5993,6 +6011,9 @@ jobs: if [ -n "$applyable_locations_file" ]; then fallback_args+=(--applyable-locations "$applyable_locations_file") fi + if [ -n "$leftover_diff_locations_file" ]; then + fallback_args+=(--leftover-diff-locations "$leftover_diff_locations_file") + fi "${fallback_args[@]}" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 449db9fa1..3359bda14 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Distinguished applyable OpenCode GitHub suggestion ranges from leftover ```diff fences (`cannot-provide` or `LEFT`) in the overview receipts so authors can see which hunks are one-click applies and which still need a manual edit. - Listed applyable OpenCode GitHub suggestion ranges (`path:line` or `path:start-end`) in the overview receipts so authors can see which surviving hunks shipped as one-click applies. - Set `start_line`/`line` on surviving multi-line OpenCode GitHub suggestions so a replacement that spans more than one current-head hunk line applies as one range. - Converted surviving OpenCode inline suggested diffs into GitHub `suggestion` blocks so authors can apply the replacement on the current-head hunk in one click. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index b4b7a0fec..1a49c5721 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -61,8 +61,13 @@ and `start_side` so GitHub applies one multi-line suggestion range (GitHub, n.d.-b). A range that would leave the hunk stays single-line. The publisher then persists those applyable ranges as overview receipts (``path:line`` or ``path:start-end``) so the author can see which hunks -shipped as one-click GitHub suggestions (GitHub, n.d.-c). Suggested diffs -still stay out of the PR-level body. +shipped as one-click GitHub suggestions (GitHub, n.d.-c). Comments that +kept only a `` ```diff `` fence are listed separately with the reason +``cannot-provide`` (``n/a``, “cannot provide”, fence-breaking +replacement, or no ``+`` lines) or ``LEFT`` (GitHub cannot apply a +suggestion on the deleted side; GitHub, n.d.-b, n.d.-c). A comment that +already has `` ```suggestion `` is applyable, not leftover. Suggested +diffs still stay out of the PR-level body. The publisher calls this helper from `build_inline_comment_failure_body` with the same control object used to build the inline `comments` array. @@ -80,8 +85,10 @@ with the same control object used to build the inline `comments` array. parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, `start_line`/`line` ranges when a multi-line replacement sits on one - current-head hunk, and overview receipts that list applyable - ``path:start-end`` suggestion ranges. + current-head hunk, overview receipts that list applyable + ``path:start-end`` suggestion ranges, and a separate leftover-diff + receipt list that labels remaining `` ```diff `` fences as + ``cannot-provide`` or ``LEFT``. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 21ddf19a0..47645c2f7 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -13,6 +13,7 @@ DEFAULT_SINGLE_COMMENT_RETRY_LIMIT = 20 ERROR_PHRASE_MAX_CHARS = 240 +LEFTOVER_DIFF_REASONS = frozenset({"LEFT", "cannot-provide"}) HTTP_422_LINE_RE = re.compile(r"(?im)^(?:gh:\s*)?(.*HTTP 422.*)$") HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") PLUS_PATH_RE = re.compile(r"^\+\+\+ b/(.+?)(?:\t.*)?$") @@ -431,12 +432,68 @@ def render_applyable_receipts(ranges: list[tuple[str, int, int]]) -> list[str]: return [f"- `{format_applyable_range(path, start, end)}`" for path, start, end in ranges] +def leftover_diff_fence_reason(comment: dict[str, Any]) -> str | None: + """Return ``LEFT`` or ``cannot-provide`` when a comment kept only a diff fence.""" + body = comment.get("body") + if not isinstance(body, str) or "```diff" not in body: + return None + if "```suggestion" in body: + return None + if comment.get("side") == "LEFT": + return "LEFT" + return "cannot-provide" + + +def leftover_diff_fence_receipts( + payload: dict[str, Any], +) -> list[tuple[str, int, str]]: + """Return ``(path, line, reason)`` for comments that kept only a `` ```diff `` fence.""" + comments = payload.get("comments") + if not isinstance(comments, list): + return [] + receipts: list[tuple[str, int, str]] = [] + seen: set[tuple[str, int]] = set() + for comment in comments: + if not isinstance(comment, dict): + continue + reason = leftover_diff_fence_reason(comment) + if reason is None: + continue + path = safe_finding_path(comment.get("path")) + line = safe_finding_line(comment.get("line")) + if path is None or line is None: + continue + key = (path, line) + if key in seen: + continue + seen.add(key) + receipts.append((path, line, reason)) + return receipts + + +def parse_leftover_diff_receipts(text: str) -> list[tuple[str, int, str]]: + """Parse ``path:lineLEFT|cannot-provide`` leftover-diff rows.""" + return [ + (path, line, phrase) + for path, line, phrase in parse_refused_receipts(text) + if phrase in LEFTOVER_DIFF_REASONS + ] + + +def render_leftover_diff_receipts( + receipts: list[tuple[str, int, str]], +) -> list[str]: + """Return overview receipt lines for leftover `` ```diff `` fences.""" + return [f"- `{path}:{line}` — {reason}" for path, line, reason in receipts] + + def write_hunk_filtered_payload( payload: dict[str, Any], hunks: dict[str, dict[str, set[int]]], output: Path, skipped_path: Path | None = None, applyable_path: Path | None = None, + leftover_path: Path | None = None, ) -> int: """Write a hunk-filtered review payload and optional skipped ``path:line`` rows.""" filtered, skipped = filter_payload_comments_to_hunks(payload, hunks) @@ -456,6 +513,14 @@ def write_hunk_filtered_payload( ), encoding="utf-8", ) + if leftover_path is not None: + leftover_path.write_text( + "".join( + f"{path}:{line}\t{reason}\n" + for path, line, reason in leftover_diff_fence_receipts(filtered) + ), + encoding="utf-8", + ) return len(comments) if isinstance(comments, list) else 0 @@ -638,6 +703,7 @@ def render_inline_comment_failure_suffix( deferred_locations: list[tuple[str, int]] | None = None, skipped_locations: list[tuple[str, int]] | None = None, applyable_locations: list[tuple[str, int, int]] | None = None, + leftover_locations: list[tuple[str, int, str]] | None = None, retry_limit: int | None = None, ) -> str: """Return the PR-body suffix used when GitHub rejects inline comments.""" @@ -645,9 +711,18 @@ def render_inline_comment_failure_suffix( deferred = deferred_locations or [] skipped = skipped_locations or [] applyable = applyable_locations or [] + leftover = leftover_locations or [] heading = ( "## Inline comment publication receipts" - if error_phrase or mixed_success or attached or deferred or skipped or applyable + if ( + error_phrase + or mixed_success + or attached + or deferred + or skipped + or applyable + or leftover + ) else "## Inline comment publishing failed" ) lines = [ @@ -689,7 +764,7 @@ def render_inline_comment_failure_suffix( "current-head changed hunks, or inspect the workflow log/control " "JSON and apply the changes manually." ) - elif not attached and not deferred and not skipped and not applyable: + elif not attached and not deferred and not skipped and not applyable and not leftover: lines.append( "GitHub did not accept the inline review comments, and the " "control JSON had no trusted path:line findings. Inspect the " @@ -739,6 +814,14 @@ def render_inline_comment_failure_suffix( lines.append("GitHub can apply these suggested replacements:") lines.append("") lines.extend(render_applyable_receipts(applyable)) + if leftover: + if locations or attached or deferred or skipped or applyable: + lines.append("") + lines.append( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + lines.append("") + lines.extend(render_leftover_diff_receipts(leftover)) lines.append("") return "\n".join(lines) @@ -778,6 +861,27 @@ def _trusted_range_subset( return kept +def _trusted_receipt_subset( + items: list[tuple[str, int, str]] | None, + allowed: set[tuple[str, int]], +) -> list[tuple[str, int, str]]: + """Return first-seen leftover receipts whose path:line is a trusted finding.""" + if not items: + return [] + kept: list[tuple[str, int, str]] = [] + seen: set[tuple[str, int]] = set() + for path, line, reason in items: + if ( + (path, line) not in allowed + or (path, line) in seen + or reason not in LEFTOVER_DIFF_REASONS + ): + continue + seen.add((path, line)) + kept.append((path, line, reason)) + return kept + + def render_inline_comment_failure_body( body: str, control: dict[str, Any], @@ -789,6 +893,7 @@ def render_inline_comment_failure_body( deferred_locations: list[tuple[str, int]] | None = None, skipped_locations: list[tuple[str, int]] | None = None, applyable_locations: list[tuple[str, int, int]] | None = None, + leftover_locations: list[tuple[str, int, str]] | None = None, retry_limit: int | None = None, ) -> str: """Append the 422 fallback suffix to an existing REQUEST_CHANGES body.""" @@ -814,6 +919,11 @@ def render_inline_comment_failure_body( if applyable_locations is not None else [] ) + leftover = ( + _trusted_receipt_subset(leftover_locations, allowed) + if leftover_locations is not None + else [] + ) phrases: dict[tuple[str, int], str] | None = None if refused_receipts is not None: locations = [ @@ -827,7 +937,14 @@ def render_inline_comment_failure_body( if phrase and (path, line) in allowed } mixed_success = True - if not locations and not attached and not deferred and not skipped and not applyable: + if ( + not locations + and not attached + and not deferred + and not skipped + and not applyable + and not leftover + ): return body.rstrip("\n") + "\n" elif ( refused_locations is None @@ -835,18 +952,26 @@ def render_inline_comment_failure_body( and deferred_locations is None and skipped_locations is None and applyable_locations is None + and leftover_locations is None ): locations = trusted_finding_locations(control) mixed_success = False elif refused_locations is None: locations = [] mixed_success = True - if not attached and not deferred and not skipped and not applyable: + if not attached and not deferred and not skipped and not applyable and not leftover: return body.rstrip("\n") + "\n" else: locations = [item for item in refused_locations if item in allowed] mixed_success = True - if not locations and not attached and not deferred and not skipped and not applyable: + if ( + not locations + and not attached + and not deferred + and not skipped + and not applyable + and not leftover + ): return body.rstrip("\n") + "\n" return body.rstrip("\n") + render_inline_comment_failure_suffix( locations, @@ -857,6 +982,7 @@ def render_inline_comment_failure_body( deferred_locations=deferred or None, skipped_locations=skipped or None, applyable_locations=applyable or None, + leftover_locations=leftover or None, retry_limit=retry_limit, ) @@ -887,6 +1013,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--deferred-locations", type=Path) parser.add_argument("--skipped-locations", type=Path) parser.add_argument("--applyable-locations", type=Path) + parser.add_argument("--leftover-diff-locations", type=Path) parser.add_argument("--retry-limit", type=int) parser.add_argument("--record-refusal", action="store_true") parser.add_argument("--record-attach", action="store_true") @@ -912,6 +1039,7 @@ def main(argv: list[str] | None = None) -> int: args.output, skipped_path=args.skipped_locations, applyable_path=args.applyable_locations, + leftover_path=args.leftover_diff_locations, ) return 0 if args.record_attach: @@ -992,6 +1120,7 @@ def main(argv: list[str] | None = None) -> int: deferred_locations = None skipped_locations = None applyable_locations = None + leftover_locations = None if args.refused_locations is not None: parsed_receipts = parse_refused_receipts( args.refused_locations.read_text(encoding="utf-8") @@ -1018,6 +1147,10 @@ def main(argv: list[str] | None = None) -> int: applyable_locations = parse_applyable_ranges( args.applyable_locations.read_text(encoding="utf-8") ) + if args.leftover_diff_locations is not None: + leftover_locations = parse_leftover_diff_receipts( + args.leftover_diff_locations.read_text(encoding="utf-8") + ) except (OSError, UnicodeDecodeError, ValueError) as exc: print(exc, file=sys.stderr) return 2 @@ -1032,6 +1165,7 @@ def main(argv: list[str] | None = None) -> int: deferred_locations=deferred_locations, skipped_locations=skipped_locations, applyable_locations=applyable_locations, + leftover_locations=leftover_locations, retry_limit=args.retry_limit, ), encoding="utf-8", diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index e76d25544..a8cd41e4c 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1493,6 +1493,10 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "start_line" "opencode multi-line suggestions set GitHub start_line" assert_file_contains "$workflow_file" "--applyable-locations" "opencode persists applyable suggestion ranges in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "GitHub can apply these suggested replacements:" "opencode overview lists applyable suggestion ranges" + assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover suggested-diff fences separately from applyable ranges" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover LEFT and cannot-provide diffs" + assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" assert_file_contains "$workflow_file" "publish_request_changes_from_control" "opencode review REQUEST_CHANGES path publishes findings from the control JSON" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index ae3658e91..5ef087afa 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1650,6 +1650,20 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "GitHub can apply these suggested replacements:" in Path( "scripts/ci/opencode_inline_comment_fallback.py" ).read_text(encoding="utf-8") + assert "--leftover-diff-locations" in workflow + helper = Path("scripts/ci/opencode_inline_comment_fallback.py").read_text( + encoding="utf-8" + ) + assert "leftover_diff_fence_receipts" in helper + assert ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + in helper + ) + assert "--leftover-diff-locations" in workflow + assert ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + in Path("scripts/ci/opencode_inline_comment_fallback.py").read_text(encoding="utf-8") + ) assert "accepted some inline comments" not in workflow assert "OPENCODE_EXHAUSTED_REKICK_" not in publish_step assert 'OPENCODE_TOTAL_RETRY_BUDGET_SECONDS: "10800"' not in publish_step diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 164c5df70..a698cfa64 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -6,8 +6,13 @@ from scripts.ci.opencode_inline_comment_fallback import ( DEFAULT_SINGLE_COMMENT_RETRY_LIMIT, + LEFTOVER_DIFF_REASONS, apply_github_suggestion_blocks, applyable_suggestion_ranges, + leftover_diff_fence_reason, + leftover_diff_fence_receipts, + parse_leftover_diff_receipts, + render_leftover_diff_receipts, comment_on_changed_hunk, count_removed_suggestion_lines, extract_suggestion_replacement, @@ -1701,3 +1706,556 @@ def test_overview_receipts_list_applyable_suggestion_ranges(tmp_path): ) == 2 ) + + +CANNOT_PROVIDE_DIFF_BODY = """\ +### HIGH no replacement + +- Location: `scripts/ci/example.py:12` + +#### Suggested diff +```diff +Cannot provide diff - original file inaccessible +``` +""" + +NA_DIFF_BODY = """\ +### HIGH n/a + +#### Suggested diff +```diff +n/a +``` +""" + + +def test_leftover_diff_receipts_separate_left_and_cannot_provide_from_applyable(): + hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF) + payload = apply_github_suggestion_blocks( + _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 5, + "side": "RIGHT", + "body": MULTILINE_DIFF_BODY, + }, + { + "path": "scripts/ci/example.py", + "line": 12, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + }, + { + "path": "scripts/ci/removed.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/example.py", + "line": 8, + "side": "RIGHT", + "body": NA_DIFF_BODY, + }, + { + "path": "scripts/ci/plain.py", + "line": 4, + "side": "RIGHT", + "body": "no suggested diff", + }, + ), + hunks, + ) + applyable = applyable_suggestion_ranges(payload) + leftover = leftover_diff_fence_receipts(payload) + leftover_keys = {(path, line) for path, line, _reason in leftover} + applyable_starts = {(path, start) for path, start, _end in applyable} + assert applyable == [("scripts/ci/example.py", 5, 7)] + assert leftover == [ + ("scripts/ci/example.py", 12, "cannot-provide"), + ("scripts/ci/removed.py", 11, "LEFT"), + ("scripts/ci/example.py", 8, "cannot-provide"), + ] + assert leftover_keys.isdisjoint(applyable_starts) + assert leftover_diff_fence_reason( + {"side": "RIGHT", "body": CANNOT_PROVIDE_DIFF_BODY} + ) == "cannot-provide" + assert leftover_diff_fence_reason( + {"side": "LEFT", "body": SUGGESTED_DIFF_BODY} + ) == "LEFT" + assert leftover_diff_fence_reason( + {"side": "RIGHT", "body": "already\n```suggestion\nnew\n```\n"} + ) is None + assert leftover_diff_fence_reason({"side": "RIGHT", "body": "no fence"}) is None + assert leftover_diff_fence_receipts({"comments": "bad"}) == [] + assert leftover_diff_fence_receipts( + { + "comments": [ + { + "path": "../escape.py", + "line": 1, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/example.py", + "line": 12, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + }, + { + "path": "scripts/ci/example.py", + "line": 12, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + }, + "not-an-object", + ] + } + ) == [("scripts/ci/example.py", 12, "cannot-provide")] + parsed = parse_leftover_diff_receipts( + "scripts/ci/example.py:12\tcannot-provide\n" + "scripts/ci/removed.py:11\tLEFT\n" + "scripts/ci/example.py:9\tunknown\n" + ) + assert parsed == [ + ("scripts/ci/example.py", 12, "cannot-provide"), + ("scripts/ci/removed.py", 11, "LEFT"), + ] + assert LEFTOVER_DIFF_REASONS == {"LEFT", "cannot-provide"} + assert render_leftover_diff_receipts(parsed) == [ + "- `scripts/ci/example.py:12` — cannot-provide", + "- `scripts/ci/removed.py:11` — LEFT", + ] + + +def test_overview_lists_applyable_and_leftover_under_distinct_headings(tmp_path): + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/example.py", "line": 5}, + {"path": "scripts/ci/example.py", "line": 12}, + {"path": "scripts/ci/removed.py", "line": 11}, + {"path": "scripts/ci/foreign.py", "line": 1}, + ), + applyable_locations=[("scripts/ci/example.py", 5, 7)], + leftover_locations=[ + ("scripts/ci/example.py", 12, "cannot-provide"), + ("scripts/ci/removed.py", 11, "LEFT"), + ("scripts/ci/foreign.py", 1, "cannot-provide"), + ], + ) + applyable_heading = "GitHub can apply these suggested replacements:" + leftover_heading = ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + assert applyable_heading in body + assert leftover_heading in body + applyable_section = body.split(applyable_heading, 1)[1].split(leftover_heading, 1)[0] + leftover_section = body.split(leftover_heading, 1)[1] + assert "- `scripts/ci/example.py:5-7`" in applyable_section + assert "cannot-provide" not in applyable_section + assert "LEFT" not in applyable_section + assert "- `scripts/ci/example.py:12` — cannot-provide" in leftover_section + assert "- `scripts/ci/removed.py:11` — LEFT" in leftover_section + assert "scripts/ci/example.py:5-7" not in leftover_section + leftover_only = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/example.py", "line": 12}), + leftover_locations=[("scripts/ci/example.py", 12, "cannot-provide")], + ) + assert leftover_heading in leftover_only + assert applyable_heading not in leftover_only + empty_leftover = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/ok.py", "line": 4}), + leftover_locations=[], + ) + assert leftover_heading not in empty_leftover + + payload = tmp_path / "batch.json" + payload.write_text( + json.dumps( + _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 5, + "side": "RIGHT", + "body": MULTILINE_DIFF_BODY, + }, + { + "path": "scripts/ci/example.py", + "line": 12, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + }, + { + "path": "scripts/ci/removed.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + ) + ), + encoding="utf-8", + ) + hunks_diff = tmp_path / "hunks.diff" + hunks_diff.write_text(EXAMPLE_UNIFIED_DIFF, encoding="utf-8") + output = tmp_path / "filtered.json" + applyable = tmp_path / "applyable.txt" + leftover = tmp_path / "leftover.txt" + assert ( + main( + [ + "--filter-hunks", + "--payload", + str(payload), + "--hunks-diff", + str(hunks_diff), + "--output", + str(output), + "--applyable-locations", + str(applyable), + "--leftover-diff-locations", + str(leftover), + ] + ) + == 0 + ) + assert applyable.read_text(encoding="utf-8") == "scripts/ci/example.py:5-7\n" + leftover_text = leftover.read_text(encoding="utf-8") + assert "scripts/ci/example.py:12\tcannot-provide\n" in leftover_text + assert "scripts/ci/removed.py:11\tLEFT\n" in leftover_text + assert "scripts/ci/example.py:5-7" not in leftover_text + control_path = tmp_path / "control.json" + body_path = tmp_path / "body.md" + receipt = tmp_path / "receipt.md" + control_path.write_text( + json.dumps( + control( + {"path": "scripts/ci/example.py", "line": 5}, + {"path": "scripts/ci/example.py", "line": 12}, + {"path": "scripts/ci/removed.py", "line": 11}, + ) + ), + encoding="utf-8", + ) + body_path.write_text("## Findings\n", encoding="utf-8") + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--applyable-locations", + str(applyable), + "--leftover-diff-locations", + str(leftover), + ] + ) + == 0 + ) + rendered = receipt.read_text(encoding="utf-8") + assert applyable_heading in rendered + assert leftover_heading in rendered + assert "- `scripts/ci/example.py:5-7`" in rendered + assert "- `scripts/ci/example.py:12` — cannot-provide" in rendered + assert "- `scripts/ci/removed.py:11` — LEFT" in rendered + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--leftover-diff-locations", + str(tmp_path / "missing-leftover.txt"), + ] + ) + == 2 + ) + + +CANNOT_PROVIDE_DIFF_BODY = """\ +### HIGH no replacement + +- Location: `scripts/ci/blocked.py:4` + +#### Suggested diff +```diff +Cannot provide diff - inaccessible +``` +""" + + +def test_leftover_diff_fences_are_not_applyable_suggestions(): + left_comment = { + "path": "scripts/ci/removed.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + } + cannot_comment = { + "path": "scripts/ci/blocked.py", + "line": 4, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + } + applyable_comment = { + "path": "scripts/ci/example.py", + "line": 7, + "side": "RIGHT", + "body": SUGGESTED_DIFF_BODY, + } + assert leftover_diff_fence_reason(left_comment) == "LEFT" + assert leftover_diff_fence_reason(cannot_comment) == "cannot-provide" + assert leftover_diff_fence_reason({"body": "no fence"}) is None + assert leftover_diff_fence_reason({"body": 12, "side": "LEFT"}) is None + assert leftover_diff_fence_reason({"body": "```suggestion\nx\n```"}) is None + assert leftover_diff_fence_reason( + {"body": "```diff\nn/a\n```\n\n```suggestion\nkept\n```\n"} + ) is None + converted = apply_github_suggestion_blocks( + _batch_payload(applyable_comment, left_comment, cannot_comment) + ) + assert leftover_diff_fence_reason(converted["comments"][0]) is None + assert leftover_diff_fence_receipts({"comments": "bad"}) == [] + assert leftover_diff_fence_receipts( + _batch_payload( + converted["comments"][0], + left_comment, + cannot_comment, + { + "path": "scripts/ci/removed.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "../escape.py", + "line": 1, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/blocked.py", + "line": True, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + }, + "not-an-object", + ) + ) == [ + ("scripts/ci/removed.py", 11, "LEFT"), + ("scripts/ci/blocked.py", 4, "cannot-provide"), + ] + parsed = parse_leftover_diff_receipts( + "\n".join( + [ + "scripts/ci/removed.py:11\tLEFT", + "scripts/ci/blocked.py:4\tcannot-provide", + "scripts/ci/removed.py:11\tLEFT", + "scripts/ci/example.py:7\tHTTP 422", + "scripts/ci/ok.py:4", + "../escape.py:1\tLEFT", + "# comment", + "", + ] + ) + ) + assert parsed == [ + ("scripts/ci/removed.py", 11, "LEFT"), + ("scripts/ci/blocked.py", 4, "cannot-provide"), + ] + assert render_leftover_diff_receipts(parsed) == [ + "- `scripts/ci/removed.py:11` — LEFT", + "- `scripts/ci/blocked.py:4` — cannot-provide", + ] + + +def test_overview_receipts_distinguish_applyable_from_leftover_diff_fences(tmp_path): + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/example.py", "line": 5}, + {"path": "scripts/ci/removed.py", "line": 11}, + {"path": "scripts/ci/blocked.py", "line": 4}, + ), + applyable_locations=[("scripts/ci/example.py", 5, 7)], + leftover_locations=[ + ("scripts/ci/removed.py", 11, "LEFT"), + ("scripts/ci/blocked.py", 4, "cannot-provide"), + ("scripts/ci/foreign.py", 1, "LEFT"), + ("scripts/ci/blocked.py", 4, "LEFT"), + ("scripts/ci/example.py", 5, "HTTP 422"), + ], + ) + assert "GitHub can apply these suggested replacements:" in body + assert "- `scripts/ci/example.py:5-7`" in body + assert "These comments still have a suggested-diff fence that GitHub cannot apply:" in body + assert "- `scripts/ci/removed.py:11` — LEFT" in body + assert "- `scripts/ci/blocked.py:4` — cannot-provide" in body + assert body.index("GitHub can apply these suggested replacements:") < body.index( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + assert "scripts/ci/foreign.py" not in body + leftover_only = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/removed.py", "line": 11}), + leftover_locations=[("scripts/ci/removed.py", 11, "LEFT")], + ) + assert "These comments still have a suggested-diff fence that GitHub cannot apply:" in leftover_only + assert "- `scripts/ci/removed.py:11` — LEFT" in leftover_only + assert "GitHub can apply these suggested replacements:" not in leftover_only + assert "did not accept the inline review comments" not in leftover_only + empty_leftover = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/ok.py", "line": 4}), + leftover_locations=[], + ) + assert ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + not in empty_leftover + ) + refused_with_leftover = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "scripts/ci/blocked.py", "line": 4}, + ), + refused_receipts=[], + leftover_locations=[("scripts/ci/blocked.py", 4, "cannot-provide")], + ) + assert "- `scripts/ci/blocked.py:4` — cannot-provide" in refused_with_leftover + refused_locations_with_leftover = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/removed.py", "line": 11}), + refused_locations=[], + leftover_locations=[("scripts/ci/removed.py", 11, "LEFT")], + ) + assert "- `scripts/ci/removed.py:11` — LEFT" in refused_locations_with_leftover + empty_trusted_leftover = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/ok.py", "line": 4}), + leftover_locations=[("scripts/ci/removed.py", 11, "LEFT")], + ) + assert ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + not in empty_trusted_leftover + ) + + payload = tmp_path / "batch.json" + payload.write_text( + json.dumps( + _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 7, + "side": "RIGHT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/removed.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/blocked.py", + "line": 4, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + }, + ) + ), + encoding="utf-8", + ) + hunks_diff = tmp_path / "hunks.diff" + hunks_diff.write_text( + EXAMPLE_UNIFIED_DIFF + + "diff --git a/scripts/ci/blocked.py b/scripts/ci/blocked.py\n" + + "--- a/scripts/ci/blocked.py\n+++ b/scripts/ci/blocked.py\n" + + "@@ -4,1 +4,1 @@\n- old\n+ new\n", + encoding="utf-8", + ) + output = tmp_path / "filtered.json" + leftover = tmp_path / "leftover.txt" + applyable = tmp_path / "applyable.txt" + assert ( + main( + [ + "--filter-hunks", + "--payload", + str(payload), + "--hunks-diff", + str(hunks_diff), + "--output", + str(output), + "--applyable-locations", + str(applyable), + "--leftover-diff-locations", + str(leftover), + ] + ) + == 0 + ) + assert applyable.read_text(encoding="utf-8") == "scripts/ci/example.py:7\n" + assert leftover.read_text(encoding="utf-8") == ( + "scripts/ci/removed.py:11\tLEFT\n" + "scripts/ci/blocked.py:4\tcannot-provide\n" + ) + control_path = tmp_path / "control.json" + body_path = tmp_path / "body.md" + receipt = tmp_path / "receipt.md" + control_path.write_text( + json.dumps( + control( + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "scripts/ci/removed.py", "line": 11}, + {"path": "scripts/ci/blocked.py", "line": 4}, + ) + ), + encoding="utf-8", + ) + body_path.write_text("## Findings\n", encoding="utf-8") + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--applyable-locations", + str(applyable), + "--leftover-diff-locations", + str(leftover), + ] + ) + == 0 + ) + receipt_text = receipt.read_text(encoding="utf-8") + assert "scripts/ci/example.py:7" in receipt_text + assert "scripts/ci/removed.py:11` — LEFT" in receipt_text + assert "scripts/ci/blocked.py:4` — cannot-provide" in receipt_text + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--leftover-diff-locations", + str(tmp_path / "missing-leftover.txt"), + ] + ) + == 2 + ) From 58d5b0aa72080a5d2f775fb9fe1b1ab6b591b583 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 18:00:33 +0900 Subject: [PATCH 13/34] fix(review): persist leftover suggested-diff text as a manual-edit block Leftover cannot-provide and LEFT fences now keep a bounded excerpt in overview receipts as a distinct non-applyable ```diff block so authors can copy the replacement by hand without treating it as a GitHub suggestion range. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 14 +- .../ci/opencode_inline_comment_fallback.py | 149 +++++++++-- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 2 + .../test_opencode_inline_comment_fallback.py | 250 +++++++++++++++--- 6 files changed, 355 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3359bda14..359292f60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Persisted leftover OpenCode `cannot-provide` and `LEFT` suggested-diff replacement text as a distinct overview “Manual edit (not a GitHub suggestion):” ```diff block so authors can copy the change by hand without treating it as an applyable `path:line` / `path:start-end` GitHub suggestion. - Distinguished applyable OpenCode GitHub suggestion ranges from leftover ```diff fences (`cannot-provide` or `LEFT`) in the overview receipts so authors can see which hunks are one-click applies and which still need a manual edit. - Listed applyable OpenCode GitHub suggestion ranges (`path:line` or `path:start-end`) in the overview receipts so authors can see which surviving hunks shipped as one-click applies. - Set `start_line`/`line` on surviving multi-line OpenCode GitHub suggestions so a replacement that spans more than one current-head hunk line applies as one range. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 1a49c5721..9ca01a8ec 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -65,9 +65,14 @@ shipped as one-click GitHub suggestions (GitHub, n.d.-c). Comments that kept only a `` ```diff `` fence are listed separately with the reason ``cannot-provide`` (``n/a``, “cannot provide”, fence-breaking replacement, or no ``+`` lines) or ``LEFT`` (GitHub cannot apply a -suggestion on the deleted side; GitHub, n.d.-b, n.d.-c). A comment that -already has `` ```suggestion `` is applyable, not leftover. Suggested -diffs still stay out of the PR-level body. +suggestion on the deleted side; GitHub, n.d.-b, n.d.-c). Each leftover +row also keeps a bounded excerpt of that fence as a distinct +“Manual edit (not a GitHub suggestion):” `` ```diff `` block so the +author can copy the replacement by hand. That block is never a GitHub +`` ```suggestion `` fence and is never listed under the applyable +``path:line`` / ``path:start-end`` heading (GitHub, n.d.-c). A comment +that already has `` ```suggestion `` is applyable, not leftover. +Suggested diffs still stay out of the PR-level body. The publisher calls this helper from `build_inline_comment_failure_body` with the same control object used to build the inline `comments` array. @@ -88,7 +93,8 @@ with the same control object used to build the inline `comments` array. current-head hunk, overview receipts that list applyable ``path:start-end`` suggestion ranges, and a separate leftover-diff receipt list that labels remaining `` ```diff `` fences as - ``cannot-provide`` or ``LEFT``. + ``cannot-provide`` or ``LEFT`` and renders their replacement text as + a non-applyable manual-edit `` ```diff `` block. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 47645c2f7..a1715ffaf 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -14,6 +14,8 @@ DEFAULT_SINGLE_COMMENT_RETRY_LIMIT = 20 ERROR_PHRASE_MAX_CHARS = 240 LEFTOVER_DIFF_REASONS = frozenset({"LEFT", "cannot-provide"}) +MANUAL_EDIT_MAX_CHARS = 400 +MANUAL_EDIT_HEADING = "Manual edit (not a GitHub suggestion):" HTTP_422_LINE_RE = re.compile(r"(?im)^(?:gh:\s*)?(.*HTTP 422.*)$") HUNK_HEADER_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") PLUS_PATH_RE = re.compile(r"^\+\+\+ b/(.+?)(?:\t.*)?$") @@ -432,6 +434,53 @@ def render_applyable_receipts(ranges: list[tuple[str, int, int]]) -> list[str]: return [f"- `{format_applyable_range(path, start, end)}`" for path, start, end in ranges] +def leftover_manual_edit_text(body: object) -> str: + """Return bounded leftover `` ```diff `` text for a manual-edit overview block.""" + if not isinstance(body, str): + return "" + excerpt = "" + for match in DIFF_FENCE_RE.finditer(body): + text = match.group(1) or "" + replacement = extract_suggestion_replacement(text) + if replacement: + excerpt = replacement + break + stripped = text.strip() + if stripped: + excerpt = stripped + break + excerpt = excerpt.replace("```", "").replace("\r\n", "\n").strip("\n") + if not excerpt.strip(): + return "" + if len(excerpt) > MANUAL_EDIT_MAX_CHARS: + excerpt = excerpt[:MANUAL_EDIT_MAX_CHARS].rstrip() + "…" + return excerpt + + +def encode_manual_edit_field(text: str) -> str: + """Encode an already-extracted leftover excerpt for one leftover-receipt row.""" + excerpt = (text or "").replace("```", "").replace("\t", " ").replace("\r\n", "\n") + if len(excerpt) > MANUAL_EDIT_MAX_CHARS: + excerpt = excerpt[:MANUAL_EDIT_MAX_CHARS].rstrip() + "…" + return excerpt.replace("\n", "\\n") + + +def decode_manual_edit_field(text: str) -> str: + """Decode a leftover excerpt stored on one leftover-receipt row.""" + return (text or "").replace("\\n", "\n") + + +def _leftover_receipt_parts( + item: tuple[str, int, str] | tuple[str, int, str, str], +) -> tuple[str, int, str, str]: + """Normalize a leftover receipt to ``(path, line, reason, excerpt)``.""" + path, line, reason = item[0], item[1], item[2] + excerpt = item[3] if len(item) >= 4 else "" + if not isinstance(excerpt, str): + excerpt = "" + return path, line, reason, excerpt + + def leftover_diff_fence_reason(comment: dict[str, Any]) -> str | None: """Return ``LEFT`` or ``cannot-provide`` when a comment kept only a diff fence.""" body = comment.get("body") @@ -446,12 +495,12 @@ def leftover_diff_fence_reason(comment: dict[str, Any]) -> str | None: def leftover_diff_fence_receipts( payload: dict[str, Any], -) -> list[tuple[str, int, str]]: - """Return ``(path, line, reason)`` for comments that kept only a `` ```diff `` fence.""" +) -> list[tuple[str, int, str, str]]: + """Return ``(path, line, reason, excerpt)`` for leftover `` ```diff `` comments.""" comments = payload.get("comments") if not isinstance(comments, list): return [] - receipts: list[tuple[str, int, str]] = [] + receipts: list[tuple[str, int, str, str]] = [] seen: set[tuple[str, int]] = set() for comment in comments: if not isinstance(comment, dict): @@ -467,24 +516,62 @@ def leftover_diff_fence_receipts( if key in seen: continue seen.add(key) - receipts.append((path, line, reason)) + receipts.append( + (path, line, reason, leftover_manual_edit_text(comment.get("body"))) + ) return receipts -def parse_leftover_diff_receipts(text: str) -> list[tuple[str, int, str]]: - """Parse ``path:lineLEFT|cannot-provide`` leftover-diff rows.""" - return [ - (path, line, phrase) - for path, line, phrase in parse_refused_receipts(text) - if phrase in LEFTOVER_DIFF_REASONS - ] +def parse_leftover_diff_receipts(text: str) -> list[tuple[str, int, str, str]]: + """Parse ``path:lineLEFT|cannot-provide[excerpt]`` leftover rows.""" + receipts: list[tuple[str, int, str, str]] = [] + seen: set[tuple[str, int]] = set() + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith("#"): + continue + loc_text, sep, rest = line.partition("\t") + if not sep or ":" not in loc_text: + continue + path_text, _, line_text = loc_text.rpartition(":") + path = safe_finding_path(path_text) + try: + parsed_line = int(line_text) + except ValueError: + parsed_line = 0 + line_number = safe_finding_line(parsed_line) + if path is None or line_number is None: + continue + reason, excerpt_sep, excerpt_field = rest.partition("\t") + reason = reason.strip() + if reason not in LEFTOVER_DIFF_REASONS: + continue + location = (path, line_number) + if location in seen: + continue + seen.add(location) + excerpt = decode_manual_edit_field(excerpt_field) if excerpt_sep else "" + receipts.append((path, line_number, reason, excerpt)) + return receipts def render_leftover_diff_receipts( - receipts: list[tuple[str, int, str]], + receipts: list[tuple[str, int, str, str]] | list[tuple[str, int, str]], ) -> list[str]: - """Return overview receipt lines for leftover `` ```diff `` fences.""" - return [f"- `{path}:{line}` — {reason}" for path, line, reason in receipts] + """Return overview lines with a non-applyable leftover manual-edit block.""" + lines: list[str] = [] + for item in receipts: + path, line, reason, excerpt = _leftover_receipt_parts(item) + excerpt = excerpt.replace("```", "") + lines.append(f"- `{path}:{line}` — {reason}") + if not excerpt: + continue + lines.append(f" {MANUAL_EDIT_HEADING}") + lines.append(" ```diff") + for excerpt_line in excerpt.splitlines(): + lines.append(f" {excerpt_line}") + lines.append(" ```") + return lines def write_hunk_filtered_payload( @@ -514,13 +601,14 @@ def write_hunk_filtered_payload( encoding="utf-8", ) if leftover_path is not None: - leftover_path.write_text( - "".join( - f"{path}:{line}\t{reason}\n" - for path, line, reason in leftover_diff_fence_receipts(filtered) - ), - encoding="utf-8", - ) + leftover_rows: list[str] = [] + for path, line, reason, excerpt in leftover_diff_fence_receipts(filtered): + encoded = encode_manual_edit_field(excerpt) + if encoded: + leftover_rows.append(f"{path}:{line}\t{reason}\t{encoded}\n") + else: + leftover_rows.append(f"{path}:{line}\t{reason}\n") + leftover_path.write_text("".join(leftover_rows), encoding="utf-8") return len(comments) if isinstance(comments, list) else 0 @@ -703,7 +791,9 @@ def render_inline_comment_failure_suffix( deferred_locations: list[tuple[str, int]] | None = None, skipped_locations: list[tuple[str, int]] | None = None, applyable_locations: list[tuple[str, int, int]] | None = None, - leftover_locations: list[tuple[str, int, str]] | None = None, + leftover_locations: ( + list[tuple[str, int, str, str]] | list[tuple[str, int, str]] | None + ) = None, retry_limit: int | None = None, ) -> str: """Return the PR-body suffix used when GitHub rejects inline comments.""" @@ -862,15 +952,16 @@ def _trusted_range_subset( def _trusted_receipt_subset( - items: list[tuple[str, int, str]] | None, + items: list[tuple[str, int, str, str]] | list[tuple[str, int, str]] | None, allowed: set[tuple[str, int]], -) -> list[tuple[str, int, str]]: +) -> list[tuple[str, int, str, str]]: """Return first-seen leftover receipts whose path:line is a trusted finding.""" if not items: return [] - kept: list[tuple[str, int, str]] = [] + kept: list[tuple[str, int, str, str]] = [] seen: set[tuple[str, int]] = set() - for path, line, reason in items: + for item in items: + path, line, reason, excerpt = _leftover_receipt_parts(item) if ( (path, line) not in allowed or (path, line) in seen @@ -878,7 +969,7 @@ def _trusted_receipt_subset( ): continue seen.add((path, line)) - kept.append((path, line, reason)) + kept.append((path, line, reason, excerpt)) return kept @@ -893,7 +984,9 @@ def render_inline_comment_failure_body( deferred_locations: list[tuple[str, int]] | None = None, skipped_locations: list[tuple[str, int]] | None = None, applyable_locations: list[tuple[str, int, int]] | None = None, - leftover_locations: list[tuple[str, int, str]] | None = None, + leftover_locations: ( + list[tuple[str, int, str, str]] | list[tuple[str, int, str]] | None + ) = None, retry_limit: int | None = None, ) -> str: """Append the 422 fallback suffix to an existing REQUEST_CHANGES body.""" diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index a8cd41e4c..5b504322f 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1495,6 +1495,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "GitHub can apply these suggested replacements:" "opencode overview lists applyable suggestion ranges" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover suggested-diff fences separately from applyable ranges" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover LEFT and cannot-provide diffs" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "Manual edit (not a GitHub suggestion):" "opencode leftover receipts include a non-applyable manual-edit block" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 5ef087afa..cbd2267a5 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1655,6 +1655,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): encoding="utf-8" ) assert "leftover_diff_fence_receipts" in helper + assert "leftover_manual_edit_text" in helper + assert "Manual edit (not a GitHub suggestion):" in helper assert ( "These comments still have a suggested-diff fence that GitHub cannot apply:" in helper diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index a698cfa64..8da7bdaba 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -7,10 +7,15 @@ from scripts.ci.opencode_inline_comment_fallback import ( DEFAULT_SINGLE_COMMENT_RETRY_LIMIT, LEFTOVER_DIFF_REASONS, + MANUAL_EDIT_HEADING, + MANUAL_EDIT_MAX_CHARS, apply_github_suggestion_blocks, applyable_suggestion_ranges, + decode_manual_edit_field, + encode_manual_edit_field, leftover_diff_fence_reason, leftover_diff_fence_receipts, + leftover_manual_edit_text, parse_leftover_diff_receipts, render_leftover_diff_receipts, comment_on_changed_hunk, @@ -1768,14 +1773,20 @@ def test_leftover_diff_receipts_separate_left_and_cannot_provide_from_applyable( ) applyable = applyable_suggestion_ranges(payload) leftover = leftover_diff_fence_receipts(payload) - leftover_keys = {(path, line) for path, line, _reason in leftover} + leftover_keys = {(path, line) for path, line, _reason, _excerpt in leftover} applyable_starts = {(path, start) for path, start, _end in applyable} + cannot_excerpt = leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) + left_excerpt = leftover_manual_edit_text(SUGGESTED_DIFF_BODY) + na_excerpt = leftover_manual_edit_text(NA_DIFF_BODY) assert applyable == [("scripts/ci/example.py", 5, 7)] assert leftover == [ - ("scripts/ci/example.py", 12, "cannot-provide"), - ("scripts/ci/removed.py", 11, "LEFT"), - ("scripts/ci/example.py", 8, "cannot-provide"), + ("scripts/ci/example.py", 12, "cannot-provide", cannot_excerpt), + ("scripts/ci/removed.py", 11, "LEFT", left_excerpt), + ("scripts/ci/example.py", 8, "cannot-provide", na_excerpt), ] + assert left_excerpt == " new" + assert na_excerpt == "n/a" + assert cannot_excerpt assert leftover_keys.isdisjoint(applyable_starts) assert leftover_diff_fence_reason( {"side": "RIGHT", "body": CANNOT_PROVIDE_DIFF_BODY} @@ -1812,20 +1823,24 @@ def test_leftover_diff_receipts_separate_left_and_cannot_provide_from_applyable( "not-an-object", ] } - ) == [("scripts/ci/example.py", 12, "cannot-provide")] + ) == [("scripts/ci/example.py", 12, "cannot-provide", cannot_excerpt)] parsed = parse_leftover_diff_receipts( "scripts/ci/example.py:12\tcannot-provide\n" - "scripts/ci/removed.py:11\tLEFT\n" + "scripts/ci/removed.py:11\tLEFT\t new\n" "scripts/ci/example.py:9\tunknown\n" ) assert parsed == [ - ("scripts/ci/example.py", 12, "cannot-provide"), - ("scripts/ci/removed.py", 11, "LEFT"), + ("scripts/ci/example.py", 12, "cannot-provide", ""), + ("scripts/ci/removed.py", 11, "LEFT", " new"), ] assert LEFTOVER_DIFF_REASONS == {"LEFT", "cannot-provide"} assert render_leftover_diff_receipts(parsed) == [ "- `scripts/ci/example.py:12` — cannot-provide", "- `scripts/ci/removed.py:11` — LEFT", + f" {MANUAL_EDIT_HEADING}", + " ```diff", + " new", + " ```", ] @@ -1840,9 +1855,14 @@ def test_overview_lists_applyable_and_leftover_under_distinct_headings(tmp_path) ), applyable_locations=[("scripts/ci/example.py", 5, 7)], leftover_locations=[ - ("scripts/ci/example.py", 12, "cannot-provide"), - ("scripts/ci/removed.py", 11, "LEFT"), - ("scripts/ci/foreign.py", 1, "cannot-provide"), + ( + "scripts/ci/example.py", + 12, + "cannot-provide", + leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY), + ), + ("scripts/ci/removed.py", 11, "LEFT", leftover_manual_edit_text(SUGGESTED_DIFF_BODY)), + ("scripts/ci/foreign.py", 1, "cannot-provide", "ignored"), ], ) applyable_heading = "GitHub can apply these suggested replacements:" @@ -1856,13 +1876,28 @@ def test_overview_lists_applyable_and_leftover_under_distinct_headings(tmp_path) assert "- `scripts/ci/example.py:5-7`" in applyable_section assert "cannot-provide" not in applyable_section assert "LEFT" not in applyable_section + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) not in applyable_section + assert MANUAL_EDIT_HEADING not in applyable_section + assert "```diff" not in applyable_section assert "- `scripts/ci/example.py:12` — cannot-provide" in leftover_section assert "- `scripts/ci/removed.py:11` — LEFT" in leftover_section + assert MANUAL_EDIT_HEADING in leftover_section + assert "```diff" in leftover_section + assert "```suggestion" not in leftover_section + assert leftover_manual_edit_text(SUGGESTED_DIFF_BODY) in leftover_section + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) in leftover_section assert "scripts/ci/example.py:5-7" not in leftover_section leftover_only = render_inline_comment_failure_body( "## Findings\n", control({"path": "scripts/ci/example.py", "line": 12}), - leftover_locations=[("scripts/ci/example.py", 12, "cannot-provide")], + leftover_locations=[ + ( + "scripts/ci/example.py", + 12, + "cannot-provide", + leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY), + ) + ], ) assert leftover_heading in leftover_only assert applyable_heading not in leftover_only @@ -1924,8 +1959,16 @@ def test_overview_lists_applyable_and_leftover_under_distinct_headings(tmp_path) ) assert applyable.read_text(encoding="utf-8") == "scripts/ci/example.py:5-7\n" leftover_text = leftover.read_text(encoding="utf-8") - assert "scripts/ci/example.py:12\tcannot-provide\n" in leftover_text - assert "scripts/ci/removed.py:11\tLEFT\n" in leftover_text + assert ( + "scripts/ci/example.py:12\tcannot-provide\t" + + encode_manual_edit_field(leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY)) + + "\n" + ) in leftover_text + assert ( + "scripts/ci/removed.py:11\tLEFT\t" + + encode_manual_edit_field(leftover_manual_edit_text(SUGGESTED_DIFF_BODY)) + + "\n" + ) in leftover_text assert "scripts/ci/example.py:5-7" not in leftover_text control_path = tmp_path / "control.json" body_path = tmp_path / "body.md" @@ -1964,6 +2007,11 @@ def test_overview_lists_applyable_and_leftover_under_distinct_headings(tmp_path) assert "- `scripts/ci/example.py:5-7`" in rendered assert "- `scripts/ci/example.py:12` — cannot-provide" in rendered assert "- `scripts/ci/removed.py:11` — LEFT" in rendered + assert MANUAL_EDIT_HEADING in rendered + assert "```suggestion" not in rendered.split(leftover_heading, 1)[1] + applyable_rendered = rendered.split(applyable_heading, 1)[1].split(leftover_heading, 1)[0] + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) not in applyable_rendered + assert leftover_manual_edit_text(SUGGESTED_DIFF_BODY) in rendered.split(leftover_heading, 1)[1] assert ( main( [ @@ -2051,14 +2099,19 @@ def test_leftover_diff_fences_are_not_applyable_suggestions(): "not-an-object", ) ) == [ - ("scripts/ci/removed.py", 11, "LEFT"), - ("scripts/ci/blocked.py", 4, "cannot-provide"), + ("scripts/ci/removed.py", 11, "LEFT", leftover_manual_edit_text(SUGGESTED_DIFF_BODY)), + ( + "scripts/ci/blocked.py", + 4, + "cannot-provide", + leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY), + ), ] parsed = parse_leftover_diff_receipts( "\n".join( [ - "scripts/ci/removed.py:11\tLEFT", - "scripts/ci/blocked.py:4\tcannot-provide", + "scripts/ci/removed.py:11\tLEFT\t new", + "scripts/ci/blocked.py:4\tcannot-provide\tCannot provide diff - inaccessible", "scripts/ci/removed.py:11\tLEFT", "scripts/ci/example.py:7\tHTTP 422", "scripts/ci/ok.py:4", @@ -2069,12 +2122,25 @@ def test_leftover_diff_fences_are_not_applyable_suggestions(): ) ) assert parsed == [ - ("scripts/ci/removed.py", 11, "LEFT"), - ("scripts/ci/blocked.py", 4, "cannot-provide"), + ("scripts/ci/removed.py", 11, "LEFT", " new"), + ( + "scripts/ci/blocked.py", + 4, + "cannot-provide", + "Cannot provide diff - inaccessible", + ), ] assert render_leftover_diff_receipts(parsed) == [ "- `scripts/ci/removed.py:11` — LEFT", + f" {MANUAL_EDIT_HEADING}", + " ```diff", + " new", + " ```", "- `scripts/ci/blocked.py:4` — cannot-provide", + f" {MANUAL_EDIT_HEADING}", + " ```diff", + " Cannot provide diff - inaccessible", + " ```", ] @@ -2088,11 +2154,16 @@ def test_overview_receipts_distinguish_applyable_from_leftover_diff_fences(tmp_p ), applyable_locations=[("scripts/ci/example.py", 5, 7)], leftover_locations=[ - ("scripts/ci/removed.py", 11, "LEFT"), - ("scripts/ci/blocked.py", 4, "cannot-provide"), - ("scripts/ci/foreign.py", 1, "LEFT"), - ("scripts/ci/blocked.py", 4, "LEFT"), - ("scripts/ci/example.py", 5, "HTTP 422"), + ("scripts/ci/removed.py", 11, "LEFT", leftover_manual_edit_text(SUGGESTED_DIFF_BODY)), + ( + "scripts/ci/blocked.py", + 4, + "cannot-provide", + leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY), + ), + ("scripts/ci/foreign.py", 1, "LEFT", "ignored"), + ("scripts/ci/blocked.py", 4, "LEFT", "duplicate"), + ("scripts/ci/example.py", 5, "HTTP 422", "not leftover"), ], ) assert "GitHub can apply these suggested replacements:" in body @@ -2100,6 +2171,17 @@ def test_overview_receipts_distinguish_applyable_from_leftover_diff_fences(tmp_p assert "These comments still have a suggested-diff fence that GitHub cannot apply:" in body assert "- `scripts/ci/removed.py:11` — LEFT" in body assert "- `scripts/ci/blocked.py:4` — cannot-provide" in body + leftover_body = body.split( + "These comments still have a suggested-diff fence that GitHub cannot apply:", 1 + )[1] + applyable_body = body.split("GitHub can apply these suggested replacements:", 1)[1].split( + "These comments still have a suggested-diff fence that GitHub cannot apply:", 1 + )[0] + assert MANUAL_EDIT_HEADING in leftover_body + assert "```suggestion" not in leftover_body + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) in leftover_body + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) not in applyable_body + assert leftover_manual_edit_text(SUGGESTED_DIFF_BODY) not in applyable_body assert body.index("GitHub can apply these suggested replacements:") < body.index( "These comments still have a suggested-diff fence that GitHub cannot apply:" ) @@ -2107,7 +2189,9 @@ def test_overview_receipts_distinguish_applyable_from_leftover_diff_fences(tmp_p leftover_only = render_inline_comment_failure_body( "## Findings\n", control({"path": "scripts/ci/removed.py", "line": 11}), - leftover_locations=[("scripts/ci/removed.py", 11, "LEFT")], + leftover_locations=[ + ("scripts/ci/removed.py", 11, "LEFT", leftover_manual_edit_text(SUGGESTED_DIFF_BODY)) + ], ) assert "These comments still have a suggested-diff fence that GitHub cannot apply:" in leftover_only assert "- `scripts/ci/removed.py:11` — LEFT" in leftover_only @@ -2129,14 +2213,23 @@ def test_overview_receipts_distinguish_applyable_from_leftover_diff_fences(tmp_p {"path": "scripts/ci/blocked.py", "line": 4}, ), refused_receipts=[], - leftover_locations=[("scripts/ci/blocked.py", 4, "cannot-provide")], + leftover_locations=[ + ( + "scripts/ci/blocked.py", + 4, + "cannot-provide", + leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY), + ) + ], ) assert "- `scripts/ci/blocked.py:4` — cannot-provide" in refused_with_leftover refused_locations_with_leftover = render_inline_comment_failure_body( "## Findings\n", control({"path": "scripts/ci/removed.py", "line": 11}), refused_locations=[], - leftover_locations=[("scripts/ci/removed.py", 11, "LEFT")], + leftover_locations=[ + ("scripts/ci/removed.py", 11, "LEFT", leftover_manual_edit_text(SUGGESTED_DIFF_BODY)) + ], ) assert "- `scripts/ci/removed.py:11` — LEFT" in refused_locations_with_leftover empty_trusted_leftover = render_inline_comment_failure_body( @@ -2206,8 +2299,12 @@ def test_overview_receipts_distinguish_applyable_from_leftover_diff_fences(tmp_p ) assert applyable.read_text(encoding="utf-8") == "scripts/ci/example.py:7\n" assert leftover.read_text(encoding="utf-8") == ( - "scripts/ci/removed.py:11\tLEFT\n" - "scripts/ci/blocked.py:4\tcannot-provide\n" + "scripts/ci/removed.py:11\tLEFT\t" + + encode_manual_edit_field(leftover_manual_edit_text(SUGGESTED_DIFF_BODY)) + + "\n" + + "scripts/ci/blocked.py:4\tcannot-provide\t" + + encode_manual_edit_field(leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY)) + + "\n" ) control_path = tmp_path / "control.json" body_path = tmp_path / "body.md" @@ -2244,6 +2341,18 @@ def test_overview_receipts_distinguish_applyable_from_leftover_diff_fences(tmp_p assert "scripts/ci/example.py:7" in receipt_text assert "scripts/ci/removed.py:11` — LEFT" in receipt_text assert "scripts/ci/blocked.py:4` — cannot-provide" in receipt_text + leftover_receipt = receipt_text.split( + "These comments still have a suggested-diff fence that GitHub cannot apply:", 1 + )[1] + applyable_receipt = receipt_text.split( + "GitHub can apply these suggested replacements:", 1 + )[1].split( + "These comments still have a suggested-diff fence that GitHub cannot apply:", 1 + )[0] + assert MANUAL_EDIT_HEADING in leftover_receipt + assert "```suggestion" not in leftover_receipt + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) in leftover_receipt + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) not in applyable_receipt assert ( main( [ @@ -2259,3 +2368,84 @@ def test_overview_receipts_distinguish_applyable_from_leftover_diff_fences(tmp_p ) == 2 ) + + +def test_leftover_manual_edit_excerpt_is_distinct_non_applyable_block(tmp_path): + assert leftover_manual_edit_text(12) == "" + assert leftover_manual_edit_text("no suggested-diff fence") == "" + assert leftover_manual_edit_text("```diff\n\n```") == "" + assert leftover_manual_edit_text(SUGGESTED_DIFF_BODY) == " new" + assert leftover_manual_edit_text(NA_DIFF_BODY) == "n/a" + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY).startswith( + "Cannot provide" + ) + assert leftover_manual_edit_text( + "#### Suggested diff\n```diff\n\n```\n\n#### Suggested diff\n```diff\n+fixed\n```\n" + ) == "fixed" + assert leftover_manual_edit_text("```diff\n+has ``` fence\n```") == "has " + long_text = "x" * (MANUAL_EDIT_MAX_CHARS + 25) + bounded = leftover_manual_edit_text(f"```diff\n{long_text}\n```") + assert bounded.endswith("…") + assert len(bounded) == MANUAL_EDIT_MAX_CHARS + 1 + encoded = encode_manual_edit_field("keep\tthis\nline```end") + assert "\t" not in encoded + assert "```" not in encoded + assert encoded == "keep this\\nlineend" + assert decode_manual_edit_field(encoded) == "keep this\nlineend" + assert encode_manual_edit_field(long_text).endswith("…") + assert decode_manual_edit_field("") == "" + assert render_leftover_diff_receipts([("scripts/ci/a.py", 1, "LEFT")]) == [ + "- `scripts/ci/a.py:1` — LEFT" + ] + assert render_leftover_diff_receipts( + [("scripts/ci/a.py", 1, "LEFT", 12)] # type: ignore[list-item] + ) == ["- `scripts/ci/a.py:1` — LEFT"] + rendered = render_leftover_diff_receipts( + [("scripts/ci/a.py", 1, "cannot-provide", "n/a")] + ) + assert rendered == [ + "- `scripts/ci/a.py:1` — cannot-provide", + f" {MANUAL_EDIT_HEADING}", + " ```diff", + " n/a", + " ```", + ] + assert "```suggestion" not in "\n".join(rendered) + parsed = parse_leftover_diff_receipts( + "scripts/ci/a.py:1\tcannot-provide\tn/a\\nmore\n" + "scripts/ci/b.py:2\tLEFT\n" + "scripts/ci/c.py:x\tLEFT\n" + "scripts/ci/d.py:3\n" + ) + assert parsed == [ + ("scripts/ci/a.py", 1, "cannot-provide", "n/a\nmore"), + ("scripts/ci/b.py", 2, "LEFT", ""), + ] + + empty_fence_body = "#### Suggested diff\n```diff\n```\n" + payload = _batch_payload( + { + "path": "scripts/ci/empty.py", + "line": 4, + "side": "RIGHT", + "body": empty_fence_body, + } + ) + leftover_path = tmp_path / "leftover.txt" + write_hunk_filtered_payload( + payload, + parse_unified_diff_hunk_lines( + "diff --git a/scripts/ci/empty.py b/scripts/ci/empty.py\n" + "--- a/scripts/ci/empty.py\n+++ b/scripts/ci/empty.py\n" + "@@ -4,1 +4,1 @@\n- old\n+ new\n" + ), + tmp_path / "filtered.json", + leftover_path=leftover_path, + ) + assert leftover_path.read_text(encoding="utf-8") == ( + "scripts/ci/empty.py:4\tcannot-provide\n" + ) + assert leftover_diff_fence_receipts(payload) == [ + ("scripts/ci/empty.py", 4, "cannot-provide", "") + ] + From 9aaaa4c783435b97ba0c0143d610bba0db49fc11 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 18:10:10 +0900 Subject: [PATCH 14/34] fix(review): remap leftover LEFT comments onto same-path RIGHT hunks When a leftover LEFT suggested-diff still has an extractable replacement and the same path has a current-head RIGHT hunk, move the comment onto that hunk so GitHub can apply it. Pure deletions and cannot-provide fences stay leftover manual-edit blocks. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 15 +- .../ci/opencode_inline_comment_fallback.py | 62 ++++- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 2 + .../test_opencode_inline_comment_fallback.py | 250 ++++++++++++++++++ 6 files changed, 324 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 359292f60..a38006871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Remapped leftover OpenCode LEFT suggested-diff comments onto a same-path current-head RIGHT hunk when one exists so those replacements become one-click GitHub suggestions instead of leftover manual-edit blocks. Pure deletions and cannot-provide fences stay leftover. - Persisted leftover OpenCode `cannot-provide` and `LEFT` suggested-diff replacement text as a distinct overview “Manual edit (not a GitHub suggestion):” ```diff block so authors can copy the change by hand without treating it as an applyable `path:line` / `path:start-end` GitHub suggestion. - Distinguished applyable OpenCode GitHub suggestion ranges from leftover ```diff fences (`cannot-provide` or `LEFT`) in the overview receipts so authors can see which hunks are one-click applies and which still need a manual edit. - Listed applyable OpenCode GitHub suggestion ranges (`path:line` or `path:start-end`) in the overview receipts so authors can see which surviving hunks shipped as one-click applies. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 9ca01a8ec..9b25915b2 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -52,9 +52,14 @@ Sadowski et al., 2018). After that filter, surviving RIGHT-side comments convert their `` ```diff `` suggested-diff fence into a GitHub `` ```suggestion `` block so the author can apply the replacement in one click (GitHub, -n.d.-c). Only `+` lines become the replacement; `n/a`, “cannot provide”, -LEFT-side comments, and replacements that would break the fence stay as -the original `` ```diff `` context. When the suggested_diff removes more +n.d.-c). A LEFT-side leftover that still has an extractable replacement +is remapped onto a same-path current-head RIGHT hunk when one exists +(same line when that line is still commentable, otherwise the first +RIGHT hunk line) so GitHub can apply it as a suggestion instead of a +manual edit (GitHub, n.d.-b, n.d.-c). Only `+` lines become the +replacement; `n/a`, “cannot provide”, fence-breaking replacements, and +LEFT comments on paths with no RIGHT hunk (pure deletions) stay as the +original `` ```diff `` leftover. When the suggested_diff removes more than one current-head line and every line from the finding through that span sits on the same hunk, the comment also sets `start_line`, `line`, and `start_side` so GitHub applies one multi-line suggestion range @@ -94,7 +99,9 @@ with the same control object used to build the inline `comments` array. ``path:start-end`` suggestion ranges, and a separate leftover-diff receipt list that labels remaining `` ```diff `` fences as ``cannot-provide`` or ``LEFT`` and renders their replacement text as - a non-applyable manual-edit `` ```diff `` block. + a non-applyable manual-edit `` ```diff `` block, and remapping of + applyable LEFT leftovers onto a same-path RIGHT hunk so they become + GitHub suggestion ranges. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index a1715ffaf..e73b444d0 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -303,6 +303,60 @@ def suggestion_comment_range( return None, safe_line +def right_hunk_anchor_line( + path: str, + left_line: int, + hunks: dict[str, dict[str, set[int]]] | None, +) -> int | None: + """Return a same-path RIGHT hunk line to host a remapped LEFT comment.""" + if not hunks: + return None + safe_path = safe_finding_path(path) + safe_line = safe_finding_line(left_line) + if safe_path is None or safe_line is None: + return None + right = hunks.get(safe_path, {}).get("RIGHT", set()) + if not right: + return None + if safe_line in right: + return safe_line + return min(right) + + +def remap_left_comment_to_right_hunk( + comment: dict[str, Any], + hunks: dict[str, dict[str, set[int]]] | None, +) -> dict[str, Any]: + """Move an applyable LEFT leftover onto a same-path RIGHT hunk when one exists.""" + if comment.get("side") != "LEFT": + return comment + body = comment.get("body") + if not isinstance(body, str) or "```diff" not in body: + return comment + if "```suggestion" in body: + return comment + replacement: str | None = None + for match in DIFF_FENCE_RE.finditer(body): + replacement = extract_suggestion_replacement(match.group(1)) + if replacement is not None: + break + if replacement is None: + return comment + path = safe_finding_path(comment.get("path")) + line = safe_finding_line(comment.get("line")) + if path is None or line is None: + return comment + anchor = right_hunk_anchor_line(path, line, hunks) + if anchor is None: + return comment + remapped = dict(comment) + remapped["side"] = "RIGHT" + remapped["line"] = anchor + remapped.pop("start_line", None) + remapped.pop("start_side", None) + return remapped + + def apply_github_suggestion_blocks( payload: dict[str, Any], hunks: dict[str, dict[str, set[int]]] | None = None, @@ -316,6 +370,7 @@ def apply_github_suggestion_blocks( if not isinstance(comment, dict): updated.append(comment) continue + comment = remap_left_comment_to_right_hunk(comment, hunks) body = comment.get("body") side = comment.get("side") if not isinstance(body, str) or side == "LEFT": @@ -937,14 +992,15 @@ def _trusted_range_subset( items: list[tuple[str, int, int]] | None, allowed: set[tuple[str, int]], ) -> list[tuple[str, int, int]]: - """Return first-seen applyable ranges whose start is a trusted finding.""" + """Return first-seen applyable ranges on a path that has a trusted finding.""" if not items: return [] + allowed_paths = {path for path, _line in allowed} kept: list[tuple[str, int, int]] = [] seen: set[tuple[str, int, int]] = set() for item in items: - path, start, _end = item - if (path, start) not in allowed or item in seen: + path, _start, _end = item + if path not in allowed_paths or item in seen: continue seen.add(item) kept.append(item) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 5b504322f..9e8298973 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1496,6 +1496,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover suggested-diff fences separately from applyable ranges" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover LEFT and cannot-provide diffs" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "Manual edit (not a GitHub suggestion):" "opencode leftover receipts include a non-applyable manual-edit block" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "remap_left_comment_to_right_hunk" "opencode remaps applyable LEFT leftovers onto a same-path RIGHT hunk" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index cbd2267a5..fda5bd139 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1656,6 +1656,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): ) assert "leftover_diff_fence_receipts" in helper assert "leftover_manual_edit_text" in helper + assert "remap_left_comment_to_right_hunk" in helper + assert "right_hunk_anchor_line" in helper assert "Manual edit (not a GitHub suggestion):" in helper assert ( "These comments still have a suggested-diff fence that GitHub cannot apply:" diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 8da7bdaba..cb7e676fa 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -17,7 +17,9 @@ leftover_diff_fence_receipts, leftover_manual_edit_text, parse_leftover_diff_receipts, + remap_left_comment_to_right_hunk, render_leftover_diff_receipts, + right_hunk_anchor_line, comment_on_changed_hunk, count_removed_suggestion_lines, extract_suggestion_replacement, @@ -2449,3 +2451,251 @@ def test_leftover_manual_edit_excerpt_is_distinct_non_applyable_block(tmp_path): ("scripts/ci/empty.py", 4, "cannot-provide", "") ] + +REWRITE_UNIFIED_DIFF = """\ +diff --git a/scripts/ci/rewrite.py b/scripts/ci/rewrite.py +--- a/scripts/ci/rewrite.py ++++ b/scripts/ci/rewrite.py +@@ -10,3 +20,3 @@ +-old +-old +-old ++new ++new ++new +""" + + +def test_right_hunk_anchor_prefers_same_line_then_first_right_line(): + hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF + REWRITE_UNIFIED_DIFF) + assert right_hunk_anchor_line("scripts/ci/example.py", 7, hunks) == 7 + assert right_hunk_anchor_line("scripts/ci/rewrite.py", 11, hunks) == 20 + assert right_hunk_anchor_line("scripts/ci/removed.py", 11, hunks) is None + assert right_hunk_anchor_line("scripts/ci/example.py", 7, None) is None + assert right_hunk_anchor_line("scripts/ci/example.py", 7, {}) is None + assert right_hunk_anchor_line("../escape.py", 7, hunks) is None + assert right_hunk_anchor_line("scripts/ci/example.py", 0, hunks) is None + + +def test_remap_left_comment_onto_same_path_right_hunk(): + hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF + REWRITE_UNIFIED_DIFF) + left_applyable = { + "path": "scripts/ci/example.py", + "line": 7, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + } + remapped = remap_left_comment_to_right_hunk(left_applyable, hunks) + assert remapped["side"] == "RIGHT" + assert remapped["line"] == 7 + assert remapped["body"] == SUGGESTED_DIFF_BODY + rewrite = remap_left_comment_to_right_hunk( + { + "path": "scripts/ci/rewrite.py", + "line": 11, + "side": "LEFT", + "start_line": 10, + "start_side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + hunks, + ) + assert rewrite["side"] == "RIGHT" + assert rewrite["line"] == 20 + assert "start_line" not in rewrite + assert "start_side" not in rewrite + unchanged_right = { + "path": "scripts/ci/example.py", + "line": 7, + "side": "RIGHT", + "body": SUGGESTED_DIFF_BODY, + } + assert remap_left_comment_to_right_hunk(unchanged_right, hunks) is unchanged_right + deleted_only = { + "path": "scripts/ci/removed.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + } + assert remap_left_comment_to_right_hunk(deleted_only, hunks) is deleted_only + cannot = { + "path": "scripts/ci/example.py", + "line": 8, + "side": "LEFT", + "body": CANNOT_PROVIDE_DIFF_BODY, + } + assert remap_left_comment_to_right_hunk(cannot, hunks) is cannot + assert remap_left_comment_to_right_hunk( + {"path": "scripts/ci/example.py", "line": 7, "side": "LEFT", "body": "no fence"}, + hunks, + )["side"] == "LEFT" + already = { + "path": "scripts/ci/example.py", + "line": 7, + "side": "LEFT", + "body": "```diff\n+x\n```\n```suggestion\nx\n```", + } + assert remap_left_comment_to_right_hunk(already, hunks) is already + assert remap_left_comment_to_right_hunk( + { + "path": "../escape.py", + "line": 7, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + hunks, + )["side"] == "LEFT" + assert remap_left_comment_to_right_hunk(left_applyable, None) is left_applyable + + +def test_left_leftover_becomes_applyable_on_same_path_right_hunk(tmp_path): + hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF + REWRITE_UNIFIED_DIFF) + payload = apply_github_suggestion_blocks( + _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 7, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/rewrite.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/removed.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/example.py", + "line": 8, + "side": "LEFT", + "body": CANNOT_PROVIDE_DIFF_BODY, + }, + ), + hunks, + ) + comments = payload["comments"] + assert comments[0]["side"] == "RIGHT" + assert comments[0]["line"] == 7 + assert "```suggestion\n new\n```" in comments[0]["body"] + assert comments[1]["side"] == "RIGHT" + assert comments[1]["line"] == 20 + assert "```suggestion\n new\n```" in comments[1]["body"] + assert comments[2]["side"] == "LEFT" + assert "```suggestion" not in comments[2]["body"] + assert comments[3]["side"] == "LEFT" + assert leftover_diff_fence_reason(comments[3]) == "LEFT" + applyable = applyable_suggestion_ranges(payload) + leftover = leftover_diff_fence_receipts(payload) + leftover_keys = {(path, line) for path, line, _reason, _excerpt in leftover} + applyable_starts = {(path, start) for path, start, _end in applyable} + assert ("scripts/ci/example.py", 7) in applyable_starts + assert ("scripts/ci/rewrite.py", 20) in applyable_starts + assert leftover_keys.isdisjoint(applyable_starts) + assert ("scripts/ci/removed.py", 11) in leftover_keys + assert ("scripts/ci/example.py", 8) in leftover_keys + assert leftover_diff_fence_reason(comments[0]) is None + assert leftover_diff_fence_reason(comments[1]) is None + + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "scripts/ci/rewrite.py", "line": 11}, + {"path": "scripts/ci/removed.py", "line": 11}, + {"path": "scripts/ci/example.py", "line": 8}, + ), + applyable_locations=applyable, + leftover_locations=leftover, + ) + applyable_heading = "GitHub can apply these suggested replacements:" + leftover_heading = ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + applyable_section = body.split(applyable_heading, 1)[1].split(leftover_heading, 1)[0] + leftover_section = body.split(leftover_heading, 1)[1] + assert "- `scripts/ci/example.py:7`" in applyable_section + assert "- `scripts/ci/rewrite.py:20`" in applyable_section + assert "scripts/ci/removed.py" not in applyable_section + assert "cannot-provide" not in applyable_section + assert MANUAL_EDIT_HEADING not in applyable_section + assert "- `scripts/ci/removed.py:11` — LEFT" in leftover_section + assert "- `scripts/ci/example.py:8` — LEFT" in leftover_section + assert MANUAL_EDIT_HEADING in leftover_section + assert "```suggestion" not in leftover_section + assert "scripts/ci/rewrite.py:20" not in leftover_section + + payload_path = tmp_path / "batch.json" + payload_path.write_text( + json.dumps( + _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 7, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/rewrite.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/removed.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/example.py", + "line": 8, + "side": "LEFT", + "body": CANNOT_PROVIDE_DIFF_BODY, + }, + ) + ), + encoding="utf-8", + ) + hunks_diff = tmp_path / "hunks.diff" + hunks_diff.write_text(EXAMPLE_UNIFIED_DIFF + REWRITE_UNIFIED_DIFF, encoding="utf-8") + output = tmp_path / "filtered.json" + applyable_file = tmp_path / "applyable.txt" + leftover_file = tmp_path / "leftover.txt" + assert ( + main( + [ + "--filter-hunks", + "--payload", + str(payload_path), + "--hunks-diff", + str(hunks_diff), + "--output", + str(output), + "--applyable-locations", + str(applyable_file), + "--leftover-diff-locations", + str(leftover_file), + ] + ) + == 0 + ) + applyable_text = applyable_file.read_text(encoding="utf-8") + leftover_text = leftover_file.read_text(encoding="utf-8") + assert "scripts/ci/example.py:7\n" in applyable_text + assert "scripts/ci/rewrite.py:20\n" in applyable_text + assert "scripts/ci/removed.py" not in applyable_text + assert "scripts/ci/removed.py:11\tLEFT\t" in leftover_text + assert "scripts/ci/example.py:8\tLEFT\t" in leftover_text + assert "scripts/ci/rewrite.py" not in leftover_text + filtered = json.loads(output.read_text(encoding="utf-8")) + sides = [(item["path"], item["side"], item["line"]) for item in filtered["comments"]] + assert ("scripts/ci/example.py", "RIGHT", 7) in sides + assert ("scripts/ci/rewrite.py", "RIGHT", 20) in sides + assert ("scripts/ci/removed.py", "LEFT", 11) in sides + From 8928bb57e387680c0d201f6b118b609f3880e8f2 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 18:17:36 +0900 Subject: [PATCH 15/34] fix(review): anchor remapped LEFT leftovers to the same @@ hunk When a leftover LEFT comment cannot stay on the same RIGHT line, attach it to the first RIGHT line of that @@ hunk instead of the first RIGHT line of the whole path. Multi-hunk files no longer land on an earlier hunk. Pure-deletion hunks stay leftover. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 9 +- .../ci/opencode_inline_comment_fallback.py | 55 ++++-- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 2 + .../test_opencode_inline_comment_fallback.py | 161 ++++++++++++++++++ 6 files changed, 216 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a38006871..fbaa7ae09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Anchored remapped leftover OpenCode LEFT comments to the first RIGHT line of the same `@@` hunk when the original line is gone, so multi-hunk files do not attach the suggestion to an earlier hunk. - Remapped leftover OpenCode LEFT suggested-diff comments onto a same-path current-head RIGHT hunk when one exists so those replacements become one-click GitHub suggestions instead of leftover manual-edit blocks. Pure deletions and cannot-provide fences stay leftover. - Persisted leftover OpenCode `cannot-provide` and `LEFT` suggested-diff replacement text as a distinct overview “Manual edit (not a GitHub suggestion):” ```diff block so authors can copy the change by hand without treating it as an applyable `path:line` / `path:start-end` GitHub suggestion. - Distinguished applyable OpenCode GitHub suggestion ranges from leftover ```diff fences (`cannot-provide` or `LEFT`) in the overview receipts so authors can see which hunks are one-click applies and which still need a manual edit. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 9b25915b2..c478aacca 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -55,8 +55,10 @@ block so the author can apply the replacement in one click (GitHub, n.d.-c). A LEFT-side leftover that still has an extractable replacement is remapped onto a same-path current-head RIGHT hunk when one exists (same line when that line is still commentable, otherwise the first -RIGHT hunk line) so GitHub can apply it as a suggestion instead of a -manual edit (GitHub, n.d.-b, n.d.-c). Only `+` lines become the +RIGHT line of the same ``@@`` hunk — not the first RIGHT line of the +whole path) so GitHub can apply it as a suggestion instead of a +manual edit (GitHub, n.d.-b, n.d.-c). A LEFT line whose own hunk has +no RIGHT side (a deletion hunk inside a multi-hunk file) stays leftover. Only `+` lines become the replacement; `n/a`, “cannot provide”, fence-breaking replacements, and LEFT comments on paths with no RIGHT hunk (pure deletions) stay as the original `` ```diff `` leftover. When the suggested_diff removes more @@ -101,7 +103,8 @@ with the same control object used to build the inline `comments` array. ``cannot-provide`` or ``LEFT`` and renders their replacement text as a non-applyable manual-edit `` ```diff `` block, and remapping of applyable LEFT leftovers onto a same-path RIGHT hunk so they become - GitHub suggestion ranges. + GitHub suggestion ranges, using the same ``@@`` hunk rather than the + first RIGHT line of the whole path. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index e73b444d0..c2adb55e3 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -150,11 +150,16 @@ def _diff_path(raw_path: str) -> str | None: return safe_finding_path(raw_path.strip().strip('"')) +def _empty_hunk_bucket() -> dict[str, Any]: + """Return an empty LEFT/RIGHT/SPANS hunk bucket.""" + return {"LEFT": set(), "RIGHT": set(), "SPANS": []} + + def parse_unified_diff_hunk_lines( diff_text: str, -) -> dict[str, dict[str, set[int]]]: - """Return LEFT/RIGHT commentable lines for each path in a unified diff.""" - hunks: dict[str, dict[str, set[int]]] = {} +) -> dict[str, dict[str, Any]]: + """Return LEFT/RIGHT commentable lines and per-``@@`` spans for each path.""" + hunks: dict[str, dict[str, Any]] = {} current_left: str | None = None current_right: str | None = None for raw in (diff_text or "").splitlines(): @@ -172,11 +177,16 @@ def parse_unified_diff_hunk_lines( left_lines = _hunk_side_lines(header.group(1), header.group(2)) right_lines = _hunk_side_lines(header.group(3), header.group(4)) if current_left and left_lines: - bucket = hunks.setdefault(current_left, {"LEFT": set(), "RIGHT": set()}) + bucket = hunks.setdefault(current_left, _empty_hunk_bucket()) bucket["LEFT"].update(left_lines) if current_right and right_lines: - bucket = hunks.setdefault(current_right, {"LEFT": set(), "RIGHT": set()}) + bucket = hunks.setdefault(current_right, _empty_hunk_bucket()) bucket["RIGHT"].update(right_lines) + span = (set(left_lines), set(right_lines)) + if current_right: + hunks.setdefault(current_right, _empty_hunk_bucket())["SPANS"].append(span) + if current_left and current_left != current_right: + hunks.setdefault(current_left, _empty_hunk_bucket())["SPANS"].append(span) return hunks @@ -303,24 +313,49 @@ def suggestion_comment_range( return None, safe_line +def _same_hunk_right_anchor( + bucket: dict[str, Any], + left_line: int, +) -> int | None: + """Return the first RIGHT line of the ``@@`` hunk that contains ``left_line``.""" + spans = bucket.get("SPANS") + if not isinstance(spans, list): + return None + for item in spans: + if not isinstance(item, tuple) or len(item) != 2: + continue + left_span, right_span = item + if ( + isinstance(left_span, set) + and isinstance(right_span, set) + and left_line in left_span + and right_span + ): + return min(right_span) + return None + + def right_hunk_anchor_line( path: str, left_line: int, - hunks: dict[str, dict[str, set[int]]] | None, + hunks: dict[str, dict[str, Any]] | None, ) -> int | None: - """Return a same-path RIGHT hunk line to host a remapped LEFT comment.""" + """Return a same-``@@``-hunk RIGHT line to host a remapped LEFT comment.""" if not hunks: return None safe_path = safe_finding_path(path) safe_line = safe_finding_line(left_line) if safe_path is None or safe_line is None: return None - right = hunks.get(safe_path, {}).get("RIGHT", set()) - if not right: + bucket = hunks.get(safe_path) + if not isinstance(bucket, dict): + return None + right = bucket.get("RIGHT", set()) + if not isinstance(right, set) or not right: return None if safe_line in right: return safe_line - return min(right) + return _same_hunk_right_anchor(bucket, safe_line) def remap_left_comment_to_right_hunk( diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 9e8298973..20b7182e0 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1497,6 +1497,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover LEFT and cannot-provide diffs" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "Manual edit (not a GitHub suggestion):" "opencode leftover receipts include a non-applyable manual-edit block" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "remap_left_comment_to_right_hunk" "opencode remaps applyable LEFT leftovers onto a same-path RIGHT hunk" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "_same_hunk_right_anchor" "opencode remapped LEFT leftovers stay on the same @@ hunk" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index fda5bd139..6136317a1 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1658,6 +1658,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "leftover_manual_edit_text" in helper assert "remap_left_comment_to_right_hunk" in helper assert "right_hunk_anchor_line" in helper + assert "_same_hunk_right_anchor" in helper + assert '"SPANS"' in helper assert "Manual edit (not a GitHub suggestion):" in helper assert ( "These comments still have a suggested-diff fence that GitHub cannot apply:" diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index cb7e676fa..f942fd484 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -2466,6 +2466,23 @@ def test_leftover_manual_edit_excerpt_is_distinct_non_applyable_block(tmp_path): """ +MULTI_HUNK_UNIFIED_DIFF = """\ +diff --git a/scripts/ci/multi.py b/scripts/ci/multi.py +--- a/scripts/ci/multi.py ++++ b/scripts/ci/multi.py +@@ -5,3 +5,3 @@ + keep +-old ++new + keep +@@ -40,3 +50,3 @@ + keep +-old ++new + keep +""" + + def test_right_hunk_anchor_prefers_same_line_then_first_right_line(): hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF + REWRITE_UNIFIED_DIFF) assert right_hunk_anchor_line("scripts/ci/example.py", 7, hunks) == 7 @@ -2477,6 +2494,54 @@ def test_right_hunk_anchor_prefers_same_line_then_first_right_line(): assert right_hunk_anchor_line("scripts/ci/example.py", 0, hunks) is None +def test_right_hunk_anchor_uses_same_at_hunk_not_path_min(): + hunks = parse_unified_diff_hunk_lines(MULTI_HUNK_UNIFIED_DIFF) + assert hunks["scripts/ci/multi.py"]["LEFT"] == {5, 6, 7, 40, 41, 42} + assert hunks["scripts/ci/multi.py"]["RIGHT"] == {5, 6, 7, 50, 51, 52} + assert right_hunk_anchor_line("scripts/ci/multi.py", 6, hunks) == 6 + assert right_hunk_anchor_line("scripts/ci/multi.py", 41, hunks) == 50 + assert right_hunk_anchor_line("scripts/ci/multi.py", 41, hunks) != min( + hunks["scripts/ci/multi.py"]["RIGHT"] + ) + delete_then_edit = parse_unified_diff_hunk_lines( + "diff --git a/scripts/ci/mixed.py b/scripts/ci/mixed.py\n" + "--- a/scripts/ci/mixed.py\n+++ b/scripts/ci/mixed.py\n" + "@@ -10,3 +0,0 @@\n-gone\n-gone\n-gone\n" + "@@ -40,3 +50,3 @@\n keep\n-old\n+new\n keep\n" + ) + assert right_hunk_anchor_line("scripts/ci/mixed.py", 11, delete_then_edit) is None + assert right_hunk_anchor_line("scripts/ci/mixed.py", 41, delete_then_edit) == 50 + assert right_hunk_anchor_line( + "scripts/ci/x.py", 2, {"scripts/ci/x.py": "bad"} + ) is None + assert right_hunk_anchor_line( + "scripts/ci/x.py", 2, {"scripts/ci/x.py": {"RIGHT": {2, 3}, "SPANS": "bad"}} + ) == 2 + assert right_hunk_anchor_line( + "scripts/ci/x.py", 11, {"scripts/ci/x.py": {"RIGHT": {20}, "SPANS": "bad"}} + ) is None + assert right_hunk_anchor_line( + "scripts/ci/x.py", + 11, + { + "scripts/ci/x.py": { + "RIGHT": {20}, + "SPANS": [ + None, + (1,), + ("n", {20}), + ({11}, "n"), + ({11}, set()), + ({11}, {20}), + ], + } + }, + ) == 20 + assert right_hunk_anchor_line( + "scripts/ci/x.py", 11, {"scripts/ci/x.py": {"RIGHT": "bad"}} + ) is None + + def test_remap_left_comment_onto_same_path_right_hunk(): hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF + REWRITE_UNIFIED_DIFF) left_applyable = { @@ -2699,3 +2764,99 @@ def test_left_leftover_becomes_applyable_on_same_path_right_hunk(tmp_path): assert ("scripts/ci/rewrite.py", "RIGHT", 20) in sides assert ("scripts/ci/removed.py", "LEFT", 11) in sides + +def test_multi_hunk_left_remap_attaches_to_same_at_hunk(tmp_path): + hunks = parse_unified_diff_hunk_lines(MULTI_HUNK_UNIFIED_DIFF) + remapped = remap_left_comment_to_right_hunk( + { + "path": "scripts/ci/multi.py", + "line": 41, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + hunks, + ) + assert remapped["side"] == "RIGHT" + assert remapped["line"] == 50 + payload = apply_github_suggestion_blocks( + _batch_payload( + { + "path": "scripts/ci/multi.py", + "line": 41, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + { + "path": "scripts/ci/multi.py", + "line": 6, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + ), + hunks, + ) + comments = payload["comments"] + assert comments[0]["line"] == 50 + assert comments[0]["side"] == "RIGHT" + assert "```suggestion\n new\n```" in comments[0]["body"] + assert comments[1]["line"] == 6 + applyable = applyable_suggestion_ranges(payload) + leftover = leftover_diff_fence_receipts(payload) + assert applyable == [ + ("scripts/ci/multi.py", 50, 50), + ("scripts/ci/multi.py", 6, 6), + ] + assert leftover == [] + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/multi.py", "line": 41}, + {"path": "scripts/ci/multi.py", "line": 6}, + ), + applyable_locations=applyable, + leftover_locations=leftover, + ) + assert "- `scripts/ci/multi.py:50`" in body + assert "- `scripts/ci/multi.py:6`" in body + assert "These comments still have a suggested-diff fence that GitHub cannot apply:" not in body + assert "- `scripts/ci/multi.py:5`" not in body + + payload_path = tmp_path / "batch.json" + payload_path.write_text( + json.dumps( + _batch_payload( + { + "path": "scripts/ci/multi.py", + "line": 41, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + } + ) + ), + encoding="utf-8", + ) + hunks_diff = tmp_path / "hunks.diff" + hunks_diff.write_text(MULTI_HUNK_UNIFIED_DIFF, encoding="utf-8") + applyable_file = tmp_path / "applyable.txt" + leftover_file = tmp_path / "leftover.txt" + assert ( + main( + [ + "--filter-hunks", + "--payload", + str(payload_path), + "--hunks-diff", + str(hunks_diff), + "--output", + str(tmp_path / "filtered.json"), + "--applyable-locations", + str(applyable_file), + "--leftover-diff-locations", + str(leftover_file), + ] + ) + == 0 + ) + assert applyable_file.read_text(encoding="utf-8") == "scripts/ci/multi.py:50\n" + assert leftover_file.read_text(encoding="utf-8") == "" + From 9ebe7e0b973f0254118775f66c723f4e07b85607 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 18:25:32 +0900 Subject: [PATCH 16/34] fix(review): label remapped applyable ranges with LEFT origin Overview applyable receipts now show path:right came from LEFT path:left when a leftover comment was remapped onto a RIGHT hunk. Local origin keys are stripped before the GitHub POST. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 11 +- .../ci/opencode_inline_comment_fallback.py | 169 +++++++++++++++--- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 2 + .../test_opencode_inline_comment_fallback.py | 157 ++++++++++++++-- 6 files changed, 295 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbaa7ae09..3108e8f1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Labeled remapped leftover OpenCode applyable ranges with the original LEFT `path:line` so the overview shows `path:right` came from LEFT `path:left`. - Anchored remapped leftover OpenCode LEFT comments to the first RIGHT line of the same `@@` hunk when the original line is gone, so multi-hunk files do not attach the suggestion to an earlier hunk. - Remapped leftover OpenCode LEFT suggested-diff comments onto a same-path current-head RIGHT hunk when one exists so those replacements become one-click GitHub suggestions instead of leftover manual-edit blocks. Pure deletions and cannot-provide fences stay leftover. - Persisted leftover OpenCode `cannot-provide` and `LEFT` suggested-diff replacement text as a distinct overview “Manual edit (not a GitHub suggestion):” ```diff block so authors can copy the change by hand without treating it as an applyable `path:line` / `path:start-end` GitHub suggestion. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index c478aacca..43a910730 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -57,8 +57,12 @@ is remapped onto a same-path current-head RIGHT hunk when one exists (same line when that line is still commentable, otherwise the first RIGHT line of the same ``@@`` hunk — not the first RIGHT line of the whole path) so GitHub can apply it as a suggestion instead of a -manual edit (GitHub, n.d.-b, n.d.-c). A LEFT line whose own hunk has -no RIGHT side (a deletion hunk inside a multi-hunk file) stays leftover. Only `+` lines become the +manual edit (GitHub, n.d.-b, n.d.-c). The overview applyable receipt +then names both the RIGHT range GitHub will apply and the original +LEFT ``path:line`` (``from LEFT `path:line```) so the author can see +the finding moved. Local origin keys are stripped before the GitHub +POST. A LEFT line whose own hunk has no RIGHT side (a deletion hunk +inside a multi-hunk file) stays leftover. Only `+` lines become the replacement; `n/a`, “cannot provide”, fence-breaking replacements, and LEFT comments on paths with no RIGHT hunk (pure deletions) stay as the original `` ```diff `` leftover. When the suggested_diff removes more @@ -104,7 +108,8 @@ with the same control object used to build the inline `comments` array. a non-applyable manual-edit `` ```diff `` block, and remapping of applyable LEFT leftovers onto a same-path RIGHT hunk so they become GitHub suggestion ranges, using the same ``@@`` hunk rather than the - first RIGHT line of the whole path. + first RIGHT line of the whole path, and overview labels that name + the original LEFT ``path:line`` beside the applyable RIGHT range. - `tests/test_opencode_agent_contract.py` and `scripts/ci/test_strix_quick_gate.sh` pin the workflow call with `$control_json`. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index c2adb55e3..0e33be7b3 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -14,6 +14,8 @@ DEFAULT_SINGLE_COMMENT_RETRY_LIMIT = 20 ERROR_PHRASE_MAX_CHARS = 240 LEFTOVER_DIFF_REASONS = frozenset({"LEFT", "cannot-provide"}) +LEFT_ORIGIN_LINE_KEY = "_left_origin_line" +LEFT_ORIGIN_PATH_KEY = "_left_origin_path" MANUAL_EDIT_MAX_CHARS = 400 MANUAL_EDIT_HEADING = "Manual edit (not a GitHub suggestion):" HTTP_422_LINE_RE = re.compile(r"(?im)^(?:gh:\s*)?(.*HTTP 422.*)$") @@ -387,6 +389,8 @@ def remap_left_comment_to_right_hunk( remapped = dict(comment) remapped["side"] = "RIGHT" remapped["line"] = anchor + remapped[LEFT_ORIGIN_PATH_KEY] = path + remapped[LEFT_ORIGIN_LINE_KEY] = line remapped.pop("start_line", None) remapped.pop("start_side", None) return remapped @@ -449,15 +453,65 @@ def format_applyable_range(path: str, start: int, end: int) -> str: return f"{path}:{start}-{end}" -def parse_applyable_ranges(text: str) -> list[tuple[str, int, int]]: - """Parse ``path:line`` or ``path:start-end`` applyable-suggestion rows.""" - ranges: list[tuple[str, int, int]] = [] +def format_applyable_origin( + origin_path: str | None, + origin_line: int | None, +) -> str: + """Return ``LEFT path:line`` for a remapped leftover origin, or empty.""" + if origin_path is None or origin_line is None: + return "" + return f"LEFT {origin_path}:{origin_line}" + + +def parse_applyable_origin_field( + text: str, +) -> tuple[str | None, int | None]: + """Parse ``LEFT path:line`` from one applyable-receipt origin field.""" + raw = (text or "").strip() + if not raw.startswith("LEFT "): + return None, None + loc_text = raw[5:].strip() + if ":" not in loc_text: + return None, None + path_text, _, line_text = loc_text.rpartition(":") + path = safe_finding_path(path_text) + try: + parsed_line = int(line_text) + except ValueError: + parsed_line = 0 + line = safe_finding_line(parsed_line) + if path is None or line is None: + return None, None + return path, line + + +def _applyable_receipt_parts( + item: tuple[str, int, int] | tuple[str, int, int, str | None, int | None], +) -> tuple[str, int, int, str | None, int | None]: + """Normalize an applyable receipt to ``(path, start, end, origin_path, origin_line)``.""" + path, start, end = item[0], item[1], item[2] + origin_path = item[3] if len(item) >= 5 else None + origin_line = item[4] if len(item) >= 5 else None + if not isinstance(origin_path, str): + origin_path = None + if not isinstance(origin_line, int) or isinstance(origin_line, bool): + origin_line = None + if origin_path is None or origin_line is None: + return path, start, end, None, None + return path, start, end, origin_path, origin_line + + +def parse_applyable_ranges( + text: str, +) -> list[tuple[str, int, int, str | None, int | None]]: + """Parse ``path:line`` or ``path:start-end`` applyable rows with optional LEFT origin.""" + ranges: list[tuple[str, int, int, str | None, int | None]] = [] seen: set[tuple[str, int, int]] = set() for raw_line in text.splitlines(): line = raw_line.strip() if not line or line.startswith("#"): continue - loc_text, _sep, _phrase = line.partition("\t") + loc_text, sep, origin_text = line.partition("\t") if ":" not in loc_text: continue path_text, _, rest = loc_text.rpartition(":") @@ -484,18 +538,21 @@ def parse_applyable_ranges(text: str) -> list[tuple[str, int, int]]: if key in seen: continue seen.add(key) - ranges.append(key) + origin_path, origin_line = ( + parse_applyable_origin_field(origin_text) if sep else (None, None) + ) + ranges.append((path, start, end, origin_path, origin_line)) return ranges def applyable_suggestion_ranges( payload: dict[str, Any], -) -> list[tuple[str, int, int]]: - """Return ``(path, start, end)`` for comments that carry a suggestion fence.""" +) -> list[tuple[str, int, int, str | None, int | None]]: + """Return applyable ranges plus leftover LEFT origins when a comment was remapped.""" comments = payload.get("comments") if not isinstance(comments, list): return [] - ranges: list[tuple[str, int, int]] = [] + ranges: list[tuple[str, int, int, str | None, int | None]] = [] seen: set[tuple[str, int, int]] = set() for comment in comments: if not isinstance(comment, dict): @@ -515,13 +572,52 @@ def applyable_suggestion_ranges( if key in seen: continue seen.add(key) - ranges.append(key) + origin_path = safe_finding_path(comment.get(LEFT_ORIGIN_PATH_KEY)) + origin_line = safe_finding_line(comment.get(LEFT_ORIGIN_LINE_KEY)) + if origin_path is None or origin_line is None: + origin_path = None + origin_line = None + ranges.append((path, start, end, origin_path, origin_line)) return ranges -def render_applyable_receipts(ranges: list[tuple[str, int, int]]) -> list[str]: - """Return overview receipt lines for applyable suggestion ranges.""" - return [f"- `{format_applyable_range(path, start, end)}`" for path, start, end in ranges] +def render_applyable_receipts( + ranges: ( + list[tuple[str, int, int]] + | list[tuple[str, int, int, str | None, int | None]] + ), +) -> list[str]: + """Return overview receipt lines for applyable ranges, with LEFT origin when remapped.""" + lines: list[str] = [] + for item in ranges: + path, start, end, origin_path, origin_line = _applyable_receipt_parts(item) + line = f"- `{format_applyable_range(path, start, end)}`" + if origin_path is not None and origin_line is not None: + line += f" — from LEFT `{origin_path}:{origin_line}`" + lines.append(line) + return lines + + +def strip_left_origin_fields(payload: dict[str, Any]) -> dict[str, Any]: + """Remove local leftover-origin keys before the GitHub review payload is posted.""" + comments = payload.get("comments") + if not isinstance(comments, list): + return payload + cleaned: list[Any] = [] + for comment in comments: + if not isinstance(comment, dict): + cleaned.append(comment) + continue + cleaned.append( + { + key: value + for key, value in comment.items() + if key not in {LEFT_ORIGIN_PATH_KEY, LEFT_ORIGIN_LINE_KEY} + } + ) + rewritten = dict(payload) + rewritten["comments"] = cleaned + return rewritten def leftover_manual_edit_text(body: object) -> str: @@ -675,6 +771,8 @@ def write_hunk_filtered_payload( """Write a hunk-filtered review payload and optional skipped ``path:line`` rows.""" filtered, skipped = filter_payload_comments_to_hunks(payload, hunks) filtered = apply_github_suggestion_blocks(filtered, hunks) + applyable = applyable_suggestion_ranges(filtered) + filtered = strip_left_origin_fields(filtered) comments = filtered.get("comments") output.write_text(json.dumps(filtered, ensure_ascii=True), encoding="utf-8") if skipped_path is not None: @@ -683,13 +781,15 @@ def write_hunk_filtered_payload( encoding="utf-8", ) if applyable_path is not None: - applyable_path.write_text( - "".join( - f"{format_applyable_range(path, start, end)}\n" - for path, start, end in applyable_suggestion_ranges(filtered) - ), - encoding="utf-8", - ) + applyable_rows: list[str] = [] + for path, start, end, origin_path, origin_line in applyable: + loc = format_applyable_range(path, start, end) + origin = format_applyable_origin(origin_path, origin_line) + if origin: + applyable_rows.append(f"{loc}\t{origin}\n") + else: + applyable_rows.append(f"{loc}\n") + applyable_path.write_text("".join(applyable_rows), encoding="utf-8") if leftover_path is not None: leftover_rows: list[str] = [] for path, line, reason, excerpt in leftover_diff_fence_receipts(filtered): @@ -880,7 +980,11 @@ def render_inline_comment_failure_suffix( attached_locations: list[tuple[str, int]] | None = None, deferred_locations: list[tuple[str, int]] | None = None, skipped_locations: list[tuple[str, int]] | None = None, - applyable_locations: list[tuple[str, int, int]] | None = None, + applyable_locations: ( + list[tuple[str, int, int]] + | list[tuple[str, int, int, str | None, int | None]] + | None + ) = None, leftover_locations: ( list[tuple[str, int, str, str]] | list[tuple[str, int, str]] | None ) = None, @@ -1024,21 +1128,26 @@ def _trusted_location_subset( def _trusted_range_subset( - items: list[tuple[str, int, int]] | None, + items: ( + list[tuple[str, int, int]] + | list[tuple[str, int, int, str | None, int | None]] + | None + ), allowed: set[tuple[str, int]], -) -> list[tuple[str, int, int]]: +) -> list[tuple[str, int, int, str | None, int | None]]: """Return first-seen applyable ranges on a path that has a trusted finding.""" if not items: return [] allowed_paths = {path for path, _line in allowed} - kept: list[tuple[str, int, int]] = [] + kept: list[tuple[str, int, int, str | None, int | None]] = [] seen: set[tuple[str, int, int]] = set() for item in items: - path, _start, _end = item - if path not in allowed_paths or item in seen: + path, start, end, origin_path, origin_line = _applyable_receipt_parts(item) + key = (path, start, end) + if path not in allowed_paths or key in seen: continue - seen.add(item) - kept.append(item) + seen.add(key) + kept.append((path, start, end, origin_path, origin_line)) return kept @@ -1074,7 +1183,11 @@ def render_inline_comment_failure_body( attached_locations: list[tuple[str, int]] | None = None, deferred_locations: list[tuple[str, int]] | None = None, skipped_locations: list[tuple[str, int]] | None = None, - applyable_locations: list[tuple[str, int, int]] | None = None, + applyable_locations: ( + list[tuple[str, int, int]] + | list[tuple[str, int, int, str | None, int | None]] + | None + ) = None, leftover_locations: ( list[tuple[str, int, str, str]] | list[tuple[str, int, str]] | None ) = None, diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 20b7182e0..21c2f2059 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1498,6 +1498,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "Manual edit (not a GitHub suggestion):" "opencode leftover receipts include a non-applyable manual-edit block" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "remap_left_comment_to_right_hunk" "opencode remaps applyable LEFT leftovers onto a same-path RIGHT hunk" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "_same_hunk_right_anchor" "opencode remapped LEFT leftovers stay on the same @@ hunk" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "from LEFT" "opencode remapped applyable ranges cite the original LEFT path:line" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 6136317a1..fc2f822ed 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1660,6 +1660,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "right_hunk_anchor_line" in helper assert "_same_hunk_right_anchor" in helper assert '"SPANS"' in helper + assert "from LEFT" in helper + assert "strip_left_origin_fields" in helper assert "Manual edit (not a GitHub suggestion):" in helper assert ( "These comments still have a suggested-diff fence that GitHub cannot apply:" diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index f942fd484..676b3815a 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -13,6 +13,9 @@ applyable_suggestion_ranges, decode_manual_edit_field, encode_manual_edit_field, + format_applyable_origin, + parse_applyable_origin_field, + strip_left_origin_fields, leftover_diff_fence_reason, leftover_diff_fence_receipts, leftover_manual_edit_text, @@ -1525,8 +1528,8 @@ def test_applyable_ranges_parse_and_render_path_start_end(): ) ) assert parsed == [ - ("scripts/ci/example.py", 5, 7), - ("scripts/ci/ok.py", 4, 4), + ("scripts/ci/example.py", 5, 7, None, None), + ("scripts/ci/ok.py", 4, 4, None, None), ] assert render_applyable_receipts(parsed) == [ "- `scripts/ci/example.py:5-7`", @@ -1557,8 +1560,8 @@ def test_applyable_ranges_parse_and_render_path_start_end(): parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF), ) assert applyable_suggestion_ranges(payload) == [ - ("scripts/ci/example.py", 5, 7), - ("scripts/ci/example.py", 7, 7), + ("scripts/ci/example.py", 5, 7, None, None), + ("scripts/ci/example.py", 7, 7, None, None), ] swapped = applyable_suggestion_ranges( { @@ -1579,7 +1582,7 @@ def test_applyable_ranges_parse_and_render_path_start_end(): ] } ) - assert swapped == [("scripts/ci/example.py", 5, 7)] + assert swapped == [("scripts/ci/example.py", 5, 7, None, None)] assert applyable_suggestion_ranges( { "comments": [ @@ -1776,11 +1779,11 @@ def test_leftover_diff_receipts_separate_left_and_cannot_provide_from_applyable( applyable = applyable_suggestion_ranges(payload) leftover = leftover_diff_fence_receipts(payload) leftover_keys = {(path, line) for path, line, _reason, _excerpt in leftover} - applyable_starts = {(path, start) for path, start, _end in applyable} + applyable_starts = {(path, start) for path, start, _end, *_rest in applyable} cannot_excerpt = leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) left_excerpt = leftover_manual_edit_text(SUGGESTED_DIFF_BODY) na_excerpt = leftover_manual_edit_text(NA_DIFF_BODY) - assert applyable == [("scripts/ci/example.py", 5, 7)] + assert applyable == [("scripts/ci/example.py", 5, 7, None, None)] assert leftover == [ ("scripts/ci/example.py", 12, "cannot-provide", cannot_excerpt), ("scripts/ci/removed.py", 11, "LEFT", left_excerpt), @@ -2658,7 +2661,7 @@ def test_left_leftover_becomes_applyable_on_same_path_right_hunk(tmp_path): applyable = applyable_suggestion_ranges(payload) leftover = leftover_diff_fence_receipts(payload) leftover_keys = {(path, line) for path, line, _reason, _excerpt in leftover} - applyable_starts = {(path, start) for path, start, _end in applyable} + applyable_starts = {(path, start) for path, start, _end, *_rest in applyable} assert ("scripts/ci/example.py", 7) in applyable_starts assert ("scripts/ci/rewrite.py", 20) in applyable_starts assert leftover_keys.isdisjoint(applyable_starts) @@ -2686,6 +2689,8 @@ def test_left_leftover_becomes_applyable_on_same_path_right_hunk(tmp_path): leftover_section = body.split(leftover_heading, 1)[1] assert "- `scripts/ci/example.py:7`" in applyable_section assert "- `scripts/ci/rewrite.py:20`" in applyable_section + assert "from LEFT `scripts/ci/example.py:7`" in applyable_section + assert "from LEFT `scripts/ci/rewrite.py:11`" in applyable_section assert "scripts/ci/removed.py" not in applyable_section assert "cannot-provide" not in applyable_section assert MANUAL_EDIT_HEADING not in applyable_section @@ -2752,8 +2757,8 @@ def test_left_leftover_becomes_applyable_on_same_path_right_hunk(tmp_path): ) applyable_text = applyable_file.read_text(encoding="utf-8") leftover_text = leftover_file.read_text(encoding="utf-8") - assert "scripts/ci/example.py:7\n" in applyable_text - assert "scripts/ci/rewrite.py:20\n" in applyable_text + assert "scripts/ci/example.py:7\tLEFT scripts/ci/example.py:7\n" in applyable_text + assert "scripts/ci/rewrite.py:20\tLEFT scripts/ci/rewrite.py:11\n" in applyable_text assert "scripts/ci/removed.py" not in applyable_text assert "scripts/ci/removed.py:11\tLEFT\t" in leftover_text assert "scripts/ci/example.py:8\tLEFT\t" in leftover_text @@ -2803,8 +2808,8 @@ def test_multi_hunk_left_remap_attaches_to_same_at_hunk(tmp_path): applyable = applyable_suggestion_ranges(payload) leftover = leftover_diff_fence_receipts(payload) assert applyable == [ - ("scripts/ci/multi.py", 50, 50), - ("scripts/ci/multi.py", 6, 6), + ("scripts/ci/multi.py", 50, 50, "scripts/ci/multi.py", 41), + ("scripts/ci/multi.py", 6, 6, "scripts/ci/multi.py", 6), ] assert leftover == [] body = render_inline_comment_failure_body( @@ -2816,8 +2821,8 @@ def test_multi_hunk_left_remap_attaches_to_same_at_hunk(tmp_path): applyable_locations=applyable, leftover_locations=leftover, ) - assert "- `scripts/ci/multi.py:50`" in body - assert "- `scripts/ci/multi.py:6`" in body + assert "- `scripts/ci/multi.py:50` — from LEFT `scripts/ci/multi.py:41`" in body + assert "- `scripts/ci/multi.py:6` — from LEFT `scripts/ci/multi.py:6`" in body assert "These comments still have a suggested-diff fence that GitHub cannot apply:" not in body assert "- `scripts/ci/multi.py:5`" not in body @@ -2857,6 +2862,128 @@ def test_multi_hunk_left_remap_attaches_to_same_at_hunk(tmp_path): ) == 0 ) - assert applyable_file.read_text(encoding="utf-8") == "scripts/ci/multi.py:50\n" + assert applyable_file.read_text(encoding="utf-8") == ( + "scripts/ci/multi.py:50\tLEFT scripts/ci/multi.py:41\n" + ) assert leftover_file.read_text(encoding="utf-8") == "" + posted = json.loads((tmp_path / "filtered.json").read_text(encoding="utf-8")) + assert "_left_origin_path" not in posted["comments"][0] + assert "_left_origin_line" not in posted["comments"][0] + + +def test_applyable_left_origin_parse_render_and_strip(tmp_path): + assert format_applyable_origin(None, 11) == "" + assert format_applyable_origin("scripts/ci/multi.py", None) == "" + assert format_applyable_origin("scripts/ci/multi.py", 41) == ( + "LEFT scripts/ci/multi.py:41" + ) + assert parse_applyable_origin_field("LEFT scripts/ci/multi.py:41") == ( + "scripts/ci/multi.py", + 41, + ) + assert parse_applyable_origin_field("cannot-provide") == (None, None) + assert parse_applyable_origin_field("LEFT no-colon") == (None, None) + assert parse_applyable_origin_field("LEFT ../escape.py:1") == (None, None) + assert parse_applyable_origin_field("LEFT scripts/ci/multi.py:x") == (None, None) + parsed = parse_applyable_ranges( + "\n".join( + [ + "scripts/ci/multi.py:50\tLEFT scripts/ci/multi.py:41", + "scripts/ci/ok.py:4", + "scripts/ci/bad.py:5\tLEFT ../escape.py:1", + "scripts/ci/also.py:6\tHTTP 422", + ] + ) + ) + assert parsed == [ + ("scripts/ci/multi.py", 50, 50, "scripts/ci/multi.py", 41), + ("scripts/ci/ok.py", 4, 4, None, None), + ("scripts/ci/bad.py", 5, 5, None, None), + ("scripts/ci/also.py", 6, 6, None, None), + ] + assert render_applyable_receipts(parsed) == [ + "- `scripts/ci/multi.py:50` — from LEFT `scripts/ci/multi.py:41`", + "- `scripts/ci/ok.py:4`", + "- `scripts/ci/bad.py:5`", + "- `scripts/ci/also.py:6`", + ] + assert render_applyable_receipts([("scripts/ci/ok.py", 4, 4)]) == [ + "- `scripts/ci/ok.py:4`" + ] + assert render_applyable_receipts( + [("scripts/ci/ok.py", 4, 4, 12, "nope")] # type: ignore[list-item] + ) == ["- `scripts/ci/ok.py:4`"] + remapped = remap_left_comment_to_right_hunk( + { + "path": "scripts/ci/rewrite.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + parse_unified_diff_hunk_lines(REWRITE_UNIFIED_DIFF), + ) + assert remapped["_left_origin_path"] == "scripts/ci/rewrite.py" + assert remapped["_left_origin_line"] == 11 + stripped = strip_left_origin_fields(_batch_payload(remapped, "not-an-object")) + assert "_left_origin_path" not in stripped["comments"][0] + assert "_left_origin_line" not in stripped["comments"][0] + assert stripped["comments"][1] == "not-an-object" + assert strip_left_origin_fields({"comments": "bad"})["comments"] == "bad" + body = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/rewrite.py", "line": 11}), + applyable_locations=[ + ("scripts/ci/rewrite.py", 20, 20, "scripts/ci/rewrite.py", 11) + ], + ) + assert ( + "- `scripts/ci/rewrite.py:20` — from LEFT `scripts/ci/rewrite.py:11`" + in body + ) + assert "GitHub can apply these suggested replacements:" in body + assert applyable_suggestion_ranges( + { + "comments": [ + { + "path": "scripts/ci/example.py", + "line": 7, + "body": "```suggestion\nx\n```", + "_left_origin_path": "../escape.py", + "_left_origin_line": 11, + } + ] + } + ) == [("scripts/ci/example.py", 7, 7, None, None)] + applyable_file = tmp_path / "applyable.txt" + applyable_file.write_text( + "scripts/ci/rewrite.py:20\tLEFT scripts/ci/rewrite.py:11\n", + encoding="utf-8", + ) + control_path = tmp_path / "control.json" + body_path = tmp_path / "body.md" + receipt = tmp_path / "receipt.md" + control_path.write_text( + json.dumps(control({"path": "scripts/ci/rewrite.py", "line": 11})), + encoding="utf-8", + ) + body_path.write_text("## Findings\n", encoding="utf-8") + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--applyable-locations", + str(applyable_file), + ] + ) + == 0 + ) + assert ( + "- `scripts/ci/rewrite.py:20` — from LEFT `scripts/ci/rewrite.py:11`" + in receipt.read_text(encoding="utf-8") + ) From cd5ca09e6a56c441d9c03900c2aa2ffb2252d8db Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 18:33:45 +0900 Subject: [PATCH 17/34] fix(review): keep start_line on remapped leftover 422 retries One-at-a-time retry after a batch 422 now copies start_line and start_side so a remapped leftover that spans a multi-line RIGHT hunk still posts as one GitHub suggestion. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 10 +- .../ci/opencode_inline_comment_fallback.py | 50 ++++++---- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 1 + .../test_opencode_inline_comment_fallback.py | 91 +++++++++++++++++++ 6 files changed, 135 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3108e8f1a..edb491268 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Kept `start_line`/`start_side` on remapped leftover OpenCode suggestions when a batch 422 is retried one comment at a time, so a multi-line RIGHT range still posts as one GitHub suggestion. - Labeled remapped leftover OpenCode applyable ranges with the original LEFT `path:line` so the overview shows `path:right` came from LEFT `path:left`. - Anchored remapped leftover OpenCode LEFT comments to the first RIGHT line of the same `@@` hunk when the original line is gone, so multi-hunk files do not attach the suggestion to an earlier hunk. - Remapped leftover OpenCode LEFT suggested-diff comments onto a same-path current-head RIGHT hunk when one exists so those replacements become one-click GitHub suggestions instead of leftover manual-edit blocks. Pure deletions and cannot-provide fences stay leftover. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 43a910730..8af6ce4ad 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -23,7 +23,11 @@ lines are omitted. An empty location set is stated explicitly. After a refused attach, the publisher first checks that the failure is HTTP 422, splits the batch `comments` array into at most 20 single-comment review payloads (`OPENCODE_INLINE_COMMENT_RETRY_LIMIT`, -default 20), and retries each with the same write helper. Comments past +default 20), and retries each with the same write helper. A remapped +leftover that already has a multi-line GitHub suggestion range keeps +``start_line`` and ``start_side`` on those single-comment retries so +the replacement still applies as one range after the split (GitHub, +n.d.-b, n.d.-c). Comments past that cap are recorded as not retried instead of opening unbounded `gh api` writes. The first success uses `REQUEST_CHANGES` plus the review body; later successes use `COMMENT`. Survivors therefore still appear on @@ -96,7 +100,9 @@ with the same control object used to build the inline `comments` array. fail-closed unreadable control or error input, batch-to-single comment splitting, `--is-unprocessable` classification, mixed-success receipts that list attached path:line beside refused path:line, - per-comment 422 phrases, the 20-comment one-at-a-time retry cap, and + per-comment 422 phrases, the 20-comment one-at-a-time retry cap, + preservation of ``start_line``/``start_side`` on remapped multi-line + suggestion retries, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 0e33be7b3..74ae285ad 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -867,6 +867,21 @@ def github_error_is_unprocessable(text: str) -> bool: return "422" in github_publication_error_phrase(raw) +def single_comment_range_fields( + comment: dict[str, Any], + line: int, + side: str, +) -> dict[str, Any]: + """Return ``start_line``/``start_side`` when a multi-line suggestion range is safe.""" + start = safe_finding_line(comment.get("start_line")) + if start is None or start >= line: + return {} + start_side = comment.get("start_side") + if start_side not in {"LEFT", "RIGHT"}: + start_side = side + return {"start_line": start, "start_side": start_side} + + def iter_single_comment_payloads(payload: dict[str, Any]) -> list[dict[str, Any]]: """Return safe single-comment slices from a batch review payload.""" comments = payload.get("comments") @@ -886,15 +901,16 @@ def iter_single_comment_payloads(payload: dict[str, Any]) -> list[dict[str, Any] if path is None or line is None or not isinstance(body, str) or not body.strip(): continue side = comment.get("side") - singles.append( - { - "path": path, - "line": line, - "side": side if side in {"LEFT", "RIGHT"} else "RIGHT", - "body": body, - "commit_id": commit_id, - } - ) + side_key = side if side in {"LEFT", "RIGHT"} else "RIGHT" + item = { + "path": path, + "line": line, + "side": side_key, + "body": body, + "commit_id": commit_id, + } + item.update(single_comment_range_fields(comment, line, side_key)) + singles.append(item) return singles @@ -905,18 +921,18 @@ def render_single_comment_review( review_body: str, ) -> dict[str, Any]: """Return one GitHub review payload that carries a single inline comment.""" + comment = { + "path": item["path"], + "line": item["line"], + "side": item["side"], + "body": item["body"], + } + comment.update(single_comment_range_fields(item, item["line"], item["side"])) return { "event": event, "body": review_body, "commit_id": item["commit_id"], - "comments": [ - { - "path": item["path"], - "line": item["line"], - "side": item["side"], - "body": item["body"], - } - ], + "comments": [comment], } diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 21c2f2059..db2f0be08 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1499,6 +1499,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "remap_left_comment_to_right_hunk" "opencode remaps applyable LEFT leftovers onto a same-path RIGHT hunk" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "_same_hunk_right_anchor" "opencode remapped LEFT leftovers stay on the same @@ hunk" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "from LEFT" "opencode remapped applyable ranges cite the original LEFT path:line" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "single_comment_range_fields" "opencode one-at-a-time retry keeps multi-line start_line and start_side" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index fc2f822ed..05ae17e0a 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1662,6 +1662,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert '"SPANS"' in helper assert "from LEFT" in helper assert "strip_left_origin_fields" in helper + assert "single_comment_range_fields" in helper assert "Manual edit (not a GitHub suggestion):" in helper assert ( "These comments still have a suggested-diff fence that GitHub cannot apply:" diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 676b3815a..2709e9f95 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -44,6 +44,7 @@ render_inline_comment_failure_body, render_inline_comment_receipts, render_single_comment_review, + single_comment_range_fields, single_comment_retry_limit, trusted_finding_locations, write_hunk_filtered_payload, @@ -646,6 +647,96 @@ def test_iter_single_comment_payloads_keeps_only_safe_comments(): assert iter_single_comment_payloads({"commit_id": "", "comments": [{}]}) == [] +def test_single_comment_retry_keeps_multiline_start_line_and_start_side(tmp_path): + assert single_comment_range_fields({"start_line": 5, "start_side": "RIGHT"}, 7, "RIGHT") == { + "start_line": 5, + "start_side": "RIGHT", + } + assert single_comment_range_fields({"start_line": 7}, 7, "RIGHT") == {} + assert single_comment_range_fields({"start_line": 8, "start_side": "RIGHT"}, 7, "RIGHT") == {} + assert single_comment_range_fields({"start_line": 0, "start_side": "RIGHT"}, 7, "RIGHT") == {} + assert single_comment_range_fields({"start_line": 5, "start_side": "NOPE"}, 7, "RIGHT") == { + "start_line": 5, + "start_side": "RIGHT", + } + assert single_comment_range_fields({"start_line": 5}, 7, "LEFT") == { + "start_line": 5, + "start_side": "LEFT", + } + + hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF) + remapped = apply_github_suggestion_blocks( + _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 5, + "side": "LEFT", + "body": MULTILINE_DIFF_BODY, + } + ), + hunks, + ) + comment = remapped["comments"][0] + assert comment["side"] == "RIGHT" + assert comment["start_line"] == 5 + assert comment["line"] == 7 + assert comment["start_side"] == "RIGHT" + payload = { + "event": "REQUEST_CHANGES", + "body": "review body", + "commit_id": remapped["commit_id"], + "comments": remapped["comments"], + } + singles = iter_single_comment_payloads(payload) + assert singles[0]["start_line"] == 5 + assert singles[0]["start_side"] == "RIGHT" + assert singles[0]["line"] == 7 + rendered = render_single_comment_review( + singles[0], event="REQUEST_CHANGES", review_body="review body" + ) + assert rendered["comments"][0]["start_line"] == 5 + assert rendered["comments"][0]["start_side"] == "RIGHT" + assert rendered["comments"][0]["line"] == 7 + assert "_left_origin_path" not in rendered["comments"][0] + + output_dir = tmp_path / "singles" + assert write_single_comment_payloads(payload, output_dir) == 1 + written = json.loads((output_dir / "comment-000.json").read_text(encoding="utf-8")) + assert written["comments"][0]["start_line"] == 5 + assert written["comments"][0]["start_side"] == "RIGHT" + assert written["comments"][0]["line"] == 7 + assert "start_line" not in render_single_comment_review( + { + "path": "scripts/ci/example.py", + "line": 7, + "side": "RIGHT", + "body": "single", + "commit_id": "c" * 40, + }, + event="COMMENT", + review_body="", + )["comments"][0] + + payload_path = tmp_path / "batch.json" + payload_path.write_text(json.dumps(payload), encoding="utf-8") + cli_dir = tmp_path / "cli-singles" + assert ( + main( + [ + "--split-payload", + str(payload_path), + "--output-dir", + str(cli_dir), + ] + ) + == 0 + ) + cli_written = json.loads((cli_dir / "comment-000.json").read_text(encoding="utf-8")) + assert cli_written["comments"][0]["start_line"] == 5 + assert cli_written["comments"][0]["start_side"] == "RIGHT" + assert cli_written["comments"][0]["line"] == 7 + + def test_cli_splits_batch_payload_into_single_comment_files(tmp_path): payload = tmp_path / "batch.json" payload.write_text( From 9facb237edde0a8aba189aa10d7b872be0064028 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 18:44:19 +0900 Subject: [PATCH 18/34] fix(review): keep deferred leftover range and origin off applyable list Comments past the 20-comment 422 retry cap are not posted as GitHub suggestions. Deferred overview rows now keep path:start-end and the LEFT origin, and those ranges are removed from the applyable heading. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 12 +- .../ci/opencode_inline_comment_fallback.py | 133 ++++++++- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 2 + .../test_opencode_inline_comment_fallback.py | 266 ++++++++++++++++++ 6 files changed, 405 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index edb491268..7a9ecb92e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Recorded leftover OpenCode comments past the 20-comment 422 retry cap as deferred overview ranges with their LEFT origin, and stopped listing them under applyable GitHub suggestions because those comments are never posted. - Kept `start_line`/`start_side` on remapped leftover OpenCode suggestions when a batch 422 is retried one comment at a time, so a multi-line RIGHT range still posts as one GitHub suggestion. - Labeled remapped leftover OpenCode applyable ranges with the original LEFT `path:line` so the overview shows `path:right` came from LEFT `path:left`. - Anchored remapped leftover OpenCode LEFT comments to the first RIGHT line of the same `@@` hunk when the original line is gone, so multi-hunk files do not attach the suggestion to an earlier hunk. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 8af6ce4ad..6bf24dcdb 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -27,9 +27,12 @@ default 20), and retries each with the same write helper. A remapped leftover that already has a multi-line GitHub suggestion range keeps ``start_line`` and ``start_side`` on those single-comment retries so the replacement still applies as one range after the split (GitHub, -n.d.-b, n.d.-c). Comments past -that cap are recorded as not retried instead of opening unbounded `gh -api` writes. The first success uses `REQUEST_CHANGES` plus the review +n.d.-b, n.d.-c). Comments past that cap are not posted as GitHub +suggestions: their ``path:start-end`` range and leftover LEFT origin +stay on the deferred overview list only, they are removed from the +applyable heading so the receipt does not claim GitHub can apply a +comment that was never sent, and they are recorded as not retried +instead of opening unbounded ``gh api`` writes. The first success uses `REQUEST_CHANGES` plus the review body; later successes use `COMMENT`. Survivors therefore still appear on Files changed. Remaining failures still rebuild the fallback from the `gh api` error file and write durable receipts into the OpenCode @@ -102,7 +105,8 @@ with the same control object used to build the inline `comments` array. receipts that list attached path:line beside refused path:line, per-comment 422 phrases, the 20-comment one-at-a-time retry cap, preservation of ``start_line``/``start_side`` on remapped multi-line - suggestion retries, and + suggestion retries, deferred leftovers past the retry cap that keep + range and LEFT origin as non-applyable overview context, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 74ae285ad..c55867a50 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -910,6 +910,11 @@ def iter_single_comment_payloads(payload: dict[str, Any]) -> list[dict[str, Any] "commit_id": commit_id, } item.update(single_comment_range_fields(comment, line, side_key)) + origin_path = safe_finding_path(comment.get(LEFT_ORIGIN_PATH_KEY)) + origin_line = safe_finding_line(comment.get(LEFT_ORIGIN_LINE_KEY)) + if origin_path is not None and origin_line is not None: + item[LEFT_ORIGIN_PATH_KEY] = origin_path + item[LEFT_ORIGIN_LINE_KEY] = origin_line singles.append(item) return singles @@ -959,12 +964,117 @@ def write_single_comment_payloads( count += 1 if deferred_path is not None: deferred_path.write_text( - "".join(f"{item['path']}:{item['line']}\n" for item in items[cap:]), + "".join(format_deferred_receipt_row(item) for item in items[cap:]), encoding="utf-8", ) return count +def format_deferred_receipt_row(item: dict[str, Any]) -> str: + """Return one deferred leftover row with range and LEFT origin, never a suggestion.""" + path = item.get("path") + line = safe_finding_line(item.get("line")) + if not isinstance(path, str) or line is None: + return "" + start = safe_finding_line(item.get("start_line")) + loc = format_applyable_range(path, start, line) if start is not None and start < line else f"{path}:{line}" + origin = format_applyable_origin( + safe_finding_path(item.get(LEFT_ORIGIN_PATH_KEY)), + safe_finding_line(item.get(LEFT_ORIGIN_LINE_KEY)), + ) + if origin: + return f"{loc}\t{origin}\n" + return f"{loc}\n" + + +def normalize_deferred_receipt( + item: tuple[Any, ...], +) -> tuple[str, int, int, str | None, int | None] | None: + """Normalize a deferred leftover row to an applyable-shaped receipt.""" + if len(item) >= 5: + return _applyable_receipt_parts( + item # type: ignore[arg-type] + ) + if len(item) == 3: + path, start_raw, end_raw = item[0], item[1], item[2] + start = safe_finding_line(start_raw) + end = safe_finding_line(end_raw) + if not isinstance(path, str) or start is None or end is None or end < start: + return None + return path, start, end, None, None + if len(item) != 2: + return None + path, line_raw = item[0], item[1] + line = safe_finding_line(line_raw) + if not isinstance(path, str) or line is None: + return None + return path, line, line, None, None + + +def _trusted_deferred_subset( + items: list[tuple[Any, ...]] | None, + allowed: set[tuple[str, int]], +) -> list[tuple[str, int, int, str | None, int | None]]: + """Return trusted deferred leftovers as range receipts.""" + if not items: + return [] + normalized: list[tuple[str, int, int, str | None, int | None]] = [] + for item in items: + receipt = normalize_deferred_receipt(item) + if receipt is not None: + normalized.append(receipt) + return _trusted_range_subset(normalized, allowed) + + +def merge_deferred_origins( + deferred: list[tuple[str, int, int, str | None, int | None]], + applyable: list[tuple[str, int, int, str | None, int | None]], +) -> list[tuple[str, int, int, str | None, int | None]]: + """Copy LEFT origin from applyable rows onto matching deferred leftovers.""" + origins: dict[tuple[str, int, int], tuple[str, int]] = {} + for item in applyable: + path, start, end, origin_path, origin_line = _applyable_receipt_parts(item) + if origin_path is None or origin_line is None: + continue + origins[(path, start, end)] = (origin_path, origin_line) + origins[(path, start, start)] = (origin_path, origin_line) + origins[(path, end, end)] = (origin_path, origin_line) + merged: list[tuple[str, int, int, str | None, int | None]] = [] + for path, start, end, origin_path, origin_line in deferred: + if origin_path is None or origin_line is None: + found = ( + origins.get((path, start, end)) + or origins.get((path, start, start)) + or origins.get((path, end, end)) + ) + if found is not None: + origin_path, origin_line = found + merged.append((path, start, end, origin_path, origin_line)) + return merged + + +def exclude_deferred_applyable( + applyable: list[tuple[str, int, int, str | None, int | None]], + deferred: list[tuple[str, int, int, str | None, int | None]], +) -> list[tuple[str, int, int, str | None, int | None]]: + """Drop applyable ranges that were not retried, so they are not posted as suggestions.""" + exact = {(path, start, end) for path, start, end, _origin_path, _origin_line in deferred} + points = { + (path, start) + for path, start, end, _origin_path, _origin_line in deferred + if start == end + } + kept: list[tuple[str, int, int, str | None, int | None]] = [] + for item in applyable: + path, start, end, origin_path, origin_line = _applyable_receipt_parts(item) + if (path, start, end) in exact: + continue + if (path, start) in points or (path, end) in points: + continue + kept.append((path, start, end, origin_path, origin_line)) + return kept + + def render_inline_comment_receipts( locations: list[tuple[str, int]], error_phrase: str = "", @@ -994,7 +1104,11 @@ def render_inline_comment_failure_suffix( mixed_success: bool = False, phrases: dict[tuple[str, int], str] | None = None, attached_locations: list[tuple[str, int]] | None = None, - deferred_locations: list[tuple[str, int]] | None = None, + deferred_locations: ( + list[tuple[str, int]] + | list[tuple[str, int, int, str | None, int | None]] + | None + ) = None, skipped_locations: list[tuple[str, int]] | None = None, applyable_locations: ( list[tuple[str, int, int]] @@ -1082,7 +1196,7 @@ def render_inline_comment_failure_suffix( f"(retry limit {single_comment_retry_limit(retry_limit)}):" ) lines.append("") - lines.extend(render_inline_comment_receipts(deferred)) + lines.extend(render_applyable_receipts(deferred)) if not locations: lines.append("") lines.append( @@ -1197,7 +1311,11 @@ def render_inline_comment_failure_body( refused_locations: list[tuple[str, int]] | None = None, refused_receipts: list[tuple[str, int, str]] | None = None, attached_locations: list[tuple[str, int]] | None = None, - deferred_locations: list[tuple[str, int]] | None = None, + deferred_locations: ( + list[tuple[str, int]] + | list[tuple[str, int, int, str | None, int | None]] + | None + ) = None, skipped_locations: list[tuple[str, int]] | None = None, applyable_locations: ( list[tuple[str, int, int]] @@ -1218,7 +1336,7 @@ def render_inline_comment_failure_body( else [] ) deferred = ( - _trusted_location_subset(deferred_locations, allowed) + _trusted_deferred_subset(deferred_locations, allowed) if deferred_locations is not None else [] ) @@ -1237,6 +1355,9 @@ def render_inline_comment_failure_body( if leftover_locations is not None else [] ) + if deferred and applyable: + deferred = merge_deferred_origins(deferred, applyable) + applyable = exclude_deferred_applyable(applyable, deferred) phrases: dict[tuple[str, int], str] | None = None if refused_receipts is not None: locations = [ @@ -1449,7 +1570,7 @@ def main(argv: list[str] | None = None) -> int: args.attached_locations.read_text(encoding="utf-8") ) if args.deferred_locations is not None: - deferred_locations = parse_refused_locations( + deferred_locations = parse_applyable_ranges( args.deferred_locations.read_text(encoding="utf-8") ) if args.skipped_locations is not None: diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index db2f0be08..9ba41e695 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1500,6 +1500,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "_same_hunk_right_anchor" "opencode remapped LEFT leftovers stay on the same @@ hunk" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "from LEFT" "opencode remapped applyable ranges cite the original LEFT path:line" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "single_comment_range_fields" "opencode one-at-a-time retry keeps multi-line start_line and start_side" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "exclude_deferred_applyable" "opencode deferred leftovers past the retry cap are not listed as applyable suggestions" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 05ae17e0a..ac7f0dad1 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1663,6 +1663,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "from LEFT" in helper assert "strip_left_origin_fields" in helper assert "single_comment_range_fields" in helper + assert "format_deferred_receipt_row" in helper + assert "exclude_deferred_applyable" in helper assert "Manual edit (not a GitHub suggestion):" in helper assert ( "These comments still have a suggested-diff fence that GitHub cannot apply:" diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 2709e9f95..be6ff7527 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -13,7 +13,11 @@ applyable_suggestion_ranges, decode_manual_edit_field, encode_manual_edit_field, + exclude_deferred_applyable, format_applyable_origin, + format_deferred_receipt_row, + merge_deferred_origins, + normalize_deferred_receipt, parse_applyable_origin_field, strip_left_origin_fields, leftover_diff_fence_reason, @@ -849,6 +853,268 @@ def test_write_single_comment_payloads_caps_retry_and_records_deferred(tmp_path) assert empty_deferred.read_text(encoding="utf-8") == "" +def test_deferred_past_retry_cap_keeps_range_origin_and_is_not_applyable(tmp_path): + hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF) + remapped = apply_github_suggestion_blocks( + _batch_payload( + { + "path": "scripts/ci/ok.py", + "line": 4, + "side": "RIGHT", + "body": "first posted", + }, + { + "path": "scripts/ci/example.py", + "line": 5, + "side": "LEFT", + "body": MULTILINE_DIFF_BODY, + }, + ), + hunks, + ) + deferred_comment = remapped["comments"][1] + assert deferred_comment["start_line"] == 5 + assert deferred_comment["line"] == 7 + assert deferred_comment["_left_origin_line"] == 5 + output_dir = tmp_path / "singles" + deferred = tmp_path / "deferred.txt" + assert ( + write_single_comment_payloads( + remapped, output_dir, limit=1, deferred_path=deferred + ) + == 1 + ) + assert sorted(path.name for path in output_dir.glob("comment-*.json")) == [ + "comment-000.json" + ] + posted = json.loads((output_dir / "comment-000.json").read_text(encoding="utf-8")) + assert posted["comments"][0]["path"] == "scripts/ci/ok.py" + assert "start_line" not in posted["comments"][0] + deferred_text = deferred.read_text(encoding="utf-8") + assert deferred_text == ( + "scripts/ci/example.py:5-7\tLEFT scripts/ci/example.py:5\n" + ) + assert "```suggestion" not in deferred_text + assert format_deferred_receipt_row({"path": 12, "line": 4}) == "" + assert format_deferred_receipt_row({"path": "scripts/ci/a.py", "line": 4}) == ( + "scripts/ci/a.py:4\n" + ) + assert format_deferred_receipt_row( + {"path": "scripts/ci/a.py", "line": 7, "start_line": 5} + ) == "scripts/ci/a.py:5-7\n" + assert format_deferred_receipt_row( + {"path": "scripts/ci/a.py", "line": 5, "start_line": 5} + ) == "scripts/ci/a.py:5\n" + assert normalize_deferred_receipt(("scripts/ci/later.py", 20)) == ( + "scripts/ci/later.py", + 20, + 20, + None, + None, + ) + assert normalize_deferred_receipt(("scripts/ci/later.py", 20, 20)) == ( + "scripts/ci/later.py", + 20, + 20, + None, + None, + ) + assert normalize_deferred_receipt( + ("scripts/ci/later.py", 20, 22, "scripts/ci/later.py", 11) + ) == ("scripts/ci/later.py", 20, 22, "scripts/ci/later.py", 11) + assert normalize_deferred_receipt(("scripts/ci/later.py",)) is None + assert normalize_deferred_receipt(("scripts/ci/later.py", 0)) is None + assert normalize_deferred_receipt(("scripts/ci/later.py", 7, 3)) is None + assert normalize_deferred_receipt(("scripts/ci/later.py", 20, 22, None)) is None + assert merge_deferred_origins( + [("scripts/ci/example.py", 5, 7, None, None)], + [("scripts/ci/example.py", 5, 7, "scripts/ci/example.py", 5)], + ) == [("scripts/ci/example.py", 5, 7, "scripts/ci/example.py", 5)] + assert merge_deferred_origins( + [("scripts/ci/example.py", 7, 7, None, None)], + [("scripts/ci/example.py", 5, 7, "scripts/ci/example.py", 5)], + ) == [("scripts/ci/example.py", 7, 7, "scripts/ci/example.py", 5)] + assert merge_deferred_origins( + [("scripts/ci/example.py", 5, 7, "scripts/ci/example.py", 5)], + [("scripts/ci/other.py", 1, 2, "scripts/ci/other.py", 1)], + ) == [("scripts/ci/example.py", 5, 7, "scripts/ci/example.py", 5)] + assert exclude_deferred_applyable( + [("scripts/ci/example.py", 5, 7, None, None)], + [("scripts/ci/example.py", 7, 7, None, None)], + ) == [] + stripped = strip_left_origin_fields(remapped) + stripped_deferred = tmp_path / "stripped-deferred.txt" + assert ( + write_single_comment_payloads( + stripped, tmp_path / "stripped-singles", limit=1, deferred_path=stripped_deferred + ) + == 1 + ) + assert stripped_deferred.read_text(encoding="utf-8") == "scripts/ci/example.py:5-7\n" + assert merge_deferred_origins( + parse_applyable_ranges(stripped_deferred.read_text(encoding="utf-8")), + applyable_suggestion_ranges(remapped), + ) == [("scripts/ci/example.py", 5, 7, "scripts/ci/example.py", 5)] + + rewrite = apply_github_suggestion_blocks( + _batch_payload( + { + "path": "scripts/ci/ok.py", + "line": 4, + "side": "RIGHT", + "body": "first posted", + }, + { + "path": "scripts/ci/rewrite.py", + "line": 11, + "side": "LEFT", + "body": MULTILINE_DIFF_BODY, + }, + ), + parse_unified_diff_hunk_lines(REWRITE_UNIFIED_DIFF), + ) + assert rewrite["comments"][1]["line"] == 22 + assert rewrite["comments"][1]["start_line"] == 20 + rewrite_deferred = tmp_path / "rewrite-deferred.txt" + assert ( + write_single_comment_payloads( + rewrite, tmp_path / "rewrite-singles", limit=1, deferred_path=rewrite_deferred + ) + == 1 + ) + assert rewrite_deferred.read_text(encoding="utf-8") == ( + "scripts/ci/rewrite.py:20-22\tLEFT scripts/ci/rewrite.py:11\n" + ) + applyable = applyable_suggestion_ranges(remapped) + deferred_receipts = parse_applyable_ranges(deferred_text) + merged = merge_deferred_origins(deferred_receipts, applyable) + assert merged == [ + ("scripts/ci/example.py", 5, 7, "scripts/ci/example.py", 5) + ] + remaining = exclude_deferred_applyable(applyable, merged) + assert all(item[0] != "scripts/ci/example.py" for item in remaining) + from scripts.ci.opencode_inline_comment_fallback import _trusted_deferred_subset + + assert _trusted_deferred_subset(None, {("scripts/ci/ok.py", 4)}) == [] + assert _trusted_deferred_subset( + [("scripts/ci/ok.py", 0), ("scripts/ci/ok.py", 4)], + {("scripts/ci/ok.py", 4)}, + ) == [("scripts/ci/ok.py", 4, 4, None, None)] + assert merge_deferred_origins( + [("scripts/ci/a.py", 2, 2, None, None)], + [("scripts/ci/a.py", 2, 2, None, None)], + ) == [("scripts/ci/a.py", 2, 2, None, None)] + assert merge_deferred_origins( + [("scripts/ci/a.py", 2, 2, "scripts/ci/a.py", 1)], + [("scripts/ci/a.py", 2, 2, "scripts/ci/a.py", 9)], + ) == [("scripts/ci/a.py", 2, 2, "scripts/ci/a.py", 1)] + assert merge_deferred_origins( + [("scripts/ci/example.py", 5, 7, None, None)], + [("scripts/ci/example.py", 5, 7, "scripts/ci/example.py", 5)], + ) == [("scripts/ci/example.py", 5, 7, "scripts/ci/example.py", 5)] + assert exclude_deferred_applyable( + [("scripts/ci/example.py", 5, 7, None, None)], + [("scripts/ci/example.py", 5, 5, None, None)], + ) == [] + kept_other = exclude_deferred_applyable( + [ + ("scripts/ci/example.py", 5, 7, "scripts/ci/example.py", 5), + ("scripts/ci/ok.py", 4, 4, None, None), + ], + [("scripts/ci/example.py", 5, 7, "scripts/ci/example.py", 5)], + ) + assert kept_other == [("scripts/ci/ok.py", 4, 4, None, None)] + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/ok.py", "line": 4}, + {"path": "scripts/ci/example.py", "line": 5}, + ), + attached_locations=[("scripts/ci/ok.py", 4)], + deferred_locations=deferred_receipts, + applyable_locations=applyable, + retry_limit=1, + ) + applyable_heading = "GitHub can apply these suggested replacements:" + deferred_heading = "were not retried (retry limit 1):" + assert deferred_heading in body + assert "- `scripts/ci/example.py:5-7` — from LEFT `scripts/ci/example.py:5`" in body + if applyable_heading in body: + applyable_section = body.split(applyable_heading, 1)[1] + assert "scripts/ci/example.py:5-7" not in applyable_section + assert "```suggestion" not in applyable_section + deferred_section = body.split(deferred_heading, 1)[1] + if applyable_heading in deferred_section: + deferred_section = deferred_section.split(applyable_heading, 1)[0] + assert "```suggestion" not in deferred_section + assert "from LEFT `scripts/ci/example.py:5`" in deferred_section + + payload_path = tmp_path / "batch.json" + payload_path.write_text(json.dumps(remapped), encoding="utf-8") + cli_dir = tmp_path / "cli-singles" + cli_deferred = tmp_path / "cli-deferred.txt" + assert ( + main( + [ + "--split-payload", + str(payload_path), + "--output-dir", + str(cli_dir), + "--deferred-locations", + str(cli_deferred), + "--retry-limit", + "1", + ] + ) + == 0 + ) + assert cli_deferred.read_text(encoding="utf-8") == ( + "scripts/ci/example.py:5-7\tLEFT scripts/ci/example.py:5\n" + ) + control_path = tmp_path / "control.json" + body_path = tmp_path / "body.md" + receipt = tmp_path / "receipt.md" + applyable_file = tmp_path / "applyable.txt" + applyable_file.write_text( + "scripts/ci/example.py:5-7\tLEFT scripts/ci/example.py:5\n", + encoding="utf-8", + ) + control_path.write_text( + json.dumps( + control( + {"path": "scripts/ci/ok.py", "line": 4}, + {"path": "scripts/ci/example.py", "line": 5}, + ) + ), + encoding="utf-8", + ) + body_path.write_text("## Findings\n", encoding="utf-8") + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--deferred-locations", + str(cli_deferred), + "--applyable-locations", + str(applyable_file), + "--retry-limit", + "1", + ] + ) + == 0 + ) + rendered = receipt.read_text(encoding="utf-8") + assert "were not retried (retry limit 1):" in rendered + assert "- `scripts/ci/example.py:5-7` — from LEFT `scripts/ci/example.py:5`" in rendered + if applyable_heading in rendered: + assert "scripts/ci/example.py:5-7" not in rendered.split(applyable_heading, 1)[1] + + def test_mixed_success_receipts_list_attached_beside_refused(): body = render_inline_comment_failure_body( "## Findings\n", From 776bb8d0c83ca8e123d80feadb8d1c9f92462bd4 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 18:54:00 +0900 Subject: [PATCH 19/34] fix(review): keep Manual edit when leftover fences are deferred A cannot-provide or pure-deletion leftover past the 20-comment retry cap still shows the Manual-edit ```diff block and the deferred range/origin row. Those fences stay off the applyable suggestion list. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 9 +- .../ci/opencode_inline_comment_fallback.py | 50 +++++ scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 2 + .../test_opencode_inline_comment_fallback.py | 180 ++++++++++++++++++ 6 files changed, 241 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a9ecb92e..9bb7af4ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Kept both the leftover Manual-edit ` ```diff ` block and the deferred range/origin row when a cannot-provide or pure-deletion leftover sits past the 20-comment 422 retry cap, and still omitted those fences from applyable GitHub suggestions. - Recorded leftover OpenCode comments past the 20-comment 422 retry cap as deferred overview ranges with their LEFT origin, and stopped listing them under applyable GitHub suggestions because those comments are never posted. - Kept `start_line`/`start_side` on remapped leftover OpenCode suggestions when a batch 422 is retried one comment at a time, so a multi-line RIGHT range still posts as one GitHub suggestion. - Labeled remapped leftover OpenCode applyable ranges with the original LEFT `path:line` so the overview shows `path:right` came from LEFT `path:left`. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 6bf24dcdb..e35843e6a 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -32,7 +32,10 @@ suggestions: their ``path:start-end`` range and leftover LEFT origin stay on the deferred overview list only, they are removed from the applyable heading so the receipt does not claim GitHub can apply a comment that was never sent, and they are recorded as not retried -instead of opening unbounded ``gh api`` writes. The first success uses `REQUEST_CHANGES` plus the review +instead of opening unbounded ``gh api`` writes. A deferred leftover +that still has only a `` ```diff `` fence (``cannot-provide`` or a +pure-deletion ``LEFT``) keeps that Manual-edit block as well as the +deferred row. The first success uses `REQUEST_CHANGES` plus the review body; later successes use `COMMENT`. Survivors therefore still appear on Files changed. Remaining failures still rebuild the fallback from the `gh api` error file and write durable receipts into the OpenCode @@ -106,7 +109,9 @@ with the same control object used to build the inline `comments` array. per-comment 422 phrases, the 20-comment one-at-a-time retry cap, preservation of ``start_line``/``start_side`` on remapped multi-line suggestion retries, deferred leftovers past the retry cap that keep - range and LEFT origin as non-applyable overview context, and + range and LEFT origin as non-applyable overview context, deferred + cannot-provide and pure-deletion leftovers that keep both the + Manual-edit block and the deferred row, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index c55867a50..8ae067cd1 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -1075,6 +1075,53 @@ def exclude_deferred_applyable( return kept +def leftover_manual_edits_with_deferred( + leftovers: list[tuple[str, int, str, str]], + deferred: list[tuple[str, int, int, str | None, int | None]], +) -> list[tuple[str, int, str, str]]: + """Keep leftover Manual-edit receipts even when the same leftover is deferred.""" + if not leftovers: + return [] + deferred_points = { + (path, start) + for path, start, end, _origin_path, _origin_line in deferred + if start == end + } + deferred_points.update( + (path, end) for path, _start, end, _origin_path, _origin_line in deferred + ) + kept: list[tuple[str, int, str, str]] = [] + seen: set[tuple[str, int]] = set() + for item in leftovers: + path, line, reason, excerpt = _leftover_receipt_parts(item) + if reason not in LEFTOVER_DIFF_REASONS or (path, line) in seen: + continue + seen.add((path, line)) + kept.append((path, line, reason, excerpt)) + if not deferred_points: + return kept + overlapping = [item for item in kept if (item[0], item[1]) in deferred_points] + rest = [item for item in kept if (item[0], item[1]) not in deferred_points] + return overlapping + rest + + +def exclude_leftover_from_applyable( + applyable: list[tuple[str, int, int, str | None, int | None]], + leftovers: list[tuple[str, int, str, str]], +) -> list[tuple[str, int, int, str | None, int | None]]: + """Drop applyable ranges that are leftover cannot-provide or LEFT fences.""" + leftover_points = {(path, line) for path, line, _reason, _excerpt in leftovers} + if not leftover_points: + return applyable + kept: list[tuple[str, int, int, str | None, int | None]] = [] + for item in applyable: + path, start, end, origin_path, origin_line = _applyable_receipt_parts(item) + if (path, start) in leftover_points or (path, end) in leftover_points: + continue + kept.append((path, start, end, origin_path, origin_line)) + return kept + + def render_inline_comment_receipts( locations: list[tuple[str, int]], error_phrase: str = "", @@ -1355,9 +1402,12 @@ def render_inline_comment_failure_body( if leftover_locations is not None else [] ) + leftover = leftover_manual_edits_with_deferred(leftover, deferred) if deferred and applyable: deferred = merge_deferred_origins(deferred, applyable) applyable = exclude_deferred_applyable(applyable, deferred) + if leftover and applyable: + applyable = exclude_leftover_from_applyable(applyable, leftover) phrases: dict[tuple[str, int], str] | None = None if refused_receipts is not None: locations = [ diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 9ba41e695..dc573d3f0 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1501,6 +1501,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "from LEFT" "opencode remapped applyable ranges cite the original LEFT path:line" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "single_comment_range_fields" "opencode one-at-a-time retry keeps multi-line start_line and start_side" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "exclude_deferred_applyable" "opencode deferred leftovers past the retry cap are not listed as applyable suggestions" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_manual_edits_with_deferred" "opencode deferred cannot-provide leftovers keep the Manual-edit overview block" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index ac7f0dad1..47aab2ca9 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1665,6 +1665,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "single_comment_range_fields" in helper assert "format_deferred_receipt_row" in helper assert "exclude_deferred_applyable" in helper + assert "leftover_manual_edits_with_deferred" in helper + assert "exclude_leftover_from_applyable" in helper assert "Manual edit (not a GitHub suggestion):" in helper assert ( "These comments still have a suggested-diff fence that GitHub cannot apply:" diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index be6ff7527..47047fa63 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -14,6 +14,7 @@ decode_manual_edit_field, encode_manual_edit_field, exclude_deferred_applyable, + exclude_leftover_from_applyable, format_applyable_origin, format_deferred_receipt_row, merge_deferred_origins, @@ -23,6 +24,7 @@ leftover_diff_fence_reason, leftover_diff_fence_receipts, leftover_manual_edit_text, + leftover_manual_edits_with_deferred, parse_leftover_diff_receipts, remap_left_comment_to_right_hunk, render_leftover_diff_receipts, @@ -3344,3 +3346,181 @@ def test_applyable_left_origin_parse_render_and_strip(tmp_path): in receipt.read_text(encoding="utf-8") ) + +def test_deferred_cannot_provide_and_deletion_keep_manual_edit_and_deferred_row( + tmp_path, +): + hunks = parse_unified_diff_hunk_lines( + EXAMPLE_UNIFIED_DIFF + + "diff --git a/scripts/ci/blocked.py b/scripts/ci/blocked.py\n" + + "--- a/scripts/ci/blocked.py\n+++ b/scripts/ci/blocked.py\n" + + "@@ -4,1 +4,1 @@\n- old\n+ new\n" + ) + payload = _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 7, + "side": "RIGHT", + "body": "posted first", + }, + { + "path": "scripts/ci/blocked.py", + "line": 4, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + }, + { + "path": "scripts/ci/removed.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + ) + output = tmp_path / "filtered.json" + applyable_file = tmp_path / "applyable.txt" + leftover_file = tmp_path / "leftover.txt" + assert ( + write_hunk_filtered_payload( + payload, + hunks, + output, + applyable_path=applyable_file, + leftover_path=leftover_file, + ) + == 3 + ) + leftover_text = leftover_file.read_text(encoding="utf-8") + assert "scripts/ci/blocked.py:4\tcannot-provide\t" in leftover_text + assert "scripts/ci/removed.py:11\tLEFT\t" in leftover_text + applyable_text = applyable_file.read_text(encoding="utf-8") + assert "scripts/ci/blocked.py" not in applyable_text + assert "scripts/ci/removed.py" not in applyable_text + filtered = json.loads(output.read_text(encoding="utf-8")) + deferred_path = tmp_path / "deferred.txt" + singles_dir = tmp_path / "singles" + assert ( + write_single_comment_payloads( + filtered, singles_dir, limit=1, deferred_path=deferred_path + ) + == 1 + ) + posted = json.loads((singles_dir / "comment-000.json").read_text(encoding="utf-8")) + assert posted["comments"][0]["path"] == "scripts/ci/example.py" + deferred_text = deferred_path.read_text(encoding="utf-8") + assert "scripts/ci/blocked.py:4\n" in deferred_text + assert "scripts/ci/removed.py:11\n" in deferred_text + assert "```suggestion" not in deferred_text + leftover_receipts = parse_leftover_diff_receipts(leftover_text) + deferred_receipts = parse_applyable_ranges(deferred_text) + applyable_receipts = parse_applyable_ranges(applyable_text) + kept_leftover = leftover_manual_edits_with_deferred( + leftover_receipts, deferred_receipts + ) + leftover_keys = {(path, line) for path, line, _reason, _excerpt in kept_leftover} + assert ("scripts/ci/blocked.py", 4) in leftover_keys + assert ("scripts/ci/removed.py", 11) in leftover_keys + assert exclude_leftover_from_applyable(applyable_receipts, kept_leftover) == [] + assert exclude_leftover_from_applyable( + [("scripts/ci/blocked.py", 4, 4, None, None)], + leftover_receipts, + ) == [] + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "scripts/ci/blocked.py", "line": 4}, + {"path": "scripts/ci/removed.py", "line": 11}, + ), + attached_locations=[("scripts/ci/example.py", 7)], + deferred_locations=deferred_receipts, + applyable_locations=applyable_receipts + + [("scripts/ci/blocked.py", 4, 4, None, None)], + leftover_locations=leftover_receipts, + retry_limit=1, + ) + leftover_heading = ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + deferred_heading = "were not retried (retry limit 1):" + applyable_heading = "GitHub can apply these suggested replacements:" + assert leftover_heading in body + assert deferred_heading in body + leftover_section = body.split(leftover_heading, 1)[1] + assert MANUAL_EDIT_HEADING in leftover_section + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) in leftover_section + assert leftover_manual_edit_text(SUGGESTED_DIFF_BODY) in leftover_section + assert "- `scripts/ci/blocked.py:4` — cannot-provide" in leftover_section + assert "- `scripts/ci/removed.py:11` — LEFT" in leftover_section + deferred_section = body.split(deferred_heading, 1)[1] + if leftover_heading in deferred_section: + deferred_section = deferred_section.split(leftover_heading, 1)[0] + if applyable_heading in deferred_section: + deferred_section = deferred_section.split(applyable_heading, 1)[0] + assert "- `scripts/ci/blocked.py:4`" in deferred_section + assert "- `scripts/ci/removed.py:11`" in deferred_section + if applyable_heading in body: + applyable_section = body.split(applyable_heading, 1)[1] + if leftover_heading in applyable_section: + applyable_section = applyable_section.split(leftover_heading, 1)[0] + assert "scripts/ci/blocked.py" not in applyable_section + assert "scripts/ci/removed.py" not in applyable_section + assert MANUAL_EDIT_HEADING not in applyable_section + assert "```suggestion" not in applyable_section + assert leftover_manual_edits_with_deferred([], deferred_receipts) == [] + assert leftover_manual_edits_with_deferred(leftover_receipts, []) == leftover_receipts + assert leftover_manual_edits_with_deferred( + [("scripts/ci/x.py", 1, "HTTP 422", "n")], + [], + ) == [] + assert exclude_leftover_from_applyable( + [("scripts/ci/example.py", 7, 7, None, None)], + [], + ) == [("scripts/ci/example.py", 7, 7, None, None)] + assert exclude_leftover_from_applyable( + [("scripts/ci/example.py", 7, 7, None, None)], leftover_receipts + ) == [("scripts/ci/example.py", 7, 7, None, None)] + + control_path = tmp_path / "control.json" + body_path = tmp_path / "body.md" + receipt = tmp_path / "receipt.md" + control_path.write_text( + json.dumps( + control( + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "scripts/ci/blocked.py", "line": 4}, + {"path": "scripts/ci/removed.py", "line": 11}, + ) + ), + encoding="utf-8", + ) + body_path.write_text("## Findings\n", encoding="utf-8") + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--deferred-locations", + str(deferred_path), + "--applyable-locations", + str(applyable_file), + "--leftover-diff-locations", + str(leftover_file), + "--retry-limit", + "1", + ] + ) + == 0 + ) + rendered = receipt.read_text(encoding="utf-8") + assert leftover_heading in rendered + assert deferred_heading in rendered + assert MANUAL_EDIT_HEADING in rendered + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) in rendered + assert leftover_manual_edit_text(SUGGESTED_DIFF_BODY) in rendered + if applyable_heading in rendered: + assert "scripts/ci/blocked.py" not in rendered.split(applyable_heading, 1)[1] + From 46bf600c62f9b65959c13bef870db419a214dc86 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:00:41 +0900 Subject: [PATCH 20/34] fix(review): list deferred leftover before Manual-edit excerpt When leftover and deferred share a path:line, the leftover heading prints the deferred range/origin first, then the Manual-edit excerpt. Deferred leftovers also appear before leftovers that were already posted. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 8 +- .../ci/opencode_inline_comment_fallback.py | 23 ++++- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 1 + .../test_opencode_inline_comment_fallback.py | 99 +++++++++++++++++++ 6 files changed, 129 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bb7af4ad..6389bc284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Listed a deferred leftover ahead of its Manual-edit excerpt in the leftover heading when the same `path:line` is both deferred and leftover, so authors see the unposted fence first. - Kept both the leftover Manual-edit ` ```diff ` block and the deferred range/origin row when a cannot-provide or pure-deletion leftover sits past the 20-comment 422 retry cap, and still omitted those fences from applyable GitHub suggestions. - Recorded leftover OpenCode comments past the 20-comment 422 retry cap as deferred overview ranges with their LEFT origin, and stopped listing them under applyable GitHub suggestions because those comments are never posted. - Kept `start_line`/`start_side` on remapped leftover OpenCode suggestions when a batch 422 is retried one comment at a time, so a multi-line RIGHT range still posts as one GitHub suggestion. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index e35843e6a..8469b1ff4 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -35,7 +35,10 @@ comment that was never sent, and they are recorded as not retried instead of opening unbounded ``gh api`` writes. A deferred leftover that still has only a `` ```diff `` fence (``cannot-provide`` or a pure-deletion ``LEFT``) keeps that Manual-edit block as well as the -deferred row. The first success uses `REQUEST_CHANGES` plus the review +deferred row. When leftover and deferred share a ``path:line``, the +leftover heading lists the deferred range/origin first, then the +Manual-edit excerpt, and deferred leftovers appear before leftovers +that were already posted. The first success uses `REQUEST_CHANGES` plus the review body; later successes use `COMMENT`. Survivors therefore still appear on Files changed. Remaining failures still rebuild the fallback from the `gh api` error file and write durable receipts into the OpenCode @@ -111,7 +114,8 @@ with the same control object used to build the inline `comments` array. suggestion retries, deferred leftovers past the retry cap that keep range and LEFT origin as non-applyable overview context, deferred cannot-provide and pure-deletion leftovers that keep both the - Manual-edit block and the deferred row, and + Manual-edit block and the deferred row, leftover-heading order that + lists a shared deferred leftover before its Manual-edit excerpt, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 8ae067cd1..eb0228414 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -741,14 +741,33 @@ def parse_leftover_diff_receipts(text: str) -> list[tuple[str, int, str, str]]: return receipts +def leftover_deferred_matches( + deferred: list[tuple[str, int, int, str | None, int | None]] | None, +) -> dict[tuple[str, int], tuple[str, int, int, str | None, int | None]]: + """Map leftover path:line to the deferred range that shares that location.""" + matches: dict[tuple[str, int], tuple[str, int, int, str | None, int | None]] = {} + if not deferred: + return matches + for item in deferred: + path, start, end, origin_path, origin_line = _applyable_receipt_parts(item) + matches[(path, start)] = (path, start, end, origin_path, origin_line) + matches[(path, end)] = (path, start, end, origin_path, origin_line) + return matches + + def render_leftover_diff_receipts( receipts: list[tuple[str, int, str, str]] | list[tuple[str, int, str]], + deferred: list[tuple[str, int, int, str | None, int | None]] | None = None, ) -> list[str]: - """Return overview lines with a non-applyable leftover manual-edit block.""" + """Return leftover lines with deferred range/origin before a Manual-edit excerpt.""" + matches = leftover_deferred_matches(deferred) lines: list[str] = [] for item in receipts: path, line, reason, excerpt = _leftover_receipt_parts(item) excerpt = excerpt.replace("```", "") + deferred_item = matches.get((path, line)) + if deferred_item is not None: + lines.extend(render_applyable_receipts([deferred_item])) lines.append(f"- `{path}:{line}` — {reason}") if not excerpt: continue @@ -1282,7 +1301,7 @@ def render_inline_comment_failure_suffix( "These comments still have a suggested-diff fence that GitHub cannot apply:" ) lines.append("") - lines.extend(render_leftover_diff_receipts(leftover)) + lines.extend(render_leftover_diff_receipts(leftover, deferred=deferred)) lines.append("") return "\n".join(lines) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index dc573d3f0..b553f95b2 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1502,6 +1502,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "single_comment_range_fields" "opencode one-at-a-time retry keeps multi-line start_line and start_side" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "exclude_deferred_applyable" "opencode deferred leftovers past the retry cap are not listed as applyable suggestions" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_manual_edits_with_deferred" "opencode deferred cannot-provide leftovers keep the Manual-edit overview block" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_deferred_matches" "opencode leftover heading lists deferred leftovers before Manual-edit excerpts" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 47aab2ca9..b94df4640 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1667,6 +1667,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "exclude_deferred_applyable" in helper assert "leftover_manual_edits_with_deferred" in helper assert "exclude_leftover_from_applyable" in helper + assert "leftover_deferred_matches" in helper assert "Manual edit (not a GitHub suggestion):" in helper assert ( "These comments still have a suggested-diff fence that GitHub cannot apply:" diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 47047fa63..cd5bf192e 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -21,6 +21,7 @@ normalize_deferred_receipt, parse_applyable_origin_field, strip_left_origin_fields, + leftover_deferred_matches, leftover_diff_fence_reason, leftover_diff_fence_receipts, leftover_manual_edit_text, @@ -3524,3 +3525,101 @@ def test_deferred_cannot_provide_and_deletion_keep_manual_edit_and_deferred_row( if applyable_heading in rendered: assert "scripts/ci/blocked.py" not in rendered.split(applyable_heading, 1)[1] + +def test_leftover_heading_lists_deferred_leftover_before_manual_edit(tmp_path): + hunks = parse_unified_diff_hunk_lines( + EXAMPLE_UNIFIED_DIFF + + "diff --git a/scripts/ci/blocked.py b/scripts/ci/blocked.py\n" + + "--- a/scripts/ci/blocked.py\n+++ b/scripts/ci/blocked.py\n" + + "@@ -4,1 +4,1 @@\n- old\n+ new\n" + ) + payload = _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 8, + "side": "RIGHT", + "body": NA_DIFF_BODY, + }, + { + "path": "scripts/ci/blocked.py", + "line": 4, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + }, + { + "path": "scripts/ci/removed.py", + "line": 11, + "side": "LEFT", + "body": SUGGESTED_DIFF_BODY, + }, + ) + output = tmp_path / "filtered.json" + leftover_file = tmp_path / "leftover.txt" + applyable_file = tmp_path / "applyable.txt" + assert write_hunk_filtered_payload( + payload, hunks, output, applyable_path=applyable_file, leftover_path=leftover_file + ) == 3 + filtered = json.loads(output.read_text(encoding="utf-8")) + deferred_path = tmp_path / "deferred.txt" + assert ( + write_single_comment_payloads( + filtered, tmp_path / "singles", limit=1, deferred_path=deferred_path + ) + == 1 + ) + leftover_receipts = parse_leftover_diff_receipts( + leftover_file.read_text(encoding="utf-8") + ) + deferred_receipts = parse_applyable_ranges(deferred_path.read_text(encoding="utf-8")) + ordered = leftover_manual_edits_with_deferred(leftover_receipts, deferred_receipts) + leftover_keys = [(path, line) for path, line, _reason, _excerpt in ordered] + assert leftover_keys[0] in {("scripts/ci/blocked.py", 4), ("scripts/ci/removed.py", 11)} + assert leftover_keys[-1] == ("scripts/ci/example.py", 8) + assert leftover_deferred_matches([]) == {} + assert leftover_deferred_matches(None) == {} + matches = leftover_deferred_matches(deferred_receipts) + assert ("scripts/ci/blocked.py", 4) in matches + rendered_leftover = render_leftover_diff_receipts( + ordered, deferred=deferred_receipts + ) + joined = "\n".join(rendered_leftover) + blocked_row = joined.index("- `scripts/ci/blocked.py:4`") + blocked_reason = joined.index("- `scripts/ci/blocked.py:4` — cannot-provide") + blocked_edit = joined.index(MANUAL_EDIT_HEADING) + posted_reason = joined.index("- `scripts/ci/example.py:8` — cannot-provide") + assert blocked_row < blocked_reason < blocked_edit < posted_reason + assert leftover_manual_edit_text(NA_DIFF_BODY) in joined + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) in joined + + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/example.py", "line": 8}, + {"path": "scripts/ci/blocked.py", "line": 4}, + {"path": "scripts/ci/removed.py", "line": 11}, + ), + attached_locations=[("scripts/ci/example.py", 8)], + deferred_locations=deferred_receipts, + applyable_locations=parse_applyable_ranges( + applyable_file.read_text(encoding="utf-8") + ), + leftover_locations=leftover_receipts, + retry_limit=1, + ) + leftover_heading = ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + leftover_section = body.split(leftover_heading, 1)[1] + assert leftover_section.index("- `scripts/ci/blocked.py:4`") < leftover_section.index( + "- `scripts/ci/example.py:8` — cannot-provide" + ) + blocked_excerpt_at = leftover_section.index( + leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) + ) + assert leftover_section.index("- `scripts/ci/blocked.py:4`") < blocked_excerpt_at + assert leftover_section.index("- `scripts/ci/blocked.py:4` — cannot-provide") < blocked_excerpt_at + applyable_heading = "GitHub can apply these suggested replacements:" + if applyable_heading in leftover_section: + leftover_section = leftover_section.split(applyable_heading, 1)[0] + assert "```suggestion" not in leftover_section + From 06392ece9bc992ca3a34913a5532013a53d7283b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:06:13 +0900 Subject: [PATCH 21/34] fix(review): omit leftover reason bullet after deferred prefix When leftover heading already prefixes a deferred range/origin for the same path:line, skip the duplicate cannot-provide/LEFT reason bullet so authors see one deferred line then the Manual-edit excerpt. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 8 +++-- .../ci/opencode_inline_comment_fallback.py | 19 ++++++++++-- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 1 + .../test_opencode_inline_comment_fallback.py | 31 ++++++++++++++++--- 6 files changed, 51 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6389bc284..70519daf4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Omitted the duplicate leftover reason bullet when the leftover heading already prefixes a deferred range/origin for the same `path:line`, so authors see one deferred line then the Manual-edit excerpt. - Listed a deferred leftover ahead of its Manual-edit excerpt in the leftover heading when the same `path:line` is both deferred and leftover, so authors see the unposted fence first. - Kept both the leftover Manual-edit ` ```diff ` block and the deferred range/origin row when a cannot-provide or pure-deletion leftover sits past the 20-comment 422 retry cap, and still omitted those fences from applyable GitHub suggestions. - Recorded leftover OpenCode comments past the 20-comment 422 retry cap as deferred overview ranges with their LEFT origin, and stopped listing them under applyable GitHub suggestions because those comments are never posted. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 8469b1ff4..8b851f12b 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -37,8 +37,9 @@ that still has only a `` ```diff `` fence (``cannot-provide`` or a pure-deletion ``LEFT``) keeps that Manual-edit block as well as the deferred row. When leftover and deferred share a ``path:line``, the leftover heading lists the deferred range/origin first, then the -Manual-edit excerpt, and deferred leftovers appear before leftovers -that were already posted. The first success uses `REQUEST_CHANGES` plus the review +Manual-edit excerpt, without repeating the leftover reason bullet, +and deferred leftovers appear before leftovers that were already +posted. The first success uses `REQUEST_CHANGES` plus the review body; later successes use `COMMENT`. Survivors therefore still appear on Files changed. Remaining failures still rebuild the fallback from the `gh api` error file and write durable receipts into the OpenCode @@ -115,7 +116,8 @@ with the same control object used to build the inline `comments` array. range and LEFT origin as non-applyable overview context, deferred cannot-provide and pure-deletion leftovers that keep both the Manual-edit block and the deferred row, leftover-heading order that - lists a shared deferred leftover before its Manual-edit excerpt, and + lists a shared deferred leftover before its Manual-edit excerpt + without a duplicate reason bullet, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index eb0228414..f14590fc4 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -755,11 +755,25 @@ def leftover_deferred_matches( return matches +def leftover_reason_bullet_duplicates_deferred( + path: str, + line: int, + deferred_item: tuple[str, int, int, str | None, int | None] | None, +) -> bool: + """Return whether a leftover reason bullet would repeat a prefixed deferred row.""" + if deferred_item is None: + return False + deferred_path, start, end, _origin_path, _origin_line = _applyable_receipt_parts( + deferred_item + ) + return deferred_path == path and start <= line <= end + + def render_leftover_diff_receipts( receipts: list[tuple[str, int, str, str]] | list[tuple[str, int, str]], deferred: list[tuple[str, int, int, str | None, int | None]] | None = None, ) -> list[str]: - """Return leftover lines with deferred range/origin before a Manual-edit excerpt.""" + """Return leftover lines with one deferred row, then a Manual-edit excerpt.""" matches = leftover_deferred_matches(deferred) lines: list[str] = [] for item in receipts: @@ -768,7 +782,8 @@ def render_leftover_diff_receipts( deferred_item = matches.get((path, line)) if deferred_item is not None: lines.extend(render_applyable_receipts([deferred_item])) - lines.append(f"- `{path}:{line}` — {reason}") + if not leftover_reason_bullet_duplicates_deferred(path, line, deferred_item): + lines.append(f"- `{path}:{line}` — {reason}") if not excerpt: continue lines.append(f" {MANUAL_EDIT_HEADING}") diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index b553f95b2..1927ef7ea 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1503,6 +1503,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "exclude_deferred_applyable" "opencode deferred leftovers past the retry cap are not listed as applyable suggestions" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_manual_edits_with_deferred" "opencode deferred cannot-provide leftovers keep the Manual-edit overview block" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_deferred_matches" "opencode leftover heading lists deferred leftovers before Manual-edit excerpts" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_reason_bullet_duplicates_deferred" "opencode leftover heading omits a duplicate reason bullet after a deferred prefix" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" assert_file_contains "$workflow_file" '--skipped-locations "$skipped_locations_file"' "opencode records off-hunk path:line rows that were not posted" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index b94df4640..f41b54183 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1668,6 +1668,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "leftover_manual_edits_with_deferred" in helper assert "exclude_leftover_from_applyable" in helper assert "leftover_deferred_matches" in helper + assert "leftover_reason_bullet_duplicates_deferred" in helper assert "Manual edit (not a GitHub suggestion):" in helper assert ( "These comments still have a suggested-diff fence that GitHub cannot apply:" diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index cd5bf192e..09d50c6b0 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -22,6 +22,7 @@ parse_applyable_origin_field, strip_left_origin_fields, leftover_deferred_matches, + leftover_reason_bullet_duplicates_deferred, leftover_diff_fence_reason, leftover_diff_fence_receipts, leftover_manual_edit_text, @@ -3450,8 +3451,10 @@ def test_deferred_cannot_provide_and_deletion_keep_manual_edit_and_deferred_row( assert MANUAL_EDIT_HEADING in leftover_section assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) in leftover_section assert leftover_manual_edit_text(SUGGESTED_DIFF_BODY) in leftover_section - assert "- `scripts/ci/blocked.py:4` — cannot-provide" in leftover_section - assert "- `scripts/ci/removed.py:11` — LEFT" in leftover_section + assert "- `scripts/ci/blocked.py:4`" in leftover_section + assert "- `scripts/ci/removed.py:11`" in leftover_section + assert "- `scripts/ci/blocked.py:4` — cannot-provide" not in leftover_section + assert "- `scripts/ci/removed.py:11` — LEFT" not in leftover_section deferred_section = body.split(deferred_heading, 1)[1] if leftover_heading in deferred_section: deferred_section = deferred_section.split(leftover_heading, 1)[0] @@ -3584,12 +3587,30 @@ def test_leftover_heading_lists_deferred_leftover_before_manual_edit(tmp_path): ) joined = "\n".join(rendered_leftover) blocked_row = joined.index("- `scripts/ci/blocked.py:4`") - blocked_reason = joined.index("- `scripts/ci/blocked.py:4` — cannot-provide") blocked_edit = joined.index(MANUAL_EDIT_HEADING) posted_reason = joined.index("- `scripts/ci/example.py:8` — cannot-provide") - assert blocked_row < blocked_reason < blocked_edit < posted_reason + assert blocked_row < blocked_edit < posted_reason + assert "- `scripts/ci/blocked.py:4` — cannot-provide" not in joined assert leftover_manual_edit_text(NA_DIFF_BODY) in joined assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) in joined + assert leftover_reason_bullet_duplicates_deferred( + "scripts/ci/blocked.py", 4, None + ) is False + assert leftover_reason_bullet_duplicates_deferred( + "scripts/ci/blocked.py", + 4, + ("scripts/ci/other.py", 4, 4, None, None), + ) is False + assert leftover_reason_bullet_duplicates_deferred( + "scripts/ci/blocked.py", + 9, + ("scripts/ci/blocked.py", 4, 4, None, None), + ) is False + assert leftover_reason_bullet_duplicates_deferred( + "scripts/ci/blocked.py", + 4, + ("scripts/ci/blocked.py", 4, 4, None, None), + ) is True body = render_inline_comment_failure_body( "## Findings\n", @@ -3617,7 +3638,7 @@ def test_leftover_heading_lists_deferred_leftover_before_manual_edit(tmp_path): leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) ) assert leftover_section.index("- `scripts/ci/blocked.py:4`") < blocked_excerpt_at - assert leftover_section.index("- `scripts/ci/blocked.py:4` — cannot-provide") < blocked_excerpt_at + assert "- `scripts/ci/blocked.py:4` — cannot-provide" not in leftover_section applyable_heading = "GitHub can apply these suggested replacements:" if applyable_heading in leftover_section: leftover_section = leftover_section.split(applyable_heading, 1)[0] From 2e39a06606c2c14de7eb4e3cc63349f13058e65c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:16:31 +0900 Subject: [PATCH 22/34] fix(review): omit leftover reason bullet inside deferred start-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a leftover line sits inside a deferred multi-line path:start-end, prefix the deferred range and keep the Manual-edit excerpt immediately after it instead of repeating path:line — reason. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 3 +- .../ci/opencode_inline_comment_fallback.py | 22 ++-- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 1 + .../test_opencode_inline_comment_fallback.py | 116 ++++++++++++++++++ 6 files changed, 129 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70519daf4..8414a6bda 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Omitted the duplicate leftover reason bullet when a leftover line sits inside a deferred multi-line `path:start-end`, so authors see the deferred range then the Manual-edit excerpt. - Omitted the duplicate leftover reason bullet when the leftover heading already prefixes a deferred range/origin for the same `path:line`, so authors see one deferred line then the Manual-edit excerpt. - Listed a deferred leftover ahead of its Manual-edit excerpt in the leftover heading when the same `path:line` is both deferred and leftover, so authors see the unposted fence first. - Kept both the leftover Manual-edit ` ```diff ` block and the deferred range/origin row when a cannot-provide or pure-deletion leftover sits past the 20-comment 422 retry cap, and still omitted those fences from applyable GitHub suggestions. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 8b851f12b..2389f983e 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -117,7 +117,8 @@ with the same control object used to build the inline `comments` array. cannot-provide and pure-deletion leftovers that keep both the Manual-edit block and the deferred row, leftover-heading order that lists a shared deferred leftover before its Manual-edit excerpt - without a duplicate reason bullet, and + without a duplicate reason bullet, including leftover lines inside a + deferred ``path:start-end``, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index f14590fc4..158ed179e 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -744,14 +744,15 @@ def parse_leftover_diff_receipts(text: str) -> list[tuple[str, int, str, str]]: def leftover_deferred_matches( deferred: list[tuple[str, int, int, str | None, int | None]] | None, ) -> dict[tuple[str, int], tuple[str, int, int, str | None, int | None]]: - """Map leftover path:line to the deferred range that shares that location.""" + """Map leftover path:line to the deferred range that contains that location.""" matches: dict[tuple[str, int], tuple[str, int, int, str | None, int | None]] = {} if not deferred: return matches for item in deferred: path, start, end, origin_path, origin_line = _applyable_receipt_parts(item) - matches[(path, start)] = (path, start, end, origin_path, origin_line) - matches[(path, end)] = (path, start, end, origin_path, origin_line) + receipt = (path, start, end, origin_path, origin_line) + for line in range(start, end + 1): + matches[(path, line)] = receipt return matches @@ -1116,14 +1117,7 @@ def leftover_manual_edits_with_deferred( """Keep leftover Manual-edit receipts even when the same leftover is deferred.""" if not leftovers: return [] - deferred_points = { - (path, start) - for path, start, end, _origin_path, _origin_line in deferred - if start == end - } - deferred_points.update( - (path, end) for path, _start, end, _origin_path, _origin_line in deferred - ) + matches = leftover_deferred_matches(deferred) kept: list[tuple[str, int, str, str]] = [] seen: set[tuple[str, int]] = set() for item in leftovers: @@ -1132,10 +1126,10 @@ def leftover_manual_edits_with_deferred( continue seen.add((path, line)) kept.append((path, line, reason, excerpt)) - if not deferred_points: + if not matches: return kept - overlapping = [item for item in kept if (item[0], item[1]) in deferred_points] - rest = [item for item in kept if (item[0], item[1]) not in deferred_points] + overlapping = [item for item in kept if (item[0], item[1]) in matches] + rest = [item for item in kept if (item[0], item[1]) not in matches] return overlapping + rest diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 1927ef7ea..01cfc8d5f 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1503,6 +1503,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "exclude_deferred_applyable" "opencode deferred leftovers past the retry cap are not listed as applyable suggestions" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_manual_edits_with_deferred" "opencode deferred cannot-provide leftovers keep the Manual-edit overview block" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_deferred_matches" "opencode leftover heading lists deferred leftovers before Manual-edit excerpts" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "range(start, end + 1)" "opencode leftover heading matches interior lines of a deferred path:start-end" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_reason_bullet_duplicates_deferred" "opencode leftover heading omits a duplicate reason bullet after a deferred prefix" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index f41b54183..50fe472bd 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1669,6 +1669,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "exclude_leftover_from_applyable" in helper assert "leftover_deferred_matches" in helper assert "leftover_reason_bullet_duplicates_deferred" in helper + assert "range(start, end + 1)" in helper assert "Manual edit (not a GitHub suggestion):" in helper assert ( "These comments still have a suggested-diff fence that GitHub cannot apply:" diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 09d50c6b0..05ca3e05c 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -3644,3 +3644,119 @@ def test_leftover_heading_lists_deferred_leftover_before_manual_edit(tmp_path): leftover_section = leftover_section.split(applyable_heading, 1)[0] assert "```suggestion" not in leftover_section + +def test_leftover_interior_of_deferred_range_omits_reason_bullet(tmp_path): + excerpt = leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) + leftover = [ + ("scripts/ci/example.py", 12, "cannot-provide", excerpt), + ("scripts/ci/example.py", 6, "cannot-provide", excerpt), + ] + deferred = [("scripts/ci/example.py", 5, 7, None, None)] + matches = leftover_deferred_matches(deferred) + assert matches[("scripts/ci/example.py", 5)] == deferred[0] + assert matches[("scripts/ci/example.py", 6)] == deferred[0] + assert matches[("scripts/ci/example.py", 7)] == deferred[0] + assert leftover_reason_bullet_duplicates_deferred( + "scripts/ci/example.py", 6, deferred[0] + ) is True + ordered = leftover_manual_edits_with_deferred(leftover, deferred) + assert [(path, line) for path, line, _reason, _excerpt in ordered] == [ + ("scripts/ci/example.py", 6), + ("scripts/ci/example.py", 12), + ] + rendered = render_leftover_diff_receipts(ordered, deferred=deferred) + assert rendered[0] == "- `scripts/ci/example.py:5-7`" + assert rendered[1] == f" {MANUAL_EDIT_HEADING}" + joined = "\n".join(rendered) + assert "- `scripts/ci/example.py:6` — cannot-provide" not in joined + assert "- `scripts/ci/example.py:12` — cannot-provide" in joined + assert excerpt in joined + + hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF) + payload = _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 6, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + }, + { + "path": "scripts/ci/example.py", + "line": 5, + "side": "RIGHT", + "body": MULTILINE_DIFF_BODY, + }, + ) + output = tmp_path / "filtered.json" + leftover_file = tmp_path / "leftover.txt" + applyable_file = tmp_path / "applyable.txt" + assert write_hunk_filtered_payload( + payload, hunks, output, applyable_path=applyable_file, leftover_path=leftover_file + ) == 2 + leftover_receipts = parse_leftover_diff_receipts( + leftover_file.read_text(encoding="utf-8") + ) + applyable_receipts = parse_applyable_ranges( + applyable_file.read_text(encoding="utf-8") + ) + assert leftover_receipts[0][:3] == ("scripts/ci/example.py", 6, "cannot-provide") + assert applyable_receipts[0][:3] == ("scripts/ci/example.py", 5, 7) + deferred_path = tmp_path / "deferred.txt" + assert ( + write_single_comment_payloads( + json.loads(output.read_text(encoding="utf-8")), + tmp_path / "singles", + limit=1, + deferred_path=deferred_path, + ) + == 1 + ) + deferred_receipts = parse_applyable_ranges(deferred_path.read_text(encoding="utf-8")) + assert deferred_receipts[0][:3] == ("scripts/ci/example.py", 5, 7) + fixture_matches = leftover_deferred_matches(deferred_receipts) + assert ("scripts/ci/example.py", 6) in fixture_matches + fixture_rendered = render_leftover_diff_receipts( + leftover_receipts, deferred=deferred_receipts + ) + assert fixture_rendered[0] == "- `scripts/ci/example.py:5-7`" + assert fixture_rendered[1] == f" {MANUAL_EDIT_HEADING}" + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) in "\n".join( + fixture_rendered + ) + assert "- `scripts/ci/example.py:6` — cannot-provide" not in "\n".join( + fixture_rendered + ) + + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/example.py", "line": 6}, + {"path": "scripts/ci/example.py", "line": 5}, + ), + attached_locations=[("scripts/ci/example.py", 6)], + deferred_locations=deferred_receipts, + applyable_locations=applyable_receipts, + leftover_locations=leftover_receipts, + retry_limit=1, + ) + leftover_heading = ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + leftover_section = body.split(leftover_heading, 1)[1] + applyable_heading = "GitHub can apply these suggested replacements:" + if applyable_heading in leftover_section: + leftover_section = leftover_section.split(applyable_heading, 1)[0] + assert leftover_section.index("- `scripts/ci/example.py:5-7`") < leftover_section.index( + MANUAL_EDIT_HEADING + ) + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) in leftover_section + assert "- `scripts/ci/example.py:6` — cannot-provide" not in leftover_section + if applyable_heading in body: + applyable_section = body.split(applyable_heading, 1)[1] + if leftover_heading in applyable_section: + applyable_section = applyable_section.split(leftover_heading, 1)[0] + assert leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) not in applyable_section + assert MANUAL_EDIT_HEADING not in applyable_section + assert "scripts/ci/example.py:5-7" not in applyable_section + assert "```suggestion" not in applyable_section + From b36d308731b93bbdad0fb197f75d1d8f47d8e3c9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:24:40 +0900 Subject: [PATCH 23/34] fix(review): emit one deferred leftover range for interior leftovers When several leftover lines sit inside the same deferred path:start-end, prefix that range once and keep each Manual-edit excerpt under it. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 3 +- .../ci/opencode_inline_comment_fallback.py | 11 +++- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 1 + .../test_opencode_inline_comment_fallback.py | 60 +++++++++++++++++++ 6 files changed, 74 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8414a6bda..6fa5a53bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Grouped leftover Manual-edit excerpts that sit inside the same deferred multi-line `path:start-end` under one deferred range line, so authors do not see a repeated deferred prefix. - Omitted the duplicate leftover reason bullet when a leftover line sits inside a deferred multi-line `path:start-end`, so authors see the deferred range then the Manual-edit excerpt. - Omitted the duplicate leftover reason bullet when the leftover heading already prefixes a deferred range/origin for the same `path:line`, so authors see one deferred line then the Manual-edit excerpt. - Listed a deferred leftover ahead of its Manual-edit excerpt in the leftover heading when the same `path:line` is both deferred and leftover, so authors see the unposted fence first. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 2389f983e..cdd61d1fb 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -118,7 +118,8 @@ with the same control object used to build the inline `comments` array. Manual-edit block and the deferred row, leftover-heading order that lists a shared deferred leftover before its Manual-edit excerpt without a duplicate reason bullet, including leftover lines inside a - deferred ``path:start-end``, and + deferred ``path:start-end`` grouped under one deferred range prefix, + and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 158ed179e..838af1803 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -774,15 +774,22 @@ def render_leftover_diff_receipts( receipts: list[tuple[str, int, str, str]] | list[tuple[str, int, str]], deferred: list[tuple[str, int, int, str | None, int | None]] | None = None, ) -> list[str]: - """Return leftover lines with one deferred row, then a Manual-edit excerpt.""" + """Return leftover lines with one deferred row, then each Manual-edit excerpt.""" matches = leftover_deferred_matches(deferred) lines: list[str] = [] + seen_deferred: set[tuple[str, int, int]] = set() for item in receipts: path, line, reason, excerpt = _leftover_receipt_parts(item) excerpt = excerpt.replace("```", "") deferred_item = matches.get((path, line)) if deferred_item is not None: - lines.extend(render_applyable_receipts([deferred_item])) + deferred_path, start, end, _origin_path, _origin_line = ( + _applyable_receipt_parts(deferred_item) + ) + deferred_key = (deferred_path, start, end) + if deferred_key not in seen_deferred: + lines.extend(render_applyable_receipts([deferred_item])) + seen_deferred.add(deferred_key) if not leftover_reason_bullet_duplicates_deferred(path, line, deferred_item): lines.append(f"- `{path}:{line}` — {reason}") if not excerpt: diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 01cfc8d5f..de6c1b238 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1504,6 +1504,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_manual_edits_with_deferred" "opencode deferred cannot-provide leftovers keep the Manual-edit overview block" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_deferred_matches" "opencode leftover heading lists deferred leftovers before Manual-edit excerpts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "range(start, end + 1)" "opencode leftover heading matches interior lines of a deferred path:start-end" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "seen_deferred" "opencode leftover heading emits one deferred range prefix for multiple interior leftovers" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_reason_bullet_duplicates_deferred" "opencode leftover heading omits a duplicate reason bullet after a deferred prefix" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 50fe472bd..85ed63ce6 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1670,6 +1670,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "leftover_deferred_matches" in helper assert "leftover_reason_bullet_duplicates_deferred" in helper assert "range(start, end + 1)" in helper + assert "seen_deferred" in helper assert "Manual edit (not a GitHub suggestion):" in helper assert ( "These comments still have a suggested-diff fence that GitHub cannot apply:" diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 05ca3e05c..da51ff7ad 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -3760,3 +3760,63 @@ def test_leftover_interior_of_deferred_range_omits_reason_bullet(tmp_path): assert "scripts/ci/example.py:5-7" not in applyable_section assert "```suggestion" not in applyable_section + +def test_leftover_heading_emits_deferred_range_once_for_multiple_interior_leftovers(): + cannot_excerpt = leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) + na_excerpt = leftover_manual_edit_text(NA_DIFF_BODY) + leftover = [ + ("scripts/ci/example.py", 6, "cannot-provide", cannot_excerpt), + ("scripts/ci/example.py", 7, "cannot-provide", na_excerpt), + ("scripts/ci/example.py", 12, "cannot-provide", cannot_excerpt), + ] + deferred = [("scripts/ci/example.py", 5, 7, None, None)] + rendered = render_leftover_diff_receipts(leftover, deferred=deferred) + joined = "\n".join(rendered) + assert rendered[0] == "- `scripts/ci/example.py:5-7`" + assert rendered[1] == f" {MANUAL_EDIT_HEADING}" + assert joined.count("- `scripts/ci/example.py:5-7`") == 1 + assert cannot_excerpt in joined + assert na_excerpt in joined + assert "- `scripts/ci/example.py:6` — cannot-provide" not in joined + assert "- `scripts/ci/example.py:7` — cannot-provide" not in joined + assert "- `scripts/ci/example.py:12` — cannot-provide" in joined + first_edit = joined.index(MANUAL_EDIT_HEADING) + second_edit = joined.index(MANUAL_EDIT_HEADING, first_edit + 1) + outside = joined.index("- `scripts/ci/example.py:12` — cannot-provide") + assert first_edit < second_edit < outside + + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/example.py", "line": 6}, + {"path": "scripts/ci/example.py", "line": 7}, + {"path": "scripts/ci/example.py", "line": 12}, + ), + deferred_locations=deferred, + applyable_locations=deferred, + leftover_locations=leftover, + retry_limit=1, + ) + leftover_heading = ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + leftover_section = body.split(leftover_heading, 1)[1] + applyable_heading = "GitHub can apply these suggested replacements:" + if applyable_heading in leftover_section: + leftover_section = leftover_section.split(applyable_heading, 1)[0] + assert leftover_section.count("- `scripts/ci/example.py:5-7`") == 1 + assert leftover_section.index("- `scripts/ci/example.py:5-7`") < leftover_section.index( + MANUAL_EDIT_HEADING + ) + assert cannot_excerpt in leftover_section + assert na_excerpt in leftover_section + assert "- `scripts/ci/example.py:6` — cannot-provide" not in leftover_section + assert "- `scripts/ci/example.py:7` — cannot-provide" not in leftover_section + if applyable_heading in body: + applyable_section = body.split(applyable_heading, 1)[1] + if leftover_heading in applyable_section: + applyable_section = applyable_section.split(leftover_heading, 1)[0] + assert cannot_excerpt not in applyable_section + assert na_excerpt not in applyable_section + assert "scripts/ci/example.py:5-7" not in applyable_section + From a06f7a47c10ee126deb18dbeba52cd5cdfc93d6a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:38:05 +0900 Subject: [PATCH 24/34] fix(review): keep Manual-edit leftover inside trusted deferred range When a leftover line sits inside a trusted deferred multi-line path:start-end but is not itself a trusted finding, _trusted_receipt_subset used to drop the Manual-edit excerpt. Keep that excerpt under the deferred range and still drop untrusted-path leftovers. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 4 +- .../ci/opencode_inline_comment_fallback.py | 42 +++++---- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 1 + .../test_opencode_inline_comment_fallback.py | 92 +++++++++++++++++++ 6 files changed, 123 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fa5a53bc..627f43af6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Kept leftover Manual-edit excerpts that sit inside a trusted deferred multi-line `path:start-end` even when that exact leftover line is not itself a trusted finding, so authors still see the deferred range then the Manual-edit. - Grouped leftover Manual-edit excerpts that sit inside the same deferred multi-line `path:start-end` under one deferred range line, so authors do not see a repeated deferred prefix. - Omitted the duplicate leftover reason bullet when a leftover line sits inside a deferred multi-line `path:start-end`, so authors see the deferred range then the Manual-edit excerpt. - Omitted the duplicate leftover reason bullet when the leftover heading already prefixes a deferred range/origin for the same `path:line`, so authors see one deferred line then the Manual-edit excerpt. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index cdd61d1fb..f21299d3d 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -119,7 +119,9 @@ with the same control object used to build the inline `comments` array. lists a shared deferred leftover before its Manual-edit excerpt without a duplicate reason bullet, including leftover lines inside a deferred ``path:start-end`` grouped under one deferred range prefix, - and + leftover Manual-edit excerpts kept when the leftover line sits inside + a trusted deferred range even if that exact line is not a trusted + finding, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 838af1803..c6cdcf4d5 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -1118,19 +1118,33 @@ def exclude_deferred_applyable( def leftover_manual_edits_with_deferred( - leftovers: list[tuple[str, int, str, str]], + leftovers: list[tuple[str, int, str, str]] | list[tuple[str, int, str]], deferred: list[tuple[str, int, int, str | None, int | None]], + allowed: set[tuple[str, int]] | None = None, ) -> list[tuple[str, int, str, str]]: - """Keep leftover Manual-edit receipts even when the same leftover is deferred.""" + """Keep leftover Manual-edit receipts on a trusted finding or deferred range.""" if not leftovers: return [] matches = leftover_deferred_matches(deferred) + if allowed is not None: + allowed_paths = {path for path, _line in allowed} + matches = { + location: receipt + for location, receipt in matches.items() + if location[0] in allowed_paths + } kept: list[tuple[str, int, str, str]] = [] seen: set[tuple[str, int]] = set() for item in leftovers: path, line, reason, excerpt = _leftover_receipt_parts(item) if reason not in LEFTOVER_DIFF_REASONS or (path, line) in seen: continue + if ( + allowed is not None + and (path, line) not in allowed + and (path, line) not in matches + ): + continue seen.add((path, line)) kept.append((path, line, reason, excerpt)) if not matches: @@ -1366,23 +1380,17 @@ def _trusted_range_subset( def _trusted_receipt_subset( items: list[tuple[str, int, str, str]] | list[tuple[str, int, str]] | None, allowed: set[tuple[str, int]], + deferred: list[tuple[str, int, int, str | None, int | None]] | None = None, ) -> list[tuple[str, int, str, str]]: - """Return first-seen leftover receipts whose path:line is a trusted finding.""" + """Return leftover receipts on a trusted finding or trusted deferred range.""" if not items: return [] - kept: list[tuple[str, int, str, str]] = [] - seen: set[tuple[str, int]] = set() - for item in items: - path, line, reason, excerpt = _leftover_receipt_parts(item) - if ( - (path, line) not in allowed - or (path, line) in seen - or reason not in LEFTOVER_DIFF_REASONS - ): - continue - seen.add((path, line)) - kept.append((path, line, reason, excerpt)) - return kept + trusted_deferred = ( + _trusted_deferred_subset(deferred, allowed) if deferred else [] + ) + return leftover_manual_edits_with_deferred( + items, trusted_deferred, allowed=allowed + ) def render_inline_comment_failure_body( @@ -1433,7 +1441,7 @@ def render_inline_comment_failure_body( else [] ) leftover = ( - _trusted_receipt_subset(leftover_locations, allowed) + _trusted_receipt_subset(leftover_locations, allowed, deferred=deferred) if leftover_locations is not None else [] ) diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index de6c1b238..711dcf503 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1505,6 +1505,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_deferred_matches" "opencode leftover heading lists deferred leftovers before Manual-edit excerpts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "range(start, end + 1)" "opencode leftover heading matches interior lines of a deferred path:start-end" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "seen_deferred" "opencode leftover heading emits one deferred range prefix for multiple interior leftovers" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "allowed=allowed" "opencode leftover heading keeps Manual-edit excerpts inside a trusted deferred range" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_reason_bullet_duplicates_deferred" "opencode leftover heading omits a duplicate reason bullet after a deferred prefix" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 85ed63ce6..baf41a446 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1671,6 +1671,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "leftover_reason_bullet_duplicates_deferred" in helper assert "range(start, end + 1)" in helper assert "seen_deferred" in helper + assert "allowed=allowed" in helper assert "Manual edit (not a GitHub suggestion):" in helper assert ( "These comments still have a suggested-diff fence that GitHub cannot apply:" diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index da51ff7ad..d8552cc94 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -3820,3 +3820,95 @@ def test_leftover_heading_emits_deferred_range_once_for_multiple_interior_leftov assert na_excerpt not in applyable_section assert "scripts/ci/example.py:5-7" not in applyable_section + +def trusted_interior_leftover_fixture() -> tuple[ + list[tuple[str, int, str, str]], + list[tuple[str, int, int, str | None, int | None]], + set[tuple[str, int]], + str, +]: + """Interior leftover inside a trusted deferred range, plus an untrusted leftover.""" + excerpt = leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) + leftover = [ + ("scripts/ci/example.py", 6, "cannot-provide", excerpt), + ("scripts/ci/foreign.py", 1, "cannot-provide", "ignored"), + ] + deferred = [("scripts/ci/example.py", 5, 7, None, None)] + allowed = {("scripts/ci/example.py", 5)} + return leftover, deferred, allowed, excerpt + + +def test_trusted_interior_leftover_kept_under_deferred_range(): + from scripts.ci.opencode_inline_comment_fallback import _trusted_receipt_subset + + leftover, deferred, allowed, excerpt = trusted_interior_leftover_fixture() + kept = _trusted_receipt_subset(leftover, allowed, deferred=deferred) + assert kept == [("scripts/ci/example.py", 6, "cannot-provide", excerpt)] + assert _trusted_receipt_subset(leftover, allowed) == [] + assert _trusted_receipt_subset([], allowed, deferred=deferred) == [] + assert _trusted_receipt_subset(None, allowed, deferred=deferred) == [] + untrusted_deferred = [ + *deferred, + ("scripts/ci/foreign.py", 1, 3, None, None), + ] + assert _trusted_receipt_subset( + leftover, allowed, deferred=untrusted_deferred + ) == kept + assert leftover_manual_edits_with_deferred( + leftover, deferred, allowed=allowed + ) == kept + assert leftover_manual_edits_with_deferred( + leftover, untrusted_deferred, allowed=allowed + ) == kept + assert leftover_manual_edits_with_deferred(leftover, [], allowed=allowed) == [] + assert leftover_manual_edits_with_deferred( + leftover, deferred, allowed={("scripts/ci/example.py", 6)} + )[0][:2] == ("scripts/ci/example.py", 6) + assert leftover_manual_edits_with_deferred( + leftover, + [("scripts/ci/foreign.py", 1, 3, None, None)], + allowed=allowed, + ) == [] + assert _trusted_receipt_subset( + leftover, + allowed, + deferred=[ + ("scripts/ci/foreign.py", 1, 3, None, None), + ("scripts/ci/example.py", 5, 7, None, None), + ], + ) == [("scripts/ci/example.py", 6, "cannot-provide", excerpt)] + assert _trusted_receipt_subset( + leftover, + allowed, + deferred=[("scripts/ci/foreign.py", 1, 3, None, None)], + ) == [] + + body = render_inline_comment_failure_body( + "## Findings\n", + control({"path": "scripts/ci/example.py", "line": 5}), + deferred_locations=deferred, + applyable_locations=deferred, + leftover_locations=leftover, + retry_limit=1, + ) + leftover_heading = ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + assert leftover_heading in body + leftover_section = body.split(leftover_heading, 1)[1] + applyable_heading = "GitHub can apply these suggested replacements:" + if applyable_heading in leftover_section: + leftover_section = leftover_section.split(applyable_heading, 1)[0] + assert leftover_section.index("- `scripts/ci/example.py:5-7`") < leftover_section.index( + MANUAL_EDIT_HEADING + ) + assert excerpt in leftover_section + assert "- `scripts/ci/example.py:6` — cannot-provide" not in leftover_section + assert "foreign.py" not in leftover_section + if applyable_heading in body: + applyable_section = body.split(applyable_heading, 1)[1] + if leftover_heading in applyable_section: + applyable_section = applyable_section.split(leftover_heading, 1)[0] + assert excerpt not in applyable_section + assert "scripts/ci/example.py:5-7" not in applyable_section + From ff3a8fdd7b4a03c4d169b40e34a2bbc945e8fe76 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:48:21 +0900 Subject: [PATCH 25/34] fix(review): keep CLI overview Manual-edit for interior leftovers When leftover-diff-locations sits inside a trusted deferred path:start-end but that leftover line is not a trusted control finding, the overview CLI still keeps the Manual-edit excerpt under the deferred range. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 3 +- scripts/ci/test_strix_quick_gate.sh | 2 + tests/test_opencode_agent_contract.py | 2 + .../test_opencode_inline_comment_fallback.py | 97 +++++++++++++++++++ 5 files changed, 104 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 627f43af6..8120ceb66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Kept leftover Manual-edit excerpts on the overview CLI when `--leftover-diff-locations` sits inside a trusted `--deferred-locations` `path:start-end` even if that leftover line is not a trusted control finding. - Kept leftover Manual-edit excerpts that sit inside a trusted deferred multi-line `path:start-end` even when that exact leftover line is not itself a trusted finding, so authors still see the deferred range then the Manual-edit. - Grouped leftover Manual-edit excerpts that sit inside the same deferred multi-line `path:start-end` under one deferred range line, so authors do not see a repeated deferred prefix. - Omitted the duplicate leftover reason bullet when a leftover line sits inside a deferred multi-line `path:start-end`, so authors see the deferred range then the Manual-edit excerpt. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index f21299d3d..9a790b680 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -121,7 +121,8 @@ with the same control object used to build the inline `comments` array. deferred ``path:start-end`` grouped under one deferred range prefix, leftover Manual-edit excerpts kept when the leftover line sits inside a trusted deferred range even if that exact line is not a trusted - finding, and + finding, including the overview CLI with ``--leftover-diff-locations`` + plus ``--deferred-locations``, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 711dcf503..f27654b31 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1506,6 +1506,8 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "range(start, end + 1)" "opencode leftover heading matches interior lines of a deferred path:start-end" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "seen_deferred" "opencode leftover heading emits one deferred range prefix for multiple interior leftovers" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "allowed=allowed" "opencode leftover heading keeps Manual-edit excerpts inside a trusted deferred range" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--leftover-diff-locations" "opencode overview CLI accepts leftover diff-fence receipts" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--deferred-locations" "opencode overview CLI accepts deferred leftover ranges" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_reason_bullet_duplicates_deferred" "opencode leftover heading omits a duplicate reason bullet after a deferred prefix" assert_file_contains "$workflow_file" "--leftover-diff-locations" "opencode persists leftover diff-fence receipts in overview receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "These comments still have a suggested-diff fence that GitHub cannot apply:" "opencode overview lists leftover cannot-provide and LEFT diff fences" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index baf41a446..5eea0408d 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1672,6 +1672,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "range(start, end + 1)" in helper assert "seen_deferred" in helper assert "allowed=allowed" in helper + assert "--leftover-diff-locations" in helper + assert "--deferred-locations" in helper assert "Manual edit (not a GitHub suggestion):" in helper assert ( "These comments still have a suggested-diff fence that GitHub cannot apply:" diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index d8552cc94..e4f0e814f 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -3912,3 +3912,100 @@ def test_trusted_interior_leftover_kept_under_deferred_range(): assert excerpt not in applyable_section assert "scripts/ci/example.py:5-7" not in applyable_section + +def test_cli_overview_keeps_interior_leftover_not_in_control(tmp_path): + hunks = parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF) + payload = _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 6, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + }, + { + "path": "scripts/ci/example.py", + "line": 5, + "side": "RIGHT", + "body": MULTILINE_DIFF_BODY, + }, + ) + output = tmp_path / "filtered.json" + leftover_file = tmp_path / "leftover.txt" + applyable_file = tmp_path / "applyable.txt" + assert write_hunk_filtered_payload( + payload, hunks, output, applyable_path=applyable_file, leftover_path=leftover_file + ) == 2 + leftover_receipts = parse_leftover_diff_receipts( + leftover_file.read_text(encoding="utf-8") + ) + assert leftover_receipts[0][:3] == ("scripts/ci/example.py", 6, "cannot-provide") + deferred_path = tmp_path / "deferred.txt" + assert ( + write_single_comment_payloads( + json.loads(output.read_text(encoding="utf-8")), + tmp_path / "singles", + limit=1, + deferred_path=deferred_path, + ) + == 1 + ) + deferred_receipts = parse_applyable_ranges(deferred_path.read_text(encoding="utf-8")) + assert deferred_receipts[0][:3] == ("scripts/ci/example.py", 5, 7) + excerpt = leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) + leftover_file.write_text( + leftover_file.read_text(encoding="utf-8") + + "scripts/ci/foreign.py:1\tcannot-provide\tignored\n", + encoding="utf-8", + ) + control_path = tmp_path / "control.json" + body_path = tmp_path / "body.md" + receipt = tmp_path / "receipt.md" + control_path.write_text( + json.dumps(control({"path": "scripts/ci/example.py", "line": 5})), + encoding="utf-8", + ) + body_path.write_text("## Findings\n", encoding="utf-8") + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--deferred-locations", + str(deferred_path), + "--applyable-locations", + str(applyable_file), + "--leftover-diff-locations", + str(leftover_file), + "--retry-limit", + "1", + ] + ) + == 0 + ) + rendered = receipt.read_text(encoding="utf-8") + leftover_heading = ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + assert leftover_heading in rendered + leftover_section = rendered.split(leftover_heading, 1)[1] + applyable_heading = "GitHub can apply these suggested replacements:" + if applyable_heading in leftover_section: + leftover_section = leftover_section.split(applyable_heading, 1)[0] + assert leftover_section.index("- `scripts/ci/example.py:5-7`") < leftover_section.index( + MANUAL_EDIT_HEADING + ) + assert excerpt in leftover_section + assert "- `scripts/ci/example.py:6` — cannot-provide" not in leftover_section + assert "foreign.py" not in leftover_section + if applyable_heading in rendered: + applyable_section = rendered.split(applyable_heading, 1)[1] + if leftover_heading in applyable_section: + applyable_section = applyable_section.split(leftover_heading, 1)[0] + assert excerpt not in applyable_section + assert "scripts/ci/example.py:5-7" not in applyable_section + assert "```suggestion" not in applyable_section + From 9fa54aeb9938d31d8e9384a25afc247c63c8d86b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 19:55:51 +0900 Subject: [PATCH 26/34] fix(review): omit LEFT leftover suggestion fences from applyable ranges GitHub cannot apply a suggestion on the deleted LEFT side, so leftover LEFT fences must stay off the applyable overview. Darwin hosts now mock the linux x86_64 trusted-uv runner for installer verification. --- AGENTS.md | 2 ++ CHANGELOG.md | 1 + scripts/ci/opencode_inline_comment_fallback.py | 6 +++++- tests/test_materialize_base_python_requirements.py | 11 +++++++++++ tests/test_opencode_inline_comment_fallback.py | 12 ++++++++++++ 5 files changed, 31 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 688b33035..0a6a1bfd0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,3 +2,5 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. +LEFT leftover suggestion fences are not applyable overview ranges. + diff --git a/CHANGELOG.md b/CHANGELOG.md index 8120ceb66..5cbe10d67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Stopped listing LEFT-side leftover GitHub suggestion fences as applyable ranges, because GitHub cannot apply a suggestion on the deleted side. - Kept leftover Manual-edit excerpts on the overview CLI when `--leftover-diff-locations` sits inside a trusted `--deferred-locations` `path:start-end` even if that leftover line is not a trusted control finding. - Kept leftover Manual-edit excerpts that sit inside a trusted deferred multi-line `path:start-end` even when that exact leftover line is not itself a trusted finding, so authors still see the deferred range then the Manual-edit. - Grouped leftover Manual-edit excerpts that sit inside the same deferred multi-line `path:start-end` under one deferred range line, so authors do not see a repeated deferred prefix. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index c6cdcf4d5..669cd5145 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -558,7 +558,11 @@ def applyable_suggestion_ranges( if not isinstance(comment, dict): continue body = comment.get("body") - if not isinstance(body, str) or "```suggestion" not in body: + if ( + not isinstance(body, str) + or comment.get("side") == "LEFT" + or "```suggestion" not in body + ): continue path = safe_finding_path(comment.get("path")) end = safe_finding_line(comment.get("line")) diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 8a383f0c2..62d445046 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -14,6 +14,14 @@ from tests.conftest import FakeHttpResponse +def _simulate_linux_x86_64_runner(monkeypatch: pytest.MonkeyPatch) -> None: + """Let installer verification tests run on a non-Linux developer host.""" + monkeypatch.setattr(materializer.sys, "platform", "linux") + monkeypatch.setattr(materializer.platform, "machine", lambda: "x86_64") + materializer._install_trusted_uv.cache_clear() + + + def git(repo: Path, *args: str) -> str: """Run git in a temporary fixture repository.""" return subprocess.run( @@ -644,6 +652,7 @@ def test_install_trusted_uv_verifies_version_and_caches_path( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The installer writes one executable, verifies its version, and caches it.""" + _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -690,6 +699,7 @@ def test_install_trusted_uv_rejects_version_process_failures( failure: OSError | subprocess.TimeoutExpired, ) -> None: """A missing or hung downloaded executable is removed and rejected.""" + _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / "uv" monkeypatch.setattr( materializer.tempfile, @@ -721,6 +731,7 @@ def test_install_trusted_uv_rejects_wrong_version_or_exit_status( completed: subprocess.CompletedProcess[bytes], ) -> None: """Unexpected version output or a nonzero status cannot satisfy the pin.""" + _simulate_linux_x86_64_runner(monkeypatch) tool_dir = tmp_path / f"uv-{completed.returncode}-{len(completed.stdout)}" monkeypatch.setattr( materializer.tempfile, diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index e4f0e814f..cd928c372 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -1897,6 +1897,18 @@ def test_applyable_ranges_parse_and_render_path_start_end(): "- `scripts/ci/ok.py:4`", ] assert applyable_suggestion_ranges({"comments": "bad"}) == [] + assert applyable_suggestion_ranges( + { + "comments": [ + { + "path": "scripts/ci/removed.py", + "line": 11, + "side": "LEFT", + "body": "```suggestion\nremoved = True\n```\n", + } + ] + } + ) == [] payload = apply_github_suggestion_blocks( _batch_payload( { From 63ce471564d7ae36ab6fb401342bd7c8c97db0d9 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 20:19:02 +0900 Subject: [PATCH 27/34] fix(review): omit applyable ranges that contain a leftover line When a leftover cannot-provide or LEFT line sits inside path:start-end, drop that range from the applyable overview so authors see Manual-edit instead of a one-click apply for the same span. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 3 +- .../ci/opencode_inline_comment_fallback.py | 5 ++- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 1 + .../test_opencode_inline_comment_fallback.py | 42 +++++++++++++++++++ 6 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cbe10d67..7eca0de56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Dropped applyable overview `path:start-end` rows when a leftover cannot-provide or LEFT line sits inside that range, so authors see the Manual-edit instead of a one-click apply for the same span. - Stopped listing LEFT-side leftover GitHub suggestion fences as applyable ranges, because GitHub cannot apply a suggestion on the deleted side. - Kept leftover Manual-edit excerpts on the overview CLI when `--leftover-diff-locations` sits inside a trusted `--deferred-locations` `path:start-end` even if that leftover line is not a trusted control finding. - Kept leftover Manual-edit excerpts that sit inside a trusted deferred multi-line `path:start-end` even when that exact leftover line is not itself a trusted finding, so authors still see the deferred range then the Manual-edit. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 9a790b680..4bc721885 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -122,7 +122,8 @@ with the same control object used to build the inline `comments` array. leftover Manual-edit excerpts kept when the leftover line sits inside a trusted deferred range even if that exact line is not a trusted finding, including the overview CLI with ``--leftover-diff-locations`` - plus ``--deferred-locations``, and + plus ``--deferred-locations``, applyable ``path:start-end`` rows + omitted when a leftover line sits inside that range, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 669cd5145..b820643cb 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -1169,7 +1169,10 @@ def exclude_leftover_from_applyable( kept: list[tuple[str, int, int, str | None, int | None]] = [] for item in applyable: path, start, end, origin_path, origin_line = _applyable_receipt_parts(item) - if (path, start) in leftover_points or (path, end) in leftover_points: + if any( + leftover_path == path and start <= leftover_line <= end + for leftover_path, leftover_line in leftover_points + ): continue kept.append((path, start, end, origin_path, origin_line)) return kept diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index f27654b31..5819ae318 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1506,6 +1506,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "range(start, end + 1)" "opencode leftover heading matches interior lines of a deferred path:start-end" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "seen_deferred" "opencode leftover heading emits one deferred range prefix for multiple interior leftovers" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "allowed=allowed" "opencode leftover heading keeps Manual-edit excerpts inside a trusted deferred range" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "start <= leftover_line <= end" "opencode applyable overview omits ranges that contain a leftover line" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--leftover-diff-locations" "opencode overview CLI accepts leftover diff-fence receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--deferred-locations" "opencode overview CLI accepts deferred leftover ranges" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_reason_bullet_duplicates_deferred" "opencode leftover heading omits a duplicate reason bullet after a deferred prefix" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 5eea0408d..be0723297 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1667,6 +1667,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "exclude_deferred_applyable" in helper assert "leftover_manual_edits_with_deferred" in helper assert "exclude_leftover_from_applyable" in helper + assert "start <= leftover_line <= end" in helper assert "leftover_deferred_matches" in helper assert "leftover_reason_bullet_duplicates_deferred" in helper assert "range(start, end + 1)" in helper diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index cd928c372..26bea8aaa 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -4021,3 +4021,45 @@ def test_cli_overview_keeps_interior_leftover_not_in_control(tmp_path): assert "scripts/ci/example.py:5-7" not in applyable_section assert "```suggestion" not in applyable_section + +def test_exclude_leftover_from_applyable_drops_interior_leftover_ranges(): + excerpt = leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) + leftover = [("scripts/ci/example.py", 6, "cannot-provide", excerpt)] + applyable = [ + ("scripts/ci/example.py", 5, 7, None, None), + ("scripts/ci/ok.py", 4, 4, None, None), + ] + assert exclude_leftover_from_applyable(applyable, leftover) == [ + ("scripts/ci/ok.py", 4, 4, None, None) + ] + assert exclude_leftover_from_applyable(applyable, []) == applyable + assert exclude_leftover_from_applyable( + [("scripts/ci/example.py", 8, 10, None, None)], leftover + ) == [("scripts/ci/example.py", 8, 10, None, None)] + + body = render_inline_comment_failure_body( + "## Findings\n", + control( + {"path": "scripts/ci/example.py", "line": 6}, + {"path": "scripts/ci/ok.py", "line": 4}, + ), + applyable_locations=applyable, + leftover_locations=leftover, + ) + leftover_heading = ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + leftover_section = body.split(leftover_heading, 1)[1] + applyable_heading = "GitHub can apply these suggested replacements:" + if applyable_heading in leftover_section: + leftover_section = leftover_section.split(applyable_heading, 1)[0] + assert excerpt in leftover_section + assert "- `scripts/ci/example.py:6` — cannot-provide" in leftover_section + if applyable_heading in body: + applyable_section = body.split(applyable_heading, 1)[1] + if leftover_heading in applyable_section: + applyable_section = applyable_section.split(leftover_heading, 1)[0] + assert "scripts/ci/example.py:5-7" not in applyable_section + assert "- `scripts/ci/ok.py:4`" in applyable_section + assert excerpt not in applyable_section + From 1b1a23134518585109e635e647f13c4f01af462f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 20:24:09 +0900 Subject: [PATCH 28/34] fix(review): omit overlapping applyable ranges on leftover CLI The overview CLI now drops applyable path:start-end rows that contain a leftover cannot-provide or LEFT line so authors see Manual-edit only. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 4 +- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 1 + .../test_opencode_inline_comment_fallback.py | 61 +++++++++++++++++++ 5 files changed, 67 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7eca0de56..3b6231005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- The leftover overview CLI now omits applyable `path:start-end` rows that contain a leftover cannot-provide or LEFT line, so `--leftover-diff-locations` plus `--applyable-locations` show Manual-edit instead of a one-click apply for the same span. - Dropped applyable overview `path:start-end` rows when a leftover cannot-provide or LEFT line sits inside that range, so authors see the Manual-edit instead of a one-click apply for the same span. - Stopped listing LEFT-side leftover GitHub suggestion fences as applyable ranges, because GitHub cannot apply a suggestion on the deleted side. - Kept leftover Manual-edit excerpts on the overview CLI when `--leftover-diff-locations` sits inside a trusted `--deferred-locations` `path:start-end` even if that leftover line is not a trusted control finding. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 4bc721885..7df5a0a6b 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -123,7 +123,9 @@ with the same control object used to build the inline `comments` array. a trusted deferred range even if that exact line is not a trusted finding, including the overview CLI with ``--leftover-diff-locations`` plus ``--deferred-locations``, applyable ``path:start-end`` rows - omitted when a leftover line sits inside that range, and + omitted when a leftover line sits inside that range, including the + overview CLI with ``--applyable-locations`` plus + ``--leftover-diff-locations``, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 5819ae318..c7dd8603e 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1507,6 +1507,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "seen_deferred" "opencode leftover heading emits one deferred range prefix for multiple interior leftovers" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "allowed=allowed" "opencode leftover heading keeps Manual-edit excerpts inside a trusted deferred range" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "start <= leftover_line <= end" "opencode applyable overview omits ranges that contain a leftover line" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--applyable-locations" "opencode overview CLI accepts applyable suggestion ranges" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--leftover-diff-locations" "opencode overview CLI accepts leftover diff-fence receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--deferred-locations" "opencode overview CLI accepts deferred leftover ranges" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_reason_bullet_duplicates_deferred" "opencode leftover heading omits a duplicate reason bullet after a deferred prefix" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index be0723297..5dd92f478 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1668,6 +1668,7 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "leftover_manual_edits_with_deferred" in helper assert "exclude_leftover_from_applyable" in helper assert "start <= leftover_line <= end" in helper + assert "--applyable-locations" in helper assert "leftover_deferred_matches" in helper assert "leftover_reason_bullet_duplicates_deferred" in helper assert "range(start, end + 1)" in helper diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 26bea8aaa..d473c4e2f 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -4063,3 +4063,64 @@ def test_exclude_leftover_from_applyable_drops_interior_leftover_ranges(): assert "- `scripts/ci/ok.py:4`" in applyable_section assert excerpt not in applyable_section + +def test_cli_overview_omits_applyable_range_containing_leftover_line(tmp_path): + excerpt = leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) + leftover_file = tmp_path / "leftover.txt" + leftover_file.write_text( + f"scripts/ci/example.py:6\tcannot-provide\t{encode_manual_edit_field(excerpt)}\n", + encoding="utf-8", + ) + applyable_file = tmp_path / "applyable.txt" + applyable_file.write_text( + "scripts/ci/example.py:5-7\n" + "scripts/ci/ok.py:4\n", + encoding="utf-8", + ) + control_path = tmp_path / "control.json" + control_path.write_text( + json.dumps( + control( + {"path": "scripts/ci/example.py", "line": 6}, + {"path": "scripts/ci/ok.py", "line": 4}, + ) + ), + encoding="utf-8", + ) + body_path = tmp_path / "body.md" + body_path.write_text("## Findings\n", encoding="utf-8") + receipt = tmp_path / "receipt.md" + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--applyable-locations", + str(applyable_file), + "--leftover-diff-locations", + str(leftover_file), + ] + ) + == 0 + ) + rendered = receipt.read_text(encoding="utf-8") + leftover_heading = ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + leftover_section = rendered.split(leftover_heading, 1)[1] + applyable_heading = "GitHub can apply these suggested replacements:" + if applyable_heading in leftover_section: + leftover_section = leftover_section.split(applyable_heading, 1)[0] + assert excerpt in leftover_section + assert "- `scripts/ci/example.py:6` — cannot-provide" in leftover_section + applyable_section = rendered.split(applyable_heading, 1)[1] + if leftover_heading in applyable_section: + applyable_section = applyable_section.split(leftover_heading, 1)[0] + assert "scripts/ci/example.py:5-7" not in applyable_section + assert "- `scripts/ci/ok.py:4`" in applyable_section + assert excerpt not in applyable_section + From d9dc77d06ff3a1663be66ca8f923be22af61a501 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 20:51:43 +0900 Subject: [PATCH 29/34] fix(review): omit leftover interiors from applyable write path write_hunk_filtered_payload now drops applyable path:start-end rows that contain a leftover cannot-provide or LEFT line so applyable.txt cannot list a one-click apply for the same span as leftover example.py:6. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 11 +- .../ci/opencode_inline_comment_fallback.py | 9 +- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 2 + .../test_opencode_inline_comment_fallback.py | 101 +++++++++++++++++- 6 files changed, 119 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b6231005..0d0ab9754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- The hunk-filter write path now omits applyable `path:start-end` rows from `--applyable-locations` when a leftover cannot-provide or LEFT line sits inside that range, so `applyable.txt` cannot list a one-click apply for the same span as leftover `example.py:6`. - The leftover overview CLI now omits applyable `path:start-end` rows that contain a leftover cannot-provide or LEFT line, so `--leftover-diff-locations` plus `--applyable-locations` show Manual-edit instead of a one-click apply for the same span. - Dropped applyable overview `path:start-end` rows when a leftover cannot-provide or LEFT line sits inside that range, so authors see the Manual-edit instead of a one-click apply for the same span. - Stopped listing LEFT-side leftover GitHub suggestion fences as applyable ranges, because GitHub cannot apply a suggestion on the deleted side. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 7df5a0a6b..059a5a1d9 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -86,7 +86,11 @@ and `start_side` so GitHub applies one multi-line suggestion range (GitHub, n.d.-b). A range that would leave the hunk stays single-line. The publisher then persists those applyable ranges as overview receipts (``path:line`` or ``path:start-end``) so the author can see which hunks -shipped as one-click GitHub suggestions (GitHub, n.d.-c). Comments that +shipped as one-click GitHub suggestions (GitHub, n.d.-c). ``write_hunk_filtered_payload`` +and ``--filter-hunks`` omit an applyable ``path:start-end`` from +``--applyable-locations`` when a leftover cannot-provide or LEFT line +sits inside that range, so ``applyable.txt`` cannot advertise a +one-click apply for the same span as leftover ``example.py:6``. Comments that kept only a `` ```diff `` fence are listed separately with the reason ``cannot-provide`` (``n/a``, “cannot provide”, fence-breaking replacement, or no ``+`` lines) or ``LEFT`` (GitHub cannot apply a @@ -125,7 +129,10 @@ with the same control object used to build the inline `comments` array. plus ``--deferred-locations``, applyable ``path:start-end`` rows omitted when a leftover line sits inside that range, including the overview CLI with ``--applyable-locations`` plus - ``--leftover-diff-locations``, and + ``--leftover-diff-locations``, the hunk-filter write path and + ``--filter-hunks`` ``applyable.txt`` that omit an overlapping + applyable range when leftover ``example.py:6`` sits inside + ``example.py:5-7``, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index b820643cb..3d6acbddb 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -814,10 +814,13 @@ def write_hunk_filtered_payload( applyable_path: Path | None = None, leftover_path: Path | None = None, ) -> int: - """Write a hunk-filtered review payload and optional skipped ``path:line`` rows.""" + """Write a hunk-filtered review payload and leftover-safe applyable receipts.""" filtered, skipped = filter_payload_comments_to_hunks(payload, hunks) filtered = apply_github_suggestion_blocks(filtered, hunks) - applyable = applyable_suggestion_ranges(filtered) + leftovers = leftover_diff_fence_receipts(filtered) + applyable = exclude_leftover_from_applyable( + applyable_suggestion_ranges(filtered), leftovers + ) filtered = strip_left_origin_fields(filtered) comments = filtered.get("comments") output.write_text(json.dumps(filtered, ensure_ascii=True), encoding="utf-8") @@ -838,7 +841,7 @@ def write_hunk_filtered_payload( applyable_path.write_text("".join(applyable_rows), encoding="utf-8") if leftover_path is not None: leftover_rows: list[str] = [] - for path, line, reason, excerpt in leftover_diff_fence_receipts(filtered): + for path, line, reason, excerpt in leftovers: encoded = encode_manual_edit_field(excerpt) if encoded: leftover_rows.append(f"{path}:{line}\t{reason}\t{encoded}\n") diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index c7dd8603e..ffcd6378e 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1507,6 +1507,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "seen_deferred" "opencode leftover heading emits one deferred range prefix for multiple interior leftovers" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "allowed=allowed" "opencode leftover heading keeps Manual-edit excerpts inside a trusted deferred range" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "start <= leftover_line <= end" "opencode applyable overview omits ranges that contain a leftover line" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftovers = leftover_diff_fence_receipts(filtered)" "opencode hunk-filter write path omits applyable ranges that contain a leftover line" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--applyable-locations" "opencode overview CLI accepts applyable suggestion ranges" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--leftover-diff-locations" "opencode overview CLI accepts leftover diff-fence receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--deferred-locations" "opencode overview CLI accepts deferred leftover ranges" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 5dd92f478..9038e51a2 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1667,6 +1667,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "exclude_deferred_applyable" in helper assert "leftover_manual_edits_with_deferred" in helper assert "exclude_leftover_from_applyable" in helper + assert "leftover-safe applyable receipts" in helper + assert "leftovers = leftover_diff_fence_receipts(filtered)" in helper assert "start <= leftover_line <= end" in helper assert "--applyable-locations" in helper assert "leftover_deferred_matches" in helper diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index d473c4e2f..566186b6e 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -3712,7 +3712,7 @@ def test_leftover_interior_of_deferred_range_omits_reason_bullet(tmp_path): applyable_file.read_text(encoding="utf-8") ) assert leftover_receipts[0][:3] == ("scripts/ci/example.py", 6, "cannot-provide") - assert applyable_receipts[0][:3] == ("scripts/ci/example.py", 5, 7) + assert applyable_receipts == [] deferred_path = tmp_path / "deferred.txt" assert ( write_single_comment_payloads( @@ -4124,3 +4124,102 @@ def test_cli_overview_omits_applyable_range_containing_leftover_line(tmp_path): assert "- `scripts/ci/ok.py:4`" in applyable_section assert excerpt not in applyable_section + +def test_write_hunk_filtered_payload_omits_applyable_range_containing_interior_leftover( + tmp_path, +): + hunks = parse_unified_diff_hunk_lines( + EXAMPLE_UNIFIED_DIFF + + "diff --git a/scripts/ci/ok.py b/scripts/ci/ok.py\n" + + "--- a/scripts/ci/ok.py\n+++ b/scripts/ci/ok.py\n" + + "@@ -4,1 +4,1 @@\n- old\n+ new\n" + ) + payload = _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 6, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + }, + { + "path": "scripts/ci/example.py", + "line": 5, + "side": "RIGHT", + "body": MULTILINE_DIFF_BODY, + }, + { + "path": "scripts/ci/ok.py", + "line": 4, + "side": "RIGHT", + "body": SUGGESTED_DIFF_BODY, + }, + ) + output = tmp_path / "filtered.json" + leftover_file = tmp_path / "leftover.txt" + applyable_file = tmp_path / "applyable.txt" + assert ( + write_hunk_filtered_payload( + payload, + hunks, + output, + applyable_path=applyable_file, + leftover_path=leftover_file, + ) + == 3 + ) + leftover_text = leftover_file.read_text(encoding="utf-8") + applyable_text = applyable_file.read_text(encoding="utf-8") + assert leftover_text.startswith("scripts/ci/example.py:6\tcannot-provide\t") + assert "scripts/ci/example.py:5-7" not in applyable_text + assert applyable_text == "scripts/ci/ok.py:4\n" + + applyable_without_leftover_file = tmp_path / "applyable-no-leftover-file.txt" + assert ( + write_hunk_filtered_payload( + payload, + hunks, + tmp_path / "filtered-no-leftover-file.json", + applyable_path=applyable_without_leftover_file, + ) + == 3 + ) + assert applyable_without_leftover_file.read_text(encoding="utf-8") == ( + "scripts/ci/ok.py:4\n" + ) + + payload_path = tmp_path / "batch.json" + payload_path.write_text(json.dumps(payload), encoding="utf-8") + hunks_diff = tmp_path / "hunks.diff" + hunks_diff.write_text( + EXAMPLE_UNIFIED_DIFF + + "diff --git a/scripts/ci/ok.py b/scripts/ci/ok.py\n" + + "--- a/scripts/ci/ok.py\n+++ b/scripts/ci/ok.py\n" + + "@@ -4,1 +4,1 @@\n- old\n+ new\n", + encoding="utf-8", + ) + cli_applyable = tmp_path / "cli-applyable.txt" + cli_leftover = tmp_path / "cli-leftover.txt" + assert ( + main( + [ + "--filter-hunks", + "--payload", + str(payload_path), + "--hunks-diff", + str(hunks_diff), + "--output", + str(tmp_path / "cli-filtered.json"), + "--applyable-locations", + str(cli_applyable), + "--leftover-diff-locations", + str(cli_leftover), + ] + ) + == 0 + ) + assert cli_leftover.read_text(encoding="utf-8").startswith( + "scripts/ci/example.py:6\tcannot-provide\t" + ) + assert "scripts/ci/example.py:5-7" not in cli_applyable.read_text(encoding="utf-8") + assert cli_applyable.read_text(encoding="utf-8") == "scripts/ci/ok.py:4\n" + From e5690e4104f157936da11592a0a81fd88def5e14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 21:03:56 +0900 Subject: [PATCH 30/34] fix(review): consume leftover path:start-end receipts Leftover cannot-provide rows written as path:start-end were dropped by the overview consumer, so applyable interiors such as example.py:6 stayed listed as one-click applies. Parse leftover ranges, cover every interior line, and write start_line-spanned leftover comments as path:start-end. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 10 +- .../ci/opencode_inline_comment_fallback.py | 206 +++++++++++++----- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 2 + .../test_opencode_inline_comment_fallback.py | 153 +++++++++++++ 6 files changed, 311 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d0ab9754..b3c3562ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Leftover receipts now accept `path:start-end` as well as `path:line`, so a leftover cannot-provide range such as `example.py:5-7` drops overlapping applyable interiors (`example.py:6` and `example.py:5-7`) instead of being ignored. - The hunk-filter write path now omits applyable `path:start-end` rows from `--applyable-locations` when a leftover cannot-provide or LEFT line sits inside that range, so `applyable.txt` cannot list a one-click apply for the same span as leftover `example.py:6`. - The leftover overview CLI now omits applyable `path:start-end` rows that contain a leftover cannot-provide or LEFT line, so `--leftover-diff-locations` plus `--applyable-locations` show Manual-edit instead of a one-click apply for the same span. - Dropped applyable overview `path:start-end` rows when a leftover cannot-provide or LEFT line sits inside that range, so authors see the Manual-edit instead of a one-click apply for the same span. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 059a5a1d9..396f2b7e8 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -90,7 +90,11 @@ shipped as one-click GitHub suggestions (GitHub, n.d.-c). ``write_hunk_filtered_ and ``--filter-hunks`` omit an applyable ``path:start-end`` from ``--applyable-locations`` when a leftover cannot-provide or LEFT line sits inside that range, so ``applyable.txt`` cannot advertise a -one-click apply for the same span as leftover ``example.py:6``. Comments that +one-click apply for the same span as leftover ``example.py:6``. Leftover +receipts also accept ``path:start-end`` (and leftover comments with +``start_line`` write that range), so leftover ``example.py:5-7`` covers +every interior line when the overview consumer drops overlapping +applyable rows. Comments that kept only a `` ```diff `` fence are listed separately with the reason ``cannot-provide`` (``n/a``, “cannot provide”, fence-breaking replacement, or no ``+`` lines) or ``LEFT`` (GitHub cannot apply a @@ -132,7 +136,9 @@ with the same control object used to build the inline `comments` array. ``--leftover-diff-locations``, the hunk-filter write path and ``--filter-hunks`` ``applyable.txt`` that omit an overlapping applyable range when leftover ``example.py:6`` sits inside - ``example.py:5-7``, and + ``example.py:5-7``, leftover ``path:start-end`` receipts that cover + every interior leftover line so leftover ``example.py:5-7`` omits + applyable ``example.py:6``, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 3d6acbddb..02237fce4 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -660,15 +660,49 @@ def decode_manual_edit_field(text: str) -> str: return (text or "").replace("\\n", "\n") -def _leftover_receipt_parts( - item: tuple[str, int, str] | tuple[str, int, str, str], -) -> tuple[str, int, str, str]: - """Normalize a leftover receipt to ``(path, line, reason, excerpt)``.""" - path, line, reason = item[0], item[1], item[2] - excerpt = item[3] if len(item) >= 4 else "" +def leftover_receipt_range( + item: tuple[Any, ...], +) -> tuple[str, int, int, str, str]: + """Normalize a leftover receipt to ``(path, start, end, reason, excerpt)``.""" + path = item[0] if item else "" + if len(item) >= 4 and isinstance(item[2], int) and not isinstance(item[2], bool): + start = safe_finding_line(item[1]) + end = safe_finding_line(item[2]) + reason = item[3] if len(item) >= 4 else "" + excerpt = item[4] if len(item) >= 5 else "" + else: + start = end = safe_finding_line(item[1]) if len(item) >= 2 else None + reason = item[2] if len(item) >= 3 else "" + excerpt = item[3] if len(item) >= 4 else "" + if not isinstance(path, str): + path = "" + if start is None: + start = 1 + if end is None: + end = start + if end < start: + start, end = end, start + if not isinstance(reason, str): + reason = "" if not isinstance(excerpt, str): excerpt = "" - return path, line, reason, excerpt + return path, start, end, reason, excerpt + + +def leftover_coverage_points( + leftovers: list[tuple[Any, ...]] | None, +) -> set[tuple[str, int]]: + """Return every leftover ``path:line``, including interiors of ``path:start-end``.""" + points: set[tuple[str, int]] = set() + if not leftovers: + return points + for item in leftovers: + path, start, end, reason, _excerpt = leftover_receipt_range(item) + if reason not in LEFTOVER_DIFF_REASONS: + continue + for line in range(start, end + 1): + points.add((path, line)) + return points def leftover_diff_fence_reason(comment: dict[str, Any]) -> str | None: @@ -685,13 +719,13 @@ def leftover_diff_fence_reason(comment: dict[str, Any]) -> str | None: def leftover_diff_fence_receipts( payload: dict[str, Any], -) -> list[tuple[str, int, str, str]]: - """Return ``(path, line, reason, excerpt)`` for leftover `` ```diff `` comments.""" +) -> list[tuple[Any, ...]]: + """Return leftover `` ```diff `` receipts, using ``path:start-end`` when spanned.""" comments = payload.get("comments") if not isinstance(comments, list): return [] - receipts: list[tuple[str, int, str, str]] = [] - seen: set[tuple[str, int]] = set() + receipts: list[tuple[Any, ...]] = [] + seen: set[tuple[str, int, int]] = set() for comment in comments: if not isinstance(comment, dict): continue @@ -699,23 +733,29 @@ def leftover_diff_fence_receipts( if reason is None: continue path = safe_finding_path(comment.get("path")) - line = safe_finding_line(comment.get("line")) - if path is None or line is None: + end = safe_finding_line(comment.get("line")) + start_raw = comment.get("start_line") + start = safe_finding_line(start_raw) if start_raw is not None else end + if path is None or end is None or start is None: continue - key = (path, line) + if start > end: + start, end = end, start + key = (path, start, end) if key in seen: continue seen.add(key) - receipts.append( - (path, line, reason, leftover_manual_edit_text(comment.get("body"))) - ) + excerpt = leftover_manual_edit_text(comment.get("body")) + if start == end: + receipts.append((path, start, reason, excerpt)) + else: + receipts.append((path, start, end, reason, excerpt)) return receipts -def parse_leftover_diff_receipts(text: str) -> list[tuple[str, int, str, str]]: - """Parse ``path:lineLEFT|cannot-provide[excerpt]`` leftover rows.""" - receipts: list[tuple[str, int, str, str]] = [] - seen: set[tuple[str, int]] = set() +def parse_leftover_diff_receipts(text: str) -> list[tuple[Any, ...]]: + """Parse leftover ``path:line`` or ``path:start-end`` rows with optional excerpt.""" + receipts: list[tuple[Any, ...]] = [] + seen: set[tuple[str, int, int]] = set() for raw_line in text.splitlines(): line = raw_line.strip() if not line or line.startswith("#"): @@ -723,25 +763,39 @@ def parse_leftover_diff_receipts(text: str) -> list[tuple[str, int, str, str]]: loc_text, sep, rest = line.partition("\t") if not sep or ":" not in loc_text: continue - path_text, _, line_text = loc_text.rpartition(":") + path_text, _, loc_rest = loc_text.rpartition(":") path = safe_finding_path(path_text) - try: - parsed_line = int(line_text) - except ValueError: - parsed_line = 0 - line_number = safe_finding_line(parsed_line) - if path is None or line_number is None: + if path is None: + continue + if "-" in loc_rest: + start_text, _, end_text = loc_rest.partition("-") + try: + start_value = int(start_text) + end_value = int(end_text) + except ValueError: + continue + else: + try: + start_value = end_value = int(loc_rest) + except ValueError: + continue + start = safe_finding_line(start_value) + end = safe_finding_line(end_value) + if start is None or end is None or end < start: continue reason, excerpt_sep, excerpt_field = rest.partition("\t") reason = reason.strip() if reason not in LEFTOVER_DIFF_REASONS: continue - location = (path, line_number) + location = (path, start, end) if location in seen: continue seen.add(location) excerpt = decode_manual_edit_field(excerpt_field) if excerpt_sep else "" - receipts.append((path, line_number, reason, excerpt)) + if start == end: + receipts.append((path, start, reason, excerpt)) + else: + receipts.append((path, start, end, reason, excerpt)) return receipts @@ -764,18 +818,22 @@ def leftover_reason_bullet_duplicates_deferred( path: str, line: int, deferred_item: tuple[str, int, int, str | None, int | None] | None, + end: int | None = None, ) -> bool: """Return whether a leftover reason bullet would repeat a prefixed deferred row.""" if deferred_item is None: return False - deferred_path, start, end, _origin_path, _origin_line = _applyable_receipt_parts( - deferred_item + leftover_end = line if end is None else end + deferred_path, start, deferred_end, _origin_path, _origin_line = ( + _applyable_receipt_parts(deferred_item) + ) + return ( + deferred_path == path and start <= line and leftover_end <= deferred_end ) - return deferred_path == path and start <= line <= end def render_leftover_diff_receipts( - receipts: list[tuple[str, int, str, str]] | list[tuple[str, int, str]], + receipts: list[tuple[Any, ...]], deferred: list[tuple[str, int, int, str | None, int | None]] | None = None, ) -> list[str]: """Return leftover lines with one deferred row, then each Manual-edit excerpt.""" @@ -783,19 +841,25 @@ def render_leftover_diff_receipts( lines: list[str] = [] seen_deferred: set[tuple[str, int, int]] = set() for item in receipts: - path, line, reason, excerpt = _leftover_receipt_parts(item) + path, start, end, reason, excerpt = leftover_receipt_range(item) excerpt = excerpt.replace("```", "") - deferred_item = matches.get((path, line)) + deferred_item = None + for line in range(start, end + 1): + deferred_item = matches.get((path, line)) + if deferred_item is not None: + break if deferred_item is not None: - deferred_path, start, end, _origin_path, _origin_line = ( + deferred_path, deferred_start, deferred_end, _origin_path, _origin_line = ( _applyable_receipt_parts(deferred_item) ) - deferred_key = (deferred_path, start, end) + deferred_key = (deferred_path, deferred_start, deferred_end) if deferred_key not in seen_deferred: lines.extend(render_applyable_receipts([deferred_item])) seen_deferred.add(deferred_key) - if not leftover_reason_bullet_duplicates_deferred(path, line, deferred_item): - lines.append(f"- `{path}:{line}` — {reason}") + if not leftover_reason_bullet_duplicates_deferred( + path, start, deferred_item, end=end + ): + lines.append(f"- `{format_applyable_range(path, start, end)}` — {reason}") if not excerpt: continue lines.append(f" {MANUAL_EDIT_HEADING}") @@ -841,12 +905,14 @@ def write_hunk_filtered_payload( applyable_path.write_text("".join(applyable_rows), encoding="utf-8") if leftover_path is not None: leftover_rows: list[str] = [] - for path, line, reason, excerpt in leftovers: + for item in leftovers: + path, start, end, reason, excerpt = leftover_receipt_range(item) + loc = format_applyable_range(path, start, end) encoded = encode_manual_edit_field(excerpt) if encoded: - leftover_rows.append(f"{path}:{line}\t{reason}\t{encoded}\n") + leftover_rows.append(f"{loc}\t{reason}\t{encoded}\n") else: - leftover_rows.append(f"{path}:{line}\t{reason}\n") + leftover_rows.append(f"{loc}\t{reason}\n") leftover_path.write_text("".join(leftover_rows), encoding="utf-8") return len(comments) if isinstance(comments, list) else 0 @@ -1125,10 +1191,10 @@ def exclude_deferred_applyable( def leftover_manual_edits_with_deferred( - leftovers: list[tuple[str, int, str, str]] | list[tuple[str, int, str]], + leftovers: list[tuple[Any, ...]], deferred: list[tuple[str, int, int, str | None, int | None]], allowed: set[tuple[str, int]] | None = None, -) -> list[tuple[str, int, str, str]]: +) -> list[tuple[Any, ...]]: """Keep leftover Manual-edit receipts on a trusted finding or deferred range.""" if not leftovers: return [] @@ -1140,33 +1206,53 @@ def leftover_manual_edits_with_deferred( for location, receipt in matches.items() if location[0] in allowed_paths } - kept: list[tuple[str, int, str, str]] = [] - seen: set[tuple[str, int]] = set() + + def leftover_row( + path: str, start: int, end: int, reason: str, excerpt: str + ) -> tuple[Any, ...]: + """Return a single-line leftover 4-tuple or a ranged leftover 5-tuple.""" + if start == end: + return path, start, reason, excerpt + return path, start, end, reason, excerpt + + def leftover_hits(path: str, start: int, end: int) -> bool: + """Return whether any leftover line sits inside a deferred range.""" + return any((path, line) in matches for line in range(start, end + 1)) + + kept: list[tuple[Any, ...]] = [] + seen: set[tuple[str, int, int]] = set() for item in leftovers: - path, line, reason, excerpt = _leftover_receipt_parts(item) - if reason not in LEFTOVER_DIFF_REASONS or (path, line) in seen: + path, start, end, reason, excerpt = leftover_receipt_range(item) + if reason not in LEFTOVER_DIFF_REASONS or (path, start, end) in seen: continue - if ( - allowed is not None - and (path, line) not in allowed - and (path, line) not in matches + if allowed is not None and not any( + (path, line) in allowed or (path, line) in matches + for line in range(start, end + 1) ): continue - seen.add((path, line)) - kept.append((path, line, reason, excerpt)) + seen.add((path, start, end)) + kept.append(leftover_row(path, start, end, reason, excerpt)) if not matches: return kept - overlapping = [item for item in kept if (item[0], item[1]) in matches] - rest = [item for item in kept if (item[0], item[1]) not in matches] + overlapping = [ + item + for item in kept + if leftover_hits(*leftover_receipt_range(item)[:3]) + ] + rest = [ + item + for item in kept + if not leftover_hits(*leftover_receipt_range(item)[:3]) + ] return overlapping + rest def exclude_leftover_from_applyable( applyable: list[tuple[str, int, int, str | None, int | None]], - leftovers: list[tuple[str, int, str, str]], + leftovers: list[tuple[Any, ...]], ) -> list[tuple[str, int, int, str | None, int | None]]: - """Drop applyable ranges that are leftover cannot-provide or LEFT fences.""" - leftover_points = {(path, line) for path, line, _reason, _excerpt in leftovers} + """Drop applyable ranges that overlap leftover cannot-provide or LEFT fences.""" + leftover_points = leftover_coverage_points(leftovers) if not leftover_points: return applyable kept: list[tuple[str, int, int, str | None, int | None]] = [] diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index ffcd6378e..11aece57e 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1508,6 +1508,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "allowed=allowed" "opencode leftover heading keeps Manual-edit excerpts inside a trusted deferred range" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "start <= leftover_line <= end" "opencode applyable overview omits ranges that contain a leftover line" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftovers = leftover_diff_fence_receipts(filtered)" "opencode hunk-filter write path omits applyable ranges that contain a leftover line" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_coverage_points" "opencode leftover path:start-end receipts cover interior leftover lines" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--applyable-locations" "opencode overview CLI accepts applyable suggestion ranges" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--leftover-diff-locations" "opencode overview CLI accepts leftover diff-fence receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--deferred-locations" "opencode overview CLI accepts deferred leftover ranges" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index 9038e51a2..cc2f5e73a 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1669,6 +1669,8 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "exclude_leftover_from_applyable" in helper assert "leftover-safe applyable receipts" in helper assert "leftovers = leftover_diff_fence_receipts(filtered)" in helper + assert "leftover_coverage_points" in helper + assert "leftover_receipt_range" in helper assert "start <= leftover_line <= end" in helper assert "--applyable-locations" in helper assert "leftover_deferred_matches" in helper diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 566186b6e..973f0dbc9 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -21,6 +21,8 @@ normalize_deferred_receipt, parse_applyable_origin_field, strip_left_origin_fields, + leftover_coverage_points, + leftover_receipt_range, leftover_deferred_matches, leftover_reason_bullet_duplicates_deferred, leftover_diff_fence_reason, @@ -4223,3 +4225,154 @@ def test_write_hunk_filtered_payload_omits_applyable_range_containing_interior_l assert "scripts/ci/example.py:5-7" not in cli_applyable.read_text(encoding="utf-8") assert cli_applyable.read_text(encoding="utf-8") == "scripts/ci/ok.py:4\n" + +def test_leftover_start_end_receipt_omits_interior_applyable_ranges(tmp_path): + excerpt = leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) + leftover = parse_leftover_diff_receipts( + f"scripts/ci/example.py:5-7\tcannot-provide\t{encode_manual_edit_field(excerpt)}\n" + "scripts/ci/skip.py:x\tcannot-provide\n" + ) + assert leftover_coverage_points(leftover) == { + ("scripts/ci/example.py", 5), + ("scripts/ci/example.py", 6), + ("scripts/ci/example.py", 7), + } + assert leftover_coverage_points(None) == set() + assert leftover_coverage_points([("scripts/ci/x.py", 1, "HTTP 422", "n")]) == set() + assert leftover_receipt_range(()) == ("", 1, 1, "", "") + assert leftover_receipt_range((12, 0, 3, 4, 5)) == ("", 1, 3, "", "") + assert leftover_receipt_range(("scripts/ci/p.py", 8, 5, "cannot-provide", "x")) == ( + "scripts/ci/p.py", + 5, + 8, + "cannot-provide", + "x", + ) + assert leftover_receipt_range(("scripts/ci/p.py",)) == ( + "scripts/ci/p.py", + 1, + 1, + "", + "", + ) + assert leftover_receipt_range(("scripts/ci/p.py", 4, 4, True, 12))[:4] == ( + "scripts/ci/p.py", + 4, + 4, + "", + ) + assert parse_leftover_diff_receipts( + "scripts/ci/example.py:5-x\tcannot-provide\n" + "scripts/ci/example.py:8-5\tcannot-provide\n" + "scripts/ci/example.py:0-2\tcannot-provide\n" + ) == [] + applyable = [ + ("scripts/ci/example.py", 6, 6, None, None), + ("scripts/ci/example.py", 5, 7, None, None), + ("scripts/ci/ok.py", 4, 4, None, None), + ] + assert exclude_leftover_from_applyable(applyable, leftover) == [ + ("scripts/ci/ok.py", 4, 4, None, None) + ] + rendered = render_leftover_diff_receipts(leftover) + assert rendered[0] == "- `scripts/ci/example.py:5-7` — cannot-provide" + assert excerpt in "\n".join(rendered) + assert "- `scripts/ci/example.py:6` — cannot-provide" not in "\n".join(rendered) + + leftover_file = tmp_path / "leftover.txt" + leftover_file.write_text( + f"scripts/ci/example.py:5-7\tcannot-provide\t{encode_manual_edit_field(excerpt)}\n", + encoding="utf-8", + ) + applyable_file = tmp_path / "applyable.txt" + applyable_file.write_text( + "scripts/ci/example.py:6\n" + "scripts/ci/example.py:5-7\n" + "scripts/ci/ok.py:4\n", + encoding="utf-8", + ) + control_path = tmp_path / "control.json" + control_path.write_text( + json.dumps( + control( + {"path": "scripts/ci/example.py", "line": 6}, + {"path": "scripts/ci/ok.py", "line": 4}, + ) + ), + encoding="utf-8", + ) + body_path = tmp_path / "body.md" + body_path.write_text("## Findings\n", encoding="utf-8") + receipt = tmp_path / "receipt.md" + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--applyable-locations", + str(applyable_file), + "--leftover-diff-locations", + str(leftover_file), + ] + ) + == 0 + ) + rendered_cli = receipt.read_text(encoding="utf-8") + leftover_heading = ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + leftover_section = rendered_cli.split(leftover_heading, 1)[1] + applyable_heading = "GitHub can apply these suggested replacements:" + if applyable_heading in leftover_section: + leftover_section = leftover_section.split(applyable_heading, 1)[0] + assert "- `scripts/ci/example.py:5-7` — cannot-provide" in leftover_section + assert excerpt in leftover_section + applyable_section = rendered_cli.split(applyable_heading, 1)[1] + if leftover_heading in applyable_section: + applyable_section = applyable_section.split(leftover_heading, 1)[0] + assert "scripts/ci/example.py:6" not in applyable_section + assert "scripts/ci/example.py:5-7" not in applyable_section + assert "- `scripts/ci/ok.py:4`" in applyable_section + + payload = _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 7, + "start_line": 5, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + } + ) + assert leftover_diff_fence_receipts(payload)[0][:4] == ( + "scripts/ci/example.py", + 5, + 7, + "cannot-provide", + ) + swapped = leftover_diff_fence_receipts( + _batch_payload( + { + "path": "scripts/ci/example.py", + "line": 5, + "start_line": 8, + "side": "RIGHT", + "body": CANNOT_PROVIDE_DIFF_BODY, + } + ) + ) + assert swapped[0][:4] == ("scripts/ci/example.py", 5, 8, "cannot-provide") + leftover_out = tmp_path / "written-leftover.txt" + write_hunk_filtered_payload( + payload, + parse_unified_diff_hunk_lines(EXAMPLE_UNIFIED_DIFF), + tmp_path / "filtered.json", + leftover_path=leftover_out, + ) + assert leftover_out.read_text(encoding="utf-8").startswith( + "scripts/ci/example.py:5-7\tcannot-provide\t" + ) + From beadb4e2f9a3c20529e03f0bd5e8fcd68999cc1d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 21:11:50 +0900 Subject: [PATCH 31/34] fix(review): prefix leftover start-end once for interiors Leftover example.py:6 next to leftover example.py:5-7 listed two leftover reason bullets for the same span. Prefix the leftover start-end once and keep Manual-edit excerpts for the interior leftover lines. --- CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 10 +- .../ci/opencode_inline_comment_fallback.py | 61 +++++++++- scripts/ci/test_strix_quick_gate.sh | 1 + tests/test_opencode_agent_contract.py | 3 + .../test_opencode_inline_comment_fallback.py | 106 ++++++++++++++++++ 6 files changed, 179 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c3562ac..730559529 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- The leftover heading now prefixes one leftover `path:start-end` for leftover interiors of that range, so leftover `example.py:6` next to leftover `example.py:5-7` shows the range once then Manual-edit excerpts instead of two leftover reason bullets. - Leftover receipts now accept `path:start-end` as well as `path:line`, so a leftover cannot-provide range such as `example.py:5-7` drops overlapping applyable interiors (`example.py:6` and `example.py:5-7`) instead of being ignored. - The hunk-filter write path now omits applyable `path:start-end` rows from `--applyable-locations` when a leftover cannot-provide or LEFT line sits inside that range, so `applyable.txt` cannot list a one-click apply for the same span as leftover `example.py:6`. - The leftover overview CLI now omits applyable `path:start-end` rows that contain a leftover cannot-provide or LEFT line, so `--leftover-diff-locations` plus `--applyable-locations` show Manual-edit instead of a one-click apply for the same span. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 396f2b7e8..82a64f316 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -94,7 +94,10 @@ one-click apply for the same span as leftover ``example.py:6``. Leftover receipts also accept ``path:start-end`` (and leftover comments with ``start_line`` write that range), so leftover ``example.py:5-7`` covers every interior line when the overview consumer drops overlapping -applyable rows. Comments that +applyable rows. The leftover heading prefixes one leftover +``path:start-end`` for leftover interiors of that range, so leftover +``example.py:6`` next to leftover ``example.py:5-7`` shows the range +once then the Manual-edit excerpts. Comments that kept only a `` ```diff `` fence are listed separately with the reason ``cannot-provide`` (``n/a``, “cannot provide”, fence-breaking replacement, or no ``+`` lines) or ``LEFT`` (GitHub cannot apply a @@ -138,7 +141,10 @@ with the same control object used to build the inline `comments` array. applyable range when leftover ``example.py:6`` sits inside ``example.py:5-7``, leftover ``path:start-end`` receipts that cover every interior leftover line so leftover ``example.py:5-7`` omits - applyable ``example.py:6``, and + applyable ``example.py:6``, leftover heading prefix of one leftover + ``path:start-end`` for leftover interiors of that range so leftover + ``example.py:6`` next to leftover ``example.py:5-7`` does not repeat + the leftover reason bullet, and leftover path:line rows that were not retried, unified-diff hunk parsing, the pre-POST filter that drops off-hunk comments, and conversion of surviving suggested diffs into GitHub suggestion blocks, diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index 02237fce4..bfd9391fe 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -705,6 +705,41 @@ def leftover_coverage_points( return points +def leftover_range_matches( + leftovers: list[tuple[Any, ...]] | None, +) -> dict[tuple[str, int], tuple[str, int, int, str, str]]: + """Map leftover path:line to the widest leftover start-end that contains it.""" + matches: dict[tuple[str, int], tuple[str, int, int, str, str]] = {} + if not leftovers: + return matches + for item in leftovers: + path, start, end, reason, excerpt = leftover_receipt_range(item) + if reason not in LEFTOVER_DIFF_REASONS or start == end: + continue + receipt = (path, start, end, reason, excerpt) + width = end - start + for line in range(start, end + 1): + existing = matches.get((path, line)) + if existing is None or existing[2] - existing[1] < width: + matches[(path, line)] = receipt + return matches + + +def leftover_reason_bullet_duplicates_leftover_range( + path: str, + start: int, + end: int, + leftover_range: tuple[Any, ...] | None, +) -> bool: + """Return whether a leftover reason bullet would repeat a leftover start-end prefix.""" + if leftover_range is None: + return False + range_path, range_start, range_end, _reason, _excerpt = leftover_receipt_range( + leftover_range + ) + return range_path == path and range_start <= start and end <= range_end + + def leftover_diff_fence_reason(comment: dict[str, Any]) -> str | None: """Return ``LEFT`` or ``cannot-provide`` when a comment kept only a diff fence.""" body = comment.get("body") @@ -836,13 +871,20 @@ def render_leftover_diff_receipts( receipts: list[tuple[Any, ...]], deferred: list[tuple[str, int, int, str | None, int | None]] | None = None, ) -> list[str]: - """Return leftover lines with one deferred row, then each Manual-edit excerpt.""" + """Return leftover lines with one leftover-range or deferred row, then Manual-edit.""" matches = leftover_deferred_matches(deferred) + range_matches = leftover_range_matches(receipts) lines: list[str] = [] seen_deferred: set[tuple[str, int, int]] = set() + seen_leftover_ranges: set[tuple[str, int, int]] = set() for item in receipts: path, start, end, reason, excerpt = leftover_receipt_range(item) excerpt = excerpt.replace("```", "") + leftover_range: tuple[Any, ...] | None + if start < end: + leftover_range = leftover_receipt_range(item) + else: + leftover_range = range_matches.get((path, start)) deferred_item = None for line in range(start, end + 1): deferred_item = matches.get((path, line)) @@ -856,8 +898,25 @@ def render_leftover_diff_receipts( if deferred_key not in seen_deferred: lines.extend(render_applyable_receipts([deferred_item])) seen_deferred.add(deferred_key) + if leftover_range is not None: + range_path, range_start, range_end, range_reason, _range_excerpt = ( + leftover_receipt_range(leftover_range) + ) + leftover_key = (range_path, range_start, range_end) + if leftover_key not in seen_leftover_ranges and ( + not leftover_reason_bullet_duplicates_deferred( + range_path, range_start, deferred_item, end=range_end + ) + ): + lines.append( + f"- `{format_applyable_range(range_path, range_start, range_end)}`" + f" — {range_reason}" + ) + seen_leftover_ranges.add(leftover_key) if not leftover_reason_bullet_duplicates_deferred( path, start, deferred_item, end=end + ) and not leftover_reason_bullet_duplicates_leftover_range( + path, start, end, leftover_range ): lines.append(f"- `{format_applyable_range(path, start, end)}` — {reason}") if not excerpt: diff --git a/scripts/ci/test_strix_quick_gate.sh b/scripts/ci/test_strix_quick_gate.sh index 11aece57e..31d3cf78d 100755 --- a/scripts/ci/test_strix_quick_gate.sh +++ b/scripts/ci/test_strix_quick_gate.sh @@ -1509,6 +1509,7 @@ assert_opencode_review_posts_suggested_diffs_inline() { assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "start <= leftover_line <= end" "opencode applyable overview omits ranges that contain a leftover line" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftovers = leftover_diff_fence_receipts(filtered)" "opencode hunk-filter write path omits applyable ranges that contain a leftover line" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_coverage_points" "opencode leftover path:start-end receipts cover interior leftover lines" + assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "leftover_range_matches" "opencode leftover heading prefixes one leftover start-end for interior leftovers" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--applyable-locations" "opencode overview CLI accepts applyable suggestion ranges" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--leftover-diff-locations" "opencode overview CLI accepts leftover diff-fence receipts" assert_file_contains "$REPO_ROOT/scripts/ci/opencode_inline_comment_fallback.py" "--deferred-locations" "opencode overview CLI accepts deferred leftover ranges" diff --git a/tests/test_opencode_agent_contract.py b/tests/test_opencode_agent_contract.py index cc2f5e73a..297c73d95 100644 --- a/tests/test_opencode_agent_contract.py +++ b/tests/test_opencode_agent_contract.py @@ -1671,6 +1671,9 @@ def test_workflow_provisions_sandbox_tool_and_reviewer_agent(): assert "leftovers = leftover_diff_fence_receipts(filtered)" in helper assert "leftover_coverage_points" in helper assert "leftover_receipt_range" in helper + assert "leftover_range_matches" in helper + assert "leftover_reason_bullet_duplicates_leftover_range" in helper + assert "seen_leftover_ranges" in helper assert "start <= leftover_line <= end" in helper assert "--applyable-locations" in helper assert "leftover_deferred_matches" in helper diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 973f0dbc9..4f3a24c1d 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -23,6 +23,8 @@ strip_left_origin_fields, leftover_coverage_points, leftover_receipt_range, + leftover_range_matches, + leftover_reason_bullet_duplicates_leftover_range, leftover_deferred_matches, leftover_reason_bullet_duplicates_deferred, leftover_diff_fence_reason, @@ -4376,3 +4378,107 @@ def test_leftover_start_end_receipt_omits_interior_applyable_ranges(tmp_path): "scripts/ci/example.py:5-7\tcannot-provide\t" ) + +def test_leftover_heading_prefixes_leftover_range_once_for_interior_leftovers( + tmp_path, +): + range_excerpt = leftover_manual_edit_text(CANNOT_PROVIDE_DIFF_BODY) + interior_excerpt = leftover_manual_edit_text(NA_DIFF_BODY) + leftover = parse_leftover_diff_receipts( + f"scripts/ci/example.py:6\tcannot-provide\t{encode_manual_edit_field(interior_excerpt)}\n" + f"scripts/ci/example.py:5-7\tcannot-provide\t{encode_manual_edit_field(range_excerpt)}\n" + f"scripts/ci/example.py:12\tcannot-provide\t{encode_manual_edit_field(range_excerpt)}\n" + ) + matches = leftover_range_matches(leftover) + assert matches[("scripts/ci/example.py", 6)][:3] == ( + "scripts/ci/example.py", + 5, + 7, + ) + assert leftover_range_matches([]) == {} + assert leftover_range_matches(None) == {} + assert leftover_range_matches( + [("scripts/ci/x.py", 5, 7, "HTTP 422", "n")] + ) == {} + assert leftover_reason_bullet_duplicates_leftover_range( + "scripts/ci/example.py", 6, 6, None + ) is False + wider = leftover_range_matches( + [ + ("scripts/ci/example.py", 5, 6, "cannot-provide", interior_excerpt), + ("scripts/ci/example.py", 5, 7, "cannot-provide", range_excerpt), + ] + ) + assert wider[("scripts/ci/example.py", 6)][:3] == ( + "scripts/ci/example.py", + 5, + 7, + ) + keep_wide = leftover_range_matches( + [ + ("scripts/ci/example.py", 5, 7, "cannot-provide", range_excerpt), + ("scripts/ci/example.py", 5, 6, "cannot-provide", interior_excerpt), + ] + ) + assert keep_wide[("scripts/ci/example.py", 6)][:3] == ( + "scripts/ci/example.py", + 5, + 7, + ) + rendered = render_leftover_diff_receipts(leftover) + joined = "\n".join(rendered) + assert joined.count("- `scripts/ci/example.py:5-7` — cannot-provide") == 1 + assert joined.index("- `scripts/ci/example.py:5-7` — cannot-provide") < joined.index( + interior_excerpt + ) + assert "- `scripts/ci/example.py:6` — cannot-provide" not in joined + assert interior_excerpt in joined + assert range_excerpt in joined + assert "- `scripts/ci/example.py:12` — cannot-provide" in joined + + leftover_file = tmp_path / "leftover.txt" + leftover_file.write_text( + f"scripts/ci/example.py:6\tcannot-provide\t{encode_manual_edit_field(interior_excerpt)}\n" + f"scripts/ci/example.py:5-7\tcannot-provide\t{encode_manual_edit_field(range_excerpt)}\n" + f"scripts/ci/example.py:12\tcannot-provide\t{encode_manual_edit_field(range_excerpt)}\n", + encoding="utf-8", + ) + control_path = tmp_path / "control.json" + control_path.write_text( + json.dumps( + control( + {"path": "scripts/ci/example.py", "line": 6}, + {"path": "scripts/ci/example.py", "line": 12}, + ) + ), + encoding="utf-8", + ) + body_path = tmp_path / "body.md" + body_path.write_text("## Findings\n", encoding="utf-8") + receipt = tmp_path / "receipt.md" + assert ( + main( + [ + "--control", + str(control_path), + "--body", + str(body_path), + "--output", + str(receipt), + "--leftover-diff-locations", + str(leftover_file), + ] + ) + == 0 + ) + leftover_heading = ( + "These comments still have a suggested-diff fence that GitHub cannot apply:" + ) + leftover_section = receipt.read_text(encoding="utf-8").split(leftover_heading, 1)[1] + assert leftover_section.count("- `scripts/ci/example.py:5-7` — cannot-provide") == 1 + assert leftover_section.index( + "- `scripts/ci/example.py:5-7` — cannot-provide" + ) < leftover_section.index(interior_excerpt) + assert "- `scripts/ci/example.py:6` — cannot-provide" not in leftover_section + assert "- `scripts/ci/example.py:12` — cannot-provide" in leftover_section + From 1c50e2dc5f65ff3233c0b35493cbaca1f3b24f14 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Thu, 13 Aug 2026 21:26:44 +0900 Subject: [PATCH 32/34] fix(review): sanitize leftover excerpts in overview Leftover Manual-edit text is copied into the overview HTML comment. A leftover --> or HTML metacharacter could close that comment or inject markup. Strip those sequences before the excerpt is stored. --- AGENTS.md | 1 + CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 5 +++- .../ci/opencode_inline_comment_fallback.py | 24 +++++++++++++++++-- .../test_opencode_inline_comment_fallback.py | 7 ++++++ 5 files changed, 35 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0a6a1bfd0..629d0ab7a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,4 +3,5 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. LEFT leftover suggestion fences are not applyable overview ranges. +Leftover Manual-edit excerpts strip HTML comment delimiters and metacharacters. diff --git a/CHANGELOG.md b/CHANGELOG.md index 730559529..e1862e14b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Leftover Manual-edit excerpts now drop HTML comment delimiters and `<`, `>`, `&` before they enter the overview comment, so a leftover `-->` cannot close `` (CWE-116). - The leftover heading now prefixes one leftover `path:start-end` for leftover interiors of that range, so leftover `example.py:6` next to leftover `example.py:5-7` shows the range once then Manual-edit excerpts instead of two leftover reason bullets. - Leftover receipts now accept `path:start-end` as well as `path:line`, so a leftover cannot-provide range such as `example.py:5-7` drops overlapping applyable interiors (`example.py:6` and `example.py:5-7`) instead of being ignored. - The hunk-filter write path now omits applyable `path:start-end` rows from `--applyable-locations` when a leftover cannot-provide or LEFT line sits inside that range, so `applyable.txt` cannot list a one-click apply for the same span as leftover `example.py:6`. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index 82a64f316..b087a659a 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -104,7 +104,10 @@ replacement, or no ``+`` lines) or ``LEFT`` (GitHub cannot apply a suggestion on the deleted side; GitHub, n.d.-b, n.d.-c). Each leftover row also keeps a bounded excerpt of that fence as a distinct “Manual edit (not a GitHub suggestion):” `` ```diff `` block so the -author can copy the replacement by hand. That block is never a GitHub +author can copy the replacement by hand. Excerpts drop ````, +and HTML metacharacters so leftover text cannot close +```` (CWE-116; MITRE, 2026). That block +is never a GitHub `` ```suggestion `` fence and is never listed under the applyable ``path:line`` / ``path:start-end`` heading (GitHub, n.d.-c). A comment that already has `` ```suggestion `` is applyable, not leftover. diff --git a/scripts/ci/opencode_inline_comment_fallback.py b/scripts/ci/opencode_inline_comment_fallback.py index bfd9391fe..35f3b07c7 100644 --- a/scripts/ci/opencode_inline_comment_fallback.py +++ b/scripts/ci/opencode_inline_comment_fallback.py @@ -624,6 +624,26 @@ def strip_left_origin_fields(payload: dict[str, Any]) -> dict[str, Any]: return rewritten +def sanitize_leftover_excerpt(text: str) -> str: + """Return leftover excerpt text that cannot break the overview HTML comment. + + The overview lives in ````. A leftover + ``-->`` or HTML metacharacter would close that comment or inject markup + (CWE-116). Fence markers are also removed so a leftover cannot reopen + a GitHub suggestion block. + """ + excerpt = (text or "").replace("\r\n", "\n").replace("\t", " ") + excerpt = ( + excerpt.replace("```", "") + .replace("", "") + .replace("<", "") + .replace(">", "") + .replace("&", "") + ) + return excerpt.strip("\n") + + def leftover_manual_edit_text(body: object) -> str: """Return bounded leftover `` ```diff `` text for a manual-edit overview block.""" if not isinstance(body, str): @@ -639,7 +659,7 @@ def leftover_manual_edit_text(body: object) -> str: if stripped: excerpt = stripped break - excerpt = excerpt.replace("```", "").replace("\r\n", "\n").strip("\n") + excerpt = sanitize_leftover_excerpt(excerpt) if not excerpt.strip(): return "" if len(excerpt) > MANUAL_EDIT_MAX_CHARS: @@ -649,7 +669,7 @@ def leftover_manual_edit_text(body: object) -> str: def encode_manual_edit_field(text: str) -> str: """Encode an already-extracted leftover excerpt for one leftover-receipt row.""" - excerpt = (text or "").replace("```", "").replace("\t", " ").replace("\r\n", "\n") + excerpt = sanitize_leftover_excerpt(text) if len(excerpt) > MANUAL_EDIT_MAX_CHARS: excerpt = excerpt[:MANUAL_EDIT_MAX_CHARS].rstrip() + "…" return excerpt.replace("\n", "\\n") diff --git a/tests/test_opencode_inline_comment_fallback.py b/tests/test_opencode_inline_comment_fallback.py index 4f3a24c1d..9f02c5382 100644 --- a/tests/test_opencode_inline_comment_fallback.py +++ b/tests/test_opencode_inline_comment_fallback.py @@ -30,6 +30,7 @@ leftover_diff_fence_reason, leftover_diff_fence_receipts, leftover_manual_edit_text, + sanitize_leftover_excerpt, leftover_manual_edits_with_deferred, parse_leftover_diff_receipts, remap_left_comment_to_right_hunk, @@ -2776,6 +2777,12 @@ def test_leftover_manual_edit_excerpt_is_distinct_non_applyable_block(tmp_path): assert decode_manual_edit_field(encoded) == "keep this\nlineend" assert encode_manual_edit_field(long_text).endswith("…") assert decode_manual_edit_field("") == "" + assert leftover_manual_edit_text( + "```diff\n-->\n```" + ) == " opencode scriptalert(1)/script" + assert "-->" not in leftover_manual_edit_text("```diff\nclose --> comment\n```") + assert sanitize_leftover_excerpt("a & b ") == "a b c" + assert encode_manual_edit_field(" & ") == " x" assert render_leftover_diff_receipts([("scripts/ci/a.py", 1, "LEFT")]) == [ "- `scripts/ci/a.py:1` — LEFT" ] From 2f626018d86e80f494be867b51ec3378678e629b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 03:17:44 +0900 Subject: [PATCH 33/34] fix(coverage): accept only bounded relative requirement includes Materialize a base Python lock only when every package line is an exact SHA-256 pin or a two-token relative -r/--requirement include of a candidate lock path. A lone --require-hashes directive, ./dotted paths, and -r other-hashes.txt no longer enter the trusted build context. --- AGENTS.md | 2 + CHANGELOG.md | 1 + .../review-inline-comment-422-fallback.md | 2 + .../materialize_base_python_requirements.py | 85 +++++++++++++++---- ...st_materialize_base_python_requirements.py | 19 ++++- 5 files changed, 91 insertions(+), 18 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 629d0ab7a..1a1019268 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,6 +2,8 @@ > **Agents: read the master context FIRST.** Before any work, read [`docs/CWL-MASTER-CONTEXT.md`](docs/CWL-MASTER-CONTEXT.md) (mission · naruon-as-platform + inter-component UML · cross-cutting disciplines · conventions · roadmap · current state), the live **GitHub Project #1** (work/roadmap source of truth), the full spec **ContextualWisdomLab/naruon#974**, and operate the Project per [`docs/agent-github-project-protocol.md`](docs/agent-github-project-protocol.md). The repo/Project — not any private agent memory — is the source of truth. + +Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include (no `.`/`..`); a lone `--require-hashes` directive is not trust evidence. See [`docs/doctoring/review-inline-comment-422-fallback.md`](docs/doctoring/review-inline-comment-422-fallback.md). LEFT leftover suggestion fences are not applyable overview ranges. Leftover Manual-edit excerpts strip HTML comment delimiters and metacharacters. diff --git a/CHANGELOG.md b/CHANGELOG.md index e1862e14b..8cbefdf49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Semantic Versioning where the repository publishes a release. ### Fixed +- Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Leftover Manual-edit excerpts now drop HTML comment delimiters and `<`, `>`, `&` before they enter the overview comment, so a leftover `-->` cannot close `` (CWE-116). - The leftover heading now prefixes one leftover `path:start-end` for leftover interiors of that range, so leftover `example.py:6` next to leftover `example.py:5-7` shows the range once then Manual-edit excerpts instead of two leftover reason bullets. - Leftover receipts now accept `path:start-end` as well as `path:line`, so a leftover cannot-provide range such as `example.py:5-7` drops overlapping applyable interiors (`example.py:6` and `example.py:5-7`) instead of being ignored. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index b087a659a..b09c41555 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -14,6 +14,8 @@ review expects (Bacchelli & Bird, 2013). ## Decision +Materialize accepts only exact SHA-256 pins or a bounded relative `-r` include; a lone `--require-hashes` line is not lock evidence. + `scripts/ci/opencode_inline_comment_fallback.py` reads the trusted control JSON, keeps first-seen safe relative `path` plus positive integer `line` pairs, and appends them to the fallback body as `` `path:line` `` list diff --git a/scripts/ci/materialize_base_python_requirements.py b/scripts/ci/materialize_base_python_requirements.py index 98cdad459..7a9c204b8 100755 --- a/scripts/ci/materialize_base_python_requirements.py +++ b/scripts/ci/materialize_base_python_requirements.py @@ -87,6 +87,58 @@ def _is_candidate_lock_name(name: str) -> bool: ) + +def _is_candidate_lock_path(path: pathlib.PurePosixPath) -> bool: + """Return whether one safe tracked path can name a pip requirements lock. + + In addition to conventional ``requirements*.txt`` names, repositories often + keep concrete environment closures as direct children such as + ``requirements/ci.txt`` or ``service/requirements/package.txt``. Only direct + ``.txt`` children of a directory named ``requirements`` gain this path-based + eligibility; content must still pass the independent complete hash-pin + validation before it reaches the trusted image build context. + """ + return _is_candidate_lock_name(path.name) or ( + path.suffix == ".txt" and path.parent.name == "requirements" + ) + + +def _is_bounded_requirement_include(line: str) -> bool: + """Return whether one requirements include names a bounded relative file. + + Includes are accepted only as a two-token ``-r``/``--requirement`` form + whose target is itself a candidate lock path written as a normalized + relative POSIX path. Absolute paths, ``.`` or ``..`` components, double + slashes, URLs, option-like targets, shell/Windows path separators, + fragments, queries, extra inline options or hashes, and includes of + non-lock files are rejected before a base-owned file can enter the + trusted build context. + The downstream installer still proves that the candidate is an independently + complete hash closure; this predicate grants syntax eligibility only. + """ + fields = line.split() + if len(fields) != 2 or fields[0] not in {"-r", "--requirement"}: + return False + target = fields[1] + if ( + target.startswith(("-", "~")) + or "\\" in target + or ":" in target + or "?" in target + or "#" in target + ): + return False + include_path = pathlib.PurePosixPath(target) + return ( + bool(include_path.parts) + and target == include_path.as_posix() + and not include_path.is_absolute() + and "." not in include_path.parts + and ".." not in include_path.parts + and _is_candidate_lock_path(include_path) + ) + + def _requirement_lines(content: bytes) -> list[str]: """Return logical requirement lines, joining backslash line-continuations. @@ -107,26 +159,27 @@ def _requirement_lines(content: bytes) -> list[str]: def _is_hash_pinned(content: bytes) -> bool: - """Return whether content carries hash pins and is safe to preflight. - - Discovery is content-based rather than name-based so hash-pinned locks in any - location (a service subdirectory, ``requirements-dev.txt``, - ``requirements-test.txt``) can be considered for offline coverage, while an - unpinned or PR-mutable requirements file is still excluded from the networked - build context. Hash syntax cannot prove that a file includes every transitive - dependency, so the trusted image installer separately preflights every - candidate as an independent ``--require-hashes`` closure. An empty file - carries no installable dependency and is not materialized. + """Return whether content carries only trusted pins or bounded includes. + + Discovery is content-based rather than name-based so exact hash-pinned locks + in service subdirectories and role-specific requirements files can be + considered for offline coverage. Candidate syntax is deliberately stricter + than a substring search: each package line must be an exact ``==`` pin with + one or more complete SHA-256 hashes, or a bounded relative requirements + include. A global ``--require-hashes`` directive is not trust evidence by + itself. The downstream installer separately preflights every candidate as an + independent ``pip --require-hashes`` closure, so syntax eligibility never + substitutes for dependency-closure proof. """ lines = _requirement_lines(content) - if not lines: + requirement_lines = [line for line in lines if line != "--require-hashes"] + if not requirement_lines: return False - return any(line == "--require-hashes" for line in lines) or all( - "--hash=" in line or line.startswith(("-r ", "--requirement ")) - for line in lines + return all( + _is_fully_hash_pinned_requirement(line) + or _is_bounded_requirement_include(line) + for line in requirement_lines ) - - def _is_fully_hash_pinned_requirement(line: str) -> bool: """Return whether one uv-export line is an exact package pin with SHA-256 hashes.""" fields = re.split(r"\s+(?=--hash=)", line) diff --git a/tests/test_materialize_base_python_requirements.py b/tests/test_materialize_base_python_requirements.py index 62d445046..0627bb5f0 100644 --- a/tests/test_materialize_base_python_requirements.py +++ b/tests/test_materialize_base_python_requirements.py @@ -158,9 +158,24 @@ def test_lock_name_candidates_are_pip_requirements_files() -> None: def test_hash_pin_detection_includes_pinned_and_excludes_unpinned_or_empty() -> None: """Only fully hash-pinned, non-empty lock content is materialized.""" assert not materializer._is_hash_pinned(b"# comment only\n\n") - assert materializer._is_hash_pinned(b"--require-hashes\ndemo==1\n") + assert not materializer._is_hash_pinned(b"--require-hashes\ndemo==1\n") assert materializer._is_hash_pinned(b"demo==1 --hash=sha256:" + b"a" * 64 + b"\n") - assert materializer._is_hash_pinned(b"-r other-hashes.txt\n") + assert materializer._is_hash_pinned(b"-r requirements-other.txt\n") + assert not materializer._is_hash_pinned(b"-r other-hashes.txt\n") + assert not materializer._is_hash_pinned(b"-r ./requirements-other.txt\n") + assert not materializer._is_hash_pinned(b"-r ../escape.txt\n") + assert materializer._is_bounded_requirement_include( + "--requirement requirements-other.txt" + ) + assert not materializer._is_bounded_requirement_include("-r .") + assert not materializer._is_bounded_requirement_include("-r -evil.txt") + assert not materializer._is_bounded_requirement_include("-r ~evil.txt") + assert not materializer._is_bounded_requirement_include("-r C:foo.txt") + assert not materializer._is_bounded_requirement_include("-r foo?bar.txt") + assert not materializer._is_bounded_requirement_include("-r foo#bar.txt") + assert not materializer._is_bounded_requirement_include(r"-r foo\\bar.txt") + assert not materializer._is_bounded_requirement_include("-r") + assert not materializer._is_bounded_requirement_include("-r /abs/requirements.txt") assert not materializer._is_hash_pinned(b"untrusted==1\n") # uv export / pip-compile multi-line continuation format (spec, then --hash= lines). assert materializer._is_hash_pinned( From 874e59a806d14def034ea6ebe9241d69c2a5ea0e Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 14 Aug 2026 05:38:08 +0900 Subject: [PATCH 34/34] fix(review): omit leftover overview paths with comment closers Reject leftover 422-fallback paths that contain -->, `, ``, `` or reopen an applyable GitHub suggestion block (CWE-116). - Materialized base Python locks only when every package line is an exact SHA-256 pin or a bounded relative `-r`/`--requirement` include. A lone `--require-hashes` directive, a dotted include such as `./lock.txt`, or `-r other-hashes.txt` no longer enters the trusted build context. - Leftover Manual-edit excerpts now drop HTML comment delimiters and `<`, `>`, `&` before they enter the overview comment, so a leftover `-->` cannot close `` (CWE-116). - The leftover heading now prefixes one leftover `path:start-end` for leftover interiors of that range, so leftover `example.py:6` next to leftover `example.py:5-7` shows the range once then Manual-edit excerpts instead of two leftover reason bullets. diff --git a/docs/doctoring/review-inline-comment-422-fallback.md b/docs/doctoring/review-inline-comment-422-fallback.md index b09c41555..ff747e658 100644 --- a/docs/doctoring/review-inline-comment-422-fallback.md +++ b/docs/doctoring/review-inline-comment-422-fallback.md @@ -14,6 +14,7 @@ review expects (Bacchelli & Bird, 2013). ## Decision +Leftover overview paths that contain `-->`, ```, ``", "comment.py", "line": 8}, + {"path": "scripts/ci/fence```suggestion.py", "line": 9}, "not-an-object", ) )