diff --git a/.gitea/workflows/gate-check-v3.yml b/.gitea/workflows/gate-check-v3.yml
new file mode 100644
index 00000000..406704c9
--- /dev/null
+++ b/.gitea/workflows/gate-check-v3.yml
@@ -0,0 +1,91 @@
+# gate-check-v3 — automated PR gate detector
+#
+# Runs on every open PR (push/synchronize) and hourly via cron.
+# Posts a structured [gate-check-v3] STATUS: comment on the PR.
+#
+# Inputs:
+# PR_NUMBER — set via ${{ github.event.pull_request.number }} from the trigger
+# POST_COMMENT — "true" to post/update comment on PR
+#
+# Gating logic (MVP signals 1,2,3,6):
+# 1. Author-aware agent-tag comment scan
+# 2. REQUEST_CHANGES reviews state machine
+# 3. Staleness detection (SOP-12: review.commit_id != PR.head_sha + >1 working day)
+# 6. CI required-checks awareness
+#
+# Exit code: 0=CLEAR, 1=BLOCKED, 2=ERROR
+
+name: gate-check-v3
+
+on:
+ pull_request_target:
+ types: [opened, edited, synchronize, reopened]
+ schedule:
+ # Hourly: refresh all open PRs
+ - cron: '8 * * * *'
+ workflow_dispatch:
+ inputs:
+ pr_number:
+ description: 'PR number to check (omit for all open PRs)'
+ required: false
+ type: string
+ post_comment:
+ description: 'Post comment on PR'
+ required: false
+ type: string
+ default: 'true'
+
+env:
+ GITHUB_SERVER_URL: https://git.moleculesai.app
+
+jobs:
+ gate-check:
+ runs-on: ubuntu-latest
+ continue-on-error: true # Never block on our own detector failing
+ steps:
+ - name: Check out base branch (for the script)
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ with:
+ ref: ${{ github.event.pull_request.base.sha || github.ref_name }}
+
+ - name: Run gate-check-v3 (single PR mode)
+ if: github.event_name == 'pull_request_target' || github.event.inputs.pr_number != ''
+ env:
+ GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
+ PR_NUMBER: ${{ github.event.pull_request.number || github.event.inputs.pr_number }}
+ POST_COMMENT: ${{ github.event.inputs.post_comment || 'true' }}
+ run: |
+ set -euo pipefail
+ python3 tools/gate-check-v3/gate_check.py \
+ --repo "${{ github.repository }}" \
+ --pr "$PR_NUMBER" \
+ $([ "$POST_COMMENT" = "true" ] && echo "--post-comment")
+ echo "verdict=$?" >> "$GITHUB_OUTPUT"
+
+ - name: Run gate-check-v3 (all open PRs — cron mode)
+ if: github.event_name == 'schedule'
+ env:
+ GITEA_TOKEN: ${{ secrets.SOP_TIER_CHECK_TOKEN || secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ # Fetch all open PRs and run gate-check on each
+ pr_numbers=$(python3 -c "
+ import urllib.request, json, os
+ token = os.environ['GITEA_TOKEN']
+ req = urllib.request.Request(
+ 'https://git.moleculesai.app/api/v1/repos/${{ github.repository }}/pulls?state=open&limit=100',
+ headers={'Authorization': f'token {token}', 'Accept': 'application/json'}
+ )
+ with urllib.request.urlopen(req) as r:
+ prs = json.loads(r.read())
+ for pr in prs:
+ print(pr['number'])
+ ")
+ for pr in $pr_numbers; do
+ echo "Checking PR #$pr..."
+ python3 tools/gate-check-v3/gate_check.py \
+ --repo "${{ github.repository }}" \
+ --pr "$pr" \
+ --post-comment \
+ || true
+ done
diff --git a/canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx b/canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx
index 4abdb36c..30e774c3 100644
--- a/canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx
+++ b/canvas/src/components/__tests__/PurchaseSuccessModal.test.tsx
@@ -12,7 +12,7 @@
* window.location.search in the jsdom environment.
*/
import React from "react";
-import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
+import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { PurchaseSuccessModal } from "../PurchaseSuccessModal";
@@ -30,9 +30,13 @@ function clearSearch() {
setSearch("");
}
-// Helper: wait for dialog to appear (real timers)
+// Helper: wait for the dialog to appear after React useEffect batch.
+// Uses waitFor (polling) rather than a fixed timer so the test waits
+// exactly as long as React needs — more reliable than a fixed 50ms delay.
async function waitForDialog() {
- await act(async () => { await new Promise((r) => setTimeout(r, 50)); });
+ await waitFor(() => {
+ expect(screen.queryByRole("dialog")).toBeTruthy();
+ }, { timeout: 2000 });
}
// ─── Tests ────────────────────────────────────────────────────────────────────
@@ -104,6 +108,7 @@ describe("PurchaseSuccessModal — render conditions", () => {
describe("PurchaseSuccessModal — dismiss", () => {
beforeEach(() => {
setSearch("?purchase_success=1&item=TestItem");
+ vi.useRealTimers(); // use real timers throughout so waitFor + setTimeout are synchronous-friendly
});
afterEach(() => {
@@ -116,52 +121,45 @@ describe("PurchaseSuccessModal — dismiss", () => {
it("closes the dialog when the close button is clicked", async () => {
render();
await waitForDialog();
- expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Close" }));
- await waitForDialog();
+ await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("closes the dialog when the backdrop is clicked", async () => {
render();
await waitForDialog();
- expect(screen.getByRole("dialog")).toBeTruthy();
const backdrop = document.body.querySelector('[aria-hidden="true"]');
if (backdrop) fireEvent.click(backdrop);
- await waitForDialog();
+ await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
expect(screen.queryByRole("dialog")).toBeNull();
});
it("closes on Escape key", async () => {
render();
await waitForDialog();
- expect(screen.getByRole("dialog")).toBeTruthy();
fireEvent.keyDown(window, { key: "Escape" });
- await waitForDialog();
+ await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
expect(screen.queryByRole("dialog")).toBeNull();
});
// Auto-dismiss tests use real timers — the component's setTimeout fires
- // naturally after 5s in the test environment. vi.useFakeTimers() is not used
- // here because React 18 + fake timers require careful microtask/macrotask
- // interleaving that is fragile in jsdom; real timers are reliable.
+ // naturally after 5s in the test environment.
it("auto-dismisses after 5 seconds", async () => {
render();
await waitForDialog();
- expect(screen.getByRole("dialog")).toBeTruthy();
- // The component's AUTO_DISMISS_MS = 5000ms. In jsdom, setTimeout fires
- // reliably. Wait long enough for 2 dismiss cycles to ensure the first fires.
- await act(async () => { await new Promise((r) => setTimeout(r, 11000)); });
+ // AUTO_DISMISS_MS = 5000ms. Wait 6s to ensure dismiss has fired + React updated.
+ await act(async () => { await new Promise((r) => setTimeout(r, 6000)); });
expect(screen.queryByRole("dialog")).toBeNull();
- }, 15000); // extended timeout for real-timer wait
+ }, 10000);
it("does not auto-dismiss before 5 seconds", async () => {
render();
await waitForDialog();
- expect(screen.getByRole("dialog")).toBeTruthy();
+ const dialog = screen.getByRole("dialog");
// Wait 4s — just under the 5s auto-dismiss threshold
await act(async () => { await new Promise((r) => setTimeout(r, 4000)); });
- expect(screen.getByRole("dialog")).toBeTruthy();
+ expect(screen.queryByRole("dialog")).toBeTruthy();
});
});
@@ -210,27 +208,28 @@ describe("PurchaseSuccessModal — accessibility", () => {
it("has aria-modal=true on the dialog", async () => {
render();
- await waitForDialog();
- const dialog = screen.getByRole("dialog");
- expect(dialog.getAttribute("aria-modal")).toBe("true");
+ await waitFor(() => {
+ expect(screen.getByRole("dialog").getAttribute("aria-modal")).toBe("true");
+ });
});
it("has aria-labelledby pointing to the title", async () => {
render();
- await waitForDialog();
- const dialog = screen.getByRole("dialog");
- const labelledby = dialog.getAttribute("aria-labelledby");
- expect(labelledby).toBeTruthy();
- expect(document.getElementById(labelledby!)).toBeTruthy();
- expect(document.getElementById(labelledby!)?.textContent).toMatch(/purchase successful/i);
+ await waitFor(() => {
+ const dialog = screen.getByRole("dialog");
+ const labelledby = dialog.getAttribute("aria-labelledby");
+ expect(labelledby).toBeTruthy();
+ expect(document.getElementById(labelledby!)).toBeTruthy();
+ expect(document.getElementById(labelledby!)?.textContent).toMatch(/purchase successful/i);
+ });
});
// Focus test: verify close button exists after dialog renders.
// We test presence (not focus) since rAF focus is tricky in jsdom.
it("moves focus to the close button on open", async () => {
render();
- await act(async () => { await new Promise((r) => setTimeout(r, 100)); });
- // Use getByRole which is more reliable than querySelector
- expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: "Close" })).toBeTruthy();
+ });
});
});
diff --git a/runbooks/gitea-operational-quirks.md b/runbooks/gitea-operational-quirks.md
index 43c0dbaa..3bc1cd94 100644
--- a/runbooks/gitea-operational-quirks.md
+++ b/runbooks/gitea-operational-quirks.md
@@ -8,36 +8,36 @@ runbooks.
---
-## Gitea 1.22.6 runner network isolation
+## Large repo causes fetch timeout on Gitea Actions runner
### Finding
-The Gitea Actions runner (container on host `5.78.80.188`) cannot reach the
-git remote (`https://git.moleculesai.app`) over HTTPS from inside the runner
-container. Any `git fetch`, `git clone`, or `git push` command that contacts
-the remote times out at 12–15 s.
+The Gitea Actions runner (container on host `5.78.80.188`) can reach the git
+remote (`https://git.moleculesai.app`) over HTTPS — a single-commit shallow
+fetch (`--depth=1`) succeeds in ~16 s. However, fetching the **full compressed
+repo history** (~75+ MB) exceeds the runner's network timeout window (~15 s).
-This is **not a Gitea Actions bug** — it is an operator-level network policy
-where the runner container's network namespace is restricted from reaching the
-Gitea host HTTPS endpoint. The runner can reach external hosts (GitHub,
-Docker Hub, PyPI) normally.
+This is **not a Gitea Actions bug** and **not a network isolation policy** —
+it is a repo-size constraint. The runner can reach external hosts (GitHub,
+Docker Hub, PyPI) without issue.
### Impact
-Workflows that rely on `git fetch origin [` or `actions/checkout` with
-`fetch-depth: 0` (full history) will hang or time out.
+Workflows that rely on `actions/checkout` with `fetch-depth: 0` (full history)
+or `git clone` will time out.
Specifically:
- `actions/checkout@v*` with `fetch-depth: 0` hangs (fetching full repo
- history takes >30 s before hitting the timeout).
-- `git fetch origin main --depth=1` times out at ~15 s.
-- `git clone ` times out at ~15 s.
+ history takes >15 s before hitting the timeout).
+- `git clone ` hangs for the same reason.
+- `git fetch origin ][ --depth=1` **succeeds** in ~16 s — this is the
+ working pattern.
### Affected workflows
| Workflow | Issue | Workaround |
|---|---|---|
-| `harness-replays.yml` detect-changes job | `git fetch origin main --depth=1` times out | Added `timeout 20` + graceful fallback to `run=true` (always run harness) per PR #441 |
+| `harness-replays.yml` detect-changes job | `fetch-depth: 0` + `git clone` time out | Added `timeout 20 git fetch origin base.ref --depth=1` + `continue-on-error: true` + fallback to `run=true` per PR #441 |
| `publish-workspace-server-image.yml` | In-image `git clone` of workspace templates | Pre-clone manifest deps before compose build (Task #173 pattern) |
| Any workflow using `fetch-depth: 0` | Full history fetch times out | Use `fetch-depth: 1` + explicit `git fetch` for needed refs |
@@ -46,15 +46,17 @@ Specifically:
```bash
# From inside the runner (add as a debug step):
timeout 20 git fetch origin main --depth=1
-# If this times out: runner cannot reach git remote
+# If this SUCCEEDS (~16s): runner can reach the git remote — the repo is
+# too large for full-history fetch.
+# If this times out: true network isolation (unlikely; check firewall rules).
```
### Verification
-Confirmed 2026-05-11 by running `timeout 20 git fetch origin main --depth=1`
-in the `detect-changes` job of `harness-replays.yml` — consistently times
-out at 15 s. Runner can reach `https://api.github.com` and `https://pypi.org`
-without issue.
+Confirmed 2026-05-11 by running `timeout 20 git fetch origin base.ref --depth=1`
+in the `detect-changes` job of `harness-replays.yml` — **succeeds in ~16 s**.
+Runner can reach `https://api.github.com` and `https://pypi.org` without issue,
+confirming this is a repo-size constraint, not network isolation.
### References
@@ -139,12 +141,51 @@ files. Secrets and variables are repo-level.
---
-## `fetch-depth: 0` on `actions/checkout` times out
+## Gitea combined status reports `failure` when all contexts are `null`
-`actions/checkout` with `fetch-depth: 0` triggers a full repo history fetch
-which exceeds the runner's network timeout to the git remote (~15 s).
+### Finding
-**Workaround**: Use `fetch-depth: 1` (default) and add explicit
-`git fetch origin ][ --depth=1` for any additional refs needed.
+When ALL individual status contexts for a commit have `state: null` (no runner
+has reported yet), Gitea reports the combined commit status as `failure`. This
+is a Gitea Actions bug — it conflates "no status reported yet" with "failed".
-**Reference**: PR #441 detect-changes fetch step.
+### Impact
+
+- The `main-red-watchdog` workflow opens a `[main-red]` issue for every
+ scheduled workflow run where the combined state is `failure` — even when
+ the failure is entirely due to Gitea's combined-status bug.
+- This causes spurious `[main-red]` issues that waste SRE time investigating
+ non-existent failures.
+- **This is especially confusing for `schedule:`-only workflows** (canary,
+ sweep jobs, synth-E2E): Gitea attributes their scheduled runs to `main`'s
+ HEAD commit, so if a scheduled run fires while all contexts are still
+ `state: null`, the watchdog opens a `[main-red]` issue on the latest main
+ commit even though that commit itself is perfectly fine.
+
+### How to diagnose
+
+Always check the **individual context `state` fields**, not the combined
+`state`/`combined_state`. In the `/repos/{org}/{repo}/commits/{sha}/statuses`
+API response, look for `"state": null` on every entry — if all are null, the
+combined `failure` is Gitea's bug, not a real CI failure.
+
+```json
+{
+ "combined_state": "failure", // ← Gitea bug when all are null
+ "contexts": [
+ { "context": "CI / Lint", "state": null }, // still running
+ { "context": "CI / Test", "state": null } // still running
+ ]
+}
+```
+
+### Affected workflows
+
+All workflows, but especially `schedule:`-only workflows that run on `main`.
+The main-red-watchdog (`.gitea/workflows/main-red-watchdog.yml`) is the
+primary consumer of combined status and is affected.
+
+### References
+
+- Issue #481: first real-world case of this bug (2026-05-11)
+- `feedback_no_such_thing_as_flakes`: watchdog directive
diff --git a/tools/gate-check-v3/gate_check.py b/tools/gate-check-v3/gate_check.py
new file mode 100644
index 00000000..429c2b40
--- /dev/null
+++ b/tools/gate-check-v3/gate_check.py
@@ -0,0 +1,543 @@
+#!/usr/bin/env python3
+"""
+gate-check-v3 — SOP-6 + CI gate detector for Gitea PRs.
+
+Emits structured verdict + human-readable summary. Designed to run as:
+ 1. CLI: python gate_check.py --repo org/repo --pr N
+ 2. Gitea Actions step: runs this script, captures stdout JSON
+
+Signals (MVP — signals 1,2,3,6):
+ 1. Author-aware agent-tag comment scan
+ 2. REQUEST_CHANGES reviews state machine
+ 3. Staleness detection (review.commit_id != PR.head_sha)
+ 6. CI required-checks awareness
+
+Exit codes:
+ 0 — all gates pass (verdict=CLEAR)
+ 1 — one or more gates blocking (verdict=BLOCKED)
+ 2 — API error / usage error (verdict=ERROR)
+"""
+
+import argparse
+import json
+import os
+import re
+import sys
+import time
+import urllib.request
+import urllib.error
+from datetime import datetime, timezone
+from typing import Any, Optional
+
+# ── Gitea API client ────────────────────────────────────────────────────────
+
+GITEA_HOST = os.environ.get("GITEA_HOST", "git.moleculesai.app")
+GITEA_TOKEN = os.environ.get("GITEA_TOKEN", os.environ.get("GITHUB_TOKEN", ""))
+API_BASE = f"https://{GITEA_HOST}/api/v1"
+
+
+def api_get(path: str) -> dict | list:
+ url = f"{API_BASE}{path}"
+ req = urllib.request.Request(
+ url,
+ headers={
+ "Authorization": f"token {GITEA_TOKEN}",
+ "Accept": "application/json",
+ },
+ )
+ try:
+ with urllib.request.urlopen(req) as r:
+ return json.loads(r.read())
+ except urllib.error.HTTPError as e:
+ body = e.read().decode(errors="replace")
+ raise GiteaError(f"GET {url} → {e.code}: {body[:300]}")
+
+
+def api_list(path: str, per_page: int = 100) -> list:
+ """Paginate a list endpoint using Link headers (Gitea/GitHub convention)."""
+ results = []
+ page = 1
+ while True:
+ paged_path = f"{path}?per_page={per_page}&page={page}"
+ result = api_get(paged_path)
+ if isinstance(result, list):
+ results.extend(result)
+ if len(result) < per_page:
+ break
+ page += 1
+ else:
+ # Some endpoints return an object with a data/items key
+ data = result.get("data", result.get("items", result))
+ if isinstance(data, list):
+ results.extend(data)
+ break
+ # Safety cap to avoid runaway pagination
+ if page > 20:
+ break
+ return results
+
+
+class GiteaError(Exception):
+ pass
+
+
+# ── Signal 1: Author-aware agent-tag comment scan ─────────────────────────────
+# Matches: [core-{role}-agent] VERDICT in comment body.
+# Must be authored by the agent whose role is tagged.
+# Scans BOTH issue comments (/issues/{N}/comments) and PR comments
+# (/pulls/{N}/comments) since agents post on both.
+
+# Matches [core-{role}-agent] VERDICT anywhere in the comment body.
+AGENT_TAG_RE = re.compile(
+ r"\[core-([a-z]+)-agent\]\s+(APPROVED|N/?A|CHANGES_REQUESTED|COMMENT|BLOCKED|ACK)\b",
+)
+
+# Map agent role → canonical login (from workspace registry)
+AGENT_LOGIN_MAP = {
+ "qa": "core-qa",
+ "security": "core-security",
+ "uiux": "core-uiux",
+ "lead": "core-lead",
+ "devops": "core-devops",
+ "be": "core-be",
+ "fe": "core-fe",
+ "offsec": "core-offsec",
+}
+
+# SOP-6 tier → required agent groups
+# tier:low → engineers,managers,ceo (OR: any one suffices)
+# tier:medium → managers AND engineers AND qa,security (AND)
+# tier:high → ceo (OR, but single)
+# "?" = teams not yet created; treated as optional for MVP
+TIER_AGENTS = {
+ "tier:low": {"managers": "core-lead", "engineers": "core-devops", "ceo": "ceo"},
+ "tier:medium": {"managers": "core-lead", "engineers": "core-devops", "qa": "core-qa", "security": "core-security"},
+ "tier:high": {"ceo": "ceo"},
+}
+
+POSITIVE_VERDICTS = {"APPROVED", "N/A", "ACK"}
+
+
+def _get_pr_tier(pr_number: int, repo: str) -> str:
+ """Get the PR's tier label."""
+ owner, name = repo.split("/", 1)
+ try:
+ pr = api_get(f"/repos/{owner}/{name}/pulls/{pr_number}")
+ for label in pr.get("labels", []):
+ name_l = label.get("name", "")
+ if name_l in TIER_AGENTS:
+ return name_l
+ except GiteaError:
+ pass
+ return "tier:low" # Default for untagged PRs
+
+
+def signal_1_comment_scan(pr_number: int, repo: str) -> dict:
+ """
+ Scan issue + PR comments AND reviews for agent-tag policy gates.
+ Matches tag AND author. Filters to tier-relevant agents.
+ Returns: {signal, results, verdict}
+ """
+ owner, name = repo.split("/", 1)
+
+ # Get tier label to determine relevant agents
+ tier = _get_pr_tier(pr_number, repo)
+ relevant_roles = TIER_AGENTS.get(tier, TIER_AGENTS["tier:low"])
+
+ # Build reverse map: login -> (group, agent_key)
+ login_to_group = {}
+ for group, login in relevant_roles.items():
+ for role, l in AGENT_LOGIN_MAP.items():
+ if l == login:
+ login_to_group[l] = (group, f"core-{role}")
+
+ # Collect all agent-tag matches from comments
+ comments = []
+ try:
+ comments.extend(api_list(f"/repos/{owner}/{name}/issues/{pr_number}/comments"))
+ except GiteaError:
+ pass
+ try:
+ comments.extend(api_list(f"/repos/{owner}/{name}/pulls/{pr_number}/comments"))
+ except GiteaError:
+ pass
+
+ # Collect APPROVED reviews from agent logins
+ try:
+ reviews = api_list(f"/repos/{owner}/{name}/pulls/{pr_number}/reviews")
+ for r in reviews:
+ login = r.get("user", {}).get("login", "")
+ if login in login_to_group and r.get("state") == "APPROVED":
+ comments.append(
+ {
+ "id": f"review-{r['id']}",
+ "user": {"login": login},
+ "body": f"[{login}-agent] APPROVED",
+ "created_at": r.get("submitted_at") or r.get("created_at", ""),
+ "source": "review",
+ }
+ )
+ except GiteaError:
+ pass
+
+ # Find latest verdict per agent login
+ findings = {}
+ for login, (group, agent_key) in login_to_group.items():
+ matches = []
+ for c in comments:
+ body = c.get("body", "") or ""
+ user_login = c.get("user", {}).get("login", "")
+ if user_login != login:
+ continue
+ for m in AGENT_TAG_RE.finditer(body):
+ tag_role, verdict = m.group(1), m.group(2)
+ # Match the role part of the login (e.g. "core-devops" → "devops")
+ login_role = login.replace("core-", "")
+ if tag_role == login_role:
+ matches.append(
+ {
+ "comment_id": c["id"],
+ "verdict": verdict,
+ "user": user_login,
+ "created_at": c["created_at"],
+ "source": c.get("source", "comment"),
+ }
+ )
+ latest = max(matches, key=lambda x: x["created_at"], default=None) if matches else None
+ findings[agent_key] = {
+ "group": group,
+ "tier": tier,
+ "found": latest,
+ "verdict": latest["verdict"] if latest else "MISSING",
+ }
+
+ # Compute gate verdict using tier-specific logic:
+ # - tier:low / tier:high (OR gate): ANY positive = CLEAR, ANY negative = BLOCKED
+ # - tier:medium (AND gate): ALL must be positive = CLEAR, ANY negative = BLOCKED
+ verdicts = [f["verdict"] for f in findings.values()]
+ if not verdicts:
+ gate_verdict = "N/A"
+ elif tier in ("tier:low", "tier:high"):
+ # OR gate: one positive is enough
+ if any(v in POSITIVE_VERDICTS for v in verdicts):
+ gate_verdict = "CLEAR"
+ elif any(v in ("BLOCKED", "CHANGES_REQUESTED", "COMMENT") for v in verdicts):
+ gate_verdict = "BLOCKED"
+ else:
+ gate_verdict = "INCOMPLETE"
+ else:
+ # AND gate (tier:medium): all must be positive
+ if all(v in POSITIVE_VERDICTS for v in verdicts):
+ gate_verdict = "CLEAR"
+ elif any(v in ("BLOCKED", "CHANGES_REQUESTED", "COMMENT") for v in verdicts):
+ gate_verdict = "BLOCKED"
+ else:
+ gate_verdict = "INCOMPLETE"
+
+ return {"signal": "agent_tag_comments", "results": findings, "verdict": gate_verdict, "tier": tier}
+
+
+# ── Signal 2: REQUEST_CHANGES reviews state machine ────────────────────────────
+
+def signal_2_reviews(pr_number: int, repo: str) -> dict:
+ """
+ Check /pulls/{N}/reviews for active REQUEST_CHANGES with dismissed=false.
+ This is the layer that empirically blocks Gitea merges.
+ Returns: {blocking_reviews: [...], verdict}
+ """
+ owner, name = repo.split("/", 1)
+ reviews = api_list(f"/repos/{owner}/{name}/pulls/{pr_number}/reviews")
+
+ blocking = []
+ for r in reviews:
+ if r.get("state") == "REQUEST_CHANGES" and not r.get("dismissed", False):
+ blocking.append(
+ {
+ "review_id": r["id"],
+ "user": r["user"]["login"],
+ "commit_id": r.get("commit_id", ""),
+ "created_at": r.get("submitted_at") or r.get("created_at", ""),
+ }
+ )
+ return {
+ "signal": "request_changes_reviews",
+ "blocking_reviews": blocking,
+ "verdict": "BLOCKED" if blocking else "CLEAR",
+ }
+
+
+# ── Signal 3: Staleness detection ────────────────────────────────────────────
+
+WORKING_DAY_SECONDS = 9 * 3600 # SOP-12: 1 working day threshold
+
+
+def signal_3_staleness(pr_number: int, repo: str) -> dict:
+ """
+ Flag reviews where review.commit_id != PR.head_sha AND
+ time_since_review > 1 working day. Per SOP-12 (internal#282).
+ Returns: {stale_reviews: [...], verdict}
+ """
+ owner, name = repo.split("/", 1)
+
+ # Get PR head sha
+ pr = api_get(f"/repos/{owner}/{name}/pulls/{pr_number}")
+ head_sha = pr["head"]["sha"]
+
+ reviews = api_list(f"/repos/{owner}/{name}/pulls/{pr_number}/reviews")
+
+ stale = []
+ now = datetime.now(timezone.utc)
+ for r in reviews:
+ review_commit = r.get("commit_id", "")
+ if review_commit and review_commit != head_sha:
+ # Review predates current head
+ try:
+ created = datetime.fromisoformat(r["created_at"].replace("Z", "+00:00"))
+ except (KeyError, ValueError):
+ continue
+ age_seconds = (now - created).total_seconds()
+ if age_seconds > WORKING_DAY_SECONDS:
+ stale.append(
+ {
+ "review_id": r["id"],
+ "user": r["user"]["login"],
+ "review_commit": review_commit,
+ "pr_head": head_sha,
+ "age_hours": round(age_seconds / 3600, 1),
+ "created_at": r.get("submitted_at") or r.get("created_at", ""),
+ }
+ )
+ return {
+ "signal": "stale_reviews",
+ "stale_reviews": stale,
+ "verdict": "STALE-RC" if stale else "CLEAR",
+ }
+
+
+# ── Signal 6: CI required-checks awareness ───────────────────────────────────
+
+def signal_6_ci(pr_number: int, repo: str, branch: str = "main") -> dict:
+ """
+ Query combined CI status for PR head commit.
+ Find required status checks on target branch.
+ Surface any failing required check as primary blocker.
+ """
+ owner, name = repo.split("/", 1)
+
+ pr = api_get(f"/repos/{owner}/{name}/pulls/{pr_number}")
+ head_sha = pr["head"]["sha"]
+
+ # Combined status of PR head
+ combined = api_get(f"/repos/{owner}/{name}/commits/{head_sha}/status")
+ ci_state = combined.get("state", "null")
+
+ # Individual check statuses
+ # Gitea Actions uses "status" (pending/success/failure) not "state" for
+ # individual check entries. "state" is null for pending runs.
+ check_statuses = {}
+ for s in combined.get("statuses") or []:
+ check_statuses[s["context"]] = s.get("status", "pending")
+
+ # Try to get branch protection for required checks
+ required_checks = []
+ try:
+ protection = api_get(f"/repos/{owner}/{name}/branches/{branch}/protection")
+ for check in protection.get("required_status_checks", {}).get("checks", []):
+ required_checks.append(check["context"])
+ except GiteaError:
+ pass # No protection or no read access
+
+ failing_required = []
+ passing_required = []
+ for ctx in required_checks:
+ state = check_statuses.get(ctx, "null")
+ if state == "failure":
+ failing_required.append(ctx)
+ elif state in ("success", "neutral"):
+ passing_required.append(ctx)
+ else:
+ passing_required.append(f"{ctx} (pending)")
+
+ if failing_required:
+ verdict = "CI_FAIL"
+ elif ci_state == "failure":
+ verdict = "CI_FAIL"
+ elif ci_state == "pending":
+ verdict = "CI_PENDING"
+ else:
+ verdict = "CLEAR"
+
+ return {
+ "signal": "ci_checks",
+ "combined_state": ci_state,
+ "required_checks": required_checks,
+ "failing_required": failing_required,
+ "passing_required": passing_required,
+ "all_check_statuses": check_statuses,
+ "verdict": verdict,
+ }
+
+
+# ── Gate evaluation ───────────────────────────────────────────────────────────
+
+VERDICT_ORDER = {"ERROR": 0, "CI_FAIL": 1, "BLOCKED": 2, "STALE-RC": 3, "CI_PENDING": 4, "N/A": 5, "CLEAR": 6}
+
+
+def compute_verdict(gates: list[dict]) -> tuple[str, list[dict]]:
+ """Compute overall verdict from gate results. Worst gate wins."""
+ worst = "CLEAR"
+ blockers = []
+ for g in gates:
+ v = g.get("verdict", "N/A")
+ if VERDICT_ORDER.get(v, 99) < VERDICT_ORDER.get(worst, 0):
+ worst = v
+ if v in ("BLOCKED", "CI_FAIL", "STALE-RC", "ERROR"):
+ blockers.append(g)
+ return worst, blockers
+
+
+def format_gate_verdict(v: str) -> tuple[str, str]:
+ """Return (icon, label) for a gate verdict."""
+ if v in ("APPROVED", "CLEAR"):
+ return "✅", v
+ if v in ("BLOCKED", "CI_FAIL", "ERROR"):
+ return "❌", v
+ return "⚠️", v
+
+
+def format_comment(repo: str, pr_number: int, verdict: str, gates: list[dict], blockers: list[dict]) -> str:
+ """Format human-readable Gitea PR comment."""
+ gate_labels = {
+ "agent_tag_comments": "Agent-tag gates",
+ "request_changes_reviews": "REQUEST_CHANGES reviews",
+ "stale_reviews": "Staleness check",
+ "ci_checks": "CI required checks",
+ }
+
+ lines = [f"[gate-check-v3] STATUS: **{verdict}**", ""]
+
+ # Per-gate summary
+ for g in gates:
+ sig = g.get("signal", "?")
+ label = gate_labels.get(sig, sig)
+ v = g.get("verdict", "N/A")
+ icon, _ = format_gate_verdict(v)
+ lines.append(f"{icon} **{label}**: {v}")
+
+ # Gate-specific detail
+ if blockers:
+ lines.append("")
+ lines.append("### Blockers")
+ for b in blockers:
+ sig = b.get("signal", "?")
+ if sig == "request_changes_reviews":
+ for r in b.get("blocking_reviews", []):
+ lines.append(f" - @{r['user']} requested changes (review id={r['review_id']})")
+ elif sig == "ci_checks":
+ combined = b.get("combined_state", "?")
+ lines.append(f" - CI combined state: **{combined}**")
+ for c in b.get("failing_required", []):
+ lines.append(f" - required check failing: **{c}**")
+ for c in b.get("all_check_statuses", {}).items():
+ ctx, state = c
+ lines.append(f" - {ctx}: {state}")
+ elif sig == "stale_reviews":
+ for r in b.get("stale_reviews", []):
+ lines.append(
+ f" - @{r['user']} stale (commit={r.get('review_commit','?')[:7]}, age={r.get('age_hours','?')}h)"
+ )
+ elif sig == "agent_tag_comments":
+ for agent, res in b.get("results", {}).items():
+ v = res.get("verdict", "MISSING")
+ icon, _ = format_gate_verdict(v)
+ if v == "MISSING":
+ lines.append(f" {icon} {agent}: no agent-tag comment found")
+ else:
+ lines.append(f" {icon} {agent}: {v}")
+
+ lines.append("")
+ lines.append(f"_gate-check-v3 · repo={repo} · pr={pr_number}_")
+ return "\n".join(lines)
+
+ lines.append("")
+ lines.append(f"_gate-check-v3 · repo={repo} · pr={pr_number}_")
+
+ return "\n".join(lines)
+
+
+# ── Main ─────────────────────────────────────────────────────────────────────
+
+def run(repo: str, pr_number: int, post_comment: bool = False) -> dict:
+ try:
+ gates = [
+ signal_1_comment_scan(pr_number, repo),
+ signal_2_reviews(pr_number, repo),
+ signal_3_staleness(pr_number, repo),
+ signal_6_ci(pr_number, repo),
+ ]
+ verdict, blockers = compute_verdict(gates)
+
+ result = {
+ "verdict": verdict,
+ "repo": repo,
+ "pr": pr_number,
+ "gates": gates,
+ "blockers": blockers,
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ }
+
+ # Print human-readable to stdout for Gitea Actions log
+ print(json.dumps(result, indent=2))
+
+ # Optionally post comment
+ if post_comment:
+ owner, name = repo.split("/", 1)
+ comment_body = format_comment(repo, pr_number, verdict, gates, blockers)
+ headers = {
+ "Authorization": f"token {GITEA_TOKEN}",
+ "Content-Type": "application/json",
+ "Accept": "application/json",
+ }
+ # Check if a gate-check comment already exists to avoid spamming
+ existing = api_list(f"/repos/{owner}/{name}/issues/{pr_number}/comments")
+ our_comments = [c for c in existing if "[gate-check-v3]" in (c.get("body") or "")]
+ if our_comments:
+ # Update latest
+ comment_id = our_comments[-1]["id"]
+ url = f"{API_BASE}/repos/{owner}/{name}/issues/comments/{comment_id}"
+ req = urllib.request.Request(url, data=json.dumps({"body": comment_body}).encode(), headers=headers, method="PATCH")
+ with urllib.request.urlopen(req) as r:
+ r.read()
+ else:
+ url = f"{API_BASE}/repos/{owner}/{name}/issues/{pr_number}/comments"
+ req = urllib.request.Request(url, data=json.dumps({"body": comment_body}).encode(), headers=headers, method="POST")
+ with urllib.request.urlopen(req) as r:
+ r.read()
+
+ return result
+
+ except GiteaError as e:
+ result = {"verdict": "ERROR", "error": str(e), "repo": repo, "pr": pr_number}
+ print(json.dumps(result, indent=2), file=sys.stderr)
+ return result
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="gate-check-v3 — PR gate detector")
+ parser.add_argument("--repo", required=True, help="org/repo (e.g. molecule-ai/molecule-core)")
+ parser.add_argument("--pr", type=int, required=True, help="PR number")
+ parser.add_argument("--post-comment", action="store_true", help="Post/update comment on PR")
+ args = parser.parse_args()
+
+ result = run(args.repo, args.pr, post_comment=args.post_comment)
+ verdict = result.get("verdict", "ERROR")
+
+ if verdict == "ERROR":
+ return 2
+ elif verdict in ("BLOCKED", "CI_FAIL", "STALE-RC", "ERROR"):
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
]