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
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:
parent
ec1da82fa2
commit
ead51168fe
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user