diff --git a/.github/workflows/multi-review.yml b/.github/workflows/multi-review.yml
deleted file mode 100644
index 2194473..0000000
--- a/.github/workflows/multi-review.yml
+++ /dev/null
@@ -1,35 +0,0 @@
-name: Multi-Review
-
-on:
- pull_request:
- types: [opened, synchronize, reopened, ready_for_review]
-
-jobs:
- multi-review:
- if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository
- runs-on: ubuntu-latest
- permissions:
- contents: read
- pull-requests: write
- issues: write
- steps:
- - name: Checkout PR head
- uses: actions/checkout@v6
- with:
- repository: ${{ github.event.pull_request.head.repo.full_name }}
- ref: ${{ github.event.pull_request.head.ref }}
-
- - name: Configure git identity
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
-
- - name: Run multi-agent review
- uses: ./multi-review
- with:
- model: ${{ vars.MODEL_NAME }}
- default-team: "quality:1,security:1"
- github-token: ${{ secrets.GITHUB_TOKEN }}
- zhipu-api-key: ${{ secrets.ZHIPU_API_KEY }}
- deepseek-api-key: ${{ secrets.DEEPSEEK_API_KEY }}
- opencode-go-api-key: ${{ secrets.OPENCODE_GO_API_KEY }}
diff --git a/README.md b/README.md
index 0c48803..c9cc426 100644
--- a/README.md
+++ b/README.md
@@ -31,7 +31,6 @@ npx skills add sun-praise/opencode-actions
## What it includes
- `review`: opinionated PR review wrapper with built-in prompt and model defaults
-- `multi-review`: multi-agent parallel code review with coordinator synthesis — runs multiple reviewer personas (quality, security, etc.) in parallel, then synthesizes a unified report
- `architect-review`: architecture-level PR review focusing on coupling, layering, and structural concerns
- `feature-missing`: audits PR implementation against linked issue spec to find missing features
- `spec-coverage`: cross-references project spec/task files against PR implementation to find planned but unimplemented features
@@ -102,35 +101,6 @@ Use this alongside `review` to evaluate PR changes from an architecture perspect
opencode-go-api-key: ${{ secrets.OPENCODE_GO_API_KEY }}
```
-## multi-review
-
-Use this for multi-agent parallel code review with automatic synthesis. Runs multiple reviewer personas in parallel, then a coordinator agent synthesizes a unified report with cross-validation and deduplication.
-
-- built-in personas: quality, security, performance, architecture
-- parallel execution on a single runner via Python subprocess
-- coordinator synthesis with dedup, cross-validation, and conflict resolution
-- supports custom personas via YAML config file
-- reviewer redundancy (multiple instances of the same persona)
-
-```yaml
-- name: Run multi-agent review
- uses: sun-praise/opencode-actions/multi-review@v2
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- zhipu-api-key: ${{ secrets.ZHIPU_API_KEY }}
-```
-
-With custom team:
-
-```yaml
-- name: Run multi-agent review
- uses: sun-praise/opencode-actions/multi-review@v2
- with:
- default-team: "quality:1,security:1,performance:1"
- github-token: ${{ secrets.GITHUB_TOKEN }}
- zhipu-api-key: ${{ secrets.ZHIPU_API_KEY }}
-```
-
## feature-missing
Use this alongside `review` to audit whether a PR's implementation covers all requirements from the linked issue spec.
@@ -177,7 +147,6 @@ Unlike `feature-missing` (which checks PR self-described scope), `spec-coverage`
| Action | Scope source | What it catches |
| --- | --- | --- |
| `review` | PR diff | Code quality, security, bugs |
-| `multi-review` | PR diff (multi-agent) | Quality, security, performance, architecture in parallel |
| `architect-review` | PR diff + project conventions | Coupling, layering, module placement, structural concerns |
| `feature-missing` | PR title/body + linked issues | PR self-described scope completeness |
| `spec-coverage` | Project spec/task files | Full planned scope vs implementation |
@@ -233,7 +202,6 @@ Public consumers should reference the subdirectory action path:
```yaml
uses: sun-praise/opencode-actions/review@v2
-uses: sun-praise/opencode-actions/multi-review@v2
uses: sun-praise/opencode-actions/architect-review@v2
uses: sun-praise/opencode-actions/feature-missing@v2
uses: sun-praise/opencode-actions/spec-coverage@v2
diff --git a/multi-review/README.md b/multi-review/README.md
deleted file mode 100644
index 3e3e10f..0000000
--- a/multi-review/README.md
+++ /dev/null
@@ -1,127 +0,0 @@
-# OpenCode Multi-Review Action
-
-Multi-agent parallel code review with coordinator synthesis. Runs multiple reviewer personas in parallel, then synthesizes a unified report.
-
-## Quick Start
-
-```yaml
-- uses: sun-praise/opencode-actions/multi-review@v1
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- zhipu-api-key: ${{ secrets.ZHIPU_API_KEY }}
-```
-
-This runs 2 reviewers in parallel (quality + security) and a coordinator that synthesizes the final review.
-
-## How It Works
-
-```
- ┌─────────────────┐
- │ Reviewer #1 │ quality
- ├─────────────────┤
- │ Reviewer #2 │ security
- └────────┬────────┘
- │
- ┌────────▼────────┐
- │ Coordinator │ synthesis + debate
- └─────────────────┘
-```
-
-1. **Parallel Reviewers**: Each reviewer runs an independent `opencode github run` with a domain-specific prompt (quality, security, performance, architecture). Reviewers run in parallel on the same runner.
-
-2. **Coordinator Synthesis**: After all reviewers complete, a coordinator agent receives all outputs, deduplicates findings, resolves conflicts, and produces a unified report with cross-validation markers.
-
-3. **PR Comment**: The final output is posted as a single PR comment with the coordinator's synthesis plus collapsible sections for each reviewer's raw output.
-
-## Configuration
-
-### Built-in Reviewer Personas
-
-| Persona | Focus |
-|---------|-------|
-| `quality` | Code quality, bugs, logic errors, style |
-| `security` | Input validation, injection, OWASP Top 10 |
-| `performance` | Algorithm complexity, memory, I/O |
-| `architecture` | Coupling, layering, module placement |
-
-### Default Team
-
-Without configuration, the default team is `quality:1,security:1`.
-
-### Custom Team via `default-team`
-
-```yaml
-- uses: sun-praise/opencode-actions/multi-review@v1
- with:
- default-team: "quality:1,security:1,performance:1"
-```
-
-Format: `persona:count,...` where `count` is the number of redundant instances.
-
-### Custom Reviewer Config File
-
-Create a YAML file (e.g. `.github/reviewers.yaml`):
-
-```yaml
-# Override or add custom personas
-personas:
- - name: api-design
- prompt: |
- Review this pull request for API design quality...
- (same format as built-in prompts)
-
-# Define the reviewer team
-reviewers:
- - name: quality
- count: 2 # 2 instances for redundancy
- - name: security
- count: 1
- - name: api-design
- count: 1 # uses the custom persona above
-```
-
-Then reference it:
-
-```yaml
-- uses: sun-praise/opencode-actions/multi-review@v1
- with:
- reviewer-config: .github/reviewers.yaml
-```
-
-Custom personas override built-in personas with the same name.
-
-## Inputs
-
-| Input | Default | Description |
-|-------|---------|-------------|
-| `timeout-seconds` | `900` | Global timeout for the entire process |
-| `coordinator-timeout-seconds` | `300` | Coordinator agent timeout |
-| `model` | `zhipuai-coding-plan/glm-5.1` | Model for all agents |
-| `fallback-models` | `""` | Comma-separated fallback models |
-| `model-timeout-seconds` | `300` | Per-model timeout before rotating to fallback |
-| `fallback-on-regex` | `timed out\|timeout\|...` | Rotate to next fallback model when output matches this regex |
-| `default-team` | `""` | Team definition string |
-| `reviewer-config` | `""` | Path to custom YAML config |
-| `coordinator-prompt` | `""` | Custom coordinator prompt template |
-| `attempts` | `3` | Total attempts per reviewer |
-| `retry-profile` | `github-network` | Built-in retry preset |
-| `retry-delay-seconds` | `15` | Base delay between retries |
-| `reasoning-effort` | `max` | Reasoning effort level |
-| `enable-thinking` | `true` | Enable thinking mode |
-| `use-github-token` | `true` | Whether to use GitHub token for PR access |
-| `github-token` | `""` | GitHub token |
-| `zhipu-api-key` | `""` | ZhipuAI API key |
-| `deepseek-api-key` | `""` | DeepSeek API key |
-| `opencode-go-api-key` | `""` | OpenCode Go API key |
-| `extra-env` | `""` | Extra KEY=VALUE environment variables |
-| `cleanup-error-comments` | `true` | Auto-delete error comments from failed runs |
-
-## Cost Consideration
-
-Running N reviewers + 1 coordinator means approximately (N+1)x the token cost of a single review. Default N=2 means ~3x cost.
-
-## Requirements
-
-- Linux runner
-- Python 3 (pre-installed on GitHub-hosted runners)
-- OpenCode CLI (installed automatically by the action)
diff --git a/multi-review/action.yml b/multi-review/action.yml
deleted file mode 100644
index 5a88e94..0000000
--- a/multi-review/action.yml
+++ /dev/null
@@ -1,237 +0,0 @@
-name: OpenCode Multi-Review
-description: Multi-agent parallel code review with coordinator synthesis. Runs multiple reviewer personas in parallel, then synthesizes a unified report.
-
-inputs:
- install-url:
- description: Installer URL used to bootstrap OpenCode.
- required: false
- default: https://opencode.ai/install
- install-dir:
- description: Directory where the opencode binary should be installed.
- required: false
- default: ""
- xdg-cache-home:
- description: Dedicated XDG cache directory for OpenCode.
- required: false
- default: ""
- cache:
- description: Cache the install and XDG cache directories with actions/cache.
- required: false
- default: "true"
- cache-key:
- description: Cache key suffix used to invalidate installer-based caches.
- required: false
- default: v1
- install-attempts:
- description: Number of installer retry attempts.
- required: false
- default: "3"
- allow-preinstalled:
- description: Reuse an existing opencode found on PATH instead of forcing installer bootstrap.
- required: false
- default: "false"
- version:
- description: Minimum required OpenCode version (semver). Defaults to the version in setup-opencode/default-version. Use "none" to disable version checking.
- required: false
- default: ""
- working-directory:
- description: Optional working directory before running opencode.
- required: false
- default: ""
- attempts:
- description: Total number of attempts before failing for each reviewer.
- required: false
- default: "3"
- retry-profile:
- description: Built-in retry profile, defaults to github-network.
- required: false
- default: github-network
- retry-delay-seconds:
- description: Base delay between retries in seconds.
- required: false
- default: "15"
- timeout-seconds:
- description: Global timeout for the entire multi-review process in seconds.
- required: false
- default: "900"
- coordinator-timeout-seconds:
- description: Timeout for the coordinator agent in seconds.
- required: false
- default: "300"
- model:
- description: Default model for all reviewers and coordinator.
- required: false
- default: ""
- fallback-models:
- description: Comma- or newline-delimited fallback models tried after timeout.
- required: false
- default: ""
- model-timeout-seconds:
- description: Per-model timeout before rotating to fallback. Set 0 to disable.
- required: false
- default: "300"
- fallback-on-regex:
- description: Rotate to 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|ProviderModelNotFoundError
- reviewer-config:
- description: Path to a YAML file defining custom reviewer personas and team composition.
- required: false
- default: ""
- default-team:
- description: 'Comma-separated team definition, e.g. "quality:1,security:1,performance:1". Ignored when reviewer-config is set.'
- required: false
- default: ""
- coordinator-prompt:
- description: Custom prompt template for the coordinator agent. Use {{REVIEWS}} as placeholder for reviewer outputs.
- required: false
- default: ""
- use-github-token:
- description: Value exported as USE_GITHUB_TOKEN.
- required: false
- default: "true"
- github-token:
- description: Value exported as GITHUB_TOKEN.
- required: false
- default: ""
- zhipu-api-key:
- description: Value exported as ZHIPU_API_KEY.
- required: false
- default: ""
- opencode-go-api-key:
- description: Value exported as OPENCODE_API_KEY.
- required: false
- default: ""
- deepseek-api-key:
- description: Value exported as DEEPSEEK_API_KEY.
- required: false
- default: ""
- reasoning-effort:
- description: Reasoning effort level for the model agent. Allowed values are low, medium, high, max.
- required: false
- default: "max"
- enable-thinking:
- description: Enable thinking mode for the model agent.
- required: false
- default: "true"
- extra-env:
- description: >
- Extra environment variables to pass to opencode runtime.
- Multi-line KEY=VALUE pairs, one per line. Empty lines and lines
- starting with '#' are ignored.
- required: false
- default: ""
- cleanup-error-comments:
- description: When true, automatically delete error comments posted to the PR.
- required: false
- default: "true"
-
-runs:
- using: composite
- steps:
- - if: ${{ runner.os != 'Linux' }}
- shell: bash
- run: |
- set -euo pipefail
- printf 'multi-review currently supports Linux runners only\n' >&2
- exit 1
-
- - id: version
- shell: bash
- run: |
- set -euo pipefail
- effective="${{ inputs.version }}"
- if [[ "$effective" == "none" ]]; then
- effective=""
- elif [[ -z "$effective" ]]; then
- default_file="${{ github.action_path }}/../setup-opencode/default-version"
- if [[ ! -f "$default_file" ]]; then
- printf 'error: default-version file not found at %s\n' "$default_file" >&2
- exit 1
- fi
- effective="$(tr -d '[:space:]' < "$default_file")"
- if [[ ! "$effective" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
- printf 'error: invalid version in %s: %s\n' "$default_file" "$(cat "$default_file")" >&2
- exit 1
- fi
- fi
- printf 'version=%s\n' "$effective" >>"$GITHUB_OUTPUT"
-
- - id: paths
- shell: bash
- env:
- INPUT_INSTALL_DIR: ${{ inputs.install-dir }}
- INPUT_XDG_CACHE_HOME: ${{ inputs.xdg-cache-home }}
- run: |
- set -euo pipefail
- install_dir="$INPUT_INSTALL_DIR"
- xdg_cache_home="$INPUT_XDG_CACHE_HOME"
- if [[ -z "$install_dir" ]]; then
- install_dir="${RUNNER_TOOL_CACHE:-$HOME/.cache}/opencode/bin"
- fi
- if [[ -z "$xdg_cache_home" ]]; then
- xdg_cache_home="${RUNNER_TOOL_CACHE:-$HOME/.cache}/opencode/cache"
- fi
- printf 'install_dir=%s\n' "$install_dir" >>"$GITHUB_OUTPUT"
- printf 'xdg_cache_home=%s\n' "$xdg_cache_home" >>"$GITHUB_OUTPUT"
-
- - id: key
- shell: bash
- env:
- INPUT_INSTALL_URL: ${{ inputs.install-url }}
- run: |
- set -euo pipefail
- install_url_hash="$(printf '%s' "$INPUT_INSTALL_URL" | sha256sum | cut -d' ' -f1)"
- printf 'install_url_hash=%s\n' "$install_url_hash" >>"$GITHUB_OUTPUT"
-
- - id: cache
- if: ${{ inputs.cache == 'true' }}
- uses: actions/cache@v5
- with:
- path: |
- ${{ steps.paths.outputs.install_dir }}
- ${{ steps.paths.outputs.xdg_cache_home }}
- key: multi-review-opencode-${{ runner.os }}-${{ runner.arch }}-${{ steps.key.outputs.install_url_hash }}-${{ steps.version.outputs.version }}-${{ inputs.cache-key }}
-
- - shell: bash
- env:
- OPENCODE_INSTALL_DIR: ${{ steps.paths.outputs.install_dir }}
- XDG_CACHE_HOME: ${{ steps.paths.outputs.xdg_cache_home }}
- OPENCODE_INSTALL_URL: ${{ inputs.install-url }}
- OPENCODE_INSTALL_ATTEMPTS: ${{ inputs.install-attempts }}
- OPENCODE_ALLOW_PREINSTALLED: ${{ inputs.allow-preinstalled }}
- OPENCODE_MIN_VERSION: ${{ steps.version.outputs.version }}
- run: ${{ github.action_path }}/../setup-opencode/install-opencode.sh
-
- - shell: bash
- env:
- MULTI_REVIEW_CONFIG: ${{ inputs.reviewer-config }}
- MULTI_REVIEW_DEFAULT_TEAM: ${{ inputs.default-team }}
- MULTI_REVIEW_TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }}
- MULTI_REVIEW_MODEL_TIMEOUT_SECONDS: ${{ inputs.model-timeout-seconds }}
- MULTI_REVIEW_COORDINATOR_TIMEOUT_SECONDS: ${{ inputs.coordinator-timeout-seconds }}
- MULTI_REVIEW_WORKING_DIRECTORY: ${{ inputs.working-directory }}
- MULTI_REVIEW_ATTEMPTS: ${{ inputs.attempts }}
- MULTI_REVIEW_RETRY_PROFILE: ${{ inputs.retry-profile }}
- MULTI_REVIEW_RETRY_DELAY_SECONDS: ${{ inputs.retry-delay-seconds }}
- MULTI_REVIEW_MODEL: ${{ inputs.model }}
- MULTI_REVIEW_FALLBACK_MODELS: ${{ inputs.fallback-models }}
- MULTI_REVIEW_FALLBACK_ON_REGEX: ${{ inputs.fallback-on-regex }}
- MULTI_REVIEW_COORDINATOR_PROMPT: ${{ inputs.coordinator-prompt }}
- MULTI_REVIEW_USE_GITHUB_TOKEN: ${{ inputs.use-github-token }}
- MULTI_REVIEW_GITHUB_TOKEN: ${{ inputs.github-token }}
- MULTI_REVIEW_ZHIPU_API_KEY: ${{ inputs.zhipu-api-key }}
- MULTI_REVIEW_OPENCODE_GO_API_KEY: ${{ inputs.opencode-go-api-key }}
- MULTI_REVIEW_DEEPSEEK_API_KEY: ${{ inputs.deepseek-api-key }}
- MULTI_REVIEW_EXTRA_ENV: ${{ inputs.extra-env }}
- MULTI_REVIEW_REASONING_EFFORT: ${{ inputs.reasoning-effort }}
- MULTI_REVIEW_ENABLE_THINKING: ${{ inputs.enable-thinking }}
- MULTI_REVIEW_PERMISSION: >-
- {"edit":"deny","bash":{"git commit":"deny","git commit *":"deny","git push":"deny","git push *":"deny","git add":"deny","git add *":"deny","git stash":"deny","git stash *":"deny","git reset":"deny","git reset *":"deny","git checkout":"deny","git checkout *":"deny"}}
- MULTI_REVIEW_CLEANUP_ERROR_COMMENTS: ${{ inputs.cleanup-error-comments }}
- run: |
- if ! command -v python3 >/dev/null 2>&1; then
- printf 'python3 is required but not installed on this runner\n' >&2
- exit 1
- fi
- python3 ${{ github.action_path }}/run-multi-review.py
diff --git a/multi-review/reviewers/architecture.yaml b/multi-review/reviewers/architecture.yaml
deleted file mode 100644
index 08cbf27..0000000
--- a/multi-review/reviewers/architecture.yaml
+++ /dev/null
@@ -1,33 +0,0 @@
-name: architecture
-prompt: |
- Review this pull request from an architecture perspective (read-only mode, DO NOT modify any code).
-
- First, read AGENTS.md (or CLAUDE.md) if it exists in the repository root to understand the project's architecture conventions.
-
- Then analyze the PR changes for:
- - Coupling: unnecessary dependencies between modules
- - Module placement: correct location and naming
- - Layering: separation of concerns, mixing of domains
- - Interface design: appropriate abstraction levels
- - Shotgun surgery: scattered changes vs localized modifications
- - Consistency: alignment with existing architectural patterns
-
- Please respond in Chinese. DO NOT modify any code, only provide review comments.
- The first line of your response must be exactly one of:
- - 架构合理
- - 架构有疑虑
- - 架构有问题
-
- Decision rules:
- - Use "架构合理" when the change follows existing architecture patterns.
- - Use "架构有疑虑" when there are non-blocking architecture concerns.
- - Use "架构有问题" when serious architectural violations should block merge.
-
- Review the latest PR HEAD only.
- Do not repeat issues from earlier revisions unless they are still reproducible in the current files.
-
- Output format:
- - First line: the decision only
- - Then a short summary of architecture analysis
- - Then "阻塞项" listing architecture issues that must block merge; if none, write "阻塞项:无"
- - Then "建议项" listing non-blocking architecture improvements; if none, write "建议项:无"
diff --git a/multi-review/reviewers/performance.yaml b/multi-review/reviewers/performance.yaml
deleted file mode 100644
index c105c14..0000000
--- a/multi-review/reviewers/performance.yaml
+++ /dev/null
@@ -1,32 +0,0 @@
-name: performance
-prompt: |
- Review this pull request for performance concerns (read-only mode, DO NOT modify any code).
-
- Please check:
- - Algorithm complexity and efficiency
- - Memory allocation patterns and potential leaks
- - Database query efficiency (N+1 queries, missing indexes)
- - Caching opportunities
- - Unnecessary computations or redundant operations
- - Concurrency and threading issues
- - Resource utilization (CPU, memory, network, I/O)
-
- Please respond in Chinese. DO NOT modify any code, only provide review comments.
- The first line of your response must be exactly one of:
- - 性能良好
- - 性能有疑虑
- - 性能问题严重
-
- Decision rules:
- - Use "性能良好" when no performance concerns are found.
- - Use "性能有疑虑" when non-critical performance issues are found.
- - Use "性能问题严重" when critical performance regressions are found that should block merge.
-
- Review the latest PR HEAD only.
- Do not repeat issues from earlier revisions unless they are still reproducible in the current files.
-
- Output format:
- - First line: the decision only
- - Then a short summary of performance analysis
- - Then "阻塞项" listing performance issues that must block merge; if none, write "阻塞项:无"
- - Then "建议项" listing non-blocking performance improvements; if none, write "建议项:无"
diff --git a/multi-review/reviewers/quality.yaml b/multi-review/reviewers/quality.yaml
deleted file mode 100644
index 1572082..0000000
--- a/multi-review/reviewers/quality.yaml
+++ /dev/null
@@ -1,30 +0,0 @@
-name: quality
-prompt: |
- Review this pull request for code quality (read-only mode, DO NOT modify any code).
-
- Please check:
- - Code quality issues
- - Potential bugs or logic errors
- - Code style consistency
- - Error handling completeness
-
- Please respond in Chinese. DO NOT modify any code, only provide review comments.
- The first line of your response must be exactly one of:
- - 可合并
- - 有条件合并
- - 不可合并
-
- 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.
-
- 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:
- - 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 "建议项:无"
diff --git a/multi-review/reviewers/security.yaml b/multi-review/reviewers/security.yaml
deleted file mode 100644
index 620d6c6..0000000
--- a/multi-review/reviewers/security.yaml
+++ /dev/null
@@ -1,31 +0,0 @@
-name: security
-prompt: |
- Review this pull request for security concerns (read-only mode, DO NOT modify any code).
-
- Please check:
- - Input validation and sanitization
- - Authentication and authorization issues
- - Injection vulnerabilities (SQL, XSS, command injection)
- - Sensitive data exposure (secrets, tokens, PII)
- - Insecure dependencies or APIs
- - OWASP Top 10 compliance
-
- Please respond in Chinese. DO NOT modify any code, only provide review comments.
- The first line of your response must be exactly one of:
- - 安全无虞
- - 存在风险
- - 高危漏洞
-
- Decision rules:
- - Use "安全无虞" when no security concerns are found.
- - Use "存在风险" when non-critical security issues are found that should be addressed.
- - Use "高危漏洞" when critical security vulnerabilities are found that must block merge.
-
- Review the latest PR HEAD only.
- Do not repeat issues from earlier revisions unless they are still reproducible in the current files.
-
- Output format:
- - First line: the decision only
- - Then a short summary of security analysis
- - Then "阻塞项" listing security issues that must block merge; if none, write "阻塞项:无"
- - Then "建议项" listing non-blocking security improvements; if none, write "建议项:无"
diff --git a/multi-review/run-multi-review.py b/multi-review/run-multi-review.py
deleted file mode 100644
index 283c0ae..0000000
--- a/multi-review/run-multi-review.py
+++ /dev/null
@@ -1,695 +0,0 @@
-#!/usr/bin/env python3
-"""Multi-agent review orchestrator.
-
-Launches multiple reviewer agents in parallel, collects their outputs,
-then runs a coordinator agent to synthesize a final review report.
-"""
-
-import json
-import os
-import re
-import shutil
-import subprocess
-import sys
-import tempfile
-import time
-from concurrent.futures import ThreadPoolExecutor, as_completed
-from pathlib import Path
-from typing import Any
-
-try:
- import yaml
-except ImportError:
- yaml = None
-
-SCRIPT_DIR = Path(__file__).resolve().parent
-BUILTIN_REVIEWERS_DIR = SCRIPT_DIR / "reviewers"
-
-
-def get_env(name: str, default: str = "") -> str:
- return os.environ.get(name, default)
-
-
-def set_env(name: str, value: str) -> None:
- if value:
- os.environ[name] = value
-
-
-def load_builtin_persona(name: str) -> dict[str, Any] | None:
- path = BUILTIN_REVIEWERS_DIR / f"{name}.yaml"
- if not path.exists():
- return None
- return _load_persona_file(path)
-
-
-def _load_persona_file(path: Path) -> dict[str, Any] | None:
- if yaml:
- with open(path) as f:
- data = yaml.safe_load(f)
- else:
- data = _parse_simple_yaml(path)
- if data and "prompt" in data:
- return data
- return None
-
-
-def _parse_simple_yaml(path: Path) -> dict[str, Any] | None:
- """Minimal YAML parser for persona files (no PyYAML dependency)."""
- try:
- text = path.read_text(encoding="utf-8")
- except OSError:
- return None
-
- def _flush(result, key, lines):
- if key and lines:
- result[key] = "\n".join(lines).strip()
-
- result: dict[str, Any] = {}
- current_key = None
- current_lines: list[str] = []
-
- for line in text.splitlines():
- stripped = line.strip()
- if not stripped:
- continue
- if line.startswith("name:"):
- _flush(result, current_key, current_lines)
- current_key = "name"
- current_lines = [stripped[len("name:"):].strip().strip('"').strip("'")]
- elif line.startswith("prompt:"):
- _flush(result, current_key, current_lines)
- current_key = "prompt"
- current_lines = []
- elif line.startswith(" ") or line.startswith("\t"):
- current_lines.append(line[2:] if line.startswith(" ") else line[1:])
- else:
- _flush(result, current_key, current_lines)
- current_key = None
- current_lines = []
-
- _flush(result, current_key, current_lines)
- return result if result else None
-
-
-def resolve_reviewers(
- config_path: str | None,
- default_team: str | None,
-) -> list[dict[str, Any]]:
- """Resolve the full reviewer team from config file and defaults."""
- personas: dict[str, dict[str, Any]] = {}
-
- # Load built-in personas
- for persona_file in BUILTIN_REVIEWERS_DIR.glob("*.yaml"):
- data = _load_persona_file(persona_file)
- if data and data.get("name"):
- personas[data["name"]] = data
-
- # Load custom config (overrides built-in)
- custom_reviewers: list[dict[str, Any]] = []
- if config_path:
- custom_path = Path(config_path)
- if not custom_path.exists():
- print(f"Reviewer config file not found: {config_path}", file=sys.stderr)
- sys.exit(1)
- if yaml:
- with open(custom_path) as f:
- custom_data = yaml.safe_load(f) or {}
- else:
- custom_data = _parse_simple_yaml(custom_path) or {}
-
- # Load custom persona definitions
- custom_personas = custom_data.get("personas", [])
- for p in custom_personas:
- if isinstance(p, dict) and p.get("name") and p.get("prompt"):
- personas[p["name"]] = p
-
- # Load reviewer team from custom config
- custom_reviewers = custom_data.get("reviewers", [])
-
- # Parse default_team string (e.g. "quality:1,security:1")
- team: list[dict[str, Any]] = []
- if custom_reviewers:
- for r in custom_reviewers:
- if isinstance(r, dict) and r.get("name"):
- team.append(r)
- elif default_team:
- team = _parse_team_string(default_team, personas)
- else:
- # Default: quality + security
- team = [
- {"name": "quality", "count": 1},
- {"name": "security", "count": 1},
- ]
-
- # Resolve each team entry into concrete reviewer instances
- instances: list[dict[str, Any]] = []
- for entry in team:
- name = entry.get("name", "")
- count = int(entry.get("count", 1))
- persona = personas.get(name)
- if not persona:
- print(f"Unknown reviewer persona: {name}", file=sys.stderr)
- continue
- for i in range(count):
- label = f"{name}-{i+1}" if count > 1 else name
- instances.append({
- "name": label,
- "persona": name,
- "prompt": persona.get("prompt", ""),
- })
-
- if not instances:
- print("No reviewers resolved. Check your configuration.", file=sys.stderr)
- sys.exit(1)
-
- return instances
-
-
-def _parse_team_string(team_str: str, personas: dict) -> list[dict[str, Any]]:
- """Parse 'quality:1,security:1,performance:2' format."""
- team: list[dict[str, Any]] = []
- for part in team_str.split(","):
- part = part.strip()
- if not part:
- continue
- if ":" in part:
- name, count_str = part.rsplit(":", 1)
- name = name.strip()
- try:
- count = int(count_str.strip())
- except ValueError:
- count = 1
- else:
- name = part
- count = 1
- if not personas.get(name):
- print(f"Warning: unknown persona '{name}', skipping", file=sys.stderr)
- continue
- team.append({"name": name, "count": count})
- return team
-
-
-def _run_opencode(prompt: str, model: str, timeout: int, cache_dir: str | None = None) -> tuple[int, str]:
- """Run opencode github run directly. Returns (returncode, stdout).
-
- Each invocation gets its own git clone to prevent lock file race
- conditions between parallel reviewers sharing the same .git directory.
- """
- env = os.environ.copy()
- env["MODEL"] = model
- env["PROMPT"] = prompt
- env["USE_GITHUB_TOKEN"] = "true"
-
- if cache_dir:
- env["XDG_CACHE_HOME"] = cache_dir
-
- # Clone the repo into a temp dir so each reviewer has a fully isolated .git
- clone_dir = tempfile.mkdtemp(prefix="opencode-clone-")
- try:
- clone_rc = subprocess.run(
- ["git", "clone", "--no-local", ".", clone_dir],
- capture_output=True, check=False,
- )
- if clone_rc.returncode != 0:
- print(f"[git clone] failed (rc={clone_rc.returncode}): {clone_rc.stderr.decode(errors='replace')[:300]}", file=sys.stderr)
- shutil.rmtree(clone_dir, ignore_errors=True)
- # Fallback: run in current directory without isolation
- return _run_opencode_raw(env, timeout)
- return _run_opencode_in_dir(clone_dir, env, timeout)
- finally:
- shutil.rmtree(clone_dir, ignore_errors=True)
-
-
-def _run_opencode_raw(env: dict[str, str], timeout: int) -> tuple[int, str]:
- """Execute opencode CLI in the current working directory."""
- opencode_bin = env.get("OPENCODE_BIN_PATH", "opencode")
- cmd = [opencode_bin, "github", "run", "--print-logs", "--log-level", "ERROR"]
-
- if timeout > 0:
- cmd = ["timeout", "--foreground", f"{timeout}s"] + cmd
- sub_timeout = timeout + 30
- else:
- sub_timeout = None
-
- result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, timeout=sub_timeout)
- stderr_output = result.stderr.decode("utf-8", errors="replace")
- if stderr_output.strip():
- print(f"[opencode stderr] {stderr_output[:500]}", file=sys.stderr)
- return result.returncode, result.stdout.decode("utf-8", errors="replace")
-
-
-def _run_opencode_in_dir(work_dir: str, env: dict[str, str], timeout: int) -> tuple[int, str]:
- """Execute opencode CLI in the given directory."""
- opencode_bin = env.get("OPENCODE_BIN_PATH", "opencode")
- cmd = [opencode_bin, "github", "run", "--print-logs", "--log-level", "ERROR"]
-
- if timeout > 0:
- cmd = ["timeout", "--foreground", f"{timeout}s"] + cmd
- sub_timeout = timeout + 30
- else:
- sub_timeout = None
-
- result = subprocess.run(cmd, cwd=work_dir, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, timeout=sub_timeout)
- stderr_output = result.stderr.decode("utf-8", errors="replace")
- if stderr_output.strip():
- print(f"[opencode stderr] {stderr_output[:500]}", file=sys.stderr)
- return result.returncode, result.stdout.decode("utf-8", errors="replace")
-
-
-def run_reviewer(
- reviewer: dict[str, Any],
- global_deadline: float | None,
- model_timeout: int,
- fallback_models: list[str],
- fallback_on_regex: str,
-) -> dict[str, Any]:
- """Run a single reviewer agent with model fallback, return result dict."""
- name = reviewer["name"]
- prompt = reviewer["prompt"]
-
- model = os.environ.get("MODEL", "zhipuai-coding-plan/glm-5.1")
- candidates = [m for m in [model] + fallback_models if _supports_model(m)]
-
- if not candidates:
- return {"name": name, "status": "error", "output": "No eligible models available"}
-
- # Isolate XDG cache to prevent SQLite conflicts between parallel reviewers
- cache_dir = tempfile.mkdtemp(prefix=f"opencode-review-{name}-")
- try:
- return _run_reviewer_inner(name, prompt, candidates, cache_dir,
- global_deadline, model_timeout, fallback_on_regex)
- finally:
- shutil.rmtree(cache_dir, ignore_errors=True)
-
-
-def _run_reviewer_inner(
- name: str,
- prompt: str,
- candidates: list[str],
- cache_dir: str,
- global_deadline: float | None,
- model_timeout: int,
- fallback_on_regex: str,
-) -> dict[str, Any]:
- outputs: list[str] = []
- for m in candidates:
- if global_deadline:
- remaining = max(0, global_deadline - time.time())
- if remaining <= 0:
- return {"name": name, "status": "timeout", "output": "Global timeout exceeded"}
- effective_timeout = min(model_timeout, int(remaining)) if model_timeout > 0 else int(remaining)
- elif model_timeout > 0:
- effective_timeout = model_timeout
- else:
- effective_timeout = 0
-
- try:
- rc, output = _run_opencode(prompt, m, effective_timeout, cache_dir=cache_dir)
-
- if rc == 0:
- return {"name": name, "status": "success", "output": output}
-
- if rc == 124:
- print(f"Reviewer {name} model {m} timed out after {effective_timeout}s", file=sys.stderr)
- outputs.append(output)
- continue
-
- if fallback_on_regex and re.search(fallback_on_regex, output, re.IGNORECASE):
- print(f"Reviewer {name} model {m} matched fallback regex, trying next", file=sys.stderr)
- outputs.append(output)
- continue
-
- return {"name": name, "status": "error", "output": output, "returncode": rc}
-
- except subprocess.TimeoutExpired:
- print(f"Reviewer {name} model {m} process killed (timeout)", file=sys.stderr)
- continue
- except Exception as e:
- print(f"Reviewer {name} model {m} failed: {e}", file=sys.stderr)
- continue
-
- return {"name": name, "status": "error", "output": "\n".join(outputs) if outputs else "All models failed"}
-
-
-def _supports_model(model: str) -> bool:
- if model.startswith("zhipuai"):
- return bool(os.environ.get("ZHIPU_API_KEY"))
- if model.startswith("opencode-go"):
- return bool(os.environ.get("OPENCODE_API_KEY"))
- if model.startswith("deepseek"):
- return bool(os.environ.get("DEEPSEEK_API_KEY"))
- return True
-
-
-def run_coordinator(
- reviewer_results: list[dict[str, Any]],
- timeout: int,
- coordinator_prompt_template: str | None,
-) -> str | None:
- """Run the coordinator agent to synthesize all reviewer outputs."""
- reviews_text = "\n".join(
- f"\n--- Reviewer: {r['name']} (status: {r['status']}) ---\n{_strip_ansi(r.get('output', ''))}\n"
- for r in reviewer_results
- )
-
- if coordinator_prompt_template:
- prompt = coordinator_prompt_template.replace("{{REVIEWS}}", reviews_text)
- else:
- prompt = _default_coordinator_prompt(reviews_text)
-
- try:
- rc, output = _run_opencode(prompt, os.environ.get("MODEL", ""), timeout)
- if rc == 0:
- return output
- print(f"Coordinator failed (exit {rc}): {output[:500]}", file=sys.stderr)
- return None
- except subprocess.TimeoutExpired:
- print("Coordinator timed out", file=sys.stderr)
- return None
- except Exception as e:
- print(f"Coordinator failed: {e}", file=sys.stderr)
- return None
-
-
-def _default_coordinator_prompt(reviews_text: str) -> str:
- return f"""You are a code review coordinator. Synthesize the following reviewer reports into a single unified review.
-
-Rules:
-1. Deduplicate findings across reviewers. If multiple reviewers report the same issue, mark it as "多 reviewer 确认".
-2. Resolve conflicts: if reviewers disagree, use your judgment and note the disagreement.
-3. Keep only genuine issues, discard false positives.
-4. Categorize findings into "阻塞项" (blocking) and "建议项" (suggestions).
-5. Include the source reviewer name for each finding.
-
-{reviews_text}
-
-Please respond in Chinese. DO NOT modify any code.
-The first line of your response must be exactly one of:
-- 可合并
-- 有条件合并
-- 不可合并
-
-Output format:
-- First line: the final decision only
-- Then a summary synthesizing all reviewer perspectives
-- Then "阻塞项" listing issues that must block merge; if none, write "阻塞项:无"
-- Then "建议项" listing non-blocking improvements; if none, write "建议项:无"
-"""
-
-
-def _strip_ansi(raw: str) -> str:
- """Remove ANSI escape sequences from CLI output."""
- text = re.sub(r"\x1b\[[0-9;]*[a-zA-Z]", "", raw)
- return text.strip()
-
-
-def _truncate(text: str, limit: int = 8000) -> str:
- text = text.strip()
- if len(text) > limit:
- return text[:limit] + "\n... (output truncated)"
- return text
-
-
-def format_pr_comment(coordinator_output: str, reviewer_results: list[dict[str, Any]]) -> str:
- """Format the final PR comment with coordinator output and collapsible reviewer details."""
- parts = [_strip_ansi(coordinator_output).strip(), "\n\n---\n**详细审查报告:**\n"]
- for r in reviewer_results:
- status_label = "✅" if r["status"] == "success" else "⚠️"
- output = _truncate(_strip_ansi(r.get("output", "")))
- parts.append(
- f"\n\n{status_label} {r['name']}
\n\n{output}\n \n"
- )
- return "".join(parts)
-
-
-def post_fallback_comment(reviewer_results: list[dict[str, Any]]) -> str:
- """Format a fallback comment with raw reviewer outputs when coordinator fails."""
- parts = ["⚠️ Coordinator agent failed. Showing raw reviewer outputs:\n"]
- for r in reviewer_results:
- output = _truncate(_strip_ansi(r.get("output", "")))
- parts.append(f"\n### {r['name']} ({r['status']})\n\n{output}\n")
- return "".join(parts)
-
-
-def _get_pr_context() -> tuple[str, str] | None:
- """Return (pr_number, repository) if running in a PR context, else None."""
- github_ref = get_env("GITHUB_REF", "")
- github_repository = get_env("GITHUB_REPOSITORY", "")
- match = re.fullmatch(r"refs/pull/(\d+)/merge", github_ref)
- if not match or not github_repository:
- return None
- return match.group(1), github_repository
-
-
-def post_pr_comment(body: str) -> bool:
- """Post a comment to the current PR using gh CLI. Returns True on success."""
- ctx = _get_pr_context()
- if not ctx:
- return False
- pr_number, repository = ctx
-
- gh_path = shutil.which("gh")
- if not gh_path:
- return False
-
- try:
- result = subprocess.run(
- [gh_path, "pr", "comment", pr_number, "--repo", repository, "--body", body],
- capture_output=True, text=True, env=os.environ.copy(), timeout=30,
- )
- if result.returncode == 0:
- print(f"Posted synthesized review comment to PR #{pr_number}", file=sys.stderr)
- return True
- print(f"Failed to post PR comment: {result.stderr}", file=sys.stderr)
- return False
- except Exception as e:
- print(f"Failed to post PR comment: {e}", file=sys.stderr)
- return False
-
-
-def cleanup_error_comments() -> None:
- """Delete error comments posted by opencode to the current PR."""
- enabled = get_env("MULTI_REVIEW_CLEANUP_ERROR_COMMENTS", "true")
- if enabled.lower() != "true":
- return
-
- ctx = _get_pr_context()
- if not ctx:
- return
- pr_number, repository = ctx
-
- github_run_id = get_env("GITHUB_RUN_ID", "")
- if not github_run_id:
- return
-
- gh_path = shutil.which("gh")
- if not gh_path:
- return
-
- run_link_pattern = f"/{repository}/actions/runs/{github_run_id}"
- error_indicators = re.compile(
- r"(fatal:|remote:|error:\s*\d{3}|unable to access|Write access|permission denied)",
- re.IGNORECASE,
- )
-
- try:
- result = subprocess.run(
- [gh_path, "api", "-H", "Accept: application/vnd.github+json",
- f"/repos/{repository}/issues/{pr_number}/comments"],
- capture_output=True, text=True, env=os.environ.copy(), timeout=30,
- )
- if result.returncode != 0:
- return
- comments = json.loads(result.stdout)
- except Exception:
- return
-
- for comment in comments:
- comment_id = comment.get("id")
- comment_body = comment.get("body", "")
- if not comment_id or not comment_body:
- continue
- if run_link_pattern not in comment_body or not error_indicators.search(comment_body):
- continue
- try:
- subprocess.run(
- [gh_path, "api", "-X", "DELETE",
- f"/repos/{repository}/issues/comments/{comment_id}"],
- capture_output=True, text=True, env=os.environ.copy(), timeout=10,
- )
- except Exception:
- pass
-
-
-def main() -> int:
- try:
- return _main()
- finally:
- try:
- cleanup_error_comments()
- except Exception:
- pass
-
-
-def _main() -> int:
- # --- Parse configuration from env ---
- config_path = get_env("MULTI_REVIEW_CONFIG", "")
- default_team = get_env("MULTI_REVIEW_DEFAULT_TEAM", "")
- global_timeout = int(get_env("MULTI_REVIEW_TIMEOUT_SECONDS", "900"))
- model_timeout = int(get_env("MULTI_REVIEW_MODEL_TIMEOUT_SECONDS", "300"))
- coordinator_timeout = int(get_env("MULTI_REVIEW_COORDINATOR_TIMEOUT_SECONDS", "300"))
- fallback_models_str = get_env("MULTI_REVIEW_FALLBACK_MODELS", "")
- fallback_on_regex = get_env(
- "MULTI_REVIEW_FALLBACK_ON_REGEX",
- "timed out|timeout|deadline exceeded|context deadline exceeded|operation timed out|connection timed out|ProviderModelNotFoundError",
- )
- coordinator_prompt_template = get_env("MULTI_REVIEW_COORDINATOR_PROMPT", "")
-
- # Core opencode env — forward MULTI_REVIEW_* → standard env vars
- env_forward = {
- "OPENCODE_WORKING_DIRECTORY": ("MULTI_REVIEW_WORKING_DIRECTORY", ""),
- "OPENCODE_ATTEMPTS": ("MULTI_REVIEW_ATTEMPTS", "3"),
- "OPENCODE_RETRY_PROFILE": ("MULTI_REVIEW_RETRY_PROFILE", "github-network"),
- "OPENCODE_RETRY_DELAY_SECONDS": ("MULTI_REVIEW_RETRY_DELAY_SECONDS", "15"),
- "USE_GITHUB_TOKEN": ("MULTI_REVIEW_USE_GITHUB_TOKEN", "true"),
- "GITHUB_TOKEN": ("MULTI_REVIEW_GITHUB_TOKEN", ""),
- "ZHIPU_API_KEY": ("MULTI_REVIEW_ZHIPU_API_KEY", ""),
- "OPENCODE_API_KEY": ("MULTI_REVIEW_OPENCODE_GO_API_KEY", ""),
- "DEEPSEEK_API_KEY": ("MULTI_REVIEW_DEEPSEEK_API_KEY", ""),
- }
- for target, (source, default) in env_forward.items():
- set_env(target, get_env(source, default))
-
- # Model resolution
- if get_env("MULTI_REVIEW_MODEL"):
- os.environ["MODEL"] = get_env("MULTI_REVIEW_MODEL")
- elif get_env("MODEL_NAME"):
- os.environ["MODEL"] = get_env("MODEL_NAME")
- else:
- os.environ["MODEL"] = "zhipuai-coding-plan/glm-5.1"
-
- # Configure opencode.json
- reasoning_effort = get_env("MULTI_REVIEW_REASONING_EFFORT", "")
- enable_thinking = get_env("MULTI_REVIEW_ENABLE_THINKING", "true")
- working_directory = get_env("MULTI_REVIEW_WORKING_DIRECTORY", "")
-
- permission_raw = get_env("MULTI_REVIEW_PERMISSION", "")
- permission = None
- if permission_raw:
- try:
- permission = json.loads(permission_raw)
- except json.JSONDecodeError:
- print("Invalid JSON in MULTI_REVIEW_PERMISSION", file=sys.stderr)
- sys.exit(1)
-
- needs_config = reasoning_effort or enable_thinking.lower() == "true" or permission
- if needs_config:
- config_path_opencode = Path(working_directory) / "opencode.json" if working_directory else Path("opencode.json")
- config: dict = {}
- if config_path_opencode.exists():
- try:
- with open(config_path_opencode) as f:
- config = json.load(f)
- except (json.JSONDecodeError, OSError):
- config = {}
- config.setdefault("agent", {}).setdefault("build", {}).setdefault("options", {})
- if reasoning_effort:
- config["agent"]["build"]["options"]["reasoningEffort"] = reasoning_effort
- if enable_thinking.lower() == "true":
- config["agent"]["build"]["options"]["thinking"] = {"type": "enabled"}
- if permission:
- config["agent"]["build"].setdefault("permission", {})
- for key, value in permission.items():
- if isinstance(value, dict) and isinstance(config["agent"]["build"]["permission"].get(key), dict):
- config["agent"]["build"]["permission"][key].update(value)
- else:
- config["agent"]["build"]["permission"][key] = value
- with open(config_path_opencode, "w") as f:
- json.dump(config, f, indent=2, ensure_ascii=False)
- f.write("\n")
-
- # Extra env vars
- for line in get_env("MULTI_REVIEW_EXTRA_ENV").splitlines():
- line = line.strip()
- if not line or line.startswith("#") or "=" not in line:
- continue
- key, _, value = line.partition("=")
- if key.strip():
- os.environ[key.strip()] = value.strip()
-
- # --- Resolve reviewers ---
- reviewers = resolve_reviewers(config_path or None, default_team or None)
- print(f"Resolved {len(reviewers)} reviewer(s): {[r['name'] for r in reviewers]}", file=sys.stderr)
-
- fallback_models = [m.strip() for m in re.split(r"[\r\n,]+", fallback_models_str) if m.strip()]
-
- # --- Run reviewers in parallel ---
- global_deadline = time.time() + global_timeout if global_timeout > 0 else None
-
- reviewer_results: list[dict[str, Any]] = []
- with ThreadPoolExecutor(max_workers=len(reviewers)) as executor:
- futures = {}
- for reviewer in reviewers:
- f = executor.submit(
- run_reviewer,
- reviewer,
- global_deadline,
- model_timeout,
- fallback_models,
- fallback_on_regex,
- )
- futures[f] = reviewer["name"]
-
- for f in as_completed(futures):
- name = futures[f]
- try:
- result = f.result()
- reviewer_results.append(result)
- status = result["status"]
- print(f"Reviewer {name}: {status}", file=sys.stderr)
- if status == "error":
- output = result.get("output", "")
- print(f"Reviewer {name} output (first 500 chars): {output[:500]}", file=sys.stderr)
- except Exception as e:
- print(f"Reviewer {name} raised exception: {e}", file=sys.stderr)
- reviewer_results.append({"name": name, "status": "error", "output": str(e)})
-
- successful = [r for r in reviewer_results if r["status"] == "success"]
- if not successful:
- print("All reviewers failed", file=sys.stderr)
- return 1
-
- # --- Run coordinator ---
- remaining_time = 0
- if global_deadline:
- remaining_time = max(0, int(global_deadline - time.time()))
- if remaining_time <= 0:
- print("No time left for coordinator, posting raw outputs", file=sys.stderr)
- comment = post_fallback_comment(reviewer_results)
- post_pr_comment(comment)
- return 0
-
- coord_timeout = min(coordinator_timeout, remaining_time) if global_deadline else coordinator_timeout
- coordinator_output = run_coordinator(
- reviewer_results,
- coord_timeout,
- coordinator_prompt_template or None,
- )
-
- if coordinator_output:
- comment = format_pr_comment(coordinator_output, reviewer_results)
- else:
- print("Coordinator failed, posting raw reviewer outputs", file=sys.stderr)
- comment = post_fallback_comment(reviewer_results)
-
- # Post synthesized comment to PR
- posted = post_pr_comment(comment)
- if not posted:
- print("Could not post to PR via gh CLI, writing to stdout as fallback", file=sys.stderr)
- print(comment)
-
- return 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/skills/setup-ci/SKILL.md b/skills/setup-ci/SKILL.md
index a29b0a4..d062add 100644
--- a/skills/setup-ci/SKILL.md
+++ b/skills/setup-ci/SKILL.md
@@ -19,7 +19,6 @@ Configure `Svtter/opencode-actions` GitHub Actions for a user's repository.
| Need | Action | One-liner |
| --- | --- | --- |
| PR code review | `review` | Quality, bugs, security — Chinese output |
-| Multi-agent review | `multi-review` | Parallel reviewers (quality, security, etc.) + coordinator synthesis |
| Architecture review | `architect-review` | Coupling, layering, structural concerns |
| PR scope audit | `feature-missing` | Missing features vs linked issue spec |
| Spec coverage | `spec-coverage` | Missing features vs project spec files |
@@ -28,40 +27,6 @@ Configure `Svtter/opencode-actions` GitHub Actions for a user's repository.
Users typically combine `review` + `feature-missing` + `spec-coverage` for full coverage.
-## Multi-Review Setup (Recommended)
-
-Generate this in `.github/workflows/opencode-multi-review.yml`:
-
-```yaml
-name: OpenCode Multi-Review
-
-on:
- pull_request:
- types: [opened, synchronize, reopened, ready_for_review]
-
-jobs:
- multi-review:
- if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository
- runs-on: ubuntu-latest
- permissions:
- contents: read
- pull-requests: write
- issues: write
- steps:
- - name: Checkout PR head
- uses: actions/checkout@v6
- with:
- repository: ${{ github.event.pull_request.head.repo.full_name }}
- ref: ${{ github.event.pull_request.head.ref }}
-
- - name: Run multi-agent review
- uses: Svtter/opencode-actions/multi-review@v2
- with:
- github-token: ${{ secrets.GITHUB_TOKEN }}
- zhipu-api-key: ${{ secrets.ZHIPU_API_KEY }}
- deepseek-api-key: ${{ secrets.DEEPSEEK_API_KEY }}
-```
-
## Architect Review Setup
Generate this in `.github/workflows/opencode-architect-review.yml`:
diff --git a/skills/setup-ci/references/actions-reference.md b/skills/setup-ci/references/actions-reference.md
index 9875004..15b4504 100644
--- a/skills/setup-ci/references/actions-reference.md
+++ b/skills/setup-ci/references/actions-reference.md
@@ -27,12 +27,6 @@
- **输出语言**: 中文,首行给出判定(架构合理 / 架构有疑虑 / 架构有问题)
- **特有输入**: `architecture-context`(逗号分隔的架构文档路径)
-### multi-review — 多 agent 并行代码审查
-- **用途**: 并行运行多个 reviewer persona(quality、security、performance 等),再用 coordinator agent 综合报告
-- **触发**: `pull_request` (opened, synchronize, reopened, ready_for_review)
-- **输出语言**: 中文,每个 reviewer 独立输出,coordinator 综合汇总
-- **特有输入**: `reviewer-config`、`default-team`、`coordinator-timeout-seconds`、`coordinator-prompt`
-
### setup-opencode — 安装 OpenCode CLI
- **用途**: 在 runner 上安装、缓存 opencode 二进制,导出路径供后续步骤使用
- **输出**: `opencode-path`、`install-dir`、`xdg-cache-home`、`cache-hit`、`version`
@@ -187,35 +181,6 @@ Outputs:
| `extra-env` | empty | 额外环境变量 |
| `cleanup-error-comments` | `true` | 自动删除失败评论 |
-### multi-review
-
-包含 `setup-opencode` 的全部安装参数 + 以下特有输入:
-
-| Input | Default | Description |
-| --- | --- | --- |
-| `attempts` | `3` | 每个 reviewer 的重试次数 |
-| `retry-profile` | `github-network` | 内置重试预设 |
-| `retry-delay-seconds` | `15` | 重试间隔(秒) |
-| `timeout-seconds` | `900` | 全局超时(秒),`0` 禁用 |
-| `coordinator-timeout-seconds` | `300` | coordinator agent 超时(秒) |
-| `working-directory` | empty | 工作目录 |
-| `model` | auto | 默认模型(所有 reviewer 和 coordinator 共用) |
-| `fallback-models` | empty | 备选模型列表 |
-| `model-timeout-seconds` | `300` | 单模型超时(秒) |
-| `fallback-on-regex` | timeout regex | 切换备选模型正则 |
-| `reviewer-config` | empty | YAML 文件路径,定义自定义 reviewer persona 和团队配置 |
-| `default-team` | empty | 逗号分隔的团队定义(如 "quality:1,security:1"),`reviewer-config` 存在时忽略 |
-| `coordinator-prompt` | empty | 自定义 coordinator prompt 模板,用 `{{REVIEWS}}` 作占位符 |
-| `reasoning-effort` | `max` | 推理强度 |
-| `enable-thinking` | `true` | 启用 thinking 模式 |
-| `use-github-token` | `true` | 是否导出 `USE_GITHUB_TOKEN` |
-| `github-token` | empty | GitHub token |
-| `zhipu-api-key` | empty | 智谱 API key |
-| `opencode-go-api-key` | empty | OpenCode Go API key |
-| `deepseek-api-key` | empty | DeepSeek API key |
-| `extra-env` | empty | 额外环境变量 |
-| `cleanup-error-comments` | `true` | 自动删除失败评论 |
-
### github-run-opencode
包含 `setup-opencode` 的全部安装参数 + 以下特有输入:
@@ -249,7 +214,6 @@ Outputs:
# Per-job permissions — use the minimum required for each action:
#
# review: contents: read, pull-requests: write, issues: write
-# multi-review: contents: read, pull-requests: write, issues: write
# architect-review: contents: read, pull-requests: write, issues: write
# feature-missing: contents: read, pull-requests: write, issues: read
# spec-coverage: contents: read, pull-requests: write
diff --git a/tests/test_multi_review.py b/tests/test_multi_review.py
deleted file mode 100644
index 06b8ac3..0000000
--- a/tests/test_multi_review.py
+++ /dev/null
@@ -1,162 +0,0 @@
-"""Unit tests for multi-review orchestrator."""
-import os
-import sys
-from pathlib import Path
-from unittest.mock import patch
-
-# Import the module by path since the filename has a hyphen
-import importlib.util
-
-_spec = importlib.util.spec_from_file_location(
- "run_multi_review",
- Path(__file__).resolve().parent.parent / "multi-review" / "run-multi-review.py",
-)
-mr = importlib.util.module_from_spec(_spec)
-_spec.loader.exec_module(mr)
-
-
-class TestLoadPersona:
- def test_load_builtin_quality(self):
- persona = mr.load_builtin_persona("quality")
- assert persona is not None
- assert "prompt" in persona
- assert "code quality" in persona["prompt"].lower()
-
- def test_load_builtin_security(self):
- persona = mr.load_builtin_persona("security")
- assert persona is not None
- assert "security" in persona["prompt"].lower()
-
- def test_load_builtin_performance(self):
- persona = mr.load_builtin_persona("performance")
- assert persona is not None
-
- def test_load_builtin_architecture(self):
- persona = mr.load_builtin_persona("architecture")
- assert persona is not None
-
- def test_load_unknown_returns_none(self):
- persona = mr.load_builtin_persona("nonexistent")
- assert persona is None
-
-
-class TestParseTeamString:
- def test_single_persona(self):
- personas = {"quality": {"name": "quality", "prompt": "test"}}
- result = mr._parse_team_string("quality:1", personas)
- assert len(result) == 1
- assert result[0]["name"] == "quality"
- assert result[0]["count"] == 1
-
- def test_multiple_personas(self):
- personas = {
- "quality": {"name": "quality", "prompt": "test"},
- "security": {"name": "security", "prompt": "test"},
- }
- result = mr._parse_team_string("quality:1,security:1", personas)
- assert len(result) == 2
-
- def test_redundancy_count(self):
- personas = {"quality": {"name": "quality", "prompt": "test"}}
- result = mr._parse_team_string("quality:3", personas)
- assert len(result) == 1
- assert result[0]["count"] == 3
-
- def test_default_count(self):
- personas = {"quality": {"name": "quality", "prompt": "test"}}
- result = mr._parse_team_string("quality", personas)
- assert len(result) == 1
- assert result[0]["count"] == 1
-
- def test_unknown_persona_skipped(self):
- personas = {"quality": {"name": "quality", "prompt": "test"}}
- result = mr._parse_team_string("quality:1,unknown:1", personas)
- assert len(result) == 1
-
-
-class TestResolveReviewers:
- def test_default_team(self):
- reviewers = mr.resolve_reviewers(None, None)
- assert len(reviewers) == 2
- names = [r["persona"] for r in reviewers]
- assert "quality" in names
- assert "security" in names
-
- def test_custom_team_string(self):
- reviewers = mr.resolve_reviewers(None, "quality:1,performance:1")
- assert len(reviewers) == 2
- names = [r["persona"] for r in reviewers]
- assert "quality" in names
- assert "performance" in names
-
- def test_redundancy_instances(self):
- reviewers = mr.resolve_reviewers(None, "quality:2")
- assert len(reviewers) == 2
- assert reviewers[0]["name"] == "quality-1"
- assert reviewers[1]["name"] == "quality-2"
-
- def test_config_file_not_found(self):
- try:
- mr.resolve_reviewers("/nonexistent/path.yaml", None)
- assert False, "Should have exited"
- except SystemExit:
- pass
-
-
-class TestFormatPrComment:
- def test_coordinator_output_with_reviewers(self):
- results = [
- {"name": "quality", "status": "success", "output": "LGTM"},
- {"name": "security", "status": "success", "output": "No issues"},
- ]
- comment = mr.format_pr_comment("可合并\nAll good", results)
- assert "可合并" in comment
- assert "" in comment
- assert "quality" in comment
- assert "security" in comment
- assert "LGTM" in comment
-
- def test_error_reviewer_marked(self):
- results = [
- {"name": "quality", "status": "error", "output": "Failed"},
- ]
- comment = mr.format_pr_comment("有条件合并\nSome issues", results)
- assert "⚠️" in comment
-
- def test_long_output_truncated(self):
- results = [
- {"name": "quality", "status": "success", "output": "x" * 10000},
- ]
- comment = mr.format_pr_comment("ok", results)
- assert "output truncated" in comment
-
-
-class TestFallbackComment:
- def test_fallback_format(self):
- results = [
- {"name": "quality", "status": "success", "output": "good"},
- {"name": "security", "status": "timeout", "output": "timed out"},
- ]
- comment = mr.post_fallback_comment(results)
- assert "Coordinator agent failed" in comment
- assert "quality" in comment
- assert "security" in comment
-
-
-class TestSupportsModel:
- def test_zhipuai_with_key(self):
- with patch.dict(os.environ, {"ZHIPU_API_KEY": "test"}):
- assert mr._supports_model("zhipuai-model") is True
-
- def test_zhipuai_without_key(self):
- with patch.dict(os.environ, {}, clear=True):
- assert mr._supports_model("zhipuai-model") is False
-
- def test_generic_model_always_supported(self):
- with patch.dict(os.environ, {}, clear=True):
- assert mr._supports_model("some-other-model") is True
-
-
-if __name__ == "__main__":
- import pytest
- sys.exit(pytest.main([__file__, "-v"]))