#!/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") 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()) system = """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. 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()], "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)}, open(out / "prompt.json", "w")) print(f"Prompt ready; floor={floor}; plan={len(plan.get('models',[]))} models", flush=True) PY # Fire 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 # 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" \ --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 -c 'import json,os,sys m=json.loads(sys.argv[1]) open(os.environ["GITHUB_OUTPUT"],"a").write( f"risk_tier={m[\"risk_tier\"]}\nsecurity_blocking={str(m[\"security_blocking\"]).lower()}\nagreed_count={m[\"agreed_count\"]}\n" )' "$META" fi exit "$pub_ec"