Files
layue13 c6c2d071fb
Gatekeep evals / eval-floor (push) Successful in 3s
Gatekeep evals / eval-floor (pull_request) Successful in 15s
Gatekeep / gatekeep (pull_request) Failing after 1m46s
fix: harden partial 429 success, fallbacks, zh errors
Retry HTTP 429 with backoff; keep models beyond first 3 as
fallbacks; degrade agreement when some models fail; force-high
risk only if zero usable responses. Clearer zh-CN errors/docs for
dogfood uses: ./ rebase + workflow_dispatch re-run.
2026-10-27 08:35:52 +00:00

251 lines
10 KiB
Bash
Executable File

#!/usr/bin/env bash
# Orchestrate parallel multi-provider model calls → merge → publish.
set -euo pipefail
ROOT="${GATEKEEP_ROOT:-.}"
ACTION="${GATEKEEP_ACTION:-.}"
OUT="$ROOT/.gatekeep/out"
MODELS_DIR="$OUT/models"
mkdir -p "$MODELS_DIR"
EVENT="${GITHUB_EVENT_PATH:?}"
export GATEKEEP_ROOT ACTION CONFIG_DIR OUT
# shellcheck source=lib-config.sh
source "$ACTION/scripts/lib-config.sh"
if [[ "${SKIP_DRAFT:-true}" == "true" ]]; then
DRAFT=$(python3 -c 'import json,sys; print(str(json.load(open(sys.argv[1]))["pull_request"].get("draft", False)).lower())' "$EVENT")
if [[ "$DRAFT" == "true" ]]; then
echo "Draft PR — skip"
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
echo "risk_tier=skipped" >> "$GITHUB_OUTPUT"
echo "security_blocking=false" >> "$GITHUB_OUTPUT"
echo "agreed_count=0" >> "$GITHUB_OUTPUT"
fi
exit 0
fi
fi
REPO="${GITHUB_REPOSITORY:?}"
PR=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["pull_request"]["number"])' "$EVENT")
PROVIDERS_CFG="$(gatekeep_providers_config)"
PLAN="$OUT/provider-plan.json"
python3 "$ACTION/scripts/resolve-providers.py" \
"$PROVIDERS_CFG" \
"$PLAN" \
"${GATEKEEP_MODELS:-}"
AGREEMENT_MIN=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["agreement_min"])' "$PLAN")
# Prefer Action input / env override already applied in plan.language
export GATEKEEP_LANGUAGE
GATEKEEP_LANGUAGE=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("language") or "en")' "$PLAN")
export GATEKEEP_MAX_TOKENS
GATEKEEP_MAX_TOKENS=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("max_tokens",4096))' "$PLAN")
SECRETS_FILE=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["secrets_file"])' "$PLAN")
cleanup_secrets() { rm -f "$SECRETS_FILE"; }
trap cleanup_secrets EXIT
# Diff (truncated) — forge-agnostic (.diff URL)
: > "$OUT/diff.full"
SERVER="${GITHUB_SERVER_URL:-}"
TOK="${GITHUB_TOKEN:-${GITEA_TOKEN:-}}"
if [[ -n "$SERVER" ]]; then
curl -sS -H "Authorization: token ${TOK}" \
"${SERVER%/}/${REPO}/pulls/${PR}.diff" -o "$OUT/diff.full" 2>/dev/null || true
fi
if [[ ! -s "$OUT/diff.full" ]]; then
python3 "$ACTION/scripts/forge_api.py" GET "/repos/${REPO}/pulls/${PR}.diff" >"$OUT/diff.full" 2>/dev/null || true
fi
python3 -c 'import pathlib; p=pathlib.Path("'"$OUT"'/diff.full"); t=p.read_text(errors="replace"); pathlib.Path("'"$OUT"'/diff.txt").write_text(t if len(t)<=200000 else t[:200000]+"\n\n… [truncated]")'
FLOOR="${POLICY_FLOOR:-$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["floor"])' "$OUT/floor.json")}"
SCAN_HARD="${SCAN_HARD_FAIL:-false}"
export POLICY_FLOOR="$FLOOR" POLICY_REASONS="${POLICY_REASONS:-}"
python3 <<'PY'
import json, os, pathlib
out = pathlib.Path(os.environ["OUT"])
floor = os.environ.get("POLICY_FLOOR") or json.loads((out / "floor.json").read_text())["floor"]
reasons = os.environ.get("POLICY_REASONS", "")
event = json.loads(pathlib.Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
pr = event["pull_request"]
files = [l.strip() for l in (out / "changed-files.txt").read_text().splitlines() if l.strip()]
floor_j = json.loads((out / "floor.json").read_text())
diff = (out / "diff.txt").read_text() if (out / "diff.txt").exists() else ""
discover = json.loads((out / "discover.json").read_text()) if (out / "discover.json").exists() else {}
plan = json.loads((out / "provider-plan.json").read_text())
language = (plan.get("language") or os.environ.get("GATEKEEP_LANGUAGE") or "en").strip() or "en"
lang_note = (
f"LANGUAGE: Write ALL human-facing prose in {language} "
f"(summary, rationale, finding title / why_it_matters / body, human_focus). "
f"Keep JSON keys and enum tokens in English exactly as specified "
f"(risk_tier, severity Critical|High|Medium|Low, tag, kind)."
)
system = f"""You are one reviewer in Gatekeep, a multi-model PR review system for the repo owner's own projects.
Your job is useful review content that makes human triage cheap — not chat UX.
{lang_note}
Return ONLY valid JSON (no markdown fences) with:
{{
"risk_tier": "low|medium|high",
"summary": "short chatty overview",
"rationale": "why this PR risk tier",
"findings": [{{
"severity": "Critical|High|Medium|Low",
"path": "file path",
"line": 1,
"line_end": null,
"tag": "Important|Nit|Pre-existing",
"kind": "security|correctness|behavior|other",
"title": "short headline",
"why_it_matters": "concrete impact if ignored",
"body": "optional extra detail in a friendly tone",
"blocking": false
}}],
"human_focus": ["string"]
}}
Finding rules (required on EVERY finding):
1. severity — Critical/High/Medium/Low so humans see critical vs minor instantly. (Distinct from PR risk_tier.)
2. exact location — path + line (use line_end for a range when needed).
3. why_it_matters — the impact/reason, not just "please fix".
Tone: clear and human; structured fields, not robotic checklist-speak.
tag is secondary (Important/Nit/Pre-existing); severity dominates.
Prefer real harm over style nits. Drop pure style. kind=security or severity=Critical ⇒ blocking true.
Pre-existing issues: still include severity + location + why if the PR touches or worsens them.
PR risk_tier rules:
- From the change (paths, blast radius, security, CI), not author trust.
- MUST be >= the provided policy_floor.
- Security checklist seed: hardcoded secrets/tokens; prompt injection via repo files; auth bypass; unauthorized API access; billing logic bypass; CI workflow tampering; unsafe deserialization; SQL/command injection.
- You never approve as a human. Bots never fill human slots.
- Do not invent chat commands or ask humans to @-mention you.
"""
user = {
"policy_floor": floor,
"policy_floor_reasons": [r.strip() for r in reasons.split("|") if r.strip()],
"language": language,
"discover": discover,
"reviewers": [f"{m['provider']}:{m['model']}" for m in plan.get("models", [])],
"pull_request": {
"title": pr.get("title"),
"body": pr.get("body") or "",
"author": (pr.get("user") or {}).get("login"),
"base": (pr.get("base") or {}).get("ref"),
"head": (pr.get("head") or {}).get("ref"),
"labels": [l.get("name") if isinstance(l, dict) else l for l in (pr.get("labels") or [])],
"ci_status": floor_j.get("ci"),
"changed_files": files,
"additions": floor_j.get("additions"),
"deletions": floor_j.get("deletions"),
},
"diff": diff,
}
json.dump({"system": system, "user": json.dumps(user, indent=2), "language": language}, open(out / "prompt.json", "w"))
print(f"Prompt ready; floor={floor}; language={language}; plan={len(plan.get('models',[]))} models", flush=True)
PY
# Fire primary models in parallel (may be mixed providers)
pids=()
i=0
while IFS= read -r line; do
i=$((i + 1))
provider=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["provider"])')
model=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["model"])')
protocol=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["protocol"])')
base_url=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["base_url"])')
safe=$(echo "${provider}_${model}" | tr '/:' '__')
bash "$ACTION/scripts/call-model.sh" \
"$provider" "$model" "$protocol" "$base_url" \
"$OUT/prompt.json" "$MODELS_DIR/model-$i-$safe.json" "$SECRETS_FILE" &
pids+=($!)
done < <(python3 -c 'import json,sys; [print(json.dumps(m)) for m in json.load(open(sys.argv[1]))["models"]]' "$PLAN")
ec=0
for pid in "${pids[@]}"; do
wait "$pid" || ec=1
done
echo "Parallel model calls finished (wait_ec=$ec)" >&2
# On partial failure, try fallback_models from providers.yaml (beyond the first 3)
python3 - "$MODELS_DIR" "$PLAN" <<'PY' >"$OUT/fallback-needed.txt"
import json, pathlib, sys
models_dir = pathlib.Path(sys.argv[1])
plan = json.load(open(sys.argv[2]))
failed = ok = 0
for p in models_dir.glob("model-*.json"):
try:
j = json.loads(p.read_text())
except Exception:
failed += 1
continue
if j.get("error") or j.get("parse_error"):
failed += 1
else:
ok += 1
fallbacks = plan.get("fallback_models") or []
# Aim for ≥2 usable responses; if zero ok, try up to 3 reserves
if ok == 0:
need = min(len(fallbacks), 3)
elif ok < 2:
need = min(len(fallbacks), 2 - ok)
else:
need = 0
print(need)
print(f"gatekeep: primary ok={ok} failed={failed} fallbacks={len(fallbacks)} need={need}", file=sys.stderr)
PY
NEED_FB=$(head -1 "$OUT/fallback-needed.txt" | tr -d '[:space:]')
NEED_FB=${NEED_FB:-0}
if [[ "$NEED_FB" -gt 0 ]]; then
echo "gatekeep: trying $NEED_FB fallback model(s) after partial failure" >&2
fb_i=0
while IFS= read -r line; do
fb_i=$((fb_i + 1))
[[ "$fb_i" -gt "$NEED_FB" ]] && break
provider=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["provider"])')
model=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["model"])')
protocol=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["protocol"])')
base_url=$(printf '%s' "$line" | python3 -c 'import json,sys; print(json.load(sys.stdin)["base_url"])')
safe=$(echo "fb_${provider}_${model}" | tr '/:' '__')
bash "$ACTION/scripts/call-model.sh" \
"$provider" "$model" "$protocol" "$base_url" \
"$OUT/prompt.json" "$MODELS_DIR/model-fb$fb_i-$safe.json" "$SECRETS_FILE" || true
done < <(python3 -c 'import json,sys; [print(json.dumps(m)) for m in json.load(open(sys.argv[1])).get("fallback_models") or []]' "$PLAN")
fi
# Scrub secrets file from workspace artifact path if present
rm -f "$SECRETS_FILE"
node "$ACTION/scripts/merge-findings.js" \
--dir "$MODELS_DIR" \
--floor "$FLOOR" \
--agreement-min "$AGREEMENT_MIN" \
--scan-hard-fail "$SCAN_HARD" \
--language "$GATEKEEP_LANGUAGE" \
--out "$OUT/merged.json" | tee "$OUT/merge-meta.json"
set +e
node "$ACTION/scripts/publish.js" "$OUT/merged.json" "$OUT/floor.json" "$OUT/security-scan.json"
pub_ec=$?
set -e
META=$(cat "$OUT/merge-meta.json")
if [[ -n "${GITHUB_OUTPUT:-}" ]]; then
python3 - "$META" <<'PY'
import json, os, sys
m = json.loads(sys.argv[1])
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
fh.write(f"risk_tier={m['risk_tier']}\n")
fh.write(f"security_blocking={str(m['security_blocking']).lower()}\n")
fh.write(f"agreed_count={m['agreed_count']}\n")
PY
fi
exit "$pub_ec"