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"]))