Some checks failed
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 28s
Harness Replays / detect-changes (pull_request) Failing after 15s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 14s
Harness Replays / Harness Replays (pull_request) Has been skipped
CI / Detect changes (pull_request) Successful in 59s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 18s
E2E API Smoke Test / detect-changes (pull_request) Successful in 1m5s
sop-tier-check / tier-check (pull_request) Successful in 19s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 59s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 1m10s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 54s
CI / Platform (Go) (pull_request) Successful in 11s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 10s
CI / Python Lint & Test (pull_request) Successful in 8s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 10s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 7s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 9s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 9m10s
CI / Canvas (Next.js) (pull_request) Failing after 10m31s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
## Why Gitea 1.22.6's `pull_request_review` event doesn't refire workflows (go-gitea/gitea#33700). The existing sop-tier-check workflow subscribes to the review event, but the subscription is silently dead. When an approving review lands AFTER tier-check ran on PR-open/synchronize, the PR's `sop-tier-check / tier-check (pull_request)` status stays at failure forever, forcing the orchestrator down the admin force-merge path (audited via audit-force-merge.yml, but the audit trail keeps growing — see feedback_never_admin_merge_bypass). ## What New `.gitea/workflows/sop-tier-refire.yml` listening on `issue_comment` events. When a repo MEMBER/OWNER/COLLABORATOR comments `/refire-tier-check` on a PR, the workflow re-invokes the canonical sop-tier-check.sh and POSTs the resulting status directly to the PR head SHA (no empty commit, no git history bloat, no cascade re-fire of every other workflow). ## Security model Three gates in the workflow `if:` expression — all required: 1. `github.event.issue.pull_request != null` — comment is on a PR, not a plain issue. 2. `author_association` ∈ {MEMBER, OWNER, COLLABORATOR} — only repo collaborators+ can flip the status (per the internal#292 core-security review#1066 ask). 3. Comment body contains `/refire-tier-check` — slash-command-shaped, not just any word in normal review prose. Workflow does NOT check out PR HEAD; only HTTP-calls the Gitea API. Same trust boundary as sop-tier-check.yml's `pull_request_target`. ## DRY: re-uses sop-tier-check.sh Refire shells out to the canonical script with the same env the original workflow provides. We get the EXACT AND-composition gate, not a watered-down approving-count check. ## Rate-limit 30-second window between status updates per PR head SHA — prevents comment-spam status thrash. Override via SOP_REFIRE_RATE_LIMIT_SEC or disable for tests via SOP_REFIRE_DISABLE_RATE_LIMIT=1. ## Tests `.gitea/scripts/tests/test_sop_tier_refire.sh` — 23 assertions across T1-T7 covering: success POST, failure POST, no-op on closed, rate-limit skip, plus YAML-level checks of all three security gates. Real script runs against a local-fixture HTTP server (`_refire_fixture.py`) with a mock tier-check (`_mock_tier_check.sh`) — the latter sidesteps the known bash 3.2 (macOS dev) parser bug on `declare -A`; Linux Gitea runners (bash 4/5) use the real sop-tier-check.sh in production. Hostile self-review verified: - Tests FAIL on absent code (exit 1, FAIL=2 PASS=0 in existence-block). - Tests FAIL on swapped success/failure label (exit 1). - Tests PASS on correct code (exit 0, 23/23). ## Brief-falsification log (a) Keep using force_merge — no, this is the issue being closed. (b) Empty-commit re-trigger — no, status-POST is cleaner + faster + doesn't bloat git history. (c) author_association check in the script not the workflow — both work but workflow-level short-circuits faster (saves runner spin). (d) Re-implement a watered-down tier-check inside refire — no, that's a security regression (skips team-membership AND-composition). Refire shells out to the canonical script. Tier: tier:high (unblocks approved-PR-backlog drain class). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
209 lines
6.4 KiB
Python
Executable File
209 lines
6.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Stub Gitea API for sop-tier-refire test scenarios.
|
|
|
|
Reads $FIXTURE_STATE_DIR/scenario to decide what to return for each
|
|
endpoint the sop-tier-refire.sh + sop-tier-check.sh scripts call.
|
|
Captures every POST to /statuses/{sha} into posted_statuses.jsonl so
|
|
the test can assert what the script tried to write.
|
|
|
|
Scenarios:
|
|
T1_success — tier:low + APPROVED by engineer → tier-check passes
|
|
T2_no_tier_label — no tier label → tier-check exits 1 before POST
|
|
T3_no_approvals — tier:low but zero approving reviews → exits 1
|
|
T4_closed — PR state=closed → refire is a no-op
|
|
T5_rate_limited — last status update 5 seconds ago → skip
|
|
|
|
Usage:
|
|
FIXTURE_STATE_DIR=/tmp/x python3 _refire_fixture.py 8080
|
|
"""
|
|
|
|
import datetime
|
|
import http.server
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.parse
|
|
|
|
|
|
STATE_DIR = os.environ["FIXTURE_STATE_DIR"]
|
|
|
|
|
|
def scenario() -> str:
|
|
p = os.path.join(STATE_DIR, "scenario")
|
|
if not os.path.isfile(p):
|
|
return "T1_success"
|
|
with open(p) as f:
|
|
return f.read().strip()
|
|
|
|
|
|
def now_iso() -> str:
|
|
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
|
|
|
|
def append_post(body: dict) -> None:
|
|
with open(os.path.join(STATE_DIR, "posted_statuses.jsonl"), "a") as f:
|
|
f.write(json.dumps(body) + "\n")
|
|
|
|
|
|
def pr_payload() -> dict:
|
|
sc = scenario()
|
|
state = "closed" if sc == "T4_closed" else "open"
|
|
return {
|
|
"number": 999,
|
|
"state": state,
|
|
"head": {"sha": "deadbeef0000111122223333444455556666"},
|
|
"user": {"login": "feature-author"},
|
|
}
|
|
|
|
|
|
def labels_payload() -> list:
|
|
sc = scenario()
|
|
if sc == "T2_no_tier_label":
|
|
return [{"name": "bug"}]
|
|
# All other scenarios use tier:low
|
|
return [{"name": "tier:low"}, {"name": "ci"}]
|
|
|
|
|
|
def reviews_payload() -> list:
|
|
sc = scenario()
|
|
if sc == "T3_no_approvals":
|
|
return []
|
|
# All other scenarios have one APPROVED review by an engineer
|
|
return [
|
|
{
|
|
"state": "APPROVED",
|
|
"user": {"login": "reviewer-engineer"},
|
|
}
|
|
]
|
|
|
|
|
|
def teams_payload() -> list:
|
|
# Mirror the real molecule-ai org teams referenced in TIER_EXPR
|
|
return [
|
|
{"id": 5, "name": "ceo"},
|
|
{"id": 2, "name": "engineers"},
|
|
{"id": 6, "name": "managers"},
|
|
]
|
|
|
|
|
|
def statuses_payload() -> list:
|
|
sc = scenario()
|
|
if sc == "T5_rate_limited":
|
|
recent = (
|
|
datetime.datetime.now(datetime.timezone.utc)
|
|
- datetime.timedelta(seconds=5)
|
|
).isoformat()
|
|
return [
|
|
{
|
|
"context": "sop-tier-check / tier-check (pull_request)",
|
|
"state": "failure",
|
|
"updated_at": recent,
|
|
}
|
|
]
|
|
return []
|
|
|
|
|
|
def user_payload() -> dict:
|
|
# Mirrors the WHOAMI probe in sop-tier-check.sh
|
|
return {"login": "sop-tier-bot-fixture"}
|
|
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
# Quiet — keep stdout for explicit logs only.
|
|
def log_message(self, *args, **kwargs): # noqa: D401
|
|
pass
|
|
|
|
def _json(self, code: int, body) -> None:
|
|
payload = json.dumps(body).encode()
|
|
self.send_response(code)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(payload)))
|
|
self.end_headers()
|
|
self.wfile.write(payload)
|
|
|
|
def _empty(self, code: int) -> None:
|
|
self.send_response(code)
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
|
|
def do_GET(self): # noqa: N802
|
|
u = urllib.parse.urlparse(self.path)
|
|
path = u.path
|
|
|
|
if path == "/_ping":
|
|
return self._json(200, {"ok": True})
|
|
if path == "/api/v1/user":
|
|
return self._json(200, user_payload())
|
|
|
|
# /api/v1/repos/{owner}/{name}/pulls/{n}
|
|
m = re.match(r"^/api/v1/repos/[^/]+/[^/]+/pulls/(\d+)$", path)
|
|
if m:
|
|
return self._json(200, pr_payload())
|
|
|
|
# /api/v1/repos/{owner}/{name}/issues/{n}/labels
|
|
if re.match(r"^/api/v1/repos/[^/]+/[^/]+/issues/\d+/labels$", path):
|
|
return self._json(200, labels_payload())
|
|
|
|
# /api/v1/repos/{owner}/{name}/pulls/{n}/reviews
|
|
if re.match(r"^/api/v1/repos/[^/]+/[^/]+/pulls/\d+/reviews$", path):
|
|
return self._json(200, reviews_payload())
|
|
|
|
# /api/v1/orgs/{owner}/teams
|
|
if re.match(r"^/api/v1/orgs/[^/]+/teams$", path):
|
|
return self._json(200, teams_payload())
|
|
|
|
# /api/v1/teams/{id}/members/{login} → 204 if user is an engineer
|
|
m = re.match(r"^/api/v1/teams/(\d+)/members/([^/]+)$", path)
|
|
if m:
|
|
team_id, login = m.group(1), m.group(2)
|
|
# In our fixture reviewer-engineer ∈ engineers (id=2)
|
|
if team_id == "2" and login == "reviewer-engineer":
|
|
return self._empty(204)
|
|
return self._empty(404)
|
|
|
|
# /api/v1/orgs/{owner}/members/{login} — fallback path used when
|
|
# team-member probes all 403. We don't need it for these tests.
|
|
if re.match(r"^/api/v1/orgs/[^/]+/members/[^/]+$", path):
|
|
return self._empty(404)
|
|
|
|
# /api/v1/repos/{owner}/{name}/statuses/{sha}
|
|
if re.match(r"^/api/v1/repos/[^/]+/[^/]+/statuses/[^/]+$", path):
|
|
return self._json(200, statuses_payload())
|
|
|
|
return self._json(404, {"path": path, "msg": "fixture: no route"})
|
|
|
|
def do_POST(self): # noqa: N802
|
|
u = urllib.parse.urlparse(self.path)
|
|
path = u.path
|
|
length = int(self.headers.get("Content-Length") or 0)
|
|
raw = self.rfile.read(length) if length else b""
|
|
try:
|
|
body = json.loads(raw) if raw else {}
|
|
except Exception:
|
|
body = {"_raw": raw.decode(errors="replace")}
|
|
|
|
if re.match(r"^/api/v1/repos/[^/]+/[^/]+/statuses/[^/]+$", path):
|
|
append_post(body)
|
|
# Echo back something status-shaped — script only checks HTTP code.
|
|
return self._json(
|
|
201,
|
|
{
|
|
"context": body.get("context"),
|
|
"state": body.get("state"),
|
|
"created_at": now_iso(),
|
|
},
|
|
)
|
|
|
|
return self._json(404, {"path": path, "msg": "fixture: no route"})
|
|
|
|
|
|
def main():
|
|
port = int(sys.argv[1])
|
|
srv = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
|
srv.serve_forever()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|