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
This commit is contained in:
212
scripts/ci-triage-minimax-run
Executable file
212
scripts/ci-triage-minimax-run
Executable file
@@ -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 <owner/repo> --run <N> [--job <name|id>]
|
||||
# [--out <triage-file>] [--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 "<run-logs not fetched in dryrun>"
|
||||
echo "-- sub-session command --"
|
||||
echo "cd <workdir> && 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"
|
||||
253
scripts/review-minimax-run
Executable file
253
scripts/review-minimax-run
Executable file
@@ -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 <path> [--base <ref>] [--out <findings-file>]
|
||||
# [--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] <short title>
|
||||
- Location: <file>:<line>
|
||||
- Category: <checklist 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"
|
||||
65
specs/ci-triage-minimax-run.spec.md
Normal file
65
specs/ci-triage-minimax-run.spec.md
Normal file
@@ -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 <owner/repo> --run <N> [--job <name|id>] \
|
||||
[--out <triage-file>] [--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/<owner>-<repo>/run-<N>-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 <repo> --run <N> [--job <J>] --logs --json --tail <n>` into `run-logs.ndjson` and `inspect <repo> --run <N>` 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 <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 <out-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=<file>` bypasses the gac fetch (file used as `run-logs.ndjson`; `jobs.txt` gets a placeholder); `CI_TRIAGE_FAKE_JSON=<file>` substitutes the sub-session envelope; `CI_TRIAGE_FAKE_TRIAGE=<file>` (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 `<run-logs not fetched in dryrun>` 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.
|
||||
68
specs/review-minimax-run.spec.md
Normal file
68
specs/review-minimax-run.spec.md
Normal file
@@ -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 <path> [--base <ref>] [--out <findings-file>] \
|
||||
[--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/<repo>/<branch-slug>-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 <base>`).
|
||||
- **RM-2 Input materialisation.** The wrapper — never the sub-session — runs git: `diff <base>...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 <base>..HEAD` into `commits.txt`; `diff --stat <base>...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] <title>` 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.
|
||||
106
tests/test-ci-triage-minimax-run.sh
Executable file
106
tests/test-ci-triage-minimax-run.sh
Executable file
@@ -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 ]]
|
||||
139
tests/test-review-minimax-run.sh
Executable file
139
tests/test-review-minimax-run.sh
Executable file
@@ -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 ]]
|
||||
Reference in New Issue
Block a user