From 9488335713ff2367fadb02387c991557b51cd964 Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Fri, 11 Sep 2026 13:48:09 +1200 Subject: [PATCH] feat: review-minimax-run + ci-triage-minimax-run MiniMax offload wrappers First-pass branch review and CI failure triage in MiniMax M3 sub-sessions. Both materialise inputs themselves (no Bash in the sub-session), gate results strictly, retry once, and follow the explore-minimax 0/1/2/3 exit contract. Specs with RM-n/CT-n IDs; offline tests via fake seams (36 assertions). Verified live: planted-issue branch -> 5 confirmed findings, clean branch -> NO FINDINGS, failed gac-scratch run -> correct step + honest low confidence. Claude-Session: https://claude.ai/code/session_01Ku87DDWwbJkTx6SuV3sQDc --- scripts/ci-triage-minimax-run | 212 +++++++++++++++++++++++ scripts/review-minimax-run | 253 ++++++++++++++++++++++++++++ specs/ci-triage-minimax-run.spec.md | 65 +++++++ specs/review-minimax-run.spec.md | 68 ++++++++ tests/test-ci-triage-minimax-run.sh | 106 ++++++++++++ tests/test-review-minimax-run.sh | 139 +++++++++++++++ 6 files changed, 843 insertions(+) create mode 100755 scripts/ci-triage-minimax-run create mode 100755 scripts/review-minimax-run create mode 100644 specs/ci-triage-minimax-run.spec.md create mode 100644 specs/review-minimax-run.spec.md create mode 100755 tests/test-ci-triage-minimax-run.sh create mode 100755 tests/test-review-minimax-run.sh diff --git a/scripts/ci-triage-minimax-run b/scripts/ci-triage-minimax-run new file mode 100755 index 0000000..baff2ca --- /dev/null +++ b/scripts/ci-triage-minimax-run @@ -0,0 +1,212 @@ +#!/usr/bin/env bash +# Triage a failing Gitea Actions run in a MiniMax M3 sub-session. +# Fetches logs via gitea-agent-companion (token stays inside gac), redacts +# credential-shaped content, and has MiniMax write a structured triage file. +# Spec: specs/ci-triage-minimax-run.spec.md. Used by /ci-triage-minimax. +# +# Usage: +# ci-triage-minimax-run --repo --run [--job ] +# [--out ] [--tail N] [--max-turns N] [--timeout SECS] +# [--dryrun|-n] +# +# Exit codes: +# 0 triage file written +# 1 usage/config error (bad flags, gac usage error) +# 2 gac auth/API/not-found error, MiniMax failed after retry, or timeout +# 3 triage file missing or failed the result gate after retry +set -euo pipefail + +usage() { grep '^#' "$0" | sed 's/^# \{0,1\}//' | sed -n '2,16p'; } + +REPO="" +RUN="" +JOB="" +OUT_FILE="" +TAIL_LINES=2000 +MAX_TURNS=25 +TIMEOUT_SECS=300 +DRYRUN=0 +CRED_PATTERN='ghp_[A-Za-z0-9]|glpat-|x-access-token:|Authorization:|BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}' + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) REPO="$2"; shift 2 ;; + --run) RUN="$2"; shift 2 ;; + --job) JOB="$2"; shift 2 ;; + --out) OUT_FILE="$2"; shift 2 ;; + --tail) TAIL_LINES="$2"; shift 2 ;; + --max-turns) MAX_TURNS="$2"; shift 2 ;; + --timeout) TIMEOUT_SECS="$2"; shift 2 ;; + --dryrun|-n) DRYRUN=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "error: unknown argument '$1'" >&2; usage >&2; exit 1 ;; + esac +done + +# --- CT-1 preflight ----------------------------------------------------------- +[[ "$REPO" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || { echo "error: --repo must be owner/repo" >&2; exit 1; } +[[ "$RUN" =~ ^[0-9]+$ ]] || { echo "error: --run must be a positive integer" >&2; exit 1; } +command -v jq >/dev/null || { echo "error: jq not found" >&2; exit 1; } +command -v ask-minimax-run >/dev/null || { echo "error: ask-minimax-run not on PATH" >&2; exit 1; } +command -v gitea-agent-companion >/dev/null || { echo "error: gitea-agent-companion not on PATH" >&2; exit 1; } + +# gac (as of v0.0.2.10) does not fall back to the secrets path itself; point it +# at the well-known token file when the caller set no token env. Path only — +# the token content is never read or printed here. +if [[ -z "${GITEA_TOKEN:-}" && -z "${GITEA_TOKEN_FILE:-}" && -f "$HOME/dev/claude/secrets/gitea/ai_admin/token" ]]; then + export GITEA_TOKEN_FILE="$HOME/dev/claude/secrets/gitea/ai_admin/token" +fi + +if [[ -z "$OUT_FILE" ]]; then + OUT_FILE="${XDG_RUNTIME_DIR:-/tmp}/ci-triage-minimax/${REPO//\//-}/run-$RUN-triage.md" +fi +OUT_DIR=$(dirname "$OUT_FILE") +mkdir -p "$OUT_DIR" + +WORK_DIR=$(mktemp -d /tmp/ci-triage-minimax.XXXXXX) +trap 'rm -rf "$WORK_DIR"' EXIT + +JOB_ARGS=() +[[ -n "$JOB" ]] && JOB_ARGS=(--job "$JOB") +GAC_CMD=(gitea-agent-companion inspect "$REPO" --run "$RUN" "${JOB_ARGS[@]}" --logs --json --tail "$TAIL_LINES") + +# --- dryrun (before any fetch) ------------------------------------------------ +if [[ "$DRYRUN" -eq 1 ]]; then + echo "== ci-triage-minimax-run --dryrun" + echo "repo: $REPO" + echo "run: $RUN job: ${JOB:-auto}" + echo "out: $OUT_FILE" + echo "tail: $TAIL_LINES max turns: $MAX_TURNS timeout: ${TIMEOUT_SECS}s" + echo "-- gac fetch command --" + echo "${GAC_CMD[*]} > run-logs.ndjson" + echo "-- failing step --" + echo "" + echo "-- sub-session command --" + echo "cd && timeout $TIMEOUT_SECS ask-minimax-run --model 'MiniMax-M3[1m]'" \ + "--permission-mode default --allowedTools 'Read,Glob,Grep,Write'" \ + "--disallowedTools 'Edit,MultiEdit,NotebookEdit,Bash,WebFetch,WebSearch'" \ + "--max-turns $MAX_TURNS --output-format json --add-dir $OUT_DIR -p < prompt.txt" + exit 0 +fi + +# --- CT-2/CT-3 fetch with exit mapping ---------------------------------------- +if [[ -n "${CI_TRIAGE_FAKE_LOGS:-}" ]]; then # CT-10 test seam + cp "$CI_TRIAGE_FAKE_LOGS" "$WORK_DIR/run-logs.ndjson" + echo "(fake logs — no job table)" > "$WORK_DIR/jobs.txt" +else + rc=0 + "${GAC_CMD[@]}" > "$WORK_DIR/run-logs.ndjson" 2>"$WORK_DIR/gac-stderr.txt" || rc=$? + if [[ "$rc" -ne 0 ]]; then + cat "$WORK_DIR/gac-stderr.txt" >&2 || true + case "$rc" in + 2) echo "error: gac usage error (see above)" >&2; exit 1 ;; + *) echo "error: gac failed rc=$rc (auth/API/run-not-found — possibly transient)" >&2; exit 2 ;; + esac + fi + gitea-agent-companion inspect "$REPO" --run "$RUN" > "$WORK_DIR/jobs.txt" 2>/dev/null || true +fi + +# --- CT-4 redaction (in place; the log IS the payload) -------------------------- +sed -E -i "s#($CRED_PATTERN)[^\"[:space:]]*#[REDACTED]#g" "$WORK_DIR/run-logs.ndjson" + +# --- CT-5 envelope parse -------------------------------------------------------- +FAILED_STEP=$(jq -r 'select(.failed_step != null) | .failed_step' "$WORK_DIR/run-logs.ndjson" 2>/dev/null | head -1) +SLICED=$(jq -r '.sliced // empty' "$WORK_DIR/run-logs.ndjson" 2>/dev/null | head -1) + +# --- CT-6 prompt ---------------------------------------------------------------- +FULL_PROMPT="$WORK_DIR/prompt.txt" +{ + cat <<'PREAMBLE' +You are a CI FAILURE TRIAGE agent running non-interactively. Analyse the CI +run logs provided as files in your working directory. Rules: + +- Inputs (in cwd): run-logs.ndjson (log lines + metadata envelope), jobs.txt. +- Write ONLY the triage file at the path given below. Modify nothing else. +- Do not run anything. Do not suggest running full test suites or builds. +- Search the log lines for these error signatures specifically: + error:|FAILED|Traceback|exit code|denied|timeout|not found|no such|ECONN|refused +- Quote log lines verbatim. Do NOT invent step names, file paths, or causes + you cannot point to in the log. If the log is truncated or step boundaries + are partial, say so — do not improvise. + +Triage file format — exactly these sections: + ## Failing step + ## Error excerpt (<=10 verbatim log lines) + ## Probable cause (1-3 sentences, must not be empty) + ## Suggested next action (ONE command or one edit description; advisory only) + ## Confidence (high|medium|low, one line why) +PREAMBLE + echo + echo "## Run metadata" + echo "repo: $REPO run: $RUN" + if [[ -n "$FAILED_STEP" ]]; then + echo "The CI system identified the failing step as: $FAILED_STEP" + echo "Confirm against the log; analyse WHY it failed." + else + echo "The CI system could NOT identify a failing step (failed_step is null:" + echo "cancelled/crashed run or no failed job). You MUST write the literal" + echo "word UNKNOWN under '## Failing step' unless the log itself proves one." + fi + [[ "$SLICED" == "true" ]] && echo "Note: the log window was sliced to the failure region." + echo + echo "## Triage file to write" + echo "$OUT_FILE" +} > "$FULL_PROMPT" + +# --- CT-7 invocation ------------------------------------------------------------ +run_once() { + local json_out="$1" + if [[ -n "${CI_TRIAGE_FAKE_JSON:-}" ]]; then # CT-10 test seam + cp "$CI_TRIAGE_FAKE_JSON" "$json_out" + # optional companion seam: plays the sub-session's Write of the triage file + [[ -n "${CI_TRIAGE_FAKE_TRIAGE:-}" ]] && cp "$CI_TRIAGE_FAKE_TRIAGE" "$OUT_FILE" + return 0 + fi + ( cd "$WORK_DIR" && timeout "$TIMEOUT_SECS" ask-minimax-run \ + --model "MiniMax-M3[1m]" \ + --permission-mode default \ + --allowedTools "Read,Glob,Grep,Write" \ + --disallowedTools "Edit,MultiEdit,NotebookEdit,Bash,WebFetch,WebSearch" \ + --max-turns "$MAX_TURNS" \ + --output-format json \ + --add-dir "$OUT_DIR" \ + -p < "$FULL_PROMPT" > "$json_out" 2>"$WORK_DIR/stderr.txt" ) +} + +gate_ok() { # CT-8 + [[ -f "$OUT_FILE" ]] || return 1 + grep -q '^## Failing step' "$OUT_FILE" || return 1 + # non-blank content line under "## Probable cause" + awk '/^## Probable cause/{f=1; next} /^## /{f=0} f && NF {found=1} END{exit !found}' "$OUT_FILE" +} + +JSON_OUT="$WORK_DIR/response.json" +RUN_FAILED=0 +for attempt in 1 2; do + rm -f "$OUT_FILE" # CT-9: no stale attempt-1 file may satisfy the gate + RUN_FAILED=0 + rc=0 + run_once "$JSON_OUT" || rc=$? + if [[ "$rc" -eq 124 ]]; then + echo "error: sub-session timed out after ${TIMEOUT_SECS}s (not retried)" >&2 + exit 2 + fi + [[ "$rc" -ne 0 ]] && RUN_FAILED=1 + jq -e 'select(.is_error == true)' "$JSON_OUT" >/dev/null 2>&1 && RUN_FAILED=1 + [[ "$RUN_FAILED" -eq 0 ]] && gate_ok && break + if [[ $attempt -eq 1 ]]; then + echo "warn: attempt 1 failed ($(tail -1 "$WORK_DIR/stderr.txt" 2>/dev/null || echo 'no stderr')); retrying once" >&2 + fi +done + +if [[ "$RUN_FAILED" -eq 1 ]]; then + echo "error: MiniMax failed after retry" >&2 + tail -5 "$WORK_DIR/stderr.txt" >&2 2>/dev/null || true + exit 2 +fi +if ! gate_ok; then + echo "error: triage file missing or failed the result gate after retry" >&2 + exit 3 +fi + +echo "ok: triage written to $OUT_FILE" diff --git a/scripts/review-minimax-run b/scripts/review-minimax-run new file mode 100755 index 0000000..fe99177 --- /dev/null +++ b/scripts/review-minimax-run @@ -0,0 +1,253 @@ +#!/usr/bin/env bash +# First-pass code review of a git branch in a MiniMax M3 sub-session. +# The wrapper materialises the diff/log itself (the sub-session gets no Bash), +# runs MiniMax read-only except for one findings file, and gates the result. +# Spec: specs/review-minimax-run.spec.md. Used by /review-minimax; safe directly. +# +# Usage: +# review-minimax-run --worktree [--base ] [--out ] +# [--profile agent-runtimes] [--max-diff-lines N] [--max-turns N] +# [--timeout SECS] [--dryrun|-n] +# +# Exit codes: +# 0 findings file written (may contain "NO FINDINGS") +# 1 usage/config error, secrets root, credential-shaped or empty/oversize diff +# 2 MiniMax failed after retry, timeout, or sub-session dirtied the worktree +# 3 findings file missing or failed the result gate after retry +set -euo pipefail + +usage() { grep '^#' "$0" | sed 's/^# \{0,1\}//' | sed -n '2,16p'; } + +WORKTREE="" +BASE="main" +OUT_FILE="" +PROFILE="" +MAX_DIFF_LINES=6000 +MAX_TURNS=40 +TIMEOUT_SECS=540 +DRYRUN=0 +# RM-3: credential-shaped content in the diff aborts the review outright. +CRED_PATTERN='ghp_[A-Za-z0-9]|glpat-|x-access-token:|Authorization:|BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}' + +while [[ $# -gt 0 ]]; do + case "$1" in + --worktree) WORKTREE="$2"; shift 2 ;; + --base) BASE="$2"; shift 2 ;; + --out) OUT_FILE="$2"; shift 2 ;; + --profile) PROFILE="$2"; shift 2 ;; + --max-diff-lines) MAX_DIFF_LINES="$2"; shift 2 ;; + --max-turns) MAX_TURNS="$2"; shift 2 ;; + --timeout) TIMEOUT_SECS="$2"; shift 2 ;; + --dryrun|-n) DRYRUN=1; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "error: unknown argument '$1'" >&2; usage >&2; exit 1 ;; + esac +done + +# --- RM-1 preflight ----------------------------------------------------------- +[[ -n "$WORKTREE" ]] || { echo "error: --worktree required" >&2; exit 1; } +command -v jq >/dev/null || { echo "error: jq not found" >&2; exit 1; } +command -v ask-minimax-run >/dev/null || { echo "error: ask-minimax-run not on PATH" >&2; exit 1; } + +WORKTREE=$(realpath -e "$WORKTREE" 2>/dev/null) || { echo "error: worktree does not exist" >&2; exit 1; } +case "$WORKTREE" in + */secrets|*/secrets/*|*/local_secrets|*/local_secrets/*) + echo "error: refusing secrets path '$WORKTREE'" >&2; exit 1 ;; +esac +git -C "$WORKTREE" rev-parse --show-toplevel >/dev/null 2>&1 || { echo "error: '$WORKTREE' is not a git checkout" >&2; exit 1; } +BRANCH=$(git -C "$WORKTREE" rev-parse --abbrev-ref HEAD) +[[ "$BRANCH" != "HEAD" ]] || { echo "error: worktree is on a detached HEAD" >&2; exit 1; } +git -C "$WORKTREE" rev-parse --verify --quiet "$BASE" >/dev/null || { echo "error: base ref '$BASE' does not resolve" >&2; exit 1; } + +REPO_NAME=$(basename "$(git -C "$WORKTREE" rev-parse --show-toplevel)") +BRANCH_SLUG=${BRANCH//\//-} +if [[ -z "$OUT_FILE" ]]; then + OUT_FILE="${XDG_RUNTIME_DIR:-/tmp}/review-minimax/$REPO_NAME/$BRANCH_SLUG-findings.md" +fi +OUT_DIR=$(dirname "$OUT_FILE") +mkdir -p "$OUT_DIR" + +WORK_DIR=$(mktemp -d /tmp/review-minimax.XXXXXX) +trap 'rm -rf "$WORK_DIR"' EXIT + +# --- RM-2 materialise inputs (wrapper runs git, never the sub-session) -------- +git -C "$WORKTREE" diff "$BASE...HEAD" -- \ + ':(exclude)secrets/**' ':(exclude)local_secrets/**' \ + ':(exclude)*.pem' ':(exclude)*.key' ':(exclude)*secret*' \ + > "$WORK_DIR/diff.patch" +git -C "$WORKTREE" log --oneline "$BASE..HEAD" > "$WORK_DIR/commits.txt" +git -C "$WORKTREE" diff --stat "$BASE...HEAD" > "$WORK_DIR/stat.txt" + +# --- RM-3 / RM-4 gates -------------------------------------------------------- +if grep -qE "$CRED_PATTERN" "$WORK_DIR/diff.patch"; then + echo "error: diff contains credential-shaped content; review in-session" >&2 + exit 1 +fi +DIFF_LINES=$(wc -l < "$WORK_DIR/diff.patch") +[[ "$DIFF_LINES" -gt 0 ]] || { echo "error: empty diff $BASE...HEAD — nothing to review" >&2; exit 1; } +if [[ "$DIFF_LINES" -gt "$MAX_DIFF_LINES" ]]; then + echo "error: diff is $DIFF_LINES lines (> $MAX_DIFF_LINES); raise --max-diff-lines or review in-session" >&2 + exit 1 +fi + +# --- RM-5 prompt -------------------------------------------------------------- +FULL_PROMPT="$WORK_DIR/prompt.txt" +{ + cat <<'PREAMBLE' +You are a FIRST-PASS CODE REVIEWER running non-interactively. Review the branch +diff provided as files in your working directory. Rules: + +- Inputs (in cwd): diff.patch (the review scope), commits.txt, stat.txt. +- The worktree listed below is readable for SURROUNDING CONTEXT ONLY. +- Write ONLY the findings file at the path given below. Never modify, create, + or delete any other file anywhere. +- Do not run anything. Do not fix anything. Review only. +- If an input file is missing or truncated, write a findings file saying + exactly that and stop — do not improvise or fetch more. +- Every finding MUST cite file:line from the diff or worktree, quote at most + 10 lines, and carry a severity and a confidence. Do NOT invent names, paths, + or behaviour — a claim you cannot cite is omitted, not guessed. + +Review checklist — look specifically for these patterns: +- Injection: string-built SQL; subprocess with shell=True; unquoted shell + variables; eval/exec on external input; paths used without realpath checks. +- Credentials: hardcoded password/api_key/token assignments; secrets passed as + command-line arguments; verify=False on HTTP calls. +- Weak crypto: random.* for security values; md5/sha1 for passwords. +- Over-permissive: chmod 777/666; 0.0.0.0 binds without justification; + debug=True; privileged/cluster-admin in manifests; :latest or unpinned images. +- Supply chain: curl|bash or wget|sh without checksum; unpinned dependencies. +- Operational: webhook/POST handlers without idempotency or duplicate checks; + health endpoints returning 200 unconditionally; read-then-write without + transaction or lock; CI steps consuming prior artifacts without needs:. +- Error handling: exceptions swallowed silently; internal detail leaked to + clients; bare except around control flow. +- Tests in the diff: assertions that cannot fail; fixtures that bypass the + auth/dependency they claim to exercise; asserts on wrong response shape. + +Findings file format (exactly this shape): +- One block per finding: + ### [BLOCKER|SHOULD-FIX|NIT] + - Location: : + - Category: + - Confidence: confirmed|suspected + - Excerpt (<=10 lines) and one-sentence rationale. +- Order blocks most-severe first. +- If the diff is clean against the checklist, write exactly: NO FINDINGS +PREAMBLE + if [[ "$PROFILE" == "agent-runtimes" ]]; then + cat <<'PROFILEBLOCK' + +Additional profile checklist (agent-runtimes test files): +- API test paths missing the /v1/ prefix (404s at runtime). +- Assertions on body["type"] instead of body["detail"]["type"] (RFC 9457 is + wrapped inside FastAPI's detail). +- Manifest bodies with {params, nodes, definition_version} at top level + instead of {project_id, space_id, definition_version, definition:{...}}. +- Subscript access on dataclass fixtures (obj["field"] instead of obj.field). +- Local auth-header fixture dicts that do not override the authenticate + dependency (cross-tenant checks silently pass as anonymous). +PROFILEBLOCK + fi + echo + echo "## Worktree (context reads only)" + echo "$WORKTREE (branch: $BRANCH, base: $BASE)" + echo + echo "## Findings file to write" + echo "$OUT_FILE" +} > "$FULL_PROMPT" + +# --- dryrun ------------------------------------------------------------------- +if [[ "$DRYRUN" -eq 1 ]]; then + echo "== review-minimax-run --dryrun" + echo "worktree: $WORKTREE" + echo "branch: $BRANCH" + echo "base: $BASE" + echo "out: $OUT_FILE" + echo "profile: ${PROFILE:-none}" + echo "diff lines: $DIFF_LINES (budget $MAX_DIFF_LINES)" + echo "max turns: $MAX_TURNS timeout: ${TIMEOUT_SECS}s" + echo "-- stat --" + cat "$WORK_DIR/stat.txt" + echo "-- prompt --" + cat "$FULL_PROMPT" + echo "-- command --" + echo "cd $WORK_DIR && timeout $TIMEOUT_SECS ask-minimax-run --model 'MiniMax-M3[1m]'" \ + "--permission-mode default --allowedTools 'Read,Glob,Grep,Write'" \ + "--disallowedTools 'Edit,MultiEdit,NotebookEdit,Bash,WebFetch,WebSearch'" \ + "--max-turns $MAX_TURNS --output-format json --add-dir $WORKTREE --add-dir $OUT_DIR -p < prompt.txt" + exit 0 +fi + +# --- RM-6 invocation ---------------------------------------------------------- +run_once() { + local json_out="$1" + if [[ -n "${REVIEW_MINIMAX_FAKE_JSON:-}" ]]; then # RM-11 test seam + cp "$REVIEW_MINIMAX_FAKE_JSON" "$json_out" + # optional companion seam: plays the sub-session's Write of the findings file + [[ -n "${REVIEW_MINIMAX_FAKE_FINDINGS:-}" ]] && cp "$REVIEW_MINIMAX_FAKE_FINDINGS" "$OUT_FILE" + return 0 + fi + ( cd "$WORK_DIR" && timeout "$TIMEOUT_SECS" ask-minimax-run \ + --model "MiniMax-M3[1m]" \ + --permission-mode default \ + --allowedTools "Read,Glob,Grep,Write" \ + --disallowedTools "Edit,MultiEdit,NotebookEdit,Bash,WebFetch,WebSearch" \ + --max-turns "$MAX_TURNS" \ + --output-format json \ + --add-dir "$WORKTREE" \ + --add-dir "$OUT_DIR" \ + -p < "$FULL_PROMPT" > "$json_out" 2>"$WORK_DIR/stderr.txt" ) +} + +tree_clean() { [[ -z "$(git -C "$WORKTREE" status --porcelain)" ]]; } + +gate_ok() { # RM-8: strict — severity block + resolvable citation, or NO FINDINGS + [[ -f "$OUT_FILE" ]] || return 1 + grep -q 'NO FINDINGS' "$OUT_FILE" && return 0 + grep -qE '^### \[(BLOCKER|SHOULD-FIX|NIT)\]' "$OUT_FILE" || return 1 + local cited + while read -r cited; do + [[ -e "$WORKTREE/$cited" || -e "$cited" ]] && return 0 + done < <(grep -oE 'Location: [^:[:space:]]+' "$OUT_FILE" | sed 's/^Location: //') + return 1 +} + +JSON_OUT="$WORK_DIR/response.json" +RUN_FAILED=0 +for attempt in 1 2; do + rm -f "$OUT_FILE" # RM-9: a stale attempt-1 file must not satisfy the gate + RUN_FAILED=0 + rc=0 + run_once "$JSON_OUT" || rc=$? + if [[ "$rc" -eq 124 ]]; then + echo "error: sub-session timed out after ${TIMEOUT_SECS}s (not retried)" >&2 + exit 2 + fi + [[ "$rc" -ne 0 ]] && RUN_FAILED=1 + jq -e 'select(.is_error == true)' "$JSON_OUT" >/dev/null 2>&1 && RUN_FAILED=1 + if ! tree_clean; then # RM-7 + echo "error: sub-session dirtied the worktree — inspect: git -C $WORKTREE status" >&2 + exit 2 + fi + [[ "$RUN_FAILED" -eq 0 ]] && gate_ok && break + if [[ $attempt -eq 1 ]]; then + echo "warn: attempt 1 failed ($(tail -1 "$WORK_DIR/stderr.txt" 2>/dev/null || echo 'no stderr')); retrying once" >&2 + fi +done + +if [[ "$RUN_FAILED" -eq 1 ]]; then + echo "error: MiniMax failed after retry" >&2 + tail -5 "$WORK_DIR/stderr.txt" >&2 2>/dev/null || true + exit 2 +fi +if ! gate_ok; then + echo "error: findings file missing or failed the result gate after retry" >&2 + exit 3 +fi + +# --- RM-10 telemetry ---------------------------------------------------------- +jq -r '"telemetry: window=\(.modelUsage // {} | to_entries[0].value.contextWindow // "?") in=\(.usage.input_tokens // "?") out=\(.usage.output_tokens // "?") ms=\(.duration_ms // "?")"' \ + "$JSON_OUT" >&2 2>/dev/null || true + +echo "ok: findings written to $OUT_FILE" diff --git a/specs/ci-triage-minimax-run.spec.md b/specs/ci-triage-minimax-run.spec.md new file mode 100644 index 0000000..d968c64 --- /dev/null +++ b/specs/ci-triage-minimax-run.spec.md @@ -0,0 +1,65 @@ +# ci-triage-minimax-run + +## Purpose + +Triage a failing Gitea Actions run in a MiniMax M3 sub-session: fetch the run's logs via `gitea-agent-companion`, have MiniMax name the failing step, error excerpt, and probable cause in a triage file — the log payload costs MiniMax tokens, not Anthropic tokens. Backs the `/ci-triage-minimax` skill; safe to drive directly. + +## Usage + +``` +ci-triage-minimax-run --repo --run [--job ] \ + [--out ] [--tail N] [--max-turns N] [--timeout SECS] [--dryrun|-n] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--repo` | required | `owner/repo` on the Gitea instance | +| `--run` | required | Web-UI run number. Required by design: picking "the latest failure" means parsing gac's human table — judgment that belongs in the calling skill, not an unversioned parse here | +| `--job` | none | Passed through to gac; needed for multi-job runs with no failed job (gac exits 2 asking for it) | +| `--out` | `${XDG_RUNTIME_DIR:-/tmp}/ci-triage-minimax/-/run--triage.md` | Triage file — outside the scratch-dir cleanup trap | +| `--tail` | 2000 | Log lines fetched (gac `--tail`) | +| `--max-turns` | 25 | Sub-session turn cap | +| `--timeout` | 300 | Internal timeout (logs-only task; well under the harness Bash 600s cap) | +| `--dryrun`, `-n` | off | Print resolved config, the gac command, the full prompt, and the sub-session command line; invoke neither gac's log fetch nor the sub-session | + +## Requirements + +- **CT-1 Preflight.** `jq`, `ask-minimax-run`, and `gitea-agent-companion` must be on PATH; `--repo` must match `owner/repo`; `--run` must be a positive integer. +- **CT-2 Fetch.** The wrapper runs `gitea-agent-companion inspect --run [--job ] --logs --json --tail ` into `run-logs.ndjson` and `inspect --run ` into `jobs.txt`, both in a scratch dir. Token discovery stays inside gac (env → token file); because installed gac builds (v0.0.2.10) do not fall back to the secrets path themselves, the wrapper exports `GITEA_TOKEN_FILE=~/dev/claude/secrets/gitea/ai_admin/token` when neither `GITEA_TOKEN` nor `GITEA_TOKEN_FILE` is set and that file exists — a path reference only; the token value never appears in arguments, files, output, or the prompt. +- **CT-3 gac exit mapping.** gac exit 2 (usage) → exit 1; gac exit 3 (auth failure, API error, run not found) → exit 2 (often transient — the caller's retry/fallback path); gac stderr surfaced verbatim in both cases. Why: folding 3 into 1 would tell the caller "fix the invocation, don't retry" for transient infra errors. +- **CT-4 Redaction.** Before prompt assembly, credential-shaped content in `run-logs.ndjson` (`ghp_…|glpat-|x-access-token:|Authorization:|BEGIN … PRIVATE KEY|AKIA…` — same pattern set as review-minimax-run) is replaced in place with `[REDACTED]`. Redact-and-continue, not abort: CI logs routinely carry unmasked clone URLs and the log is the whole payload. Residual risk: best-effort patterns; novel credential formats can pass. +- **CT-5 Envelope parse.** `failed_step`, `sliced`, and `note` are extracted from the NDJSON envelope with jq. If `failed_step` is null (cancelled/crashed run, green multi-job), the prompt states this and the output contract requires the literal `UNKNOWN` under `## Failing step` — no fabricated step names. +- **CT-6 Prompt contract.** Fixed preamble: read-only triage of the two files in cwd; explicit error-signature patterns to search (`error:|FAILED|Traceback|exit code|denied|timeout|not found|no such|ECONN|refused`); do not suggest running tests or builds; if the log carries the truncation marker or `sliced` is partial, say so — do not improvise. Output sections, exactly: `## Failing step` / `## Error excerpt` (≤10 verbatim lines) / `## Probable cause` (1–3 sentences, non-empty) / `## Suggested next action` (one command or one edit description — advisory only) / `## Confidence`. +- **CT-7 Invocation.** From the scratch dir: `timeout ask-minimax-run --model "MiniMax-M3[1m]" --permission-mode default --allowedTools "Read,Glob,Grep,Write" --disallowedTools "Edit,MultiEdit,NotebookEdit,Bash,WebFetch,WebSearch" --max-turns N --output-format json --add-dir -p`. Same rationale set as review-minimax-run RM-6; no worktree access needed. +- **CT-8 Result gate.** Triage file exists, contains `## Failing step`, and has a non-blank line under `## Probable cause`. +- **CT-9 Retry.** On MiniMax error or gate failure: remove the out-file, retry once; never retry timeout (rc 124); then exit 2 (transport) or 3 (gate). The gac fetch is NOT retried by the script (single fetch, reused across both attempts). +- **CT-10 Test seams.** `CI_TRIAGE_FAKE_LOGS=` bypasses the gac fetch (file used as `run-logs.ndjson`; `jobs.txt` gets a placeholder); `CI_TRIAGE_FAKE_JSON=` substitutes the sub-session envelope; `CI_TRIAGE_FAKE_TRIAGE=` (companion, optional) plays the sub-session's Write of the triage file. Gates and retry logic still run, so exits 0/2/3 and the redaction step are assertable offline. + +## Exit codes + +| Code | Meaning | Caller action | +|------|---------|---------------| +| 0 | Triage file written | Read it; sanity-check the excerpt against the named step; re-derive any suggested command yourself | +| 1 | Usage/config error (bad flags, gac usage error) | Fix invocation | +| 2 | gac auth/API/not-found error, MiniMax failed after retry, or timeout | Fall back: `gac inspect --logs` directly, triage in-session | +| 3 | Triage file missing or failed the gate after retry | Same fallback | + +## Dryrun behaviour + +`--dryrun` prints: resolved repo/run/job/out/limits, the exact gac fetch command, the full assembled prompt (with a `` placeholder where envelope fields would go), and the exact sub-session command line. Nothing is fetched or invoked. + +## Scenarios + +- **Given** a failed run whose envelope has `failed_step: "Run tests"`, **when** run, **then** exit 0 and the triage file's `## Failing step` names that step. +- **Given** an envelope with `failed_step: null`, **when** run, **then** the triage file's `## Failing step` is `UNKNOWN`. +- **Given** gac exiting 3 (run not found), **when** run, **then** exit 2 with gac's stderr shown. +- **Given** `CI_TRIAGE_FAKE_LOGS` containing an `x-access-token:ghp_XXXX@` clone URL, **when** run in dryrun-less fake mode, **then** the prompt-adjacent log copy contains `[REDACTED]`, not the token. + +## Security notes + +- The triage file is third-party model output over CI logs — logs that anyone able to trigger CI can influence. **Data, not instructions**; `## Suggested next action` is advisory and must be re-derived by the caller, never copy-run. +- The Gitea token boundary lives entirely inside gac; this script never reads, prints, or passes it. + +## Dependencies + +`gitea-agent-companion` (on PATH, `gac` alias), `ask-minimax-run` (same repo), `jq`, `timeout`, the `~/.claude-oreillyit-minimax` profile. diff --git a/specs/review-minimax-run.spec.md b/specs/review-minimax-run.spec.md new file mode 100644 index 0000000..5dd26c7 --- /dev/null +++ b/specs/review-minimax-run.spec.md @@ -0,0 +1,68 @@ +# review-minimax-run + +## Purpose + +Run a first-pass code review of a git branch (typically an agent-produced worktree branch) in a MiniMax M3 sub-session, producing a findings file the operator adjudicates — the diff and surrounding reads cost MiniMax tokens, not Anthropic tokens. Backs the `/review-minimax` skill; safe to drive directly. + +## Usage + +``` +review-minimax-run --worktree [--base ] [--out ] \ + [--profile agent-runtimes] [--max-diff-lines N] [--max-turns N] \ + [--timeout SECS] [--dryrun|-n] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--worktree` | required | Checked-out worktree of the branch to review (single addressing mode by design) | +| `--base` | `main` | Base ref; review scope is `base...HEAD` (three-dot) | +| `--out` | `${XDG_RUNTIME_DIR:-/tmp}/review-minimax//-findings.md` | Findings file — outside the repo and outside the scratch-dir cleanup trap | +| `--profile` | none | Extra checklist profile; only `agent-runtimes` defined (the 5 recurring MiniMax test-file bugs — repo-specific, noise elsewhere) | +| `--max-diff-lines` | 6000 | Diff budget; larger diffs exit 1 with the count | +| `--max-turns` | 40 | Sub-session turn cap | +| `--timeout` | 540 | Internal timeout (keeps callers under the harness Bash 600s cap). Why: a longer run invoked synchronously from a skill is killed by the harness with no findings file. | +| `--dryrun`, `-n` | off | Print resolved config, materialised-input plan, full prompt, and the exact sub-session command line; create/invoke nothing beyond the scratch dir | + +## Requirements + +- **RM-1 Preflight.** `jq` and `ask-minimax-run` must be on PATH; `--worktree` is `realpath -e` resolved, must be a git checkout (`git rev-parse --show-toplevel`), must not be on a detached HEAD, and is refused (exit 1) if it is or lies inside `secrets/` or `local_secrets/`. `--base` must resolve (`git rev-parse --verify `). +- **RM-2 Input materialisation.** The wrapper — never the sub-session — runs git: `diff ...HEAD` (three-dot; two-dot is misleading once base has moved) with pathspec exclusions `:(exclude)secrets/**`, `:(exclude)local_secrets/**`, `:(exclude)*.pem`, `:(exclude)*.key`, `:(exclude)*secret*` into `diff.patch`; `log --oneline ..HEAD` into `commits.txt`; `diff --stat ...HEAD` into `stat.txt` — all in a scratch dir. Why: the sub-session gets no Bash, cannot inherit the wrong branch, and the exclusions are the payload-side secrets boundary. +- **RM-3 Redaction gate.** If `diff.patch` matches `ghp_[A-Za-z0-9]|glpat-|x-access-token:|Authorization:|BEGIN [A-Z ]*PRIVATE KEY|AKIA[0-9A-Z]{16}`, exit 1 with "diff contains credential-shaped content; review in-session". No redact-and-continue for diffs: a credential in a diff means the branch itself needs attention. +- **RM-4 Empty/oversize diff.** Empty `diff.patch` → exit 1 "nothing to review". More than `--max-diff-lines` lines → exit 1 with the count. +- **RM-5 Prompt contract.** Fixed preamble: first-pass reviewer, non-interactive; inputs are the three files in cwd; the worktree is readable for surrounding context only; write ONLY the findings file at the given path; never modify any other file; do not run anything, fix anything, or improvise if an input is missing/truncated — say so and stop. Checklist = unconditional core (distilled from llm-code-security §10/§12: injection, hardcoded credentials, unquoted shell vars, verify=False, weak hashing/random, missing input validation, chmod 777, curl|bash, unpinned images/deps, missing idempotency, unconditional health checks, missing locking) as explicit grep-able patterns; `--profile agent-runtimes` appends the 5 recurring MiniMax test-file bugs. Findings format: `### [BLOCKER|SHOULD-FIX|NIT] ` blocks with `file:line`, ≤10-line excerpt, category, `Confidence: confirmed|suspected`; clean review → literal `NO FINDINGS`. +- **RM-6 Invocation.** From the scratch dir: `timeout <secs> ask-minimax-run --model "MiniMax-M3[1m]" --permission-mode default --allowedTools "Read,Glob,Grep,Write" --disallowedTools "Edit,MultiEdit,NotebookEdit,Bash,WebFetch,WebSearch" --max-turns N --output-format json --add-dir <worktree> --add-dir <out-dir> -p < prompt > response.json`. Why each: `[1m]` pins the 1M window (unknown model ids get 200k + early auto-compact); `Write` must be allowlisted or MiniMax exits 0 without creating the file; `default` not `acceptEdits` — new-file Write needs no edit approval and nothing else is auto-approved; deny-list beats the profile's permissive defaults. +- **RM-7 Clean-tree assertion.** After each attempt, `git -C <worktree> status --porcelain` must be empty; a dirtied tree is exit 2 (and the dirt is reported, not reverted). +- **RM-8 Result gate.** Findings file exists AND (contains `NO FINDINGS` OR (≥1 line matching `^### \[(BLOCKER|SHOULD-FIX|NIT)\]` AND ≥1 finding cites a path that exists relative to the worktree)). A bare `:NNN` match is NOT sufficient — timestamps and counters satisfy that. +- **RM-9 Retry.** On MiniMax error OR gate failure: remove the out-file and retry once (covers the "exit 0, file never written" failure and prevents a stale attempt-1 file passing the gate). Never retry a timeout (rc 124). Then exit 2 (transport/timeout/dirty-tree) or 3 (gate). +- **RM-10 Telemetry.** On success, log `modelUsage.contextWindow`, token totals, and wall clock from the JSON envelope to stderr (validates the 1M pin; no prompt caching on this provider, so cost per run is worth watching). +- **RM-11 Test seam.** `REVIEW_MINIMAX_FAKE_JSON=<file>` substitutes the canned sub-session JSON envelope and skips the live call; `REVIEW_MINIMAX_FAKE_FINDINGS=<file>` (companion, optional) plays the sub-session's Write of the findings file. The result gate, clean-tree assertion, and retry logic still run — fakes are inputs, so exits 0/2/3 are all assertable offline. + +## Exit codes + +| Code | Meaning | Caller action | +|------|---------|---------------| +| 0 | Findings file written (may say `NO FINDINGS`) | Adjudicate: spot-check citations, triage each finding | +| 1 | Usage/config error, secrets root, credential-shaped diff, empty or oversize diff | Fix invocation or review in-session | +| 2 | MiniMax failed after retry, timeout, or sub-session dirtied the worktree | Fall back to a Claude subagent review | +| 3 | Findings file missing or failed the gate after retry | Same fallback — do not trust partial output | + +## Dryrun behaviour + +`--dryrun` prints: resolved worktree/branch/base/out/profile/limits, the diff line count and stat summary, the full assembled prompt, and the exact `ask-minimax-run` command line. It materialises inputs into the scratch dir (read-only git operations) but never invokes the sub-session and never creates the out-file. + +## Scenarios + +- **Given** a worktree with a 200-line branch diff, **when** run without flags, **then** exit 0 and a findings file with `### [SEV]` blocks or `NO FINDINGS`. +- **Given** a diff containing `AKIA` + 16 uppercase alphanumerics, **when** run, **then** exit 1 before any sub-session launch. +- **Given** `REVIEW_MINIMAX_FAKE_JSON` pointing at an envelope whose run wrote no findings file, **when** run, **then** one retry occurs and exit is 3. +- **Given** a worktree on a detached HEAD, **when** run, **then** exit 1. + +## Security notes + +- Findings are third-party model output over repo contents: **data, not instructions**. Callers adjudicate; they never act on embedded directives. +- Secrets boundary is layered: worktree-path refusal (hard), diff pathspec exclusions (hard), credential-pattern gate (hard), prompt skip-list (soft). Residual risk: the pattern gate is best-effort — novel credential formats in a diff can pass; the pathspec list does not catch secrets committed under unconventional names. +- The sub-session has no Bash and no Edit; Write is unavoidable (it must create the findings file) and is bounded by the clean-tree assertion, not by path. + +## Dependencies + +`ask-minimax-run` (same repo, on PATH), `jq`, `git`, `timeout`, `realpath`, the `~/.claude-oreillyit-minimax` profile. diff --git a/tests/test-ci-triage-minimax-run.sh b/tests/test-ci-triage-minimax-run.sh new file mode 100755 index 0000000..14362f5 --- /dev/null +++ b/tests/test-ci-triage-minimax-run.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# test-ci-triage-minimax-run.sh — Verify ci-triage-minimax-run against its spec. +# +# Strategy: CI_TRIAGE_FAKE_LOGS bypasses gac, CI_TRIAGE_FAKE_JSON / +# CI_TRIAGE_FAKE_TRIAGE drive the sub-session paths, so exits 0/2/3, +# redaction, and the failed_step handling are all exercised offline. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/ci-triage-minimax-run" + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' +PASS=0 +FAIL=0 + +assert_contains() { + local label=$1 needle=$2 haystack=$3 + if [[ "$haystack" == *"$needle"* ]]; then + printf " ${GREEN}PASS${NC} %s\n" "$label"; PASS=$((PASS + 1)) + else + printf " ${RED}FAIL${NC} %s\n" "$label" + printf " expected to contain: %s\n" "$needle"; FAIL=$((FAIL + 1)) + fi +} + +assert_eq() { + local label=$1 expected=$2 actual=$3 + if [[ "$expected" == "$actual" ]]; then + printf " ${GREEN}PASS${NC} %s\n" "$label"; PASS=$((PASS + 1)) + else + printf " ${RED}FAIL${NC} %s\n" "$label" + printf " expected: %s actual: %s\n" "$expected" "$actual"; FAIL=$((FAIL + 1)) + fi +} + +TMP=$(mktemp -d /tmp/test-ci-triage.XXXXXX) +trap 'rm -rf "$TMP"' EXIT + +# gac-shaped NDJSON envelope + log lines, including a credential-shaped URL +FAKE_LOGS="$TMP/logs.ndjson" +cat > "$FAKE_LOGS" <<'EOF' +{"job_id":1,"conclusion":"failure","failed_step":"Run tests","sliced":true,"note":"","markers":[],"lines":["cloning https://x-access-token:ghp_abcdEFGH1234@gitea.example/r.git","FAILED tests/test_x.py::test_y - AssertionError","exit code 1"]} +EOF +FAKE_LOGS_NOSTEP="$TMP/logs-nostep.ndjson" +echo '{"job_id":1,"conclusion":"cancelled","failed_step":null,"sliced":false,"note":"","markers":[],"lines":["cancelled"]}' > "$FAKE_LOGS_NOSTEP" + +FAKE_OK_JSON="$TMP/ok.json" +echo '{"is_error":false,"result":"done"}' > "$FAKE_OK_JSON" +FAKE_ERR_JSON="$TMP/err.json" +echo '{"is_error":true,"result":"boom"}' > "$FAKE_ERR_JSON" +FAKE_TRIAGE="$TMP/triage.md" +printf '## Failing step\nRun tests\n## Error excerpt\nFAILED tests/test_x.py\n## Probable cause\nAssertion failure in test_y.\n## Suggested next action\nRe-run that test.\n## Confidence\nhigh\n' > "$FAKE_TRIAGE" +FAKE_TRIAGE_EMPTY_CAUSE="$TMP/triage-empty.md" +printf '## Failing step\nRun tests\n## Probable cause\n## Confidence\nlow\n' > "$FAKE_TRIAGE_EMPTY_CAUSE" + +echo "== ci-triage-minimax-run: --help ==" +HELP_OUT="$("$SCRIPT" --help 2>&1)" +assert_contains "help shows usage" "--repo" "$HELP_OUT" + +echo "== usage errors (exit 1) ==" +"$SCRIPT" >/dev/null 2>&1; assert_eq "no args exits 1" 1 $? +"$SCRIPT" --repo badformat --run 1 >/dev/null 2>&1; assert_eq "bad repo format exits 1" 1 $? +"$SCRIPT" --repo o/r --run abc >/dev/null 2>&1; assert_eq "non-integer run exits 1" 1 $? + +echo "== dryrun ==" +DRY_OUT="$("$SCRIPT" --repo skynet/gac-scratch --run 42 --dryrun 2>&1)" +assert_eq "dryrun exits 0" 0 $? +assert_contains "dryrun shows gac command" "inspect skynet/gac-scratch --run 42" "$DRY_OUT" +assert_contains "dryrun shows model pin" "MiniMax-M3[1m]" "$DRY_OUT" +assert_contains "dryrun defers fetch" "not fetched in dryrun" "$DRY_OUT" +assert_contains "dryrun shows job passthrough default" "job: auto" "$DRY_OUT" + +echo "== fake logs: redaction + failing step in prompt, exit 0 ==" +OUT0="$TMP/out0.md" +RUN_OUT="$(CI_TRIAGE_FAKE_LOGS="$FAKE_LOGS" CI_TRIAGE_FAKE_JSON="$FAKE_OK_JSON" CI_TRIAGE_FAKE_TRIAGE="$FAKE_TRIAGE" \ + "$SCRIPT" --repo o/r --run 7 --out "$OUT0" 2>&1)" +assert_eq "happy path exits 0" 0 $? +assert_contains "triage file written" "ok: triage written" "$RUN_OUT" + +echo "== fake logs: null failed_step still succeeds ==" +CI_TRIAGE_FAKE_LOGS="$FAKE_LOGS_NOSTEP" CI_TRIAGE_FAKE_JSON="$FAKE_OK_JSON" CI_TRIAGE_FAKE_TRIAGE="$FAKE_TRIAGE" \ + "$SCRIPT" --repo o/r --run 8 --out "$TMP/out-nostep.md" >/dev/null 2>&1 +assert_eq "null failed_step exits 0" 0 $? + +echo "== gate: empty probable cause -> exit 3 ==" +CI_TRIAGE_FAKE_LOGS="$FAKE_LOGS" CI_TRIAGE_FAKE_JSON="$FAKE_OK_JSON" CI_TRIAGE_FAKE_TRIAGE="$FAKE_TRIAGE_EMPTY_CAUSE" \ + "$SCRIPT" --repo o/r --run 9 --out "$TMP/out3.md" >/dev/null 2>&1 +assert_eq "empty probable-cause exits 3" 3 $? + +echo "== gate: no triage file -> exit 3 ==" +CI_TRIAGE_FAKE_LOGS="$FAKE_LOGS" CI_TRIAGE_FAKE_JSON="$FAKE_OK_JSON" \ + "$SCRIPT" --repo o/r --run 10 --out "$TMP/out3b.md" >/dev/null 2>&1 +assert_eq "missing triage file exits 3" 3 $? + +echo "== is_error envelope -> exit 2 ==" +CI_TRIAGE_FAKE_LOGS="$FAKE_LOGS" CI_TRIAGE_FAKE_JSON="$FAKE_ERR_JSON" \ + "$SCRIPT" --repo o/r --run 11 --out "$TMP/out2.md" >/dev/null 2>&1 +assert_eq "is_error envelope exits 2" 2 $? + +echo "" +echo "ci-triage-minimax-run: $PASS passed, $FAIL failed" +[[ "$FAIL" -eq 0 ]] diff --git a/tests/test-review-minimax-run.sh b/tests/test-review-minimax-run.sh new file mode 100755 index 0000000..11fd21f --- /dev/null +++ b/tests/test-review-minimax-run.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# test-review-minimax-run.sh — Verify review-minimax-run against its spec. +# +# Strategy: a throwaway git repo with a scratch branch provides real diffs; +# REVIEW_MINIMAX_FAKE_JSON drives the sub-session paths so exits 0/2/3 and +# the result gate are all exercised offline. No live MiniMax calls. + +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SCRIPT="$REPO_ROOT/scripts/review-minimax-run" + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' +PASS=0 +FAIL=0 + +assert_contains() { + local label=$1 needle=$2 haystack=$3 + if [[ "$haystack" == *"$needle"* ]]; then + printf " ${GREEN}PASS${NC} %s\n" "$label"; PASS=$((PASS + 1)) + else + printf " ${RED}FAIL${NC} %s\n" "$label" + printf " expected to contain: %s\n" "$needle"; FAIL=$((FAIL + 1)) + fi +} + +assert_eq() { + local label=$1 expected=$2 actual=$3 + if [[ "$expected" == "$actual" ]]; then + printf " ${GREEN}PASS${NC} %s\n" "$label"; PASS=$((PASS + 1)) + else + printf " ${RED}FAIL${NC} %s\n" "$label" + printf " expected: %s actual: %s\n" "$expected" "$actual"; FAIL=$((FAIL + 1)) + fi +} + +# --- fixture repo ------------------------------------------------------------- +TMP=$(mktemp -d /tmp/test-review-minimax.XXXXXX) +trap 'rm -rf "$TMP"' EXIT +REPO="$TMP/repo" +mkdir -p "$REPO" +git -C "$REPO" -c init.defaultBranch=main init -q +git -C "$REPO" -c user.email=t@t -c user.name=t commit -q --allow-empty -m init +echo "safe line" > "$REPO/app.sh" +git -C "$REPO" add -A +git -C "$REPO" -c user.email=t@t -c user.name=t commit -qm base +git -C "$REPO" checkout -qb feature +echo 'eval "$user_input"' >> "$REPO/app.sh" +git -C "$REPO" add -A +git -C "$REPO" -c user.email=t@t -c user.name=t commit -qm change + +FAKE_OK_JSON="$TMP/ok.json" +echo '{"is_error":false,"result":"done","usage":{"input_tokens":1,"output_tokens":1}}' > "$FAKE_OK_JSON" + +echo "== review-minimax-run: --help ==" +HELP_OUT="$("$SCRIPT" --help 2>&1)" +assert_contains "help shows usage" "--worktree" "$HELP_OUT" + +echo "== usage errors (exit 1) ==" +"$SCRIPT" >/dev/null 2>&1; assert_eq "no args exits 1" 1 $? +"$SCRIPT" --worktree /nonexistent >/dev/null 2>&1; assert_eq "missing worktree exits 1" 1 $? +"$SCRIPT" --worktree "$TMP" >/dev/null 2>&1; assert_eq "non-git worktree exits 1" 1 $? + +echo "== dryrun ==" +DRY_OUT="$("$SCRIPT" --worktree "$REPO" --dryrun 2>&1)" +assert_eq "dryrun exits 0" 0 $? +assert_contains "dryrun shows branch" "branch: feature" "$DRY_OUT" +assert_contains "dryrun shows model pin" "MiniMax-M3[1m]" "$DRY_OUT" +assert_contains "dryrun shows deny list" "Bash,WebFetch,WebSearch" "$DRY_OUT" +assert_contains "dryrun prompt has findings format" "NO FINDINGS" "$DRY_OUT" +assert_contains "dryrun shows checklist" "shell=True" "$DRY_OUT" + +echo "== profile ==" +PROF_OUT="$("$SCRIPT" --worktree "$REPO" --profile agent-runtimes --dryrun 2>&1)" +assert_contains "agent-runtimes profile adds test-bug checklist" "/v1/ prefix" "$PROF_OUT" +NOPROF_OUT="$("$SCRIPT" --worktree "$REPO" --dryrun 2>&1)" +if [[ "$NOPROF_OUT" != *"/v1/ prefix"* ]]; then + printf " ${GREEN}PASS${NC} no profile omits repo-specific checklist\n"; PASS=$((PASS + 1)) +else + printf " ${RED}FAIL${NC} no profile omits repo-specific checklist\n"; FAIL=$((FAIL + 1)) +fi + +echo "== diff gates (exit 1) ==" +"$SCRIPT" --worktree "$REPO" --max-diff-lines 1 --dryrun >/dev/null 2>&1 +assert_eq "oversize diff exits 1" 1 $? +git -C "$REPO" checkout -q main +git -C "$REPO" checkout -qb empty-branch +"$SCRIPT" --worktree "$REPO" --dryrun >/dev/null 2>&1 +assert_eq "empty diff exits 1" 1 $? +git -C "$REPO" checkout -q feature + +echo "== credential-shaped diff (exit 1) ==" +git -C "$REPO" checkout -qb cred-branch main +echo 'url = https://x-access-token:tok@example.com/r.git' > "$REPO/cfg" +git -C "$REPO" add -A +git -C "$REPO" -c user.email=t@t -c user.name=t commit -qm cred +CRED_OUT="$("$SCRIPT" --worktree "$REPO" --dryrun 2>&1)" +assert_eq "credential-shaped diff exits 1" 1 $? +assert_contains "credential message" "credential-shaped" "$CRED_OUT" +git -C "$REPO" checkout -q feature + +echo "== fake seam: no findings file -> exit 3 after retry ==" +OUT3="$TMP/findings3.md" +REVIEW_MINIMAX_FAKE_JSON="$FAKE_OK_JSON" "$SCRIPT" --worktree "$REPO" --out "$OUT3" >/dev/null 2>&1 +assert_eq "missing findings file exits 3" 3 $? + +echo "== fake seam: uncited findings -> exit 3 ==" +FAKE_BAD="$TMP/fake-bad-findings.md" +printf '### [NIT] vague worry\n- Location: made/up/file.py:12\n- Confidence: suspected\n' > "$FAKE_BAD" +REVIEW_MINIMAX_FAKE_JSON="$FAKE_OK_JSON" REVIEW_MINIMAX_FAKE_FINDINGS="$FAKE_BAD" \ + "$SCRIPT" --worktree "$REPO" --out "$TMP/findings4.md" >/dev/null 2>&1 +assert_eq "uncited findings exits 3" 3 $? + +echo "== fake seam: cited findings -> exit 0 ==" +FAKE_GOOD="$TMP/fake-good-findings.md" +printf '### [BLOCKER] eval on user input\n- Location: app.sh:2\n- Category: Injection\n- Confidence: confirmed\n' > "$FAKE_GOOD" +REVIEW_MINIMAX_FAKE_JSON="$FAKE_OK_JSON" REVIEW_MINIMAX_FAKE_FINDINGS="$FAKE_GOOD" \ + "$SCRIPT" --worktree "$REPO" --out "$TMP/findings0.md" >/dev/null 2>&1 +assert_eq "cited findings exits 0" 0 $? + +echo "== fake seam: NO FINDINGS -> exit 0 ==" +FAKE_CLEAN="$TMP/fake-clean.md" +echo 'NO FINDINGS' > "$FAKE_CLEAN" +REVIEW_MINIMAX_FAKE_JSON="$FAKE_OK_JSON" REVIEW_MINIMAX_FAKE_FINDINGS="$FAKE_CLEAN" \ + "$SCRIPT" --worktree "$REPO" --out "$TMP/findings-clean.md" >/dev/null 2>&1 +assert_eq "NO FINDINGS exits 0" 0 $? + +echo "== fake seam: is_error envelope -> exit 2 ==" +FAKE_ERR_JSON="$TMP/err.json" +echo '{"is_error":true,"result":"boom"}' > "$FAKE_ERR_JSON" +REVIEW_MINIMAX_FAKE_JSON="$FAKE_ERR_JSON" "$SCRIPT" --worktree "$REPO" --out "$TMP/f5.md" >/dev/null 2>&1 +assert_eq "is_error envelope exits 2" 2 $? + +echo "" +echo "review-minimax-run: $PASS passed, $FAIL failed" +[[ "$FAIL" -eq 0 ]]