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())