fix(sop): add na-declarations job and /sop-n/a parsing
Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 28s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 35s
CI / Detect changes (pull_request) Successful in 1m3s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 44s
E2E API Smoke Test / detect-changes (pull_request) Successful in 44s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 55s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 24s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 21s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m36s
Lint workflow YAML (Gitea-1.22.6-hostile shapes) / Lint workflow YAML for Gitea-1.22.6-hostile shapes (pull_request) Successful in 1m52s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 1m33s
gate-check-v3 / gate-check (pull_request) Successful in 29s
lint-continue-on-error-tracking / lint-continue-on-error-tracking (pull_request) Successful in 2m58s
Lint pre-flip continue-on-error / Verify continue-on-error flips have run-log proof (pull_request) Successful in 2m43s
qa-review / approved (pull_request) Failing after 26s
security-review / approved (pull_request) Failing after 23s
lint-mask-pr-atomicity / lint-mask-pr-atomicity (pull_request) Successful in 2m49s
lint-required-context-exists-in-bp / lint-required-context-exists-in-bp (pull_request) Failing after 2m42s
sop-tier-check / tier-check (pull_request) Successful in 19s
Ops Scripts Tests / Ops scripts (unittest) (pull_request) Failing after 1m47s
CI / Python Lint & Test (pull_request) Successful in 7m40s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
CI / Platform (Go) (pull_request) Failing after 16m53s
CI / Canvas (Next.js) (pull_request) Successful in 17m1s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 11s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 11s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 13s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 13s
CI / Canvas Deploy Reminder (pull_request) Successful in 5s
CI / all-required (pull_request) Failing after 18s

Adds the missing na-declarations gate that review-check.sh reads to
waive qa-review/security-review APPROVE requirements.

Changes:
- sop-checklist.py: new --na-declarations-mode flag; parses /sop-n/a
  and /sop-revoke for gate names; computes per-gate N/A state from
  non-author peer comments with team membership verified against the
  gate's required_teams; posts
  sop-checklist / na-declarations (pull_request) status.
- sop-checklist.yml: new na-declarations job triggered by /sop-n/a
  and /sop-revoke comments; runs sop-checklist.py --na-declarations-mode.

Fixes molecule-core#1098

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Molecule AI · core-devops 2026-05-15 00:15:36 +00:00
parent ec1da82fa2
commit ead51168fe
2 changed files with 247 additions and 0 deletions

View File

@ -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
@ -301,6 +312,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, {}):
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
# ---------------------------------------------------------------------------
@ -676,6 +796,15 @@ 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",
@ -800,6 +929,89 @@ def main(argv: list[str] | None = None) -> int:
extra = " (" + "; ".join(extras) + ")" if extras else ""
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}")
if args.dry_run:

View File

@ -128,3 +128,38 @@ 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)"
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