diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 71017c1..e3c5f88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,16 @@ jobs: github-token: smoke-gh-token zhipu-api-key: smoke-zhipu-token + - name: Run test-value-detector convenience action + uses: ./test-value-detector + with: + install-url: http://127.0.0.1:8765/fake-installer.sh + cache: false + install-attempts: 1 + attempts: 1 + github-token: smoke-gh-token + zhipu-api-key: smoke-zhipu-token + - name: Stop fake installer server if: always() run: | diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index 10d388d..4f08d2f 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -36,3 +36,12 @@ jobs: attempts: "1" github-token: ${{ secrets.GITHUB_TOKEN }} continue-on-error: true + + - name: Test test-value-detector action loads + uses: ./test-value-detector + with: + model: test-model + timeout-seconds: "5" + attempts: "1" + github-token: ${{ secrets.GITHUB_TOKEN }} + continue-on-error: true diff --git a/README.md b/README.md index b848ca4..a88ef71 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ npx skills add sun-praise/opencode-actions - `review`: opinionated PR review wrapper with built-in prompt and model defaults - `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 +- `test-value-detector`: detects low-value tests in PRs — empty assertions, hardcoded mocks, detached tests, duplicates, missing edge-case coverage - `github-run-opencode`: one-step wrapper for the common `opencode github run` workflow - `setup-opencode`: installs OpenCode, restores a dedicated cache, and exports the binary path - `run-opencode`: runs `opencode` with optional retry logic for flaky GitHub network failures @@ -124,13 +125,39 @@ Unlike `feature-missing` (which checks PR self-described scope), `spec-coverage` opencode-go-api-key: ${{ secrets.OPENCODE_GO_API_KEY }} ``` -### How the three review actions differ +## test-value-detector + +Use this to automatically detect low-value tests in pull requests — tests that pass CI but contribute nothing to code quality. + +- identifies empty assertions and always-true conditions +- flags hardcoded mocks that decouple tests from real logic +- detects tests referencing non-existent or mismatched functions +- spots duplicate tests with no additional coverage value +- highlights missing boundary, error, and edge-case coverage +- classifies findings by severity: CRITICAL, MEDIUM, LOW +- shares the same inputs and cache as `review`/`github-run-opencode` + +```yaml +- name: Run test value detection + uses: Svtter/opencode-actions/test-value-detector@v2 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + zhipu-api-key: ${{ secrets.ZHIPU_API_KEY }} + opencode-go-api-key: ${{ secrets.OPENCODE_GO_API_KEY }} +``` + +### How the four review actions differ | Action | Scope source | What it catches | | --- | --- | --- | | `review` | PR diff | Code quality, security, bugs | | `feature-missing` | PR title/body + linked issues | PR self-described scope completeness | | `spec-coverage` | Project spec/task files | Full planned scope vs implementation | +| `test-value-detector` | PR test code | Low-value test patterns (empty assertions, hardcoded mocks, detached tests, duplicates, missing edge-case coverage) | + +### Customization + +Override the built-in prompt via the `prompt` input to adjust detection focus or severity thresholds for your project. ## setup-opencode @@ -185,6 +212,7 @@ Public consumers should reference the subdirectory action path: uses: Svtter/opencode-actions/review@v2 uses: Svtter/opencode-actions/feature-missing@v2 uses: Svtter/opencode-actions/spec-coverage@v2 +uses: Svtter/opencode-actions/test-value-detector@v2 uses: Svtter/opencode-actions/github-run-opencode@v2 uses: Svtter/opencode-actions/setup-opencode@v2 uses: Svtter/opencode-actions/run-opencode@v2 @@ -219,7 +247,7 @@ This repository includes a CI workflow that: - runs `shellcheck` on every bundled shell script - runs the local shell-based regression suite -- smoke-tests all actions through `uses: ./setup-opencode`, `uses: ./run-opencode`, `uses: ./github-run-opencode`, `uses: ./review`, `uses: ./feature-missing`, and `uses: ./spec-coverage` +- smoke-tests all actions through `uses: ./setup-opencode`, `uses: ./run-opencode`, `uses: ./github-run-opencode`, `uses: ./review`, `uses: ./feature-missing`, `uses: ./spec-coverage`, and `uses: ./test-value-detector` ## Release Policy @@ -234,7 +262,7 @@ This repository includes a CI workflow that: 2. Verify `CI` passes on `main`. 3. Create a GitHub release with a semver tag such as `v1.0.0`. 4. Confirm the `Update Major Tag` workflow moved `v1` to that release. -5. Use `owner/repo/review@v2` for the simplest review setup, `owner/repo/feature-missing@v2` for PR scope audit, `owner/repo/spec-coverage@v2` for spec coverage audit, `owner/repo/github-run-opencode@v2` for generic `github run`, or `owner/repo/setup-opencode@v2` plus `owner/repo/run-opencode@v2` for more control. +5. Use `owner/repo/review@v2` for the simplest review setup, `owner/repo/feature-missing@v2` for PR scope audit, `owner/repo/spec-coverage@v2` for spec coverage audit, `owner/repo/test-value-detector@v2` for low-value test detection, `owner/repo/github-run-opencode@v2` for generic `github run`, or `owner/repo/setup-opencode@v2` plus `owner/repo/run-opencode@v2` for more control. The initial release-notes template lives at `docs/releases/v1.0.0.md`. diff --git a/feature-missing/action.yml b/feature-missing/action.yml index c1b29ac..218cbfc 100644 --- a/feature-missing/action.yml +++ b/feature-missing/action.yml @@ -152,6 +152,14 @@ inputs: description: Rotate to the next fallback model when output matches this regex. required: false default: timed out|timeout|deadline exceeded|context deadline exceeded|operation timed out|connection timed out + 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. Values may contain '=' and '#' + characters; only lines starting with '#' are treated as comments. + required: false + default: "" runs: using: composite @@ -253,6 +261,7 @@ runs: GITHUB_RUN_OPENCODE_FALLBACK_MODELS: ${{ inputs.fallback-models }} GITHUB_RUN_OPENCODE_MODEL_TIMEOUT_SECONDS: ${{ inputs.model-timeout-seconds }} GITHUB_RUN_OPENCODE_FALLBACK_ON_REGEX: ${{ inputs.fallback-on-regex }} + GITHUB_RUN_OPENCODE_EXTRA_ENV: ${{ inputs.extra-env }} GITHUB_RUN_OPENCODE_PERMISSION: >- {"edit":"deny","bash":{"git commit *":"deny","git push *":"deny","git add *":"deny","git stash *":"deny","git reset *":"deny","git checkout *":"deny"}} run: | diff --git a/github-run-opencode/run-github-opencode.py b/github-run-opencode/run-github-opencode.py index d12166d..d500025 100755 --- a/github-run-opencode/run-github-opencode.py +++ b/github-run-opencode/run-github-opencode.py @@ -203,6 +203,12 @@ def main() -> int: set_env("DEEPSEEK_API_KEY", get_env("GITHUB_RUN_OPENCODE_DEEPSEEK_API_KEY")) # Extra env vars from extra-env input + BLOCKED_ENV_KEYS = frozenset({ + "PATH", "HOME", "USER", "SHELL", "MODEL", "GITHUB_TOKEN", + "GITHUB_WORKSPACE", "GITHUB_EVENT_PATH", "GITHUB_SHA", + "GITHUB_REPOSITORY", "GITHUB_REF", "GITHUB_RUN_ID", + "GITHUB_ACTIONS", "LD_LIBRARY_PATH", "PYTHONPATH", + }) extra_env_raw = get_env("GITHUB_RUN_OPENCODE_EXTRA_ENV") if extra_env_raw: for line in extra_env_raw.splitlines(): @@ -214,9 +220,17 @@ def main() -> int: continue key, _, value = line.partition("=") key = key.strip() - value = value.strip() - if key: - os.environ[key] = value + if not key: + continue + if not re.match(r'^[A-Za-z_][A-Za-z0-9_]*$', key): + print(f"::error::extra-env key '{key}' is not a valid environment variable name (must match [A-Za-z_][A-Za-z0-9_]*)", file=sys.stderr) + sys.exit(1) + if key in BLOCKED_ENV_KEYS: + print(f"::error::extra-env key '{key}' is blocked — overriding this variable is not allowed", file=sys.stderr) + sys.exit(1) + if re.search(r'(API_KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL)', key, re.IGNORECASE): + print(f"::warning::extra-env key '{key}' looks like a sensitive variable — make sure this is intentional") + os.environ[key] = value.strip() reasoning_effort = get_env("GITHUB_RUN_OPENCODE_REASONING_EFFORT", "") enable_thinking = get_env("GITHUB_RUN_OPENCODE_ENABLE_THINKING", "false") @@ -230,6 +244,9 @@ def main() -> int: except json.JSONDecodeError: print(f"Invalid JSON in GITHUB_RUN_OPENCODE_PERMISSION: {permission_raw}", file=sys.stderr) sys.exit(1) + if not isinstance(permission, dict): + print(f"GITHUB_RUN_OPENCODE_PERMISSION must be a JSON object, got {type(permission).__name__}", file=sys.stderr) + sys.exit(1) needs_config = reasoning_effort or enable_thinking.lower() == "true" or permission if needs_config: diff --git a/test-value-detector/action.yml b/test-value-detector/action.yml new file mode 100644 index 0000000..1545018 --- /dev/null +++ b/test-value-detector/action.yml @@ -0,0 +1,264 @@ +name: OpenCode Test Value Detector +description: Detects low-value tests in pull requests — empty assertions, hardcoded mocks, detached tests, duplicates, and missing edge-case coverage. + +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). If the cached binary is older, it will be reinstalled. 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. + required: false + default: "3" + retry-profile: + description: Built-in retry profile, defaults to github-network for common GitHub usage. + required: false + default: github-network + retry-on-regex: + description: Retry only when stderr or stdout matches this regex. + required: false + default: "" + retry-delay-seconds: + description: Base delay between retries in seconds. + required: false + default: "15" + timeout-seconds: + description: Maximum execution time in seconds for `opencode github run`. Set 0 to disable. + required: false + default: "600" + model: + description: Value exported as MODEL before running `opencode github run`. + required: false + default: "" + fallback-models: + description: Optional comma- or newline-delimited fallback models tried after timeout or timeout-like failures. + required: false + default: "" + model-timeout-seconds: + description: Per-model timeout used before rotating to the next fallback model when fallback-models are configured. Set 0 to disable. + required: false + default: "300" + fallback-on-regex: + description: Rotate to the next fallback model when output matches this regex. + required: false + default: timed out|timeout|deadline exceeded|context deadline exceeded|operation timed out|connection timed out + prompt: + description: Value exported as PROMPT before running `opencode github run`. + required: false + default: | + 分析本 Pull Request 中新增或修改的测试代码(只读模式,请勿修改任何代码)。 + + 请检查以下低价值测试维度: + - 空断言/软断言:测试中没有任何 assert、expect、require 等断言调用,或者断言条件永远为真 + - 硬编码 mock:mock 的返回值与实际业务逻辑无关,测试无论业务正确与否都会通过 + - 测试与实现脱节:测试引用的函数、方法、类在对应源码中不存在,或调用签名与实际定义不匹配 + - 重复测试:与已有测试逻辑完全重复,没有增加额外的覆盖价值 + - 无边界/异常覆盖:仅覆盖 happy path,缺少边界值、空值、异常输入、错误处理等场景的测试 + + 请用中文回答。请勿修改任何代码,仅提供分析评论。 + 回复的第一行必须是以下二者之一: + - 测试全部有价值 + - 发现低价值测试 + + 判定规则: + - 仅当所有测试都具备有效断言、mock 合理、与实现一致、无重复、且覆盖边界与异常场景时,使用"测试全部有价值"。 + - 只要存在上述任一维度的问题,即使用"发现低价值测试"。 + + 仅审查最新 PR HEAD 的代码。 + 不要重复先前修订中发现但已在当前版本中修复的问题。 + 在列出任何问题前,请先在当前代码中验证该问题仍然存在。 + + 输出格式: + - 第一行:仅输出判定结果 + - 然后按 CRITICAL / MEDIUM / LOW 三个等级分类列出发现的问题 + - CRITICAL:空断言、与实现完全脱节的测试 + - MEDIUM:硬编码 mock、重复测试 + - LOW:缺少边界/异常覆盖 + - 每个问题需注明所在文件、行号范围及具体原因 + - 若某个等级没有问题,则写"无" + use-github-token: + description: Value exported as USE_GITHUB_TOKEN before running `opencode github run`. + required: false + default: "true" + github-token: + description: Value exported as GITHUB_TOKEN before running `opencode github run`. + required: false + default: "" + zhipu-api-key: + description: Value exported as ZHIPU_API_KEY before running `opencode github run`. + required: false + default: "" + opencode-go-api-key: + description: Value exported as OPENCODE_API_KEY before running `opencode github run`. The opencode-go provider shares this env var with OpenCode Zen. + required: false + default: "" + deepseek-api-key: + description: Value exported as DEEPSEEK_API_KEY before running `opencode github run`. + 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. When true, generates opencode.json with thinking.type=enabled. + 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. Values may contain '=' and '#' + characters; only lines starting with '#' are treated as comments. + required: false + default: "" + permission: + description: >- + JSON object for opencode agent-level permission overrides (merged into opencode.json). + When empty, uses the default read-only permission policy. + See https://opencode.ai/docs/permissions/ for available keys. + Example: '{"edit":"deny","bash":{"git commit *":"deny"}}' + required: false + default: >- + {"edit":"deny","bash":{"git commit *":"deny","git push *":"deny","git add *":"deny","git stash *":"deny","git reset *":"deny","git checkout *":"deny"}} + +runs: + using: composite + steps: + - if: ${{ runner.os != 'Linux' }} + shell: bash + run: | + set -euo pipefail + printf 'test-value-detector 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: test-value-detector-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: + GITHUB_RUN_OPENCODE_WORKING_DIRECTORY: ${{ inputs.working-directory }} + GITHUB_RUN_OPENCODE_ATTEMPTS: ${{ inputs.attempts }} + GITHUB_RUN_OPENCODE_RETRY_PROFILE: ${{ inputs.retry-profile }} + GITHUB_RUN_OPENCODE_RETRY_ON_REGEX: ${{ inputs.retry-on-regex }} + GITHUB_RUN_OPENCODE_RETRY_DELAY_SECONDS: ${{ inputs.retry-delay-seconds }} + GITHUB_RUN_OPENCODE_TIMEOUT_SECONDS: ${{ inputs.timeout-seconds }} + GITHUB_RUN_OPENCODE_MODEL: ${{ inputs.model }} + GITHUB_RUN_OPENCODE_FALLBACK_MODELS: ${{ inputs.fallback-models }} + GITHUB_RUN_OPENCODE_MODEL_TIMEOUT_SECONDS: ${{ inputs.model-timeout-seconds }} + GITHUB_RUN_OPENCODE_FALLBACK_ON_REGEX: ${{ inputs.fallback-on-regex }} + GITHUB_RUN_OPENCODE_PROMPT: ${{ inputs.prompt }} + GITHUB_RUN_OPENCODE_USE_GITHUB_TOKEN: ${{ inputs.use-github-token }} + GITHUB_RUN_OPENCODE_GITHUB_TOKEN: ${{ inputs.github-token }} + GITHUB_RUN_OPENCODE_ZHIPU_API_KEY: ${{ inputs.zhipu-api-key }} + GITHUB_RUN_OPENCODE_OPENCODE_GO_API_KEY: ${{ inputs.opencode-go-api-key }} + GITHUB_RUN_OPENCODE_DEEPSEEK_API_KEY: ${{ inputs.deepseek-api-key }} + GITHUB_RUN_OPENCODE_REASONING_EFFORT: ${{ inputs.reasoning-effort }} + GITHUB_RUN_OPENCODE_ENABLE_THINKING: ${{ inputs.enable-thinking }} + GITHUB_RUN_OPENCODE_EXTRA_ENV: ${{ inputs.extra-env }} + GITHUB_RUN_OPENCODE_PERMISSION: ${{ inputs.permission }} + 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 }}/../github-run-opencode/run-github-opencode.py