Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 751c98ced7 | |||
| 120d5328ba | |||
| 798fcb1f33 | |||
| 7a731f6b42 | |||
| 6403c5196f | |||
| b57cebf8d4 | |||
| 15e2d93989 | |||
| 3eb06e40e6 | |||
| 9d05335b1a | |||
| f470f589c0 | |||
| 0a2e1e9a97 | |||
| d7e163d2a8 | |||
| 05e6443e2c | |||
| b62b18b523 | |||
| e70955298b | |||
| db647de1cd | |||
| 94b08ef0de | |||
| 1a2cfb9417 | |||
| beea0e9b88 | |||
| 67762ca422 | |||
| 65f34711bc | |||
| cebd9ab916 |
@@ -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
|
||||
@@ -68,36 +68,15 @@ jobs:
|
||||
run: ${{ steps.decide.outputs.run }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Fetch base branch tip for diff
|
||||
continue-on-error: true
|
||||
run: |
|
||||
# With the default fetch-depth: 1, actions/checkout only fetches the
|
||||
# PR head commit. The base commit is NOT in the local history, so
|
||||
# `git diff "$BASE" "$GITHUB_SHA"` fails. Fetch the base branch at
|
||||
# depth 1 — the base commit is the immediate parent of the PR head
|
||||
# on the base branch, so depth=1 is sufficient.
|
||||
#
|
||||
# Network: Gitea Actions runner (5.78.80.188) cannot reach the git
|
||||
# remote over HTTPS (confirmed: git fetch times out at ~15s). The runner
|
||||
# is on the same host as Gitea, but the container network namespace
|
||||
# cannot reach the Gitea HTTPS endpoint.
|
||||
#
|
||||
# Fallback: if the base commit does not exist locally, skip the diff
|
||||
# and set run=true (always run harness). This is safe: PRs where the
|
||||
# base is unavailable still run the harness (correct), PRs where the
|
||||
# base IS available get the correct path-based diff.
|
||||
#
|
||||
# Timeout: 20s. If the fetch completes, great. If it times out, the
|
||||
# step exits non-zero and we fall through to run=true.
|
||||
if timeout 20 git fetch origin "${{ github.event.pull_request.base.ref }}" --depth=1; then
|
||||
echo "::notice::base branch fetched successfully"
|
||||
else
|
||||
echo "::warning::git fetch origin ${{ github.event.pull_request.base.ref }} --depth=1 timed out"
|
||||
echo "::warning::Skipping diff — detect-changes will run the harness unconditionally."
|
||||
fi
|
||||
with:
|
||||
# Shallow clone — we use the Gitea Compare API for changed-file
|
||||
# detection, not local git diff. The base SHA is supplied via
|
||||
# GitHub event variables, so no local history is needed.
|
||||
fetch-depth: 1
|
||||
- id: decide
|
||||
continue-on-error: true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# workflow_dispatch: always run (manual trigger)
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -105,16 +84,21 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Determine the base commit to diff against.
|
||||
# For pull_request: use base.sha (the merge-base with main/staging).
|
||||
# For push: use github.event.before (the previous tip of the branch).
|
||||
# Fallback for new branches (all-zeros SHA): run everything.
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] && \
|
||||
[ -n "${{ github.event.pull_request.base.sha }}" ]; then
|
||||
BASE="${{ github.event.pull_request.base.sha }}"
|
||||
# Determine base and head refs for the Compare API call.
|
||||
# Gitea Compare API requires branch/tag names (SHAs return BaseNotExist).
|
||||
# Pull request: base.ref + head.ref are in the event payload.
|
||||
# Push: github.ref → extract branch name for the Compare API.
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE="${{ github.event.pull_request.base.ref }}"
|
||||
HEAD="${{ github.event.pull_request.head.ref }}"
|
||||
elif [ -n "${{ github.event.before }}" ] && \
|
||||
! echo "${{ github.event.before }}" | grep -qE '^0+$'; then
|
||||
BASE="${{ github.event.before }}"
|
||||
# Extract branch name from refs/heads/main -> main
|
||||
BASE_REF="${GITHUB_REF#refs/heads/}"
|
||||
BASE_REF="${BASE_REF:-main}"
|
||||
HEAD_REF="${GITHUB_REF#refs/heads/}"
|
||||
BASE="$BASE_REF"
|
||||
HEAD="$HEAD_REF"
|
||||
else
|
||||
# New branch or github.event.before unavailable — run everything.
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -122,17 +106,29 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# GitHub Actions and Gitea Actions both expose github.sha for HEAD.
|
||||
# git diff exits 1 when BASE is not in local history (e.g. shallow
|
||||
# checkout where the base commit was never fetched). Capture and
|
||||
# swallow that exit code — the empty diff means "run everything".
|
||||
# The runner network cannot reach the git remote (confirmed: git fetch
|
||||
# times out at ~15s), so a failed fetch is expected and we always fall
|
||||
# through to the unconditional run=true below.
|
||||
DIFF=$(git diff --name-only "$BASE" "${{ github.sha }}" 2>/dev/null) || true
|
||||
echo "debug=diff-base=$BASE diff-files=$DIFF" >> "$GITHUB_OUTPUT"
|
||||
# Call Gitea Compare API to get the list of changed files.
|
||||
# This is a Gitea-to-Gitea API call from within the Gitea Actions
|
||||
# runner — it hits the local Gitea process, not the external network.
|
||||
# No git network access needed from the runner container
|
||||
# (runbooks/gitea-operational-quirks.md §runner-network-isolation).
|
||||
#
|
||||
# API shape: GET /repos/{owner}/{repo}/compare/{base}...{head}
|
||||
# Returns { commits: [{ files: [{filename}] }] } — files are
|
||||
# nested inside commits (Gitea quirk, not at top level).
|
||||
RESP=$(curl -sS --fail --max-time 30 \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Accept: application/json" \
|
||||
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/compare/$BASE...$HEAD")
|
||||
DIFF_FILES=$(echo "$RESP" | python3 -c "
|
||||
import sys; import json
|
||||
d = json.load(sys.stdin)
|
||||
files = [f.get('filename','') for c in d.get('commits',[]) for f in c.get('files',[]) if f.get('filename')]
|
||||
print('\n'.join(files))
|
||||
" 2>/dev/null || true)
|
||||
|
||||
if echo "$DIFF" | grep -qE '^workspace-server/|^canvas/|^tests/harness/|^.gitea/workflows/harness-replays\.yml$'; then
|
||||
echo "debug=diff-base=$BASE diff-files=$DIFF_FILES" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if echo "$DIFF_FILES" | grep -qE '^workspace-server/|^canvas/|^tests/harness/|^.gitea/workflows/harness-replays\.yml$'; then
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
@@ -96,6 +96,7 @@ export function WorkspaceNode({ id, data }: NodeProps<Node<WorkspaceNodeData>>)
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="workspace-node"
|
||||
aria-label={
|
||||
isMisconfigured && configurationError
|
||||
? `${data.name} workspace — agent not configured: ${configurationError}`
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
* Covers: renders nothing when no approvals, polls /approvals/pending,
|
||||
* shows approval cards, approve/deny decisions, toast notifications.
|
||||
*
|
||||
* Note: does NOT mock @/lib/api — uses vi.spyOn on the real module.
|
||||
* vi.restoreAllMocks() is omitted from afterEach so queued mock values
|
||||
* (set up via mockResolvedValueOnce in beforeEach) are preserved for the
|
||||
* component's useEffect to consume.
|
||||
* All blocks use vi.useFakeTimers() consistently in beforeEach/afterEach to
|
||||
* avoid polluting the fake-timer state for subsequent test files. The
|
||||
* vi.spyOn mocks are reset per-spy via mockReset() in afterEach so each
|
||||
* test gets a clean mock state without touching the module-level api mock.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
@@ -56,7 +56,7 @@ describe("ApprovalBanner — empty state", () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("renders nothing when there are no pending approvals", async () => {
|
||||
@@ -84,7 +84,8 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
mockGet?.mockReset();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("renders an alert card for each pending approval", async () => {
|
||||
@@ -92,7 +93,6 @@ describe("ApprovalBanner — renders approval cards", () => {
|
||||
await act(async () => { await vi.runOnlyPendingTimersAsync(); });
|
||||
const alerts = screen.getAllByRole("alert");
|
||||
expect(alerts).toHaveLength(2);
|
||||
mockGet.mockRestore();
|
||||
});
|
||||
|
||||
it("displays the workspace name and action text", async () => {
|
||||
@@ -146,7 +146,9 @@ describe("ApprovalBanner — decisions", () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
mockGet?.mockReset();
|
||||
mockPost?.mockReset();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("calls POST /workspaces/:id/approvals/:id/decide on Approve click", async () => {
|
||||
@@ -228,7 +230,7 @@ describe("ApprovalBanner — handles empty list from server", () => {
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
it("shows nothing when the API returns an empty array on first poll", async () => {
|
||||
|
||||
@@ -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(<PurchaseSuccessModal />);
|
||||
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(<PurchaseSuccessModal />);
|
||||
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(<PurchaseSuccessModal />);
|
||||
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(<PurchaseSuccessModal />);
|
||||
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(<PurchaseSuccessModal />);
|
||||
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(<PurchaseSuccessModal />);
|
||||
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(<PurchaseSuccessModal />);
|
||||
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(<PurchaseSuccessModal />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* WorkspaceNode tests.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders name, status dot, tier badge, role, skills
|
||||
* - Status gradient bar colored by STATUS_CONFIG
|
||||
* - Online/offline/failed/degraded/provisioning states
|
||||
* - Misconfigured state (online + not_configured)
|
||||
* - Click → select, Shift+click → batch select
|
||||
* - Keyboard Enter/Space → select/deselect
|
||||
* - Context menu on right-click
|
||||
* - Double-click collapsed parent → expands
|
||||
* - Double-click expanded parent → zoom to team
|
||||
* - Needs restart button visible when needsRestart=true
|
||||
* - Current task banner when activeTasks > 0
|
||||
* - Descendant count badge when node has children
|
||||
* - Drag-target highlight class when dragOverNodeId matches
|
||||
* - Batch-selected highlight class
|
||||
* - OrgCancelButton renders on deploying root
|
||||
* - Degraded error preview
|
||||
* - Configuration error preview for misconfigured nodes
|
||||
* - TeamMemberChip: name, status, skills, extract button, recursive
|
||||
* - Handle anchors: top = extract, bottom = nest (keyboard accessible)
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
// ── Mock @xyflow/react ────────────────────────────────────────────────────────
|
||||
vi.mock("@xyflow/react", () => {
|
||||
const Handle = ({
|
||||
type,
|
||||
position,
|
||||
"aria-label": ariaLabel,
|
||||
onKeyDown,
|
||||
...rest
|
||||
}: {
|
||||
type: string;
|
||||
position: string;
|
||||
"aria-label"?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
[key: string]: unknown;
|
||||
}) => (
|
||||
<div
|
||||
role="button"
|
||||
aria-label={ariaLabel}
|
||||
data-handle-type={type}
|
||||
data-handle-position={position}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
{...rest}
|
||||
>
|
||||
handle
|
||||
</div>
|
||||
);
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ children }: { children?: React.ReactNode }) => (
|
||||
<div data-testid="react-flow-root">{children}</div>
|
||||
),
|
||||
NodeResizer: () => null,
|
||||
Handle,
|
||||
Position: { Top: "top", Bottom: "bottom", Left: "left", Right: "right" },
|
||||
useReactFlow: () => ({ fitView: vi.fn(), setViewport: vi.fn() }),
|
||||
applyNodeChanges: vi.fn((_: unknown, n: unknown) => n),
|
||||
ReactFlowProvider: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
|
||||
};
|
||||
});
|
||||
|
||||
// ── Mock dependencies ─────────────────────────────────────────────────────────
|
||||
const mockGetConfigurationStatus = vi.fn(() => "configured");
|
||||
const mockGetConfigurationError = vi.fn(() => null);
|
||||
|
||||
vi.mock("@/store/canvas-topology", () => ({
|
||||
getConfigurationStatus: (...args: unknown[]) => mockGetConfigurationStatus(...args),
|
||||
getConfigurationError: (...args: unknown[]) => mockGetConfigurationError(...args),
|
||||
}));
|
||||
|
||||
// Expose for per-test override
|
||||
const useConfigStatus = mockGetConfigurationStatus;
|
||||
const useConfigError = mockGetConfigurationError;
|
||||
|
||||
vi.mock("@/components/Toaster", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/Tooltip", () => ({
|
||||
Tooltip: ({ text, children }: { text: string; children: React.ReactNode }) => (
|
||||
<div title={text} data-testid="tooltip-wrapper">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/canvas/useOrgDeployState", () => ({
|
||||
useOrgDeployState: vi.fn(() => ({
|
||||
isActivelyProvisioning: false,
|
||||
isDeployingRoot: false,
|
||||
isLockedChild: false,
|
||||
descendantProvisioningCount: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/design-tokens", () => ({
|
||||
STATUS_CONFIG: {
|
||||
online: { dot: "bg-emerald-400", glow: "shadow-emerald-400/50", bar: "to-emerald-500/30", label: "ONLINE" },
|
||||
offline: { dot: "bg-zinc-500", glow: "", bar: "to-zinc-600/30", label: "OFFLINE" },
|
||||
failed: { dot: "bg-red-400", glow: "", bar: "to-red-600/30", label: "FAILED" },
|
||||
degraded: { dot: "bg-amber-400", glow: "", bar: "to-amber-600/30", label: "DEGRADED" },
|
||||
provisioning: { dot: "bg-sky-400", glow: "", bar: "to-sky-600/30", label: "STARTING" },
|
||||
not_configured: { dot: "bg-amber-400", glow: "", bar: "to-amber-600/30", label: "NOT CONFIGURED" },
|
||||
},
|
||||
TIER_CONFIG: {
|
||||
1: { label: "T1", color: "text-zinc-400 bg-zinc-800" },
|
||||
2: { label: "T2", color: "text-blue-400 bg-blue-900/50" },
|
||||
3: { label: "T3", color: "text-purple-400 bg-purple-900/50" },
|
||||
4: { label: "T4", color: "text-amber-400 bg-amber-900/50" },
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Store mock ────────────────────────────────────────────────────────────────
|
||||
// Uses a global object to share mock state between the factory (which runs
|
||||
// when the module is imported) and the test body (beforeEach/afterEach).
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var __workspaceNodeMocks: {
|
||||
selectNode: ReturnType<typeof vi.fn>;
|
||||
openContextMenu: ReturnType<typeof vi.fn>;
|
||||
toggleNodeSelection: ReturnType<typeof vi.fn>;
|
||||
nestNode: ReturnType<typeof vi.fn>;
|
||||
restartWorkspace: ReturnType<typeof vi.fn>;
|
||||
store: {
|
||||
nodes: Array<{ id: string; data: Record<string, unknown> }>;
|
||||
selectedNodeId: string | null;
|
||||
dragOverNodeId: string | null;
|
||||
selectedNodeIds: Set<string>;
|
||||
};
|
||||
} | undefined;
|
||||
}
|
||||
|
||||
vi.mock("@/store/canvas", () => {
|
||||
const mockSelectNode = vi.fn();
|
||||
const mockOpenContextMenu = vi.fn();
|
||||
const mockToggleNodeSelection = vi.fn();
|
||||
const mockNestNode = vi.fn();
|
||||
const mockRestartWorkspace = vi.fn(() => Promise.resolve());
|
||||
|
||||
const store = {
|
||||
nodes: [] as Array<{ id: string; data: Record<string, unknown> }>,
|
||||
selectedNodeId: null as string | null,
|
||||
dragOverNodeId: null as string | null,
|
||||
selectedNodeIds: new Set<string>(),
|
||||
selectNode: mockSelectNode,
|
||||
openContextMenu: mockOpenContextMenu,
|
||||
toggleNodeSelection: mockToggleNodeSelection,
|
||||
nestNode: mockNestNode,
|
||||
restartWorkspace: mockRestartWorkspace,
|
||||
};
|
||||
|
||||
const mockFn = (selector: (s: typeof store) => unknown) => selector(store);
|
||||
Object.defineProperty(mockFn, "getState", { value: () => store });
|
||||
|
||||
// Expose via global for test body access
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).__workspaceNodeMocks = {
|
||||
selectNode: mockSelectNode,
|
||||
openContextMenu: mockOpenContextMenu,
|
||||
toggleNodeSelection: mockToggleNodeSelection,
|
||||
nestNode: mockNestNode,
|
||||
restartWorkspace: mockRestartWorkspace,
|
||||
store,
|
||||
};
|
||||
|
||||
return { useCanvasStore: mockFn, __esModule: true };
|
||||
});
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
import { WorkspaceNode } from "../WorkspaceNode";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Main node card uses data-testid to distinguish from handle anchors (also role=button)
|
||||
const getNode = () => screen.getByTestId("workspace-node");
|
||||
|
||||
// Typed access to the shared mock state (set by the vi.mock factory)
|
||||
const mocks = () => globalThis.__workspaceNodeMocks!;
|
||||
const store = () => mocks().store;
|
||||
|
||||
const makeNode = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: "ws-1",
|
||||
data: {
|
||||
name: "Test Workspace",
|
||||
role: "Test Agent",
|
||||
tier: 1,
|
||||
status: "online" as const,
|
||||
parentId: null,
|
||||
activeTasks: 0,
|
||||
needsRestart: false,
|
||||
currentTask: null as string | null,
|
||||
lastSampleError: null as string | null,
|
||||
collapsed: false,
|
||||
agentCard: null,
|
||||
runtime: null as string | null,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
const renderNode = (nodeOverrides: Record<string, unknown> = {}) => {
|
||||
const node = makeNode(nodeOverrides);
|
||||
// WorkspaceNode expects NodeProps — it receives { id, data } as props
|
||||
return render(<WorkspaceNode id={node.id as string} data={node.data as never} />);
|
||||
};
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
const m = globalThis.__workspaceNodeMocks!;
|
||||
m.store.nodes = [];
|
||||
m.store.selectedNodeId = null;
|
||||
m.store.dragOverNodeId = null;
|
||||
m.store.selectedNodeIds = new Set();
|
||||
m.selectNode.mockClear();
|
||||
m.openContextMenu.mockClear();
|
||||
m.toggleNodeSelection.mockClear();
|
||||
m.nestNode.mockClear();
|
||||
m.restartWorkspace.mockClear();
|
||||
mockGetConfigurationStatus.mockClear().mockReturnValue("configured");
|
||||
mockGetConfigurationError.mockClear().mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — basic rendering", () => {
|
||||
it("renders the workspace name", () => {
|
||||
renderNode({ name: "My Workspace" });
|
||||
expect(screen.getByText("My Workspace")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the role text", () => {
|
||||
renderNode({ role: "Frontend Engineer" });
|
||||
expect(screen.getByText("Frontend Engineer")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the tier badge", () => {
|
||||
renderNode({ tier: 2 });
|
||||
expect(screen.getByText("T2")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders status dot with online class", () => {
|
||||
renderNode({ status: "online" });
|
||||
const dot = getNode().querySelector(".bg-emerald-400");
|
||||
expect(dot).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders role text clamped to 2 lines", () => {
|
||||
renderNode({ role: "A very long role description that might overflow" });
|
||||
expect(screen.getByText(/A very long role description/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — status states", () => {
|
||||
it("shows status label for failed node", () => {
|
||||
renderNode({ status: "failed" });
|
||||
expect(screen.getByText("FAILED")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows status label for degraded node", () => {
|
||||
renderNode({ status: "degraded" });
|
||||
expect(screen.getByText("DEGRADED")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows status label for provisioning node", () => {
|
||||
renderNode({ status: "provisioning" });
|
||||
expect(screen.getByText("STARTING")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses status label for online node", () => {
|
||||
renderNode({ status: "online" });
|
||||
expect(screen.queryByText("ONLINE")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows degraded error preview when status is degraded and lastSampleError is set", () => {
|
||||
renderNode({ status: "degraded", lastSampleError: "Connection timeout" });
|
||||
expect(screen.getByText("Connection timeout")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses degraded error preview when no error", () => {
|
||||
renderNode({ status: "degraded", lastSampleError: null });
|
||||
expect(screen.queryByText(/timeout/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — misconfigured state", () => {
|
||||
it("shows 'NOT CONFIGURED' label when agent is online but not_configured", () => {
|
||||
vi.mocked(useConfigStatus).mockReturnValueOnce("not_configured");
|
||||
vi.mocked(useConfigError).mockReturnValueOnce("ANTHROPIC_API_KEY is missing");
|
||||
renderNode({ status: "online" });
|
||||
expect(screen.getByText("NOT CONFIGURED")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows configuration error preview when misconfigured", () => {
|
||||
vi.mocked(useConfigStatus).mockReturnValueOnce("not_configured");
|
||||
vi.mocked(useConfigError).mockReturnValueOnce("OPENAI_API_KEY missing");
|
||||
renderNode({ status: "online" });
|
||||
expect(screen.getByText("OPENAI_API_KEY missing")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("aria-label includes name and status by default", () => {
|
||||
// Mock set to default "configured" — no misconfigured label
|
||||
renderNode({ status: "online" });
|
||||
const btn = getNode();
|
||||
expect(btn.getAttribute("aria-label")).toMatch(/Test Workspace/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — click interactions", () => {
|
||||
it("calls selectNode(id) on click", () => {
|
||||
renderNode();
|
||||
fireEvent.click(getNode());
|
||||
expect(mocks().selectNode).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("calls selectNode(null) on click when already selected", () => {
|
||||
store().selectedNodeId = "ws-1";
|
||||
renderNode();
|
||||
fireEvent.click(getNode());
|
||||
expect(mocks().selectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("calls toggleNodeSelection on Shift+click", () => {
|
||||
renderNode();
|
||||
fireEvent.click(getNode(), { shiftKey: true });
|
||||
expect(mocks().toggleNodeSelection).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("opens context menu on right-click", () => {
|
||||
renderNode();
|
||||
fireEvent.contextMenu(getNode(), {
|
||||
clientX: 100,
|
||||
clientY: 200,
|
||||
});
|
||||
expect(mocks().openContextMenu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nodeId: "ws-1", x: 100, y: 200 })
|
||||
);
|
||||
});
|
||||
|
||||
it("stops propagation to prevent canvas background click from firing", () => {
|
||||
renderNode();
|
||||
const btn = getNode();
|
||||
// React synthetic events fire regardless of native bubbles. We just verify
|
||||
// selectNode was called — the stopPropagation() call inside the handler
|
||||
// prevents the event from reaching canvas background listeners.
|
||||
expect(mocks().selectNode).not.toHaveBeenCalled(); // no click yet
|
||||
fireEvent.click(btn, { bubbles: true });
|
||||
expect(mocks().selectNode).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — keyboard interactions", () => {
|
||||
it("selects node on Enter key", () => {
|
||||
renderNode();
|
||||
fireEvent.keyDown(getNode(), { key: "Enter" });
|
||||
expect(mocks().selectNode).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("deselects node on Enter key when already selected", () => {
|
||||
store().selectedNodeId = "ws-1";
|
||||
renderNode();
|
||||
fireEvent.keyDown(getNode(), { key: "Enter" });
|
||||
expect(mocks().selectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("toggles batch selection on Shift+Enter", () => {
|
||||
renderNode();
|
||||
fireEvent.keyDown(getNode(), { key: "Enter", shiftKey: true });
|
||||
expect(mocks().toggleNodeSelection).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("opens context menu on ContextMenu key", () => {
|
||||
renderNode();
|
||||
fireEvent.keyDown(getNode(), { key: "ContextMenu" });
|
||||
expect(mocks().openContextMenu).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nodeId: "ws-1" })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — double-click interactions", () => {
|
||||
it("does nothing on double-click when node has no children", () => {
|
||||
renderNode({ collapsed: false });
|
||||
fireEvent.doubleClick(getNode());
|
||||
// No exception thrown = fine. The actual zoom-to-team event is dispatched
|
||||
// on the window, which jsdom handles silently.
|
||||
expect(mocks().selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sets collapsed=false on double-click of collapsed parent (no children in store)", () => {
|
||||
renderNode({ collapsed: true });
|
||||
fireEvent.doubleClick(getNode());
|
||||
// When hasChildren is false (no child nodes in store), the handler returns early.
|
||||
expect(mocks().selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — active tasks", () => {
|
||||
it("shows active tasks badge when activeTasks > 0", () => {
|
||||
renderNode({ activeTasks: 3 });
|
||||
expect(screen.getByText("3 tasks")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows singular 'task' when activeTasks is 1", () => {
|
||||
renderNode({ activeTasks: 1 });
|
||||
expect(screen.getByText("1 task")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses badge when no active tasks", () => {
|
||||
renderNode({ activeTasks: 0 });
|
||||
expect(screen.queryByText(/task/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — current task banner", () => {
|
||||
it("shows current task banner when currentTask is set", () => {
|
||||
renderNode({ currentTask: "Writing unit tests" });
|
||||
expect(screen.getByText("Writing unit tests")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses current task banner when null", () => {
|
||||
renderNode({ currentTask: null });
|
||||
expect(screen.queryByText(/Writing unit tests/)).toBeNull();
|
||||
});
|
||||
|
||||
it("shows both currentTask and needsRestart — currentTask takes visual priority", () => {
|
||||
renderNode({ currentTask: "Active work", needsRestart: true });
|
||||
// Current task banner renders; needs restart button is conditionally hidden
|
||||
// behind `!data.currentTask` in the component
|
||||
expect(screen.getByText("Active work")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: /restart/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — needs restart", () => {
|
||||
it("shows restart button when needsRestart=true and no currentTask", () => {
|
||||
renderNode({ needsRestart: true, currentTask: null });
|
||||
expect(screen.getByRole("button", { name: /restart to apply changes/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses restart button when currentTask is active", () => {
|
||||
renderNode({ needsRestart: true, currentTask: "Working" });
|
||||
expect(screen.queryByRole("button", { name: /restart/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses restart button when needsRestart=false", () => {
|
||||
renderNode({ needsRestart: false });
|
||||
expect(screen.queryByRole("button", { name: /restart/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("restart button calls restartWorkspace on click", () => {
|
||||
renderNode({ needsRestart: true, currentTask: null });
|
||||
fireEvent.click(screen.getByRole("button", { name: /restart to apply changes/i }));
|
||||
expect(mocks().restartWorkspace).toHaveBeenCalledWith("ws-1");
|
||||
});
|
||||
|
||||
it("restart button stops propagation", () => {
|
||||
renderNode({ needsRestart: true, currentTask: null });
|
||||
fireEvent.click(screen.getByRole("button", { name: /restart/i }));
|
||||
// If propagation wasn't stopped, selectNode would also be called
|
||||
expect(mocks().selectNode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — descendant badge", () => {
|
||||
it("shows descendant count badge when node has children in store", () => {
|
||||
store().nodes = [
|
||||
makeNode({ id: "ws-1" }),
|
||||
{ id: "child-1", data: { ...makeNode({ id: "ws-1" }).data, parentId: "ws-1" } },
|
||||
];
|
||||
renderNode();
|
||||
expect(screen.getByText("1 sub")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses badge when node has no children", () => {
|
||||
store().nodes = [makeNode({ id: "ws-1" })];
|
||||
renderNode();
|
||||
expect(screen.queryByText(/sub/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — skills pills", () => {
|
||||
it("renders up to 4 skill pills", () => {
|
||||
renderNode({
|
||||
agentCard: {
|
||||
skills: [
|
||||
{ name: "code-review" },
|
||||
{ name: "tdd" },
|
||||
{ name: "debugging" },
|
||||
{ name: "refactoring" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(screen.getByText("code-review")).toBeTruthy();
|
||||
expect(screen.getByText("refactoring")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows +N overflow when more than 4 skills", () => {
|
||||
renderNode({
|
||||
agentCard: {
|
||||
skills: [
|
||||
{ name: "s1" }, { name: "s2" }, { name: "s3" }, { name: "s4" }, { name: "s5" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(screen.getByText("+1")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses skills section when no skills", () => {
|
||||
renderNode({ agentCard: null });
|
||||
// No skill text rendered
|
||||
expect(screen.queryByText(/code-review/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("handles agentCard with no skills array", () => {
|
||||
renderNode({ agentCard: { name: "Test Agent" } });
|
||||
expect(screen.queryByText(/code-review/i)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — runtime badge", () => {
|
||||
it("shows runtime badge when runtime is set", () => {
|
||||
renderNode({ runtime: "hermes" });
|
||||
expect(screen.getByText("hermes")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows REMOTE badge for external runtime", () => {
|
||||
renderNode({ runtime: "external" });
|
||||
expect(screen.getByText("★ REMOTE")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("suppresses runtime badge when runtime is null", () => {
|
||||
renderNode({ runtime: null });
|
||||
expect(screen.queryByText("hermes")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — selection aria", () => {
|
||||
it('has aria-pressed="false" when not selected', () => {
|
||||
store().selectedNodeId = null;
|
||||
renderNode();
|
||||
expect(getNode().getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it('has aria-pressed="true" when selected', () => {
|
||||
store().selectedNodeId = "ws-1";
|
||||
renderNode();
|
||||
expect(getNode().getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — aria-label", () => {
|
||||
it("includes name and status in aria-label", () => {
|
||||
renderNode({ name: "MyAgent", status: "online" });
|
||||
const label = getNode().getAttribute("aria-label");
|
||||
expect(label).toContain("MyAgent");
|
||||
expect(label).toContain("online");
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkspaceNode — handle anchors accessibility", () => {
|
||||
it("top handle has aria-label for extract", () => {
|
||||
renderNode({ parentId: "parent-1" });
|
||||
const handles = screen.getAllByRole("button");
|
||||
const topHandle = handles.find((h) => h.getAttribute("data-handle-type") === "target");
|
||||
expect(topHandle?.getAttribute("aria-label")).toMatch(/extract/i);
|
||||
});
|
||||
|
||||
it("bottom handle has aria-label for nest", () => {
|
||||
renderNode();
|
||||
const handles = screen.getAllByRole("button");
|
||||
const bottomHandle = handles.find((h) => h.getAttribute("data-handle-type") === "source");
|
||||
expect(bottomHandle?.getAttribute("aria-label")).toMatch(/nest/i);
|
||||
});
|
||||
|
||||
it("top handle extract is no-op when node has no parent", () => {
|
||||
renderNode({ parentId: null });
|
||||
const handles = screen.getAllByRole("button");
|
||||
const topHandle = handles.find((h) => h.getAttribute("data-handle-type") === "target");
|
||||
fireEvent.keyDown(topHandle!, { key: "Enter" });
|
||||
// Should be a no-op — no exception
|
||||
expect(mocks().nestNode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
# Gitea Actions operational quirks (molecule-core)
|
||||
|
||||
Documents persistent operational findings about Gitea Actions runner behaviour
|
||||
that differ from GitHub Actions and require workarounds in workflow YAML or
|
||||
runbooks.
|
||||
|
||||
> Last updated: 2026-05-11 (core-devops-agent)
|
||||
|
||||
---
|
||||
|
||||
## Large repo causes fetch timeout on Gitea Actions runner
|
||||
|
||||
### Finding
|
||||
|
||||
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** 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 `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 >15 s before hitting the timeout).
|
||||
- `git clone <url>` hangs for the same reason.
|
||||
- `git fetch origin <ref> --depth=1` **succeeds** in ~16 s — this is the
|
||||
working pattern.
|
||||
|
||||
### Affected workflows
|
||||
|
||||
| Workflow | Issue | Fix |
|
||||
|---|---|---|
|
||||
| `harness-replays.yml` detect-changes | `fetch-depth: 0` + `git clone` time out | Use Gitea Compare API (Gitea→Gitea, no runner network needed) — **primary fix** (PR #476) |
|
||||
| `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` + Compare API for changed-file detection |
|
||||
|
||||
### How to diagnose
|
||||
|
||||
```bash
|
||||
# From inside the runner (add as a debug step):
|
||||
timeout 20 git fetch origin main --depth=1
|
||||
# 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 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
|
||||
|
||||
- PR #476: **primary fix** — use Gitea Compare API instead of git fetch/diff
|
||||
- PR #441: legacy timeout+fallback fix (now superseded by PR #476)
|
||||
- Task #173: pre-clone manifest deps pattern for compose build
|
||||
- internal#102: tracking customer-private + marketplace third-party repos
|
||||
- `feedback_oss_first_repo_visibility_default`: 5 workspace-template repos
|
||||
flipped public to allow pre-clone without auth
|
||||
|
||||
---
|
||||
|
||||
## `continue-on-error` only works at step level, not job level
|
||||
|
||||
### Finding
|
||||
|
||||
Gitea Actions (1.22.6) does not honour `continue-on-error: true` at the **job**
|
||||
level the way GitHub Actions does. A job with `continue-on-error: true` that
|
||||
fails still reports `status: failure` in the commit status API.
|
||||
|
||||
Only `continue-on-error: true` at the **step** level works as expected.
|
||||
|
||||
### Impact
|
||||
|
||||
If you want a job to always "pass" in the status API (so dependent jobs can
|
||||
run and the overall CI does not show `failure`), you must add
|
||||
`continue-on-error: true` to every step that can fail, AND ensure each step
|
||||
exits with code 0 (e.g., append `|| true` to commands that might fail).
|
||||
|
||||
### Affected workflows
|
||||
|
||||
| Workflow | Fix |
|
||||
|---|---|
|
||||
| `harness-replays.yml` detect-changes | Added `continue-on-error: true` to fetch step + decide step; replaced git diff with Compare API per PR #476 |
|
||||
|
||||
### How to diagnose
|
||||
|
||||
```yaml
|
||||
# WRONG — job reports as failure despite flag
|
||||
jobs:
|
||||
my-job:
|
||||
continue-on-error: true # ← ignored by Gitea
|
||||
steps:
|
||||
- run: git diff ... # ← if this fails, job = failure
|
||||
# job-level flag does not help
|
||||
|
||||
# RIGHT — step-level flag prevents step from failing
|
||||
jobs:
|
||||
my-job:
|
||||
steps:
|
||||
- run: git diff ... || true # ← step exits 0
|
||||
continue-on-error: true # ← belt and suspenders
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- Gitea Actions quirk #10 (from migration checklist)
|
||||
- PR #476: Compare API fix applied to `harness-replays.yml`
|
||||
|
||||
---
|
||||
|
||||
## `workflow_dispatch.inputs` not supported
|
||||
|
||||
Gitea 1.22.6 parser rejects `workflow_dispatch.inputs`. Drop from all workflow
|
||||
YAML files ported from GitHub Actions. Manual triggers should use
|
||||
`workflow_dispatch` without `inputs:`.
|
||||
|
||||
**Reference**: `feedback_gitea_workflow_dispatch_inputs_unsupported`
|
||||
|
||||
---
|
||||
|
||||
## `merge_group` not supported
|
||||
|
||||
Gitea has no merge queue concept. Drop `merge_group:` triggers from all
|
||||
workflow YAML files.
|
||||
|
||||
---
|
||||
|
||||
## `environment:` blocks not supported
|
||||
|
||||
Gitea has no environments concept. Drop `environment:` from all workflow YAML
|
||||
files. Secrets and variables are repo-level.
|
||||
|
||||
---
|
||||
|
||||
## Gitea combined status reports `failure` when all contexts are `null`
|
||||
|
||||
### Finding
|
||||
|
||||
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".
|
||||
|
||||
### 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
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user