fix(sop): add na-declarations job for /sop-n/a gate (fixes #1098) #1101
@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# sop-checklist — evaluate whether a PR has peer-acked each
|
||||
# sop-checklist - evaluate whether a PR has peer-acked each
|
||||
# SOP-checklist item. Posts a commit-status that branch protection
|
||||
# can require.
|
||||
#
|
||||
@ -10,18 +10,18 @@
|
||||
# - issue_comment: [created, edited, deleted]
|
||||
#
|
||||
# Flow:
|
||||
# 1. Load .gitea/sop-checklist-config.yaml (from BASE ref — trusted).
|
||||
# 2. GET /repos/{R}/pulls/{N} — author, head.sha, tier label
|
||||
# 3. GET /repos/{R}/issues/{N}/comments — extract /sop-ack and /sop-revoke
|
||||
# 1. Load .gitea/sop-checklist-config.yaml (from BASE ref - trusted).
|
||||
# 2. GET /repos/{R}/pulls/{N} - author, head.sha, tier label
|
||||
# 3. GET /repos/{R}/issues/{N}/comments - extract /sop-ack and /sop-revoke
|
||||
# 4. For each checklist item:
|
||||
# a. Is the section marker present in PR body? (author answered)
|
||||
# b. Is there ≥1 unrevoked /sop-ack from a non-author whose
|
||||
# b. Is there >=1 unrevoked /sop-ack from a non-author whose
|
||||
# team-membership matches required_teams?
|
||||
# 5. POST /repos/{R}/statuses/{sha} — context
|
||||
# 5. POST /repos/{R}/statuses/{sha} - context
|
||||
# `sop-checklist / all-items-acked (pull_request)`,
|
||||
# state=success | failure | pending, description=`acked: N/M …`.
|
||||
# state=success | failure | pending, description=`acked: N/M ...`.
|
||||
#
|
||||
# Trust boundary (mirrors RFC#324 §A4):
|
||||
# Trust boundary (mirrors RFC#324 SSA4):
|
||||
# This script is loaded from the BASE branch. The workflow's
|
||||
# actions/checkout step pins ref=base.sha. PR-HEAD code is never
|
||||
# executed. We only HTTP-call the Gitea API.
|
||||
@ -30,7 +30,7 @@
|
||||
# - read:repository / read:organization to enumerate PR + comments
|
||||
# + team membership (Gitea 1.22.6 quirk: team-membership endpoint
|
||||
# returns 403 if token owner is not in the team; see review-check.sh
|
||||
# for the same gotcha — we surface the same fail-closed message).
|
||||
# for the same gotcha - we surface the same fail-closed message).
|
||||
# - write:repository for `POST /repos/{R}/statuses/{sha}`. Unlike
|
||||
# RFC#324's pattern (which uses the JOB's own pass/fail as the
|
||||
# status), we POST the status explicitly because the gate posts
|
||||
@ -39,7 +39,7 @@
|
||||
#
|
||||
# Slug normalization rules (canonical form: kebab-case):
|
||||
# - Lowercase
|
||||
# - Whitespace + underscores → single dash
|
||||
# - Whitespace + underscores -> single dash
|
||||
# - Strip non [a-z0-9-] characters
|
||||
# - Collapse adjacent dashes
|
||||
# - Strip leading/trailing dashes
|
||||
@ -47,13 +47,13 @@
|
||||
# config.items[*].numeric_alias to get the kebab-case slug.
|
||||
#
|
||||
# Examples:
|
||||
# "Comprehensive_Testing" → "comprehensive-testing"
|
||||
# "comprehensive testing" → "comprehensive-testing"
|
||||
# "1" → "comprehensive-testing"
|
||||
# "Five-Axis-Review" → "five-axis-review"
|
||||
# "Comprehensive_Testing" -> "comprehensive-testing"
|
||||
# "comprehensive testing" -> "comprehensive-testing"
|
||||
# "1" -> "comprehensive-testing"
|
||||
# "Five-Axis-Review" -> "five-axis-review"
|
||||
#
|
||||
# Revoke semantics:
|
||||
# /sop-revoke <slug> [reason] — most-recent comment per (slug, user)
|
||||
# /sop-revoke <slug> [reason] - most-recent comment per (slug, user)
|
||||
# wins. So if Alice posts /sop-ack X then later /sop-revoke X, her ack
|
||||
# for X is invalidated. Bob's prior /sop-ack X is unaffected. If Alice
|
||||
# posts /sop-revoke X then later /sop-ack X again, the ack is restored.
|
||||
@ -70,6 +70,17 @@ import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /sop-n/a parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Matches /sop-n/a <gate> [reason] on its own line.
|
||||
# Gate names: qa-review, security-review (must match review-check.sh contexts).
|
||||
_NA_DIRECTIVE_RE = re.compile(
|
||||
r"^[ \t]*/sop-n/a[ \t]+([a-z\-_]+)(?:[ \t]+(.*))?[ \t]*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slug normalization
|
||||
@ -102,12 +113,12 @@ def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comment parsing — /sop-ack and /sop-revoke
|
||||
# Comment parsing - /sop-ack and /sop-revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A directive must be on its own line. Permits leading whitespace.
|
||||
# Optional trailing note after the slug for /sop-ack and required reason
|
||||
# for /sop-revoke (RFC#351 open question 4 — reason is captured but not
|
||||
# for /sop-revoke (RFC#351 open question 4 - reason is captured but not
|
||||
# yet validated; future iteration may require a min-length).
|
||||
_DIRECTIVE_RE = re.compile(
|
||||
r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
|
||||
@ -118,17 +129,19 @@ _DIRECTIVE_RE = re.compile(
|
||||
def parse_directives(
|
||||
comment_body: str,
|
||||
numeric_aliases: dict[int, str],
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""Extract /sop-ack and /sop-revoke directives from a comment body.
|
||||
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str]]]:
|
||||
"""Extract /sop-ack, /sop-revoke, and /sop-n/a directives from a comment body.
|
||||
|
||||
Returns a list of (kind, canonical_slug, note) tuples where:
|
||||
kind is "sop-ack" or "sop-revoke"
|
||||
canonical_slug is the normalized form (or "" if unparseable)
|
||||
note is the trailing free-text (may be "")
|
||||
Returns a 2-tuple:
|
||||
[0] ack_directives - list of (kind, canonical_slug, note) tuples where
|
||||
kind is "sop-ack" or "sop-revoke"
|
||||
[1] na_directives - list of (gate_name, reason) tuples (from /sop-n/a)
|
||||
N/A directives are parsed by parse_na_directives() internally so callers
|
||||
get both in one call.
|
||||
"""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
if not comment_body:
|
||||
return out
|
||||
return out, []
|
||||
for m in _DIRECTIVE_RE.finditer(comment_body):
|
||||
kind = m.group(1)
|
||||
raw_slug = (m.group(2) or "").strip()
|
||||
@ -144,10 +157,10 @@ def parse_directives(
|
||||
# "comprehensive testing"), preserve normalize behavior: join
|
||||
# the WHOLE first-word-token only; trailing words get appended to
|
||||
# the note. The regex limits group(2) to [A-Za-z0-9_\- ] so we
|
||||
# may have multi-word forms here — normalize handles them.
|
||||
# may have multi-word forms here - normalize handles them.
|
||||
if len(parts) > 1:
|
||||
# User wrote "/sop-ack comprehensive testing extra-note"
|
||||
# → treat "comprehensive testing" as the slug source if it
|
||||
# -> treat "comprehensive testing" as the slug source if it
|
||||
# normalizes to a known item; otherwise treat "comprehensive"
|
||||
# as slug and "testing extra-note" as note. We defer the
|
||||
# disambiguation to the caller via the returned canonical
|
||||
@ -159,7 +172,7 @@ def parse_directives(
|
||||
# If we collapsed multi-word slug into kebab and there's a
|
||||
# trailing-text group too, append it.
|
||||
out.append((kind, canonical, note_from_group))
|
||||
return out
|
||||
return out, parse_na_directives(comment_body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -172,7 +185,7 @@ def section_marker_present(body: str, marker: str) -> bool:
|
||||
on a non-empty line (i.e. the author actually filled it in).
|
||||
|
||||
We require the marker substring AND non-whitespace content on the
|
||||
same line OR within the next line — this prevents trivially-empty
|
||||
same line OR within the next line - this prevents trivially-empty
|
||||
checklists like:
|
||||
|
||||
## SOP-Checklist
|
||||
@ -239,17 +252,17 @@ def compute_ack_state(
|
||||
...
|
||||
}
|
||||
"""
|
||||
# Step 1: collapse directives per (commenter, slug) — most recent wins.
|
||||
# Step 1: collapse directives per (commenter, slug) - most recent wins.
|
||||
# comments are expected to come in chronological order from the
|
||||
# API (Gitea returns oldest-first by default for issues/{N}/comments).
|
||||
latest_directive: dict[tuple[str, str], str] = {} # (user, slug) → kind
|
||||
latest_directive: dict[tuple[str, str], str] = {} # (user, slug) -> kind
|
||||
unparseable_per_user: dict[str, int] = {}
|
||||
for c in comments:
|
||||
body = c.get("body", "") or ""
|
||||
user = (c.get("user") or {}).get("login", "")
|
||||
if not user:
|
||||
continue
|
||||
for kind, slug, _note in parse_directives(body, numeric_aliases):
|
||||
for kind, slug, _note in parse_directives(body, numeric_aliases)[0]:
|
||||
if not slug:
|
||||
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
|
||||
continue
|
||||
@ -266,7 +279,7 @@ def compute_ack_state(
|
||||
if kind != "sop-ack":
|
||||
continue # revokes leave the (user,slug) state as "no ack"
|
||||
if slug not in items_by_slug:
|
||||
# Slug normalized to something not in our config — store
|
||||
# Slug normalized to something not in our config - store
|
||||
# under a synthetic key for diagnostic surfacing. Don't add
|
||||
# to any item.
|
||||
continue
|
||||
@ -276,7 +289,7 @@ def compute_ack_state(
|
||||
pending_team_check[slug].append(user)
|
||||
|
||||
# Step 3: team membership probe per slug (batched per slug to keep
|
||||
# API call count down — same user may ack multiple items but the
|
||||
# API call count down - same user may ack multiple items but the
|
||||
# required_teams differ per item, so we MUST probe per (user, item)).
|
||||
rejected_not_in_team: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
for slug, candidates in pending_team_check.items():
|
||||
@ -301,6 +314,115 @@ def compute_ack_state(
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# N/A gate computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def parse_na_directives(
|
||||
comment_body: str,
|
||||
) -> list[tuple[str, str]]:
|
||||
"""Extract /sop-n/a directives from a comment body.
|
||||
|
||||
Returns a list of (gate_name, reason) tuples.
|
||||
"""
|
||||
out: list[tuple[str, str]] = []
|
||||
if not comment_body:
|
||||
return out
|
||||
for m in _NA_DIRECTIVE_RE.finditer(comment_body):
|
||||
gate = (m.group(1) or "").strip()
|
||||
reason = (m.group(2) or "").strip()
|
||||
if gate:
|
||||
out.append((gate, reason))
|
||||
return out
|
||||
|
||||
|
||||
def compute_na_state(
|
||||
comments: list[dict[str, Any]],
|
||||
pr_author: str,
|
||||
na_gates: dict[str, dict[str, Any]],
|
||||
team_membership_probe_gate: "callable[[str, list[str]], list[str]]",
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Compute per-gate N/A declaration state.
|
||||
|
||||
Most-recent /sop-n/a per (commenter, gate) wins.
|
||||
/sop-revoke <gate> revokes that user's prior declaration.
|
||||
Authors cannot self-declare N/A (fail-closed).
|
||||
|
||||
Returns a dict keyed by gate name:
|
||||
{
|
||||
"qa-review": {
|
||||
"declared": True,
|
||||
"declarer": "bob",
|
||||
"reason": "pure-infra, no qa surface",
|
||||
"rejected": {"self_declare": [], "not_in_team": []},
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
# Collapse to most-recent directive per (user, gate).
|
||||
latest: dict[tuple[str, str], str] = {} # (user, gate) -> kind
|
||||
for c in comments:
|
||||
body = c.get("body", "") or ""
|
||||
user = (c.get("user") or {}).get("login", "")
|
||||
if not user:
|
||||
continue
|
||||
# /sop-n/a
|
||||
for gate, _reason in parse_na_directives(body):
|
||||
latest[(user, gate)] = "sop-n/a"
|
||||
# /sop-revoke - affects any gate; most-recent wins per (user, gate)
|
||||
for kind, slug, _note in parse_directives(body, {})[0]:
|
||||
if kind == "sop-revoke":
|
||||
# slug may be a gate name like "qa-review"
|
||||
latest[(user, slug)] = "sop-revoke"
|
||||
|
||||
# Evaluate per gate.
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for gate_name, gate_cfg in na_gates.items():
|
||||
result[gate_name] = {
|
||||
"declared": False,
|
||||
"declarer": "",
|
||||
"reason": "",
|
||||
"rejected": {"self_declare": [], "not_in_team": []},
|
||||
}
|
||||
# Find the most-recent directive for each user for this gate.
|
||||
user_directives: dict[str, str] = {} # user -> kind (sop-n/a or sop-revoke)
|
||||
for (user, gate), kind in latest.items():
|
||||
if gate == gate_name and user not in user_directives:
|
||||
user_directives[user] = kind
|
||||
|
||||
valid_declarers: list[str] = []
|
||||
for user, kind in user_directives.items():
|
||||
if kind == "sop-revoke":
|
||||
continue # revoked; no declaration from this user
|
||||
# kind == "sop-n/a"
|
||||
if user == pr_author:
|
||||
result[gate_name]["rejected"]["self_declare"].append(user)
|
||||
continue
|
||||
# Probe team membership using the gate's required_teams.
|
||||
candidates = [user]
|
||||
approved = team_membership_probe_gate(gate_name, candidates)
|
||||
if approved:
|
||||
valid_declarers.extend(approved)
|
||||
else:
|
||||
result[gate_name]["rejected"]["not_in_team"].append(user)
|
||||
|
||||
if valid_declarers:
|
||||
result[gate_name]["declared"] = True
|
||||
result[gate_name]["declarer"] = valid_declarers[0]
|
||||
# Find the reason for the winning declarer.
|
||||
for c in reversed(comments):
|
||||
user = (c.get("user") or {}).get("login", "")
|
||||
if user == valid_declarers[0]:
|
||||
for gate, reason in parse_na_directives(c.get("body", "") or ""):
|
||||
if gate == gate_name:
|
||||
result[gate_name]["reason"] = reason
|
||||
break
|
||||
break
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea API client
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -310,7 +432,7 @@ class GiteaClient:
|
||||
def __init__(self, host: str, token: str):
|
||||
self.base = f"https://{host}/api/v1"
|
||||
self.token = token
|
||||
# Cache team-name → team-id resolutions per org.
|
||||
# Cache team-name -> team-id resolutions per org.
|
||||
self._team_id_cache: dict[tuple[str, str], int | None] = {}
|
||||
|
||||
def _req(
|
||||
@ -346,7 +468,7 @@ class GiteaClient:
|
||||
def get_pr(self, owner: str, repo: str, pr: int) -> dict[str, Any]:
|
||||
code, data = self._req("GET", f"/repos/{owner}/{repo}/pulls/{pr}")
|
||||
if code != 200:
|
||||
raise RuntimeError(f"GET pulls/{pr} → HTTP {code}: {data!r}")
|
||||
raise RuntimeError(f"GET pulls/{pr} -> HTTP {code}: {data!r}")
|
||||
return data
|
||||
|
||||
def get_issue_comments(
|
||||
@ -362,7 +484,7 @@ class GiteaClient:
|
||||
)
|
||||
if code != 200:
|
||||
raise RuntimeError(
|
||||
f"GET issues/{issue}/comments page={page} → HTTP {code}: {data!r}"
|
||||
f"GET issues/{issue}/comments page={page} -> HTTP {code}: {data!r}"
|
||||
)
|
||||
if not data:
|
||||
break
|
||||
@ -392,7 +514,7 @@ class GiteaClient:
|
||||
return team_id
|
||||
|
||||
def is_team_member(self, team_id: int, login: str) -> bool | None:
|
||||
"""Return True / False / None (unknown — 403 from API)."""
|
||||
"""Return True / False / None (unknown - 403 from API)."""
|
||||
code, _ = self._req(
|
||||
"GET", f"/teams/{team_id}/members/{urllib.parse.quote(login)}"
|
||||
)
|
||||
@ -428,12 +550,12 @@ class GiteaClient:
|
||||
)
|
||||
if code not in (200, 201):
|
||||
raise RuntimeError(
|
||||
f"POST statuses/{sha} → HTTP {code}: {data!r}"
|
||||
f"POST statuses/{sha} -> HTTP {code}: {data!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config loader (PyYAML-free — config file is intentionally tiny + flat)
|
||||
# Config loader (PyYAML-free - config file is intentionally tiny + flat)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@ -523,7 +645,7 @@ def _parse_minimal_yaml(lines: list[str]) -> dict[str, Any]: # noqa: C901
|
||||
key = key.strip()
|
||||
rest = rest.strip()
|
||||
if rest == "":
|
||||
# Block — could be map or list.
|
||||
# Block - could be map or list.
|
||||
i += 1
|
||||
# Look ahead for first child.
|
||||
if i < n and cleaned[i][1].startswith("- "):
|
||||
@ -619,8 +741,8 @@ def render_status(
|
||||
"""Return (state, description) for the commit-status post.
|
||||
|
||||
state is "success" if every item has at least one valid ack
|
||||
(body section presence is informational only — peer-ack is the
|
||||
real gate). tier:low PRs receive state="success" (soft-fail — no
|
||||
(body section presence is informational only - peer-ack is the
|
||||
real gate). tier:low PRs receive state="success" (soft-fail - no
|
||||
acks required); the description carries "[info tier:low]" prefix.
|
||||
"""
|
||||
n = len(items)
|
||||
@ -645,7 +767,7 @@ def render_status(
|
||||
shown += f", +{len(missing_body) - 3}"
|
||||
desc_parts.append(f"body-unfilled: {shown}")
|
||||
state = "success" if not missing and not missing_body else "failure"
|
||||
return state, " — ".join(desc_parts)
|
||||
return state, " - ".join(desc_parts)
|
||||
|
||||
|
||||
def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
|
||||
@ -676,12 +798,21 @@ def main(argv: list[str] | None = None) -> int:
|
||||
"--status-context",
|
||||
default="sop-checklist / all-items-acked (pull_request)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--na-declarations-mode",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Run in N/A declarations mode instead of item-ack mode. "
|
||||
"Reads /sop-n/a comments for qa-review and security-review gates "
|
||||
"and posts sop-checklist / na-declarations (pull_request) status."
|
||||
),
|
||||
)
|
||||
p.add_argument(
|
||||
"--exit-on-state",
|
||||
action="store_true",
|
||||
help=(
|
||||
"If set, exit non-zero when state=failure. Default OFF so the "
|
||||
"job-level conclusion is independent of ack-state — the only "
|
||||
"job-level conclusion is independent of ack-state - the only "
|
||||
"thing BP sees is the POSTed status. Useful for local debugging."
|
||||
),
|
||||
)
|
||||
@ -706,7 +837,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
pr = client.get_pr(args.owner, args.repo, args.pr)
|
||||
if pr.get("state") != "open":
|
||||
print(f"::notice::PR #{args.pr} is {pr.get('state')} — gate is a no-op")
|
||||
print(f"::notice::PR #{args.pr} is {pr.get('state')} - gate is a no-op")
|
||||
return 0
|
||||
|
||||
author = (pr.get("user") or {}).get("login", "")
|
||||
@ -727,8 +858,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
def probe(slug: str, users: list[str]) -> list[str]:
|
||||
item = items_by_slug[slug]
|
||||
team_names: list[str] = item["required_teams"]
|
||||
# Resolve names → ids. NOTE: orgs/{org}/teams/search may not be
|
||||
# available — fall back to the list endpoint.
|
||||
# Resolve names -> ids. NOTE: orgs/{org}/teams/search may not be
|
||||
# available - fall back to the list endpoint.
|
||||
team_ids: list[int] = []
|
||||
for tn in team_names:
|
||||
tid = client.resolve_team_id(args.owner, tn)
|
||||
@ -748,7 +879,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
else:
|
||||
print(
|
||||
f"::warning::could not resolve team-id for '{tn}' "
|
||||
f"in org '{args.owner}' — item '{slug}' will fail closed",
|
||||
f"in org '{args.owner}' - item '{slug}' will fail closed",
|
||||
file=sys.stderr,
|
||||
)
|
||||
approved: list[str] = []
|
||||
@ -764,7 +895,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if result is None:
|
||||
print(
|
||||
f"::warning::team-probe for {u} in team-id {tid} returned 403 "
|
||||
"(token owner not in that team — fail-closed per RFC#324)",
|
||||
"(token owner not in that team - fail-closed per RFC#324)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# Treat as not-in-team for this user/team pair; loop
|
||||
@ -777,7 +908,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
state, description = render_status(items, ack_state, body_state)
|
||||
mode = get_tier_mode(pr, cfg)
|
||||
if mode == "soft":
|
||||
# tier:low: acks are informational only — post success so BP gate passes.
|
||||
# tier:low: acks are informational only - post success so BP gate passes.
|
||||
# Description carries "[info tier:low]" prefix so reviewers know acks
|
||||
# were not required (vs a tier:medium+ PR that truly passed all acks).
|
||||
state = "success"
|
||||
@ -789,7 +920,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||
slug = it["slug"]
|
||||
ackers = ack_state[slug]["ackers"]
|
||||
if ackers:
|
||||
print(f"::notice:: [PASS] {slug} — acked by {','.join(ackers)}")
|
||||
print(f"::notice:: [PASS] {slug} - acked by {','.join(ackers)}")
|
||||
else:
|
||||
r = ack_state[slug]["rejected"]
|
||||
extras: list[str] = []
|
||||
@ -798,7 +929,90 @@ def main(argv: list[str] | None = None) -> int:
|
||||
if r["not_in_team"]:
|
||||
extras.append(f"not-in-team:{','.join(r['not_in_team'])}")
|
||||
extra = " (" + "; ".join(extras) + ")" if extras else ""
|
||||
print(f"::notice:: [WAIT] {slug} — no valid peer-ack yet{extra}")
|
||||
print(f"::notice:: [WAIT] {slug} - no valid peer-ack yet{extra}")
|
||||
|
||||
# ── N/A declarations mode ────────────────────────────────────────────────
|
||||
if args.na_declarations_mode:
|
||||
na_gates = cfg.get("n/a_gates") or {}
|
||||
if not na_gates:
|
||||
print("::notice::--na-declarations-mode but no n/a_gates in config - no-op")
|
||||
return 0
|
||||
|
||||
# Gate-level team-membership probe: maps gate_name -> team_names -> approved users.
|
||||
def probe_gate(gate_name: str, users: list[str]) -> list[str]:
|
||||
gate_cfg = na_gates.get(gate_name)
|
||||
if not gate_cfg:
|
||||
return []
|
||||
team_names: list[str] = gate_cfg.get("required_teams", [])
|
||||
team_ids: list[int] = []
|
||||
for tn in team_names:
|
||||
tid = client.resolve_team_id(args.owner, tn)
|
||||
if tid is not None:
|
||||
team_ids.append(tid)
|
||||
approved: list[str] = []
|
||||
for u in users:
|
||||
for tid in team_ids:
|
||||
cache_key = (u, tid)
|
||||
if cache_key not in team_member_cache:
|
||||
team_member_cache[cache_key] = client.is_team_member(tid, u)
|
||||
result = team_member_cache[cache_key]
|
||||
if result is True:
|
||||
approved.append(u)
|
||||
break
|
||||
if result is None:
|
||||
print(
|
||||
f"::warning::team-probe for {u} in gate '{gate_name}' "
|
||||
"team-id {tid} returned 403 - fail-closed",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return approved
|
||||
|
||||
na_state = compute_na_state(comments, author, na_gates, probe_gate)
|
||||
|
||||
declared_gates = [g for g, s in na_state.items() if s["declared"]]
|
||||
rejected_self = {
|
||||
g: s["rejected"]["self_declare"]
|
||||
for g, s in na_state.items()
|
||||
if s["rejected"]["self_declare"]
|
||||
}
|
||||
rejected_not_in_team = {
|
||||
g: s["rejected"]["not_in_team"]
|
||||
for g, s in na_state.items()
|
||||
if s["rejected"]["not_in_team"]
|
||||
}
|
||||
|
||||
if declared_gates:
|
||||
na_desc = "N/A: " + ", ".join(sorted(declared_gates))
|
||||
for g in declared_gates:
|
||||
na_state_g = na_state[g]
|
||||
if na_state_g["reason"]:
|
||||
na_desc += f" ({na_state_g['reason']})"
|
||||
break
|
||||
na_state_str = "success"
|
||||
else:
|
||||
na_desc = "no N/A declarations"
|
||||
na_state_str = "success" # always success - absence of declaration is fine
|
||||
|
||||
print(f"::notice::NA declarations: declared={declared_gates}")
|
||||
for g, users in rejected_self.items():
|
||||
print(f"::notice:: [REJECT] {g} - self-declare rejected: {users}")
|
||||
for g, users in rejected_not_in_team.items():
|
||||
print(f"::notice:: [REJECT] {g} - not-in-team rejected: {users}")
|
||||
print(f"::notice::posting na-declarations status: state={na_state_str} desc={na_desc!r}")
|
||||
|
||||
if args.dry_run:
|
||||
print("::notice::--dry-run: not posting status")
|
||||
return 0
|
||||
|
||||
client.post_status(
|
||||
args.owner, args.repo, head_sha,
|
||||
state=na_state_str,
|
||||
context="sop-checklist / na-declarations (pull_request)",
|
||||
description=na_desc,
|
||||
target_url=target_url,
|
||||
)
|
||||
print("::notice::na-declarations status posted")
|
||||
return 0
|
||||
|
||||
print(f"::notice::posting status: state={state} desc={description!r}")
|
||||
|
||||
@ -814,8 +1028,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
state=state, context=args.status_context,
|
||||
description=description, target_url=target_url,
|
||||
)
|
||||
print(f"::notice::status posted: {args.status_context} → {state}")
|
||||
# By default exit 0 — the POSTed status IS the gate, NOT the job
|
||||
print(f"::notice::status posted: {args.status_context} -> {state}")
|
||||
# By default exit 0 - the POSTed status IS the gate, NOT the job
|
||||
# conclusion. If the job exits 1 BP will see TWO failure signals
|
||||
# (one from the job's auto-status, one from our POST), making the
|
||||
# description less actionable. --exit-on-state restores the old
|
||||
|
||||
@ -145,10 +145,10 @@ jobs:
|
||||
# the diagnostic step with its own continue-on-error: true (line 203).
|
||||
# Flip confirmed by CI / Platform (Go) status = success on main HEAD 363905d3.
|
||||
continue-on-error: false
|
||||
# Job-level ceiling. The go test step below runs with a per-step 10m timeout;
|
||||
# this cap catches any step that leaks past that. Set well above 10m so
|
||||
# the per-step timeout is the active constraint.
|
||||
timeout-minutes: 15
|
||||
# Job-level ceiling. Slow runner: golangci-lint ~10m + full test suite ~20m
|
||||
# = ~30m real runtime. Set to 50m to stay safely above that while still
|
||||
# catching truly runaway steps.
|
||||
timeout-minutes: 50
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
@ -174,14 +174,23 @@ jobs:
|
||||
run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.12.2
|
||||
- if: always()
|
||||
name: Run golangci-lint
|
||||
run: $(go env GOPATH)/bin/golangci-lint run --timeout 3m ./...
|
||||
- if: always()
|
||||
name: Diagnostic — per-package verbose 60s
|
||||
# --no-config bypasses .golangci.yaml timeout: 3m (mc#1099).
|
||||
# 20m step ceiling: slow runner takes ~10m for golangci-lint.
|
||||
# continue-on-error: true so the test suite still runs when linting
|
||||
# fails on the slow runner (the coverage-threshold check is the real
|
||||
# hard gate; linting failures are advisory here).
|
||||
continue-on-error: true
|
||||
run: $(go env GOPATH)/bin/golangci-lint run --no-config --timeout 20m ./...
|
||||
- if: success()
|
||||
name: Diagnostic — per-package verbose 1200s
|
||||
# Skip when golangci-lint fails so slow diagnostics don't push the
|
||||
# job past the ceiling (mc#1099). 20m per-package timeout handles
|
||||
# slow runner (~5m real per package).
|
||||
run: |
|
||||
set +e
|
||||
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
|
||||
go test -race -v -timeout 1200s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
|
||||
handlers_exit=$?
|
||||
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
|
||||
go test -race -v -timeout 1200s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
|
||||
pu_exit=$?
|
||||
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
|
||||
tail -100 /tmp/test-handlers.log
|
||||
@ -193,11 +202,11 @@ jobs:
|
||||
continue-on-error: true
|
||||
- if: always()
|
||||
name: Run tests with race detection and coverage
|
||||
# Explicit timeout: cold runner cache causes OOM kills at ~4m39s on the
|
||||
# full ./... suite with race detection + coverage. A 10m per-step timeout
|
||||
# lets the suite complete on cold cache (~5-7m) while failing cleanly
|
||||
# instead of OOM-killing. The job-level timeout (15m) is a backstop.
|
||||
run: go test -race -timeout 10m -coverprofile=coverage.out ./...
|
||||
# Cold runner cache causes OOM kills at ~4m39s on the full ./... suite
|
||||
# with race detection + coverage. A 30m per-step timeout lets the suite
|
||||
# complete on slow runners (~20m real) while failing cleanly instead of
|
||||
# OOM-killing. The job-level timeout (40m) is a backstop.
|
||||
run: go test -race -timeout 30m -coverprofile=coverage.out ./...
|
||||
|
||||
- if: always()
|
||||
name: Per-file coverage report
|
||||
@ -400,9 +409,9 @@ jobs:
|
||||
canvas-deploy-reminder:
|
||||
name: Canvas Deploy Reminder
|
||||
runs-on: ubuntu-latest
|
||||
# This job must run on PRs because all-required needs it. The step exits
|
||||
# 0 when it is not a main push, giving branch protection a green no-op
|
||||
# instead of a skipped/missing required dependency.
|
||||
# This job must run on every CI trigger (including PRs) because all-required
|
||||
# needs it as a dependency. The step body exits 0 when it is not a main-push,
|
||||
# giving the aggregator a concrete success instead of a skipped/missing result.
|
||||
needs: canvas-build
|
||||
steps:
|
||||
- name: Write deploy reminder to step summary
|
||||
@ -545,104 +554,51 @@ jobs:
|
||||
# red silently merged through. See internal#286 for the three concrete
|
||||
# tonight-of-2026-05-11 incidents that prompted the emergency bump.
|
||||
#
|
||||
# This job deliberately has no `needs:`. Gitea 1.22/act_runner can mark a
|
||||
# job-level `if: always()` + `needs:` sentinel as skipped before upstream
|
||||
# jobs settle, leaving branch protection with a permanent pending
|
||||
# `CI / all-required` context. Instead, this independent sentinel polls the
|
||||
# required commit-status contexts for this SHA and fails if any fail, skip,
|
||||
# or never emit.
|
||||
#
|
||||
# canvas-deploy-reminder is intentionally NOT included in all-required.needs.
|
||||
# It is an informational main-push reminder, not a PR quality gate. Keeping
|
||||
# it in this dependency list lets a skipped reminder skip the required
|
||||
# sentinel before the `always()` guard can emit a branch-protection status.
|
||||
# Uses `needs:` so Gitea waits for all upstream jobs before this sentinel
|
||||
# emits. `if: always()` ensures the sentinel runs (and reports pass/fail)
|
||||
# even when an upstream job failed or was skipped. canvas-deploy-reminder
|
||||
# is intentionally included — it exits 0 on non-main-push events so it
|
||||
# never blocks PRs, and excluding it would leave the sentinel permanently
|
||||
# pending on main pushes where reminder is a no-op.
|
||||
#
|
||||
needs:
|
||||
- changes
|
||||
- platform-build
|
||||
- canvas-build
|
||||
- shellcheck
|
||||
- python-lint
|
||||
- canvas-deploy-reminder
|
||||
if: ${{ always() }}
|
||||
continue-on-error: false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 1
|
||||
steps:
|
||||
- name: Wait for required CI contexts
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
API_ROOT: ${{ github.server_url }}/api/v1
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
- name: Verify all required jobs succeeded
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
token = os.environ["GITEA_TOKEN"]
|
||||
api_root = os.environ["API_ROOT"].rstrip("/")
|
||||
repo = os.environ["REPOSITORY"]
|
||||
sha = os.environ["COMMIT_SHA"]
|
||||
event = os.environ["EVENT_NAME"]
|
||||
required = [
|
||||
f"CI / Detect changes ({event})",
|
||||
f"CI / Platform (Go) ({event})",
|
||||
f"CI / Canvas (Next.js) ({event})",
|
||||
f"CI / Shellcheck (E2E scripts) ({event})",
|
||||
f"CI / Python Lint & Test ({event})",
|
||||
]
|
||||
terminal_bad = {"failure", "error"}
|
||||
deadline = time.time() + 40 * 60
|
||||
last_summary = None
|
||||
|
||||
def fetch_statuses():
|
||||
statuses = []
|
||||
for page in range(1, 6):
|
||||
url = f"{api_root}/repos/{repo}/commits/{sha}/statuses?page={page}&limit=100"
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
chunk = json.load(resp)
|
||||
if not chunk:
|
||||
break
|
||||
statuses.extend(chunk)
|
||||
latest = {}
|
||||
for item in statuses:
|
||||
ctx = item.get("context")
|
||||
if not ctx:
|
||||
continue
|
||||
prev = latest.get(ctx)
|
||||
if prev is None or (item.get("updated_at") or item.get("created_at") or "") >= (prev.get("updated_at") or prev.get("created_at") or ""):
|
||||
latest[ctx] = item
|
||||
return latest
|
||||
|
||||
while True:
|
||||
try:
|
||||
latest = fetch_statuses()
|
||||
except (TimeoutError, OSError, urllib.error.URLError) as exc:
|
||||
if time.time() >= deadline:
|
||||
print(f"FAIL: status polling did not recover before deadline: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"WARN: status poll failed, retrying: {exc}", flush=True)
|
||||
time.sleep(15)
|
||||
continue
|
||||
states = {ctx: (latest.get(ctx) or {}).get("status") or (latest.get(ctx) or {}).get("state") or "missing" for ctx in required}
|
||||
summary = ", ".join(f"{ctx}={state}" for ctx, state in states.items())
|
||||
if summary != last_summary:
|
||||
print(summary, flush=True)
|
||||
last_summary = summary
|
||||
bad = {ctx: state for ctx, state in states.items() if state in terminal_bad}
|
||||
if bad:
|
||||
print("FAIL: required CI context failed:", file=sys.stderr)
|
||||
for ctx, state in bad.items():
|
||||
desc = (latest.get(ctx) or {}).get("description") or ""
|
||||
print(f" - {ctx}: {state} {desc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if all(state == "success" for state in states.values()):
|
||||
print(f"OK: all {len(required)} required CI contexts succeeded")
|
||||
sys.exit(0)
|
||||
if time.time() >= deadline:
|
||||
print("FAIL: timed out waiting for required CI contexts:", file=sys.stderr)
|
||||
for ctx, state in states.items():
|
||||
print(f" - {ctx}: {state}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
time.sleep(15)
|
||||
PY
|
||||
FAILED=0
|
||||
for job in changes platform-build canvas-build shellcheck python-lint canvas-deploy-reminder; do
|
||||
result="$(gh api repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs --jq '.jobs[] | select(.name == env.JOB) | .conclusion' 2>/dev/null || echo 'missing')"
|
||||
echo "CI / ${job^}: ${result}"
|
||||
case "$result" in
|
||||
success) ;;
|
||||
skipped)
|
||||
# canvas-deploy-reminder skips on non-main-push — expected
|
||||
if [ "$job" != "canvas-deploy-reminder" ]; then
|
||||
echo "::error::CI / ${job} was skipped"
|
||||
FAILED=1
|
||||
fi
|
||||
;;
|
||||
'') ;;
|
||||
*)
|
||||
echo "::error::CI / ${job} = ${result} (expected success)"
|
||||
FAILED=1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ "$FAILED" -ne 0 ]; then
|
||||
echo ""
|
||||
echo "One or more required CI jobs failed or skipped. Fix before merging."
|
||||
exit 1
|
||||
fi
|
||||
echo "All required CI jobs passed."
|
||||
|
||||
@ -128,3 +128,39 @@ jobs:
|
||||
--pr "$PR_NUMBER" \
|
||||
--config .gitea/sop-checklist-config.yaml \
|
||||
--gitea-host git.moleculesai.app
|
||||
|
||||
# Posts `sop-checklist / na-declarations (pull_request)` when a non-author
|
||||
# peer in the gate's required_teams posts `/sop-n/a <gate>`. This status
|
||||
# is read by review-check.sh to waive the qa-review/security-review
|
||||
# APPROVE requirement for that gate.
|
||||
# Context: review-check.sh reads "sop-checklist / na-declarations (pull_request)"
|
||||
# bp-required: pending #1098 ← BP PATCH tracked in mc#1098; merge without requiring new context in BP
|
||||
na-declarations:
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
(contains(github.event.comment.body, '/sop-n/a') ||
|
||||
contains(github.event.comment.body, '/sop-revoke')))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out BASE ref (trust boundary — never PR-head)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Run sop-checklist (N/A declarations mode)
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 .gitea/scripts/sop-checklist.py \
|
||||
--owner "$OWNER" \
|
||||
--repo "$REPO_NAME" \
|
||||
--pr "$PR_NUMBER" \
|
||||
--config .gitea/sop-checklist-config.yaml \
|
||||
--gitea-host git.moleculesai.app \
|
||||
--na-declarations-mode
|
||||
|
||||
Loading…
Reference in New Issue
Block a user