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.
251 lines
10 KiB
Bash
Executable File
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"
|