From f5e1695bb407aafc032058ad7c8ba7255c6c2400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B4=8B=E8=91=B1?= Date: Tue, 12 May 2026 23:56:00 +0000 Subject: [PATCH 1/9] feat(review): add tiered feedback with warning level, output-format and pass-level inputs Closes #61 Co-Authored-By: Claude Opus 4.7 --- examples/opencode-review.yml | 6 +++++ review/action.yml | 48 +++++++++++++++++++++++++++++++----- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/examples/opencode-review.yml b/examples/opencode-review.yml index 5a5d397..62a05ef 100644 --- a/examples/opencode-review.yml +++ b/examples/opencode-review.yml @@ -25,6 +25,12 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} zhipu-api-key: ${{ secrets.ZHIPU_API_KEY }} opencode-go-api-key: ${{ secrets.OPENCODE_GO_API_KEY }} + # Optional: output format (text or json, default: text) + # output-format: text + # Optional: pass level (strict or standard, default: strict) + # strict — 有条件合并 is not passing + # standard — only 不可合并 is not passing + # pass-level: strict # Optional: override reasoning effort and thinking mode defaults # reasoning-effort: max # enable-thinking: true diff --git a/review/action.yml b/review/action.yml index 4f40915..cb0e1d8 100644 --- a/review/action.yml +++ b/review/action.yml @@ -74,6 +74,14 @@ inputs: description: Rotate to the next fallback model when output matches this regex. required: false default: timed out|timeout|deadline exceeded|context deadline exceeded|operation timed out|connection timed out + output-format: + description: "Output format for review results. Allowed values: text, json." + required: false + default: "text" + pass-level: + description: "Pass-level threshold. strict — 有条件合并 is not passing. standard — only 不可合并 is not passing." + required: false + default: "strict" prompt: description: Value exported as PROMPT before running `opencode github run`. required: false @@ -94,20 +102,26 @@ inputs: - 有条件合并 - 不可合并 + Feedback levels (three tiers): + - 阻塞项 (blocking): Must fix before merge — correctness bugs, security vulnerabilities, logic errors, data loss risks. + - 警告项 (warning): Strongly recommended to fix but does not block merge — potential performance issues, fragile error handling, poor readability that hinders maintenance. + - 建议项 (suggestion): Optional improvements — naming, code style, refactoring opportunities. + Decision rules: - - Use "可合并" only when there are no blocking issues. - - Use "有条件合并" when merge is acceptable only after specific issues are fixed. - - Use "不可合并" when there are blocking risks, correctness issues, or major concerns that should stop the merge. + - 不可合并: there are blocking items. + - 有条件合并: no blocking items but there are warning items. + - 可合并: no blocking items and no warning items (suggestions are optional to address). Review the latest PR HEAD only. Do not repeat issues from earlier revisions unless they are still reproducible in the current files. Verify any blocking claim against the current code and current CI status before listing it. - Output format: + Text output format: - First line: the decision only - Then a short summary - - Then "阻塞项" listing required fixes for merge; if none, write "阻塞项:无" - - Then "建议项" listing non-blocking improvements; if none, write "建议项:无" + - Then "阻塞项" listing must-fix items; if none, write "阻塞项:无" + - Then "警告项" listing strongly-recommended fixes; if none, write "警告项:无" + - Then "建议项" listing optional improvements; if none, write "建议项:无" use-github-token: description: Value exported as USE_GITHUB_TOKEN before running `opencode github run`. required: false @@ -237,6 +251,8 @@ runs: GITHUB_RUN_OPENCODE_MODEL_TIMEOUT_SECONDS: ${{ inputs.model-timeout-seconds }} GITHUB_RUN_OPENCODE_FALLBACK_ON_REGEX: ${{ inputs.fallback-on-regex }} GITHUB_RUN_OPENCODE_PROMPT: ${{ inputs.prompt }} + GITHUB_RUN_OPENCODE_OUTPUT_FORMAT: ${{ inputs.output-format }} + GITHUB_RUN_OPENCODE_PASS_LEVEL: ${{ inputs.pass-level }} GITHUB_RUN_OPENCODE_USE_GITHUB_TOKEN: ${{ inputs.use-github-token }} GITHUB_RUN_OPENCODE_GITHUB_TOKEN: ${{ inputs.github-token }} GITHUB_RUN_OPENCODE_ZHIPU_API_KEY: ${{ inputs.zhipu-api-key }} @@ -250,4 +266,24 @@ runs: printf 'python3 is required but not installed on this runner\n' >&2 exit 1 fi + + effective_prompt="${GITHUB_RUN_OPENCODE_PROMPT}" + + if [[ "$GITHUB_RUN_OPENCODE_OUTPUT_FORMAT" == "json" ]]; then + json_suffix=' + +OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: +{"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} +Use empty arrays when there are no items for a level. Keep all text in Chinese.' + effective_prompt="${effective_prompt}${json_suffix}" + fi + + if [[ "$GITHUB_RUN_OPENCODE_PASS_LEVEL" == "standard" ]]; then + standard_suffix=' + +Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' + effective_prompt="${effective_prompt}${standard_suffix}" + fi + + export GITHUB_RUN_OPENCODE_PROMPT="${effective_prompt}" python3 ${{ github.action_path }}/../github-run-opencode/run-github-opencode.py From 5a76a656dc220542d314426704a5f3128484cd2b Mon Sep 17 00:00:00 2001 From: svtter Date: Thu, 14 May 2026 23:51:42 +0800 Subject: [PATCH 2/9] fix(review): fix YAML block scalar indentation and add output-format/pass-level downstream integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix YAML syntax error in review/action.yml where multi-line strings in json_suffix and standard_suffix broke the run: | block scalar due to zero-indent lines causing 'Mapping values are not allowed' error - Add input validation for output-format (text|json) and pass-level (strict|standard) with warning on invalid values - Add JSON output post-processing in run-github-opencode.py: extract decision from JSON model output and map to correct exit code - Add pass-level exit code mapping: standard treats 有条件合并 as passing (exit 0), strict treats it as failing (exit 1) - Merge json+standard prompt suffixes to avoid contradictory instructions --- github-run-opencode/run-github-opencode.py | 74 ++++++++++++++++++++++ review/action.yml | 42 +++++++++--- 2 files changed, 108 insertions(+), 8 deletions(-) diff --git a/github-run-opencode/run-github-opencode.py b/github-run-opencode/run-github-opencode.py index 4d0ce93..f52aea4 100755 --- a/github-run-opencode/run-github-opencode.py +++ b/github-run-opencode/run-github-opencode.py @@ -92,6 +92,45 @@ def configure_opencode_json(reasoning_effort: str, enable_thinking: str, working f.write("\n") +def extract_decision(output_text: str, output_format: str) -> str: + if output_format == "json": + cleaned = re.sub(r"```(?:json)?\s*", "", output_text) + brace_start = cleaned.find("{") + if brace_start >= 0: + depth = 0 + for i in range(brace_start, len(cleaned)): + if cleaned[i] == "{": + depth += 1 + elif cleaned[i] == "}": + depth -= 1 + if depth == 0: + try: + data = json.loads(cleaned[brace_start : i + 1]) + return data.get("decision", "") + except json.JSONDecodeError: + break + return "" + for line in output_text.split("\n"): + stripped = line.strip() + if stripped in ("\u53ef\u5408\u5e76", "\u6709\u6761\u4ef6\u5408\u5e76", "\u4e0d\u53ef\u5408\u5e76"): + return stripped + return "" + + +def _should_override_exit_code(output_format: str, pass_level: str) -> bool: + return output_format == "json" or pass_level != "strict" + + +def _apply_pass_level(decision: str, pass_level: str) -> int | None: + if decision == "\u53ef\u5408\u5e76": + return 0 + if decision == "\u6709\u6761\u4ef6\u5408\u5e76": + return 0 if pass_level == "standard" else 1 + if decision == "\u4e0d\u53ef\u5408\u5e76": + return 1 + return None + + def run_model(model: str, log_file: str, effective_timeout: int, run_script: Path) -> int: env = os.environ.copy() env["MODEL"] = model @@ -110,10 +149,45 @@ def run_model(model: str, log_file: str, effective_timeout: int, run_script: Pat sys.stdout.buffer.write(result.stdout) sys.stdout.buffer.flush() + output_format = get_env("GITHUB_RUN_OPENCODE_OUTPUT_FORMAT", "text") + pass_level = get_env("GITHUB_RUN_OPENCODE_PASS_LEVEL", "strict") + if _should_override_exit_code(output_format, pass_level): + with open(log_file, "r", encoding="utf-8", errors="replace") as f: + content = f.read() + decision = extract_decision(content, output_format) + override = _apply_pass_level(decision, pass_level) + if override is not None: + return override + return result.returncode def run_single(run_script: Path, timeout_sec: int) -> int: + output_format = get_env("GITHUB_RUN_OPENCODE_OUTPUT_FORMAT", "text") + pass_level = get_env("GITHUB_RUN_OPENCODE_PASS_LEVEL", "strict") + + if _should_override_exit_code(output_format, pass_level): + if timeout_sec > 0: + result = subprocess.run( + ["timeout", "--foreground", f"{timeout_sec}s", str(run_script)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + else: + result = subprocess.run( + [str(run_script)], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + output = result.stdout.decode("utf-8", errors="replace") + sys.stdout.write(output) + sys.stdout.flush() + decision = extract_decision(output, output_format) + override = _apply_pass_level(decision, pass_level) + if override is not None: + return override + return result.returncode + if timeout_sec > 0: result = subprocess.run( ["timeout", "--foreground", f"{timeout_sec}s", str(run_script)] diff --git a/review/action.yml b/review/action.yml index cb0e1d8..b1fff7b 100644 --- a/review/action.yml +++ b/review/action.yml @@ -267,23 +267,49 @@ runs: exit 1 fi + case "$GITHUB_RUN_OPENCODE_OUTPUT_FORMAT" in + text|json) ;; + *) + printf 'warning: invalid output-format "%s", falling back to text\n' "$GITHUB_RUN_OPENCODE_OUTPUT_FORMAT" >&2 + GITHUB_RUN_OPENCODE_OUTPUT_FORMAT="text" + ;; + esac + + case "$GITHUB_RUN_OPENCODE_PASS_LEVEL" in + strict|standard) ;; + *) + printf 'warning: invalid pass-level "%s", falling back to strict\n' "$GITHUB_RUN_OPENCODE_PASS_LEVEL" >&2 + GITHUB_RUN_OPENCODE_PASS_LEVEL="strict" + ;; + esac + effective_prompt="${GITHUB_RUN_OPENCODE_PROMPT}" if [[ "$GITHUB_RUN_OPENCODE_OUTPUT_FORMAT" == "json" ]]; then - json_suffix=' + if [[ "$GITHUB_RUN_OPENCODE_PASS_LEVEL" == "standard" ]]; then + json_suffix=' -OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: -{"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} -Use empty arrays when there are no items for a level. Keep all text in Chinese.' - effective_prompt="${effective_prompt}${json_suffix}" - fi + OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: + {"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} + Use empty arrays when there are no items for a level. Keep all text in Chinese. - if [[ "$GITHUB_RUN_OPENCODE_PASS_LEVEL" == "standard" ]]; then + Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' + else + json_suffix=' + + OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: + {"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} + Use empty arrays when there are no items for a level. Keep all text in Chinese.' + fi + effective_prompt="${effective_prompt}${json_suffix}" + elif [[ "$GITHUB_RUN_OPENCODE_PASS_LEVEL" == "standard" ]]; then standard_suffix=' -Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' + Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' effective_prompt="${effective_prompt}${standard_suffix}" fi export GITHUB_RUN_OPENCODE_PROMPT="${effective_prompt}" + export GITHUB_RUN_OPENCODE_OUTPUT_FORMAT + export GITHUB_RUN_OPENCODE_PASS_LEVEL python3 ${{ github.action_path }}/../github-run-opencode/run-github-opencode.py From 1587a6d0b677273d6c75f066962e0e56768cf788 Mon Sep 17 00:00:00 2001 From: svtter Date: Fri, 15 May 2026 00:01:33 +0800 Subject: [PATCH 3/9] fix(review): address review feedback for tiered feedback - Use json.JSONDecoder().raw_decode() instead of manual brace counting for robust JSON parsing (handles braces inside strings) - Extract _run_subprocess and _build_cmd helpers to reduce duplication in run_single and run_model - Remove excess indentation from json_suffix heredoc to reduce token consumption in LLM prompts - Use startswith for text mode decision extraction to handle trailing punctuation from LLM output --- github-run-opencode/run-github-opencode.py | 71 ++++++++++------------ review/action.yml | 16 ++--- 2 files changed, 39 insertions(+), 48 deletions(-) diff --git a/github-run-opencode/run-github-opencode.py b/github-run-opencode/run-github-opencode.py index f52aea4..8896e37 100755 --- a/github-run-opencode/run-github-opencode.py +++ b/github-run-opencode/run-github-opencode.py @@ -95,25 +95,25 @@ def configure_opencode_json(reasoning_effort: str, enable_thinking: str, working def extract_decision(output_text: str, output_format: str) -> str: if output_format == "json": cleaned = re.sub(r"```(?:json)?\s*", "", output_text) - brace_start = cleaned.find("{") - if brace_start >= 0: - depth = 0 - for i in range(brace_start, len(cleaned)): - if cleaned[i] == "{": - depth += 1 - elif cleaned[i] == "}": - depth -= 1 - if depth == 0: - try: - data = json.loads(cleaned[brace_start : i + 1]) - return data.get("decision", "") - except json.JSONDecodeError: - break + decoder = json.JSONDecoder() + pos = 0 + while pos < len(cleaned): + brace_idx = cleaned.find("{", pos) + if brace_idx < 0: + break + try: + obj, end = decoder.raw_decode(cleaned, brace_idx) + if isinstance(obj, dict) and "decision" in obj: + return obj["decision"] + pos = end + except json.JSONDecodeError: + pos = brace_idx + 1 return "" for line in output_text.split("\n"): stripped = line.strip() - if stripped in ("\u53ef\u5408\u5e76", "\u6709\u6761\u4ef6\u5408\u5e76", "\u4e0d\u53ef\u5408\u5e76"): - return stripped + for decision in ("\u53ef\u5408\u5e76", "\u6709\u6761\u4ef6\u5408\u5e76", "\u4e0d\u53ef\u5408\u5e76"): + if stripped == decision or stripped.startswith(decision): + return decision return "" @@ -135,17 +135,12 @@ def run_model(model: str, log_file: str, effective_timeout: int, run_script: Pat env = os.environ.copy() env["MODEL"] = model - if effective_timeout > 0: - cmd = ["timeout", "--foreground", f"{effective_timeout}s", str(run_script)] - else: - cmd = [str(run_script)] - + cmd = _build_cmd(run_script, effective_timeout) result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env) with open(log_file, "wb") as f: f.write(result.stdout) - # Replay captured output so it is visible in CI / terminal sys.stdout.buffer.write(result.stdout) sys.stdout.buffer.flush() @@ -162,23 +157,24 @@ def run_model(model: str, log_file: str, effective_timeout: int, run_script: Pat return result.returncode +def _run_subprocess(cmd: list[str], capture: bool = False) -> subprocess.CompletedProcess: + if capture: + return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + return subprocess.run(cmd) + + +def _build_cmd(run_script: Path, timeout_sec: int) -> list[str]: + if timeout_sec > 0: + return ["timeout", "--foreground", f"{timeout_sec}s", str(run_script)] + return [str(run_script)] + + def run_single(run_script: Path, timeout_sec: int) -> int: output_format = get_env("GITHUB_RUN_OPENCODE_OUTPUT_FORMAT", "text") pass_level = get_env("GITHUB_RUN_OPENCODE_PASS_LEVEL", "strict") if _should_override_exit_code(output_format, pass_level): - if timeout_sec > 0: - result = subprocess.run( - ["timeout", "--foreground", f"{timeout_sec}s", str(run_script)], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) - else: - result = subprocess.run( - [str(run_script)], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - ) + result = _run_subprocess(_build_cmd(run_script, timeout_sec), capture=True) output = result.stdout.decode("utf-8", errors="replace") sys.stdout.write(output) sys.stdout.flush() @@ -188,12 +184,7 @@ def run_single(run_script: Path, timeout_sec: int) -> int: return override return result.returncode - if timeout_sec > 0: - result = subprocess.run( - ["timeout", "--foreground", f"{timeout_sec}s", str(run_script)] - ) - else: - result = subprocess.run([str(run_script)]) + result = _run_subprocess(_build_cmd(run_script, timeout_sec)) return result.returncode diff --git a/review/action.yml b/review/action.yml index b1fff7b..555db54 100644 --- a/review/action.yml +++ b/review/action.yml @@ -289,23 +289,23 @@ runs: if [[ "$GITHUB_RUN_OPENCODE_PASS_LEVEL" == "standard" ]]; then json_suffix=' - OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: - {"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} - Use empty arrays when there are no items for a level. Keep all text in Chinese. +OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: +{"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} +Use empty arrays when there are no items for a level. Keep all text in Chinese. - Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' +Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' else json_suffix=' - OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: - {"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} - Use empty arrays when there are no items for a level. Keep all text in Chinese.' +OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: +{"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} +Use empty arrays when there are no items for a level. Keep all text in Chinese.' fi effective_prompt="${effective_prompt}${json_suffix}" elif [[ "$GITHUB_RUN_OPENCODE_PASS_LEVEL" == "standard" ]]; then standard_suffix=' - Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' +Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' effective_prompt="${effective_prompt}${standard_suffix}" fi From e62e3cf4f66a9d076961e83fbc8262c3f4409e17 Mon Sep 17 00:00:00 2001 From: svtter Date: Fri, 15 May 2026 00:05:50 +0800 Subject: [PATCH 4/9] fix: indent multiline shell strings in action.yml to fix YAML parse error The json_suffix and standard_suffix multiline strings had content at column 0, which broke the YAML literal block scalar indentation rules. Added proper indentation so the YAML parser correctly includes them in the run block. --- review/action.yml | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/review/action.yml b/review/action.yml index 555db54..c0d1506 100644 --- a/review/action.yml +++ b/review/action.yml @@ -288,24 +288,20 @@ runs: if [[ "$GITHUB_RUN_OPENCODE_OUTPUT_FORMAT" == "json" ]]; then if [[ "$GITHUB_RUN_OPENCODE_PASS_LEVEL" == "standard" ]]; then json_suffix=' - -OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: -{"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} -Use empty arrays when there are no items for a level. Keep all text in Chinese. - -Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' + OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: + {"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} + Use empty arrays when there are no items for a level. Keep all text in Chinese. + Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' else json_suffix=' - -OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: -{"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} -Use empty arrays when there are no items for a level. Keep all text in Chinese.' + OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: + {"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} + Use empty arrays when there are no items for a level. Keep all text in Chinese.' fi effective_prompt="${effective_prompt}${json_suffix}" elif [[ "$GITHUB_RUN_OPENCODE_PASS_LEVEL" == "standard" ]]; then standard_suffix=' - -Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' + Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' effective_prompt="${effective_prompt}${standard_suffix}" fi From e1cb052e0de83093c76bcd3d3cd8fb3e78eeefbd Mon Sep 17 00:00:00 2001 From: svtter Date: Fri, 15 May 2026 00:16:05 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix(review):=20address=20review=20suggestio?= =?UTF-8?q?ns=20=E2=80=94=20JSON=20enum=20notation,=20extract=5Fdecision?= =?UTF-8?q?=20robustness,=20examples=20fork=20ref?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change JSON schema decision enum from pipe-delimited string ("可合并|有条件合并|不可合并") to natural language ("可合并" or "有条件合并" or "不可合并") for better LLM comprehension - Add json.loads() fast-path in extract_decision() before falling back to incremental raw_decode scanning for improved robustness - Fix examples/opencode-review.yml to reference sun-praise/opencode-actions instead of personal fork --- examples/opencode-review.yml | 2 +- github-run-opencode/run-github-opencode.py | 6 ++++++ review/action.yml | 4 ++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/examples/opencode-review.yml b/examples/opencode-review.yml index 62a05ef..96ad7c2 100644 --- a/examples/opencode-review.yml +++ b/examples/opencode-review.yml @@ -20,7 +20,7 @@ jobs: ref: ${{ github.event.pull_request.head.ref }} - name: Run OpenCode review - uses: Svtter/opencode-actions/review@v2 + uses: sun-praise/opencode-actions/review@v2 with: github-token: ${{ secrets.GITHUB_TOKEN }} zhipu-api-key: ${{ secrets.ZHIPU_API_KEY }} diff --git a/github-run-opencode/run-github-opencode.py b/github-run-opencode/run-github-opencode.py index 8896e37..7268a53 100755 --- a/github-run-opencode/run-github-opencode.py +++ b/github-run-opencode/run-github-opencode.py @@ -95,6 +95,12 @@ def configure_opencode_json(reasoning_effort: str, enable_thinking: str, working def extract_decision(output_text: str, output_format: str) -> str: if output_format == "json": cleaned = re.sub(r"```(?:json)?\s*", "", output_text) + try: + obj = json.loads(cleaned) + if isinstance(obj, dict) and "decision" in obj: + return obj["decision"] + except json.JSONDecodeError: + pass decoder = json.JSONDecoder() pos = 0 while pos < len(cleaned): diff --git a/review/action.yml b/review/action.yml index c0d1506..611898c 100644 --- a/review/action.yml +++ b/review/action.yml @@ -289,13 +289,13 @@ runs: if [[ "$GITHUB_RUN_OPENCODE_PASS_LEVEL" == "standard" ]]; then json_suffix=' OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: - {"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} + {"decision": "可合并" or "有条件合并" or "不可合并", "summary": "brief summary", "blocking_items": ["item", ...], "warning_items": ["item", ...], "suggestion_items": ["item", ...]} Use empty arrays when there are no items for a level. Keep all text in Chinese. Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' else json_suffix=' OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: - {"decision":"可合并|有条件合并|不可合并","summary":"brief summary","blocking_items":["item",...],"warning_items":["item",...],"suggestion_items":["item",...]} + {"decision": "可合并" or "有条件合并" or "不可合并", "summary": "brief summary", "blocking_items": ["item", ...], "warning_items": ["item", ...], "suggestion_items": ["item", ...]} Use empty arrays when there are no items for a level. Keep all text in Chinese.' fi effective_prompt="${effective_prompt}${json_suffix}" From c3ed610add26ccd886bd26d8096b34964ac34076 Mon Sep 17 00:00:00 2001 From: svtter Date: Fri, 15 May 2026 00:18:15 +0800 Subject: [PATCH 6/9] fix: avoid redundant JSON re-parse when json.loads succeeds without decision key When json.loads successfully parses JSON but the resulting dict lacks a 'decision' key, return empty string immediately instead of falling through to the manual decoder which would re-parse the same text. --- github-run-opencode/run-github-opencode.py | 1 + 1 file changed, 1 insertion(+) diff --git a/github-run-opencode/run-github-opencode.py b/github-run-opencode/run-github-opencode.py index 7268a53..a32176b 100755 --- a/github-run-opencode/run-github-opencode.py +++ b/github-run-opencode/run-github-opencode.py @@ -99,6 +99,7 @@ def extract_decision(output_text: str, output_format: str) -> str: obj = json.loads(cleaned) if isinstance(obj, dict) and "decision" in obj: return obj["decision"] + return "" except json.JSONDecodeError: pass decoder = json.JSONDecoder() From fddf4e7c36533c6e2fdc099c0c621443a0da9bed Mon Sep 17 00:00:00 2001 From: svtter Date: Fri, 15 May 2026 00:23:26 +0800 Subject: [PATCH 7/9] test: add unit tests for extract_decision function Addresses medium-severity review feedback: adds TestExtractDecision class covering pure JSON, markdown fence, surrounding text, invalid JSON, missing decision field, array input, and text format decision extraction. --- tests/test_all.py | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_all.py b/tests/test_all.py index d63be4e..92ef138 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -613,6 +613,60 @@ def test_global_timeout_zero_disables_timeout(self): self.assertNotIn("TIMEOUT_DURATION", result.stdout) +class TestExtractDecision(unittest.TestCase): + """Tests for extract_decision function.""" + + @classmethod + def setUpClass(cls): + import importlib.util + + spec = importlib.util.spec_from_file_location( + "run_github_opencode", + REPO_ROOT / "github-run-opencode" / "run-github-opencode.py", + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + cls.extract_decision = staticmethod(mod.extract_decision) + + def test_json_pure(self): + self.assertEqual( + self.extract_decision('{"decision": "可合并", "summary": "ok"}', "json"), + "可合并", + ) + + def test_json_with_markdown_fence(self): + text = '```json\n{"decision": "不可合并", "summary": "bad"}\n```' + self.assertEqual(self.extract_decision(text, "json"), "不可合并") + + def test_json_with_surrounding_text(self): + text = 'Here is the review:\n{"decision": "有条件合并", "summary": "ok"}\nDone' + self.assertEqual(self.extract_decision(text, "json"), "有条件合并") + + def test_json_invalid(self): + self.assertEqual(self.extract_decision("not json at all", "json"), "") + + def test_json_missing_decision(self): + self.assertEqual(self.extract_decision('{"summary": "no decision field"}', "json"), "") + + def test_json_array_not_dict(self): + self.assertEqual(self.extract_decision('[{"decision": "可合并"}]', "json"), "") + + def test_text_可合并(self): + self.assertEqual(self.extract_decision("可合并\nother text", "text"), "可合并") + + def test_text_不可合并(self): + self.assertEqual(self.extract_decision("不可合并", "text"), "不可合并") + + def test_text_有条件合并(self): + self.assertEqual(self.extract_decision("有条件合并", "text"), "有条件合并") + + def test_text_no_decision(self): + self.assertEqual(self.extract_decision("nothing relevant here", "text"), "") + + def test_text_decision_with_prefix(self): + self.assertEqual(self.extract_decision("可合并 - everything looks good", "text"), "可合并") + + class TestReviewAction(unittest.TestCase): """Tests for review action metadata.""" From 66013b4712dc130615eee467d1f18bd71d09b899 Mon Sep 17 00:00:00 2001 From: svtter Date: Fri, 15 May 2026 00:26:23 +0800 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20address=20review=20suggestions=20?= =?UTF-8?q?=E2=80=94=20JSON=20schema=20placeholder,=20fast-path=20comment,?= =?UTF-8?q?=20whitespace=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace ["item", ...] with [{...}, ...] in JSON schema examples to match actual structured output contract (review/action.yml:292,298) - Add comment explaining json.loads fast path fallback design intent - Add test case for json.loads fast path with whitespace-surrounded JSON --- github-run-opencode/run-github-opencode.py | 1 + review/action.yml | 4 ++-- tests/test_all.py | 4 ++++ 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/github-run-opencode/run-github-opencode.py b/github-run-opencode/run-github-opencode.py index a32176b..a674da6 100755 --- a/github-run-opencode/run-github-opencode.py +++ b/github-run-opencode/run-github-opencode.py @@ -95,6 +95,7 @@ def configure_opencode_json(reasoning_effort: str, enable_thinking: str, working def extract_decision(output_text: str, output_format: str) -> str: if output_format == "json": cleaned = re.sub(r"```(?:json)?\s*", "", output_text) + # Fast path: try direct parse first; fall back to incremental decoder for text with surrounding content try: obj = json.loads(cleaned) if isinstance(obj, dict) and "decision" in obj: diff --git a/review/action.yml b/review/action.yml index 611898c..6c514b4 100644 --- a/review/action.yml +++ b/review/action.yml @@ -289,13 +289,13 @@ runs: if [[ "$GITHUB_RUN_OPENCODE_PASS_LEVEL" == "standard" ]]; then json_suffix=' OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: - {"decision": "可合并" or "有条件合并" or "不可合并", "summary": "brief summary", "blocking_items": ["item", ...], "warning_items": ["item", ...], "suggestion_items": ["item", ...]} + {"decision": "可合并" or "有条件合并" or "不可合并", "summary": "brief summary", "blocking_items": [{...}, ...], "warning_items": [{...}, ...], "suggestion_items": [{...}, ...]} Use empty arrays when there are no items for a level. Keep all text in Chinese. Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' else json_suffix=' OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: - {"decision": "可合并" or "有条件合并" or "不可合并", "summary": "brief summary", "blocking_items": ["item", ...], "warning_items": ["item", ...], "suggestion_items": ["item", ...]} + {"decision": "可合并" or "有条件合并" or "不可合并", "summary": "brief summary", "blocking_items": [{...}, ...], "warning_items": [{...}, ...], "suggestion_items": [{...}, ...]} Use empty arrays when there are no items for a level. Keep all text in Chinese.' fi effective_prompt="${effective_prompt}${json_suffix}" diff --git a/tests/test_all.py b/tests/test_all.py index 92ef138..fe64558 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -642,6 +642,10 @@ def test_json_with_surrounding_text(self): text = 'Here is the review:\n{"decision": "有条件合并", "summary": "ok"}\nDone' self.assertEqual(self.extract_decision(text, "json"), "有条件合并") + def test_json_fast_path_with_whitespace(self): + text = ' \n{"decision": "可合并", "summary": "ok"}\n ' + self.assertEqual(self.extract_decision(text, "json"), "可合并") + def test_json_invalid(self): self.assertEqual(self.extract_decision("not json at all", "json"), "") From df701e50f3ad038e4dd03dd934732a6b6c4e0ac7 Mon Sep 17 00:00:00 2001 From: svtter Date: Fri, 15 May 2026 00:30:10 +0800 Subject: [PATCH 9/9] fix: address review suggestions - explicit JSON schema, deduplicate, validate decision - Replace {...} JSON schema placeholders with explicit field definitions (file, line, description, severity, suggested_fix) to guide LLM output - Deduplicate JSON schema by extracting shared schema string into variable - Validate extract_decision returns only recognized decision values, returning empty string for unknown values - Add test case for unknown JSON decision value --- github-run-opencode/run-github-opencode.py | 11 ++++++++--- review/action.yml | 17 +++++++---------- tests/test_all.py | 3 +++ 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/github-run-opencode/run-github-opencode.py b/github-run-opencode/run-github-opencode.py index a674da6..61770db 100755 --- a/github-run-opencode/run-github-opencode.py +++ b/github-run-opencode/run-github-opencode.py @@ -99,7 +99,9 @@ def extract_decision(output_text: str, output_format: str) -> str: try: obj = json.loads(cleaned) if isinstance(obj, dict) and "decision" in obj: - return obj["decision"] + if obj["decision"] in ("可合并", "有条件合并", "不可合并"): + return obj["decision"] + return "" return "" except json.JSONDecodeError: pass @@ -112,8 +114,11 @@ def extract_decision(output_text: str, output_format: str) -> str: try: obj, end = decoder.raw_decode(cleaned, brace_idx) if isinstance(obj, dict) and "decision" in obj: - return obj["decision"] - pos = end + if obj["decision"] in ("可合并", "有条件合并", "不可合并"): + return obj["decision"] + pos = end + else: + pos = end except json.JSONDecodeError: pos = brace_idx + 1 return "" diff --git a/review/action.yml b/review/action.yml index 6c514b4..c39fa4e 100644 --- a/review/action.yml +++ b/review/action.yml @@ -286,17 +286,14 @@ runs: effective_prompt="${GITHUB_RUN_OPENCODE_PROMPT}" if [[ "$GITHUB_RUN_OPENCODE_OUTPUT_FORMAT" == "json" ]]; then + json_schema='{"decision": "可合并" or "有条件合并" or "不可合并", "summary": "brief summary", "blocking_items": [{"file":"...", "line":0, "description":"...", "severity":"high|medium|low", "suggested_fix":"..."}, ...], "warning_items": [{"file":"...", "line":0, "description":"...", "severity":"high|medium|low", "suggested_fix":"..."}, ...], "suggestion_items": [{"file":"...", "line":0, "description":"...", "severity":"high|medium|low", "suggested_fix":"..."}, ...]}' + json_suffix=' + OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: + '"${json_schema}"' + Use empty arrays when there are no items for a level. Keep all text in Chinese.' if [[ "$GITHUB_RUN_OPENCODE_PASS_LEVEL" == "standard" ]]; then - json_suffix=' - OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: - {"decision": "可合并" or "有条件合并" or "不可合并", "summary": "brief summary", "blocking_items": [{...}, ...], "warning_items": [{...}, ...], "suggestion_items": [{...}, ...]} - Use empty arrays when there are no items for a level. Keep all text in Chinese. - Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing.' - else - json_suffix=' - OVERRIDE: ignore the "Text output format" section above. Output ONLY a raw JSON object — no markdown fences, no commentary, no leading decision line. Schema: - {"decision": "可合并" or "有条件合并" or "不可合并", "summary": "brief summary", "blocking_items": [{...}, ...], "warning_items": [{...}, ...], "suggestion_items": [{...}, ...]} - Use empty arrays when there are no items for a level. Keep all text in Chinese.' + json_suffix="${json_suffix} + Pass-level: standard — only blocking items (阻塞项) prevent merge. 有条件合并 is considered passing." fi effective_prompt="${effective_prompt}${json_suffix}" elif [[ "$GITHUB_RUN_OPENCODE_PASS_LEVEL" == "standard" ]]; then diff --git a/tests/test_all.py b/tests/test_all.py index fe64558..a0342ca 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -667,6 +667,9 @@ def test_text_有条件合并(self): def test_text_no_decision(self): self.assertEqual(self.extract_decision("nothing relevant here", "text"), "") + def test_json_unknown_decision(self): + self.assertEqual(self.extract_decision('{"decision": "unknown"}', "json"), "") + def test_text_decision_with_prefix(self): self.assertEqual(self.extract_decision("可合并 - everything looks good", "text"), "可合并")