fix(gate): auto-tier + qa/security auto-trigger after 2-genuine review (#2396) #2400

Closed
agent-dev-a wants to merge 7 commits from fix/2396-sop-auto-tier-qa-security-auto-trigger into main
9 changed files with 903 additions and 18 deletions
+212
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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"
+121
View File
@@ -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"
+10 -4
View File
@@ -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)
+10 -4
View File
@@ -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)