#!/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"