Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion examples/opencode-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
102 changes: 90 additions & 12 deletions github-run-opencode/run-github-opencode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
67 changes: 61 additions & 6 deletions review/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 }}
Expand All @@ -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
61 changes: 61 additions & 0 deletions tests/test_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand Down