diff --git a/examples/opencode-review.yml b/examples/opencode-review.yml index 5a5d397..96ad7c2 100644 --- a/examples/opencode-review.yml +++ b/examples/opencode-review.yml @@ -20,11 +20,17 @@ 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 }} 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/github-run-opencode/run-github-opencode.py b/github-run-opencode/run-github-opencode.py index 4d0ce93..61770db 100755 --- a/github-run-opencode/run-github-opencode.py +++ b/github-run-opencode/run-github-opencode.py @@ -92,34 +92,112 @@ 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) + # 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: + if obj["decision"] in ("可合并", "有条件合并", "不可合并"): + return obj["decision"] + return "" + return "" + except json.JSONDecodeError: + pass + 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: + if obj["decision"] in ("可合并", "有条件合并", "不可合并"): + return obj["decision"] + pos = end + else: + pos = end + except json.JSONDecodeError: + pos = brace_idx + 1 + return "" + for line in output_text.split("\n"): + stripped = line.strip() + 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 "" + + +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 - 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() + 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: +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: - result = subprocess.run( - ["timeout", "--foreground", f"{timeout_sec}s", str(run_script)] - ) - else: - result = subprocess.run([str(run_script)]) + 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): + 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() + decision = extract_decision(output, output_format) + override = _apply_pass_level(decision, pass_level) + if override is not None: + return override + return result.returncode + + result = _run_subprocess(_build_cmd(run_script, timeout_sec)) return result.returncode diff --git a/review/action.yml b/review/action.yml index 4f40915..c39fa4e 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,43 @@ runs: printf 'python3 is required but not installed on this runner\n' >&2 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_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="${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 + 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}" + export GITHUB_RUN_OPENCODE_OUTPUT_FORMAT + export GITHUB_RUN_OPENCODE_PASS_LEVEL python3 ${{ github.action_path }}/../github-run-opencode/run-github-opencode.py diff --git a/tests/test_all.py b/tests/test_all.py index d63be4e..a0342ca 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -613,6 +613,67 @@ 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_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"), "") + + 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_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"), "可合并") + + class TestReviewAction(unittest.TestCase): """Tests for review action metadata."""