fix(gate): auto-tier + qa/security auto-trigger after 2-genuine review (#2396) #2400
Executable
+212
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env bash
|
||||
# auto-tier-check — automatically assign tier:low|medium|high label to a PR
|
||||
# based on diff heuristics when it reaches 2-genuine code-review.
|
||||
#
|
||||
# Heuristics (RFC#324 / internal#189 addendum):
|
||||
# tier:high — touches security/auth/migrations, OR >500 lines changed,
|
||||
# OR changes to .gitea/workflows/*, OR changes gate/merge-control scripts
|
||||
# tier:low — ONLY tests/ or docs/ or README changes, AND <50 lines changed
|
||||
# tier:medium — everything else
|
||||
#
|
||||
# Invoked from `.gitea/workflows/auto-tier-check.yml`.
|
||||
#
|
||||
# Required env:
|
||||
# GITEA_TOKEN — bot PAT with read:repository + write:issue (label write)
|
||||
# GITEA_HOST — e.g. git.moleculesai.app
|
||||
# REPO — owner/name
|
||||
# PR_NUMBER — int
|
||||
# PR_AUTHOR — login
|
||||
#
|
||||
# Optional:
|
||||
# AUTO_TIER_DEBUG=1 — verbose diagnostics
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
: "${GITEA_TOKEN:?GITEA_TOKEN required}"
|
||||
: "${GITEA_HOST:?GITEA_HOST required}"
|
||||
: "${REPO:?REPO required (owner/name)}"
|
||||
: "${PR_NUMBER:?PR_NUMBER required}"
|
||||
: "${PR_AUTHOR:?PR_AUTHOR required}"
|
||||
|
||||
OWNER="${REPO%%/*}"
|
||||
NAME="${REPO##*/}"
|
||||
API="https://${GITEA_HOST}/api/v1"
|
||||
AUTH="Authorization: token ${GITEA_TOKEN}"
|
||||
CONTEXT="auto-tier-check / assigned (pull_request)"
|
||||
|
||||
debug() {
|
||||
if [ "${AUTO_TIER_DEBUG:-}" = "1" ]; then
|
||||
echo " [debug] $*" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
echo "::notice::auto-tier-check start: repo=$OWNER/$NAME pr=$PR_NUMBER author=$PR_AUTHOR"
|
||||
|
||||
# 0. Sanity: token resolves to a user.
|
||||
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""') || true
|
||||
if [ -z "$WHOAMI" ]; then
|
||||
echo "::error::GITEA_TOKEN cannot resolve a user via /api/v1/user"
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::token resolves to user: $WHOAMI"
|
||||
|
||||
# 1. Check if PR already has a tier label.
|
||||
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name') || true
|
||||
for L in $LABELS; do
|
||||
case "$L" in
|
||||
tier:low|tier:medium|tier:high)
|
||||
echo "::notice::PR already has tier label '$L'. Skipping auto-assignment."
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 2. Fetch PR diff statistics.
|
||||
# Gitea API: /repos/{owner}/{repo}/pulls/{index}.diff returns the raw diff.
|
||||
# We use the PR endpoint first to get changed_files + additions + deletions.
|
||||
PR_INFO=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}")
|
||||
ADDITIONS=$(echo "$PR_INFO" | jq -r '.additions // 0')
|
||||
DELETIONS=$(echo "$PR_INFO" | jq -r '.deletions // 0')
|
||||
CHANGED_FILES=$(echo "$PR_INFO" | jq -r '.changed_files // 0')
|
||||
HEAD_SHA=$(echo "$PR_INFO" | jq -r '.head.sha // ""')
|
||||
|
||||
debug "additions=$ADDITIONS deletions=$DELETIONS changed_files=$CHANGED_FILES head_sha=$HEAD_SHA"
|
||||
|
||||
# 3. Fetch file list from diff to apply path-based heuristics.
|
||||
# Use the diff endpoint and extract changed paths.
|
||||
DIFF_PATHS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}.diff" | grep -E '^(\+\+\+|---)' | sed 's/^[+-][+-][+-] [ab]\///' | sort -u) || true
|
||||
|
||||
debug "diff paths: $(echo "$DIFF_PATHS" | tr '\n' ' ')"
|
||||
|
||||
# 4. Heuristic evaluation.
|
||||
TIER=""
|
||||
|
||||
# 4a. HIGH risk indicators
|
||||
HIGH_INDICATORS=0
|
||||
# Security/auth/migrations paths — ALWAYS high-risk (no threshold needed)
|
||||
if echo "$DIFF_PATHS" | grep -qiE '(security|auth|crypto|migrate|migration|password|token|secret|credential|cert|tls|oauth|rbac|acl|permission|audit)'; then
|
||||
HIGH_INDICATORS=$((HIGH_INDICATORS + 2))
|
||||
debug "high indicator: security/auth path detected (+2)"
|
||||
fi
|
||||
# Gate/merge-control scripts (single match = +1 indicator)
|
||||
if echo "$DIFF_PATHS" | grep -qiE '^(\.gitea/scripts/(sop-tier|review-check|audit-force|merge-queue|gate-|auto-tier|status-reaper|sop-checklist|prod-auto-deploy|ci-required-drift|lint_)|\.gitea/workflows/(qa-review|security-review|sop-tier|gitea-merge|auto-tier|sop-checklist|ci\.yml|lint-|block-internal|handlers-postgres|publish-))'; then
|
||||
HIGH_INDICATORS=$((HIGH_INDICATORS + 1))
|
||||
debug "high indicator: gate/merge-control script detected"
|
||||
fi
|
||||
# Workflow changes (CI/CD path) — any .gitea/workflows/ change
|
||||
# Reviewer requirement: gate-altering changes MUST be tier:high.
|
||||
if echo "$DIFF_PATHS" | grep -qiE '^\.gitea/workflows/'; then
|
||||
HIGH_INDICATORS=$((HIGH_INDICATORS + 2))
|
||||
debug "high indicator: workflow file detected (+2)"
|
||||
fi
|
||||
# Any .gitea/scripts/ change (gate/CI governance surface)
|
||||
# Reviewer requirement: gate-altering changes MUST be tier:high.
|
||||
if echo "$DIFF_PATHS" | grep -qiE '^\.gitea/scripts/'; then
|
||||
HIGH_INDICATORS=$((HIGH_INDICATORS + 2))
|
||||
debug "high indicator: .gitea/scripts governance surface detected (+2)"
|
||||
fi
|
||||
# Branch-protection / merge-control / SOP config — ALWAYS high-risk
|
||||
if echo "$DIFF_PATHS" | grep -qiE '^(\.gitea/sop-checklist-config|runbooks/sop-|docs/design/rfc-|\.gitea/workflows/gitea-merge|tools/branch-protection|audit-force-merge|merge-gate)'; then
|
||||
HIGH_INDICATORS=$((HIGH_INDICATORS + 2))
|
||||
debug "high indicator: SOP/branch-protection/merge-gate config detected (+2)"
|
||||
fi
|
||||
# Large diff (>500 lines total changes)
|
||||
TOTAL_LINES=$((ADDITIONS + DELETIONS))
|
||||
if [ "$TOTAL_LINES" -gt 500 ]; then
|
||||
HIGH_INDICATORS=$((HIGH_INDICATORS + 1))
|
||||
debug "high indicator: large diff ($TOTAL_LINES lines)"
|
||||
fi
|
||||
# Many files changed (>20)
|
||||
if [ "$CHANGED_FILES" -gt 20 ]; then
|
||||
HIGH_INDICATORS=$((HIGH_INDICATORS + 1))
|
||||
debug "high indicator: many files ($CHANGED_FILES)"
|
||||
fi
|
||||
|
||||
# 4b. LOW risk indicators
|
||||
LOW_INDICATORS=0
|
||||
# ALL paths are tests/docs/README (or .github metadata)
|
||||
if [ -n "$DIFF_PATHS" ] && ! echo "$DIFF_PATHS" | grep -qvE '^(tests?/|docs?/|README|\.md$|\.github/)'; then
|
||||
LOW_INDICATORS=$((LOW_INDICATORS + 1))
|
||||
debug "low indicator: only test/doc paths detected"
|
||||
fi
|
||||
# Small diff (<50 lines, <=3 files)
|
||||
if [ "$TOTAL_LINES" -le 50 ] && [ "$CHANGED_FILES" -le 3 ]; then
|
||||
LOW_INDICATORS=$((LOW_INDICATORS + 1))
|
||||
debug "low indicator: small diff ($TOTAL_LINES lines, $CHANGED_FILES files)"
|
||||
fi
|
||||
|
||||
# 4c. Decision
|
||||
if [ "$HIGH_INDICATORS" -ge 2 ]; then
|
||||
TIER="tier:high"
|
||||
echo "::notice::Heuristic result: HIGH (indicators=$HIGH_INDICATORS, total_lines=$TOTAL_LINES, files=$CHANGED_FILES)"
|
||||
elif [ "$LOW_INDICATORS" -ge 2 ] && [ "$HIGH_INDICATORS" -eq 0 ]; then
|
||||
TIER="tier:low"
|
||||
echo "::notice::Heuristic result: LOW (indicators=$LOW_INDICATORS, total_lines=$TOTAL_LINES, files=$CHANGED_FILES)"
|
||||
else
|
||||
TIER="tier:medium"
|
||||
echo "::notice::Heuristic result: MEDIUM (high_indicators=$HIGH_INDICATORS, low_indicators=$LOW_INDICATORS, total_lines=$TOTAL_LINES, files=$CHANGED_FILES)"
|
||||
fi
|
||||
|
||||
# 5. Resolve label name → id, then assign via API.
|
||||
# Gitea 1.22.6 /issues/{n}/labels endpoint accepts label IDs, not raw names.
|
||||
# Precedent: gitea-merge-queue.py:857-880, ci-required-drift.py:677-697.
|
||||
LABELS_JSON=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/labels") || true
|
||||
LABEL_ID=$(echo "$LABELS_JSON" | jq -r --arg name "$TIER" '.[] | select(.name == $name) | .id // empty') || true
|
||||
|
||||
if [ -z "$LABEL_ID" ] || [ "$LABEL_ID" = "null" ]; then
|
||||
echo "::error::Label '${TIER}' not found in repo ${OWNER}/${NAME}. Cannot auto-assign tier."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
debug "resolved label '${TIER}' → id=${LABEL_ID}"
|
||||
|
||||
HTTP_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" -X POST \
|
||||
"${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"labels\":[${LABEL_ID}]}") || true
|
||||
|
||||
debug "label POST HTTP code: $HTTP_CODE"
|
||||
|
||||
case "$HTTP_CODE" in
|
||||
200|201|204)
|
||||
echo "::notice::Assigned label '${TIER}' (id=${LABEL_ID}) to PR #${PR_NUMBER}"
|
||||
;;
|
||||
403)
|
||||
echo "::error::Label assignment forbidden (HTTP 403). Token may lack write:issue scope."
|
||||
exit 1
|
||||
;;
|
||||
404)
|
||||
echo "::error::Label or PR not found (HTTP 404). Label '${TIER}' may not exist in repo."
|
||||
exit 1
|
||||
;;
|
||||
409)
|
||||
echo "::notice::Label '${TIER}' already assigned (HTTP 409). Continuing."
|
||||
;;
|
||||
*)
|
||||
echo "::error::Label assignment failed with HTTP ${HTTP_CODE}"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# 6. Post required status context.
|
||||
# Branch protection requires the (pull_request_target) context variant.
|
||||
STATUS_PAYLOAD=$(jq -n \
|
||||
--arg state "success" \
|
||||
--arg context "$CONTEXT" \
|
||||
--arg description "Auto-assigned ${TIER}" \
|
||||
'{state: $state, context: $context, description: $description}')
|
||||
|
||||
POST_CODE=$(curl -sS -o /dev/null -w '%{http_code}' -H "$AUTH" -X POST \
|
||||
"${API}/repos/${OWNER}/${NAME}/statuses/${HEAD_SHA}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$STATUS_PAYLOAD") || true
|
||||
|
||||
debug "status POST HTTP code: $POST_CODE"
|
||||
|
||||
if [ "$POST_CODE" != "200" ] && [ "$POST_CODE" != "201" ]; then
|
||||
echo "::error::Status POST failed with HTTP ${POST_CODE}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "::notice::auto-tier-check complete: ${TIER} assigned, status posted."
|
||||
exit 0
|
||||
@@ -362,6 +362,8 @@ _passed_clauses=""
|
||||
_failed_clauses=""
|
||||
|
||||
for _raw_clause in $EXPR; do
|
||||
# Skip operator tokens produced by bash word-split on the expression string.
|
||||
[ "$_raw_clause" = "AND" ] || [ "$_raw_clause" = "OR" ] && continue
|
||||
# Normalise: strip parens, replace commas with spaces so bash word-split
|
||||
# can iterate the OR-set members. The previous form
|
||||
# _clause=$(echo ... | tr ',' '\n' | tr -d '[:space:]' | grep -v '^$')
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""Regression test #2396 — auto-tier-check structural validation.
|
||||
|
||||
Validates that auto-tier-check.yml is correctly configured to:
|
||||
1. Trigger on pull_request_target, pull_request_review, and issue_comment events.
|
||||
2. Fire the job on all review events (no unreliable review.state guard).
|
||||
3. Use STATUS_POST_TOKEN for the explicit status POST step.
|
||||
4. Emit the exact branch-protection-required context name.
|
||||
5. Support /retier and /tier-recheck slash commands.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def load_workflow(name: str) -> dict:
|
||||
with (ROOT / "workflows" / name).open() as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def _job_guard_string(workflow: dict) -> str:
|
||||
"""Return the raw job-level `if:` string for the single job."""
|
||||
jobs = workflow["jobs"]
|
||||
job = jobs["assign"]
|
||||
return str(job.get("if", ""))
|
||||
|
||||
|
||||
def _post_step(workflow: dict) -> dict:
|
||||
"""Return the explicit POST /statuses step from the job steps list."""
|
||||
jobs = workflow["jobs"]
|
||||
steps = jobs["assign"]["steps"]
|
||||
for step in steps:
|
||||
name = step.get("name", "")
|
||||
if "Post required status context" in name:
|
||||
return step
|
||||
raise AssertionError("No explicit POST status step found")
|
||||
|
||||
|
||||
def _eval_step(workflow: dict) -> dict:
|
||||
"""Return the evaluator step (runs auto-tier-check.sh)."""
|
||||
jobs = workflow["jobs"]
|
||||
steps = jobs["assign"]["steps"]
|
||||
for step in steps:
|
||||
name = step.get("name", "")
|
||||
if "Evaluate and assign tier" in name:
|
||||
return step
|
||||
raise AssertionError("No evaluator step found")
|
||||
|
||||
|
||||
class TestAutoTierDirectTrigger:
|
||||
def test_trigger_includes_pull_request_target(self):
|
||||
wf = load_workflow("auto-tier-check.yml")
|
||||
on = wf[True]
|
||||
assert "pull_request_target" in on, (
|
||||
"auto-tier-check must trigger on pull_request_target"
|
||||
)
|
||||
types = on["pull_request_target"].get("types", [])
|
||||
assert "opened" in types, "pull_request_target must include 'opened'"
|
||||
assert "synchronize" in types, "pull_request_target must include 'synchronize'"
|
||||
assert "reopened" in types, "pull_request_target must include 'reopened'"
|
||||
|
||||
def test_trigger_includes_pull_request_review(self):
|
||||
wf = load_workflow("auto-tier-check.yml")
|
||||
on = wf[True]
|
||||
assert "pull_request_review" in on, (
|
||||
"auto-tier-check must trigger on pull_request_review"
|
||||
)
|
||||
types = on["pull_request_review"].get("types", [])
|
||||
assert "submitted" in types, (
|
||||
"pull_request_review must include 'submitted' type"
|
||||
)
|
||||
|
||||
def test_trigger_includes_issue_comment(self):
|
||||
wf = load_workflow("auto-tier-check.yml")
|
||||
on = wf[True]
|
||||
assert "issue_comment" in on, (
|
||||
"auto-tier-check must trigger on issue_comment for /retier"
|
||||
)
|
||||
types = on["issue_comment"].get("types", [])
|
||||
assert "created" in types, "issue_comment must include 'created' type"
|
||||
|
||||
def test_job_guard_fires_on_review_events(self):
|
||||
wf = load_workflow("auto-tier-check.yml")
|
||||
guard = _job_guard_string(wf)
|
||||
assert "github.event_name == 'pull_request_target'" in guard, (
|
||||
"job guard must fire on pull_request_target events"
|
||||
)
|
||||
assert "github.event_name == 'pull_request_review'" in guard, (
|
||||
"job guard must fire on pull_request_review events"
|
||||
)
|
||||
assert "github.event_name == 'issue_comment'" in guard, (
|
||||
"job guard must fire on issue_comment events"
|
||||
)
|
||||
# No review.state guard (same fix as #2159)
|
||||
assert "github.event.review.state" not in guard, (
|
||||
"job guard must NOT check review.state (unreliable in Gitea payload)"
|
||||
)
|
||||
|
||||
def test_job_guard_includes_retier_commands(self):
|
||||
wf = load_workflow("auto-tier-check.yml")
|
||||
guard = _job_guard_string(wf)
|
||||
assert "'/retier'" in guard or '"/retier"' in guard, (
|
||||
"job guard must include /retier slash command"
|
||||
)
|
||||
assert "'/tier-recheck'" in guard or '"/tier-recheck"' in guard, (
|
||||
"job guard must include /tier-recheck slash command"
|
||||
)
|
||||
|
||||
def test_post_step_uses_status_post_token(self):
|
||||
wf = load_workflow("auto-tier-check.yml")
|
||||
post = _post_step(wf)
|
||||
env = post.get("env", {})
|
||||
assert env.get("GITEA_TOKEN") == "${{ secrets.STATUS_POST_TOKEN }}", (
|
||||
"POST step must use STATUS_POST_TOKEN for write-scoped status POST"
|
||||
)
|
||||
|
||||
def test_post_step_context_name_exact(self):
|
||||
"""The context POSTed must byte-match the branch-protection requirement."""
|
||||
wf = load_workflow("auto-tier-check.yml")
|
||||
post = _post_step(wf)
|
||||
run = post.get("run", "")
|
||||
assert '"auto-tier-check / assigned (pull_request)"' in run, (
|
||||
"POST step must emit exact BP-required context name"
|
||||
)
|
||||
|
||||
def test_eval_step_uses_read_token(self):
|
||||
"""Evaluator must use read-only token, not STATUS_POST_TOKEN."""
|
||||
wf = load_workflow("auto-tier-check.yml")
|
||||
eval_step = _eval_step(wf)
|
||||
env = eval_step.get("env", {})
|
||||
assert env.get("GITEA_TOKEN") == "${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}", (
|
||||
"Evaluator must use read-scoped token, not STATUS_POST_TOKEN"
|
||||
)
|
||||
|
||||
def test_permissions_include_issues_write(self):
|
||||
"""Workflow needs issues:write to assign labels."""
|
||||
wf = load_workflow("auto-tier-check.yml")
|
||||
perms = wf.get("permissions", {})
|
||||
assert perms.get("issues") == "write", (
|
||||
"workflow must have issues:write to assign tier labels"
|
||||
)
|
||||
assert perms.get("statuses") == "write", (
|
||||
"workflow must have statuses:write to POST required contexts"
|
||||
)
|
||||
+351
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regression test for auto-tier-check.sh heuristics.
|
||||
#
|
||||
# Validates that gate/CI/governance paths are correctly classified as HIGH-risk
|
||||
# and that the label-assignment fail-closed behavior works.
|
||||
#
|
||||
# Method: end-to-end with fake curl harness serving canned API responses.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
THIS_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SCRIPT_DIR="$(cd "$THIS_DIR/.." && pwd)"
|
||||
SCRIPT="$SCRIPT_DIR/auto-tier-check.sh"
|
||||
|
||||
command -v jq >/dev/null 2>&1 || { echo "::error::jq required"; exit 1; }
|
||||
[ -f "$SCRIPT" ] || { echo "::error::auto-tier-check.sh not found"; exit 1; }
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
|
||||
assert_eq() {
|
||||
local label="$1" expected="$2" got="$3"
|
||||
if [ "$expected" = "$got" ]; then
|
||||
echo " PASS $label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL $label"
|
||||
echo " expected: <$expected>"
|
||||
echo " got: <$got>"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
assert_contains() {
|
||||
local label="$1" haystack="$2" needle="$3"
|
||||
if printf '%s' "$haystack" | grep -qF -- "$needle"; then
|
||||
echo " PASS $label"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " FAIL $label (missing: <$needle>)"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# Fake curl harness.
|
||||
make_harness() {
|
||||
local FIXDIR="$1"
|
||||
local BIN="$FIXDIR/bin"
|
||||
mkdir -p "$BIN"
|
||||
cat > "$BIN/curl" <<'FAKE'
|
||||
#!/usr/bin/env bash
|
||||
set -u
|
||||
FIXDIR="${AUTO_TIER_TEST_FIXDIR:?AUTO_TIER_TEST_FIXDIR unset}"
|
||||
url=""
|
||||
out=""
|
||||
want_code="no"
|
||||
prev=""
|
||||
for a in "$@"; do
|
||||
case "$prev" in
|
||||
-o) out="$a" ;;
|
||||
-d) post_body="$a" ;;
|
||||
esac
|
||||
case "$a" in
|
||||
http*://*) url="$a" ;;
|
||||
'%{http_code}') want_code="yes" ;;
|
||||
esac
|
||||
if [ "$prev" = "-w" ] && [ "$a" = '%{http_code}' ]; then want_code="yes"; fi
|
||||
prev="$a"
|
||||
done
|
||||
path="${url#*/api/v1}"
|
||||
slug="$(printf '%s' "$path" | tr '/?=&' '____')"
|
||||
body_file="$FIXDIR/body${slug}"
|
||||
code_file="$FIXDIR/code${slug}"
|
||||
body=""
|
||||
if [ -f "$body_file" ]; then body="$(cat "$body_file")"; fi
|
||||
if [ -n "$out" ]; then
|
||||
printf '%s' "$body" > "$out"
|
||||
else
|
||||
printf '%s' "$body"
|
||||
fi
|
||||
if [ "$want_code" = "yes" ]; then
|
||||
if [ -f "$code_file" ]; then
|
||||
printf '%s' "$(cat "$code_file")"
|
||||
else
|
||||
printf '200'
|
||||
fi
|
||||
fi
|
||||
# Capture POST body for test inspection.
|
||||
if [ -n "${post_body:-}" ]; then
|
||||
printf '%s' "$post_body" > "$FIXDIR/post_body${slug}"
|
||||
fi
|
||||
exit 0
|
||||
FAKE
|
||||
chmod +x "$BIN/curl"
|
||||
echo "$BIN"
|
||||
}
|
||||
|
||||
seed_common() {
|
||||
local FIXDIR="$1" additions="$2" deletions="$3" changed_files="$4" diff_paths="$5"
|
||||
mkdir -p "$FIXDIR"
|
||||
printf '%s' '{"login":"auto-tier-bot"}' > "$FIXDIR/body_user"
|
||||
printf '%s' "{\"head\":{\"sha\":\"headsha1\"},\"additions\":$additions,\"deletions\":$deletions,\"changed_files\":$changed_files}" \
|
||||
> "$FIXDIR/body_repos_molecule-ai_molecule-core_pulls_42"
|
||||
# No existing labels (so auto-tier runs)
|
||||
printf '%s' '[]' > "$FIXDIR/body_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
# Repo labels list (for name → id resolution)
|
||||
printf '%s' '[{"id":1,"name":"tier:low"},{"id":2,"name":"tier:medium"},{"id":3,"name":"tier:high"}]' \
|
||||
> "$FIXDIR/body_repos_molecule-ai_molecule-core_labels"
|
||||
# Diff: must be raw diff format (lines starting with +++ or ---)
|
||||
# so grep/sed in auto-tier-check.sh can extract paths.
|
||||
local raw_diff=""
|
||||
while IFS= read -r p; do
|
||||
raw_diff="${raw_diff}--- a/$p\n+++ b/$p\n"
|
||||
done <<< "$diff_paths"
|
||||
printf '%b' "$raw_diff" > "$FIXDIR/body_repos_molecule-ai_molecule-core_pulls_42.diff"
|
||||
}
|
||||
|
||||
run_script() {
|
||||
local FIXDIR="$1"
|
||||
local BIN="$FIXDIR/bin"
|
||||
set +e
|
||||
OUT=$(
|
||||
AUTO_TIER_TEST_FIXDIR="$FIXDIR" \
|
||||
PATH="$BIN:$PATH" \
|
||||
GITEA_TOKEN="faketoken" \
|
||||
GITEA_HOST="git.moleculesai.app" \
|
||||
REPO="molecule-ai/molecule-core" \
|
||||
PR_NUMBER="42" \
|
||||
PR_AUTHOR="pr-author" \
|
||||
AUTO_TIER_DEBUG="1" \
|
||||
bash "$SCRIPT" 2>&1
|
||||
)
|
||||
RC=$?
|
||||
set -e
|
||||
printf '%s' "$OUT"
|
||||
return $RC
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario H1: gate-file-only diff → tier:high
|
||||
# ---------------------------------------------------------------------------
|
||||
echo "=============================================================="
|
||||
echo "H1: gate-file-only diff (.gitea/workflows + .gitea/scripts)"
|
||||
echo " EXPECT: tier:high assigned"
|
||||
echo "=============================================================="
|
||||
H1="$(mktemp -d)"
|
||||
make_harness "$H1" >/dev/null
|
||||
seed_common "$H1" "10" "5" "2" ".gitea/workflows/qa-review.yml\n.gitea/scripts/auto-tier-check.sh"
|
||||
printf '%s' '200' > "$H1/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
printf '%s' '200' > "$H1/code_repos_molecule-ai_molecule-core_pulls_42.diff"
|
||||
# Label assignment succeeds
|
||||
printf '%s' '200' > "$H1/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
# Status POST succeeds
|
||||
printf '%s' '200' > "$H1/code_repos_molecule-ai_molecule-core_statuses_headsha1"
|
||||
set +e
|
||||
OUTH1="$(run_script "$H1")"; RCH1=$?
|
||||
set -e
|
||||
echo "$OUTH1" | sed 's/^/ /'
|
||||
echo " (exit=$RCH1)"
|
||||
assert_eq "H1 exit zero (success)" "0" "$RCH1"
|
||||
assert_contains "H1 assigned tier:high" "$OUTH1" "Assigned label 'tier:high'"
|
||||
assert_contains "H1 reports HIGH" "$OUTH1" "Heuristic result: HIGH"
|
||||
rm -rf "$H1"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario H2: security/auth path only → tier:high
|
||||
# ---------------------------------------------------------------------------
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo "H2: security/auth path only (workspace-server/internal/crypto)"
|
||||
echo " EXPECT: tier:high assigned"
|
||||
echo "=============================================================="
|
||||
H2="$(mktemp -d)"
|
||||
make_harness "$H2" >/dev/null
|
||||
seed_common "$H2" "20" "10" "1" "workspace-server/internal/crypto/hash.go"
|
||||
printf '%s' '200' > "$H2/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
printf '%s' '200' > "$H2/code_repos_molecule-ai_molecule-core_pulls_42.diff"
|
||||
printf '%s' '200' > "$H2/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
printf '%s' '200' > "$H2/code_repos_molecule-ai_molecule-core_statuses_headsha1"
|
||||
set +e
|
||||
OUTH2="$(run_script "$H2")"; RCH2=$?
|
||||
set -e
|
||||
echo "$OUTH2" | sed 's/^/ /'
|
||||
echo " (exit=$RCH2)"
|
||||
assert_eq "H2 exit zero (success)" "0" "$RCH2"
|
||||
assert_contains "H2 assigned tier:high" "$OUTH2" "Assigned label 'tier:high'"
|
||||
assert_contains "H2 reports HIGH" "$OUTH2" "Heuristic result: HIGH"
|
||||
rm -rf "$H2"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario H3: tests/docs only, small diff → tier:low
|
||||
# ---------------------------------------------------------------------------
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo "H3: tests/docs only, small diff"
|
||||
echo " EXPECT: tier:low assigned"
|
||||
echo "=============================================================="
|
||||
H3="$(mktemp -d)"
|
||||
make_harness "$H3" >/dev/null
|
||||
seed_common "$H3" "5" "3" "2" "tests/e2e/test_api.sh\ndocs/runbooks/admin.md"
|
||||
printf '%s' '200' > "$H3/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
printf '%s' '200' > "$H3/code_repos_molecule-ai_molecule-core_pulls_42.diff"
|
||||
printf '%s' '200' > "$H3/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
printf '%s' '200' > "$H3/code_repos_molecule-ai_molecule-core_statuses_headsha1"
|
||||
set +e
|
||||
OUTH3="$(run_script "$H3")"; RCH3=$?
|
||||
set -e
|
||||
echo "$OUTH3" | sed 's/^/ /'
|
||||
echo " (exit=$RCH3)"
|
||||
assert_eq "H3 exit zero (success)" "0" "$RCH3"
|
||||
assert_contains "H3 assigned tier:low" "$OUTH3" "Assigned label 'tier:low'"
|
||||
assert_contains "H3 reports LOW" "$OUTH3" "Heuristic result: LOW"
|
||||
rm -rf "$H3"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario H4: already labeled → skip (idempotent)
|
||||
# ---------------------------------------------------------------------------
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo "H4: PR already has tier:medium label"
|
||||
echo " EXPECT: skip, exit 0, no label assignment"
|
||||
echo "=============================================================="
|
||||
H4="$(mktemp -d)"
|
||||
make_harness "$H4" >/dev/null
|
||||
seed_common "$H4" "100" "50" "5" "workspace-server/internal/handlers/workspace.go"
|
||||
# Already has tier:medium
|
||||
printf '%s' '[{"name":"tier:medium"}]' > "$H4/body_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
set +e
|
||||
OUTH4="$(run_script "$H4")"; RCH4=$?
|
||||
set -e
|
||||
echo "$OUTH4" | sed 's/^/ /'
|
||||
echo " (exit=$RCH4)"
|
||||
assert_eq "H4 exit zero (skip)" "0" "$RCH4"
|
||||
assert_contains "H4 skipped" "$OUTH4" "already has tier label"
|
||||
rm -rf "$H4"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario H5: label assignment 403 → fail-closed
|
||||
# ---------------------------------------------------------------------------
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo "H5: label assignment returns 403 (no write:issue scope)"
|
||||
echo " EXPECT: exit 1 (fail-closed)"
|
||||
echo "=============================================================="
|
||||
H5="$(mktemp -d)"
|
||||
make_harness "$H5" >/dev/null
|
||||
seed_common "$H5" "10" "5" "2" "workspace-server/internal/handlers/workspace.go"
|
||||
printf '%s' '200' > "$H5/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
printf '%s' '200' > "$H5/code_repos_molecule-ai_molecule-core_pulls_42.diff"
|
||||
# Label assignment forbidden
|
||||
printf '%s' '403' > "$H5/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
set +e
|
||||
OUTH5="$(run_script "$H5")"; RCH5=$?
|
||||
set -e
|
||||
echo "$OUTH5" | sed 's/^/ /'
|
||||
echo " (exit=$RCH5)"
|
||||
assert_eq "H5 exit non-zero (fail-closed)" "1" "$([ "$RCH5" -ne 0 ] && echo 1 || echo 0)"
|
||||
assert_contains "H5 reports 403" "$OUTH5" "Label assignment forbidden"
|
||||
rm -rf "$H5"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario H6: tools/branch-protection-only diff → tier:high
|
||||
# ---------------------------------------------------------------------------
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo "H6: tools/branch-protection only diff"
|
||||
echo " EXPECT: tier:high assigned"
|
||||
echo "=============================================================="
|
||||
H6="$(mktemp -d)"
|
||||
make_harness "$H6" >/dev/null
|
||||
seed_common "$H6" "15" "5" "1" "tools/branch-protection/drift_check.sh"
|
||||
printf '%s' '200' > "$H6/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
printf '%s' '200' > "$H6/code_repos_molecule-ai_molecule-core_pulls_42.diff"
|
||||
printf '%s' '200' > "$H6/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
printf '%s' '200' > "$H6/code_repos_molecule-ai_molecule-core_statuses_headsha1"
|
||||
set +e
|
||||
OUTH6="$(run_script "$H6")"; RCH6=$?
|
||||
set -e
|
||||
echo "$OUTH6" | sed 's/^/ /'
|
||||
echo " (exit=$RCH6)"
|
||||
assert_eq "H6 exit zero (success)" "0" "$RCH6"
|
||||
assert_contains "H6 assigned tier:high" "$OUTH6" "Assigned label 'tier:high'"
|
||||
assert_contains "H6 reports HIGH" "$OUTH6" "Heuristic result: HIGH"
|
||||
rm -rf "$H6"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario H7: label assignment POSTs numeric ID, not raw name
|
||||
# ---------------------------------------------------------------------------
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo "H7: label name → id resolution before POST"
|
||||
echo " EXPECT: POST body contains numeric ID, not raw string name"
|
||||
echo "=============================================================="
|
||||
H7="$(mktemp -d)"
|
||||
make_harness "$H7" >/dev/null
|
||||
seed_common "$H7" "100" "50" "5" "workspace-server/internal/handlers/workspace.go"
|
||||
printf '%s' '200' > "$H7/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
printf '%s' '200' > "$H7/code_repos_molecule-ai_molecule-core_pulls_42.diff"
|
||||
printf '%s' '200' > "$H7/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
printf '%s' '200' > "$H7/code_repos_molecule-ai_molecule-core_statuses_headsha1"
|
||||
set +e
|
||||
OUTH7="$(run_script "$H7")"; RCH7=$?
|
||||
set -e
|
||||
echo "$OUTH7" | sed 's/^/ /'
|
||||
echo " (exit=$RCH7)"
|
||||
assert_eq "H7 exit zero (success)" "0" "$RCH7"
|
||||
assert_contains "H7 resolved id in output" "$OUTH7" "id=2"
|
||||
POST_BODY=""
|
||||
if [ -f "$H7/post_body_repos_molecule-ai_molecule-core_issues_42_labels" ]; then
|
||||
POST_BODY="$(cat "$H7/post_body_repos_molecule-ai_molecule-core_issues_42_labels")"
|
||||
fi
|
||||
assert_contains "H7 POST body contains numeric id" "$POST_BODY" '{"labels":[2]}'
|
||||
# FAIL if raw name was posted (the bug we're preventing)
|
||||
if printf '%s' "$POST_BODY" | grep -qF '"tier:medium"'; then
|
||||
echo " FAIL H7 POSTed raw label name (regression)"
|
||||
FAIL=$((FAIL + 1))
|
||||
else
|
||||
echo " PASS H7 did NOT post raw label name"
|
||||
PASS=$((PASS + 1))
|
||||
fi
|
||||
rm -rf "$H7"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scenario H8: generic .gitea/scripts file (NOT in gate regex) → tier:high
|
||||
# ---------------------------------------------------------------------------
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo "H8: generic .gitea/scripts file only (detect-changes.py)"
|
||||
echo " EXPECT: tier:high (reviewers: ALL .gitea/ changes = high)"
|
||||
echo "=============================================================="
|
||||
H8="$(mktemp -d)"
|
||||
make_harness "$H8" >/dev/null
|
||||
seed_common "$H8" "20" "10" "1" ".gitea/scripts/detect-changes.py"
|
||||
printf '%s' '200' > "$H8/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
printf '%s' '200' > "$H8/code_repos_molecule-ai_molecule-core_pulls_42.diff"
|
||||
printf '%s' '200' > "$H8/code_repos_molecule-ai_molecule-core_issues_42_labels"
|
||||
printf '%s' '200' > "$H8/code_repos_molecule-ai_molecule-core_statuses_headsha1"
|
||||
set +e
|
||||
OUTH8="$(run_script "$H8")"; RCH8=$?
|
||||
set -e
|
||||
echo "$OUTH8" | sed 's/^/ /'
|
||||
echo " (exit=$RCH8)"
|
||||
assert_eq "H8 exit zero (success)" "0" "$RCH8"
|
||||
assert_contains "H8 assigned tier:high" "$OUTH8" "Assigned label 'tier:high'"
|
||||
assert_contains "H8 reports HIGH" "$OUTH8" "Heuristic result: HIGH"
|
||||
rm -rf "$H8"
|
||||
|
||||
echo
|
||||
echo "------"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
@@ -50,14 +50,17 @@ class TestQaReviewDirectTrigger:
|
||||
"pull_request_review must include 'submitted' type"
|
||||
)
|
||||
|
||||
def test_job_guard_requires_approved_state(self):
|
||||
def test_job_guard_fires_on_pull_request_review(self):
|
||||
wf = load_workflow("qa-review.yml")
|
||||
guard = _job_guard_string(wf)
|
||||
assert "github.event.review.state == 'APPROVED'" in guard, (
|
||||
"job guard must check review.state for 'APPROVED'"
|
||||
assert "github.event_name == 'pull_request_review'" in guard, (
|
||||
"job guard must fire on pull_request_review events"
|
||||
)
|
||||
assert "github.event.review.state == 'approved'" in guard, (
|
||||
"job guard must check review.state for 'approved' (case fallback per #2135)"
|
||||
# #2159: state guard removed because Gitea 1.22.6 does not reliably
|
||||
# expose review.state in the pull_request_review payload. The
|
||||
# evaluator (review-check.sh) validates APPROVED via API anyway.
|
||||
assert "github.event.review.state" not in guard, (
|
||||
"job guard must NOT check review.state (unreliable in Gitea payload)"
|
||||
)
|
||||
|
||||
def test_post_step_uses_status_post_token(self):
|
||||
@@ -91,14 +94,17 @@ class TestSecurityReviewDirectTrigger:
|
||||
"pull_request_review must include 'submitted' type"
|
||||
)
|
||||
|
||||
def test_job_guard_requires_approved_state(self):
|
||||
def test_job_guard_fires_on_pull_request_review(self):
|
||||
wf = load_workflow("security-review.yml")
|
||||
guard = _job_guard_string(wf)
|
||||
assert "github.event.review.state == 'APPROVED'" in guard, (
|
||||
"job guard must check review.state for 'APPROVED'"
|
||||
assert "github.event_name == 'pull_request_review'" in guard, (
|
||||
"job guard must fire on pull_request_review events"
|
||||
)
|
||||
assert "github.event.review.state == 'approved'" in guard, (
|
||||
"job guard must check review.state for 'approved' (case fallback per #2135)"
|
||||
# #2159: state guard removed because Gitea 1.22.6 does not reliably
|
||||
# expose review.state in the pull_request_review payload. The
|
||||
# evaluator (review-check.sh) validates APPROVED via API anyway.
|
||||
assert "github.event.review.state" not in guard, (
|
||||
"job guard must NOT check review.state (unreliable in Gitea payload)"
|
||||
)
|
||||
|
||||
def test_post_step_uses_status_post_token(self):
|
||||
|
||||
@@ -266,6 +266,41 @@ assert_contains "S3 reported a real clause FAIL (not cannot-verify)" "$OUT3" "FA
|
||||
assert_not_contains "S3 did NOT cannot-verify (404 is a verified negative)" "$OUT3" "CANNOT VERIFY"
|
||||
rm -rf "$S3"
|
||||
|
||||
echo
|
||||
|
||||
echo "=============================================================="
|
||||
echo "Scenario 4: tier:medium, auto-tier assigned label, BUT"
|
||||
echo " qa and security APPROVED reviews are ABSENT."
|
||||
echo " (Incomplete ceremony after auto-tier runs.)"
|
||||
echo " EXPECT: tier NOT granted (fail-closed)."
|
||||
echo "=============================================================="
|
||||
S4="$(mktemp -d)"
|
||||
make_harness "$S4" >/dev/null
|
||||
# Include qa (id=13) and security (id=14) teams for medium tier.
|
||||
TEAMS_JSON_4='[{"name":"ceo","id":10},{"name":"engineers","id":11},{"name":"managers","id":12},{"name":"qa","id":13},{"name":"security","id":14}]'
|
||||
seed_common "$S4" "eng-approver" "tier:medium" "$TEAMS_JSON_4"
|
||||
# Overwrite reviews: two APPROVED (engineer + manager) but NONE from qa/security.
|
||||
printf '%s' '[{"state":"APPROVED","commit_id":"headsha1","user":{"login":"eng-approver"}},{"state":"APPROVED","commit_id":"headsha1","user":{"login":"mgr-approver"}}]' \
|
||||
> "$S4/body_repos_molecule-ai_molecule-core_pulls_42_reviews"
|
||||
# Team probes: engineer and manager are members; qa and security are NOT.
|
||||
printf '%s' '204' > "$S4/code_teams_11_members_eng-approver" # engineers: member
|
||||
printf '%s' '204' > "$S4/code_teams_12_members_mgr-approver" # managers: member
|
||||
printf '%s' '404' > "$S4/code_teams_13_members_eng-approver" # qa: NOT member
|
||||
printf '%s' '404' > "$S4/code_teams_13_members_mgr-approver" # qa: NOT member
|
||||
printf '%s' '404' > "$S4/code_teams_14_members_eng-approver" # security: NOT member
|
||||
printf '%s' '404' > "$S4/code_teams_14_members_mgr-approver" # security: NOT member
|
||||
set +e
|
||||
OUT4="$(run_script "$S4")"; RC4=$?
|
||||
set -e
|
||||
echo "$OUT4" | sed 's/^/ /'
|
||||
echo " (exit=$RC4)"
|
||||
assert_eq "S4 exit non-zero (tier NOT granted)" "1" "$([ "$RC4" -ne 0 ] && echo 1 || echo 0)"
|
||||
assert_not_contains "S4 did NOT print PASSED" "$OUT4" "sop-tier-check PASSED"
|
||||
assert_contains "S4 FAILED for tier:medium" "$OUT4" "FAILED for tier:medium"
|
||||
assert_contains "S4 names missing qa clause" "$OUT4" "qa"
|
||||
assert_contains "S4 names missing security clause" "$OUT4" "security"
|
||||
rm -rf "$S4"
|
||||
|
||||
echo
|
||||
echo "------"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# auto-tier-check — automatically assign tier:low|medium|high to PRs.
|
||||
#
|
||||
# Fires when:
|
||||
# - PR opens / re-pushes (pull_request_target)
|
||||
# - A review is submitted (pull_request_review) — so tier assignment
|
||||
# happens immediately after code-review reaches 2-genuine.
|
||||
# - A comment is posted (issue_comment) — supports /retier recheck.
|
||||
#
|
||||
# Logic lives in `.gitea/scripts/auto-tier-check.sh`.
|
||||
#
|
||||
# Required env:
|
||||
# GITEA_TOKEN — bot PAT with read:repository + write:issue (label write)
|
||||
#
|
||||
# Branch protection:
|
||||
# required_status_checks: ["auto-tier-check / assigned (pull_request)"]
|
||||
#
|
||||
# Design notes:
|
||||
# - Only runs if PR has NO tier label (idempotent — skips already-labeled PRs).
|
||||
# - Evaluator is read-only + one label-write; fail-closed on API errors.
|
||||
# - Aligns with sop-tier-check.yml event triggers.
|
||||
|
||||
name: auto-tier-check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: write
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
# bp-required: yes
|
||||
assign:
|
||||
# Gate the job:
|
||||
# - On pull_request_target: always run (idempotent skip inside script).
|
||||
# - On pull_request_review: always run (script checks for existing label).
|
||||
# - On issue_comment: only for /retier or /tier-recheck.
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
github.event_name == 'pull_request_review' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
(contains(github.event.comment.body, '/retier') ||
|
||||
contains(github.event.comment.body, '/tier-recheck')))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Privilege check (informational only)
|
||||
if: github.event_name == 'issue_comment'
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
login="${{ github.event.comment.user.login }}"
|
||||
authfile=$(mktemp)
|
||||
chmod 600 "$authfile"
|
||||
printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$authfile"
|
||||
code=$(curl -sS -o /dev/null -w '%{http_code}' -K "$authfile" \
|
||||
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/collaborators/${login}")
|
||||
rm -f "$authfile"
|
||||
if [ "$code" = "204" ]; then
|
||||
echo "::notice::Recheck from ${login} (collaborator=true)"
|
||||
else
|
||||
echo "::notice::Recheck from ${login} (collaborator=false, HTTP ${code}) — proceeding with read-only eval anyway"
|
||||
fi
|
||||
|
||||
- name: Check out BASE ref
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Install jq
|
||||
run: |
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
apt-get update -qq && apt-get install -y -qq jq 2>/dev/null || \
|
||||
curl -sSL "https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" -o /usr/local/bin/jq && chmod +x /usr/local/bin/jq
|
||||
fi
|
||||
|
||||
- name: Evaluate and assign tier
|
||||
id: eval
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login || 'unknown' }}
|
||||
AUTO_TIER_DEBUG: '0'
|
||||
run: bash .gitea/scripts/auto-tier-check.sh
|
||||
|
||||
- name: Post required status context
|
||||
if: github.event_name == 'pull_request_review' && always()
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.STATUS_POST_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
EVAL_OUTCOME: ${{ steps.eval.outcome }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
API="https://${GITEA_HOST}/api/v1"
|
||||
AUTH="Authorization: token ${GITEA_TOKEN}"
|
||||
PR_INFO=$(curl -sS -H "$AUTH" "${API}/repos/${REPO}/pulls/${PR_NUMBER}")
|
||||
HEAD_SHA=$(echo "$PR_INFO" | jq -r '.head.sha // ""')
|
||||
if [ -z "$HEAD_SHA" ]; then
|
||||
echo "::error::Failed to fetch PR head SHA"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$EVAL_OUTCOME" = "success" ]; then
|
||||
STATE="success"
|
||||
DESC="Tier auto-assigned"
|
||||
else
|
||||
STATE="failure"
|
||||
DESC="Tier assignment failed"
|
||||
fi
|
||||
PAYLOAD=$(jq -n --arg state "$STATE" --arg context "auto-tier-check / assigned (pull_request)" --arg description "$DESC" '{state: $state, context: $context, description: $description}')
|
||||
curl -sS -H "$AUTH" -X POST "${API}/repos/${REPO}/statuses/${HEAD_SHA}" -H "Content-Type: application/json" -d "$PAYLOAD"
|
||||
@@ -110,13 +110,19 @@ jobs:
|
||||
approved:
|
||||
# Gate the job:
|
||||
# - On pull_request_target events: always run.
|
||||
# - On pull_request_review_approved events: run so the gate flips
|
||||
# immediately when a team member submits an APPROVE review.
|
||||
# - On pull_request_review events: always run. We do NOT guard on
|
||||
# review.state here because Gitea 1.22.6's payload shape for this
|
||||
# event does not reliably expose the state field that the GitHub-
|
||||
# style guard expects (issue #2159). The evaluator
|
||||
# (review-check.sh) reads the actual reviews from the API and
|
||||
# checks for a real APPROVE, so running on COMMENT or
|
||||
# REQUEST_CHANGES reviews is harmless (read-only, idempotent).
|
||||
# sop-tier-check.yml uses the same pattern (no state guard) and
|
||||
# provably fires on every review event.
|
||||
# Comment-triggered refires live in sop-checklist.yml review-refire job.
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'pull_request_review' &&
|
||||
(github.event.review.state == 'APPROVED' || github.event.review.state == 'approved'))
|
||||
github.event_name == 'pull_request_review'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
|
||||
|
||||
@@ -37,13 +37,19 @@ jobs:
|
||||
approved:
|
||||
# Gate the job:
|
||||
# - On pull_request_target events: always run.
|
||||
# - On pull_request_review_approved events: run so the gate flips
|
||||
# immediately when a team member submits an APPROVE review.
|
||||
# - On pull_request_review events: always run. We do NOT guard on
|
||||
# review.state here because Gitea 1.22.6's payload shape for this
|
||||
# event does not reliably expose the state field that the GitHub-
|
||||
# style guard expects (issue #2159). The evaluator
|
||||
# (review-check.sh) reads the actual reviews from the API and
|
||||
# checks for a real APPROVE, so running on COMMENT or
|
||||
# REQUEST_CHANGES reviews is harmless (read-only, idempotent).
|
||||
# sop-tier-check.yml uses the same pattern (no state guard) and
|
||||
# provably fires on every review event.
|
||||
# Comment-triggered refires live in sop-checklist.yml review-refire job.
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'pull_request_review' &&
|
||||
(github.event.review.state == 'APPROVED' || github.event.review.state == 'approved'))
|
||||
github.event_name == 'pull_request_review'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Privilege check (A1.1 — INFORMATIONAL log only, NOT a gate)
|
||||
|
||||
Reference in New Issue
Block a user