Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 24c696a7ac |
@@ -49,11 +49,11 @@ if [ "$MERGED" != "true" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha // empty') || true
|
||||
MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login // "unknown"') || true
|
||||
TITLE=$(echo "$PR" | jq -r '.title // ""') || true
|
||||
BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref // "main"') || true
|
||||
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha // empty') || true
|
||||
MERGE_SHA=$(echo "$PR" | jq -r '.merge_commit_sha // empty')
|
||||
MERGED_BY=$(echo "$PR" | jq -r '.merged_by.login // "unknown"')
|
||||
TITLE=$(echo "$PR" | jq -r '.title // ""')
|
||||
BASE_BRANCH=$(echo "$PR" | jq -r '.base.ref // "main"')
|
||||
HEAD_SHA=$(echo "$PR" | jq -r '.head.sha // empty')
|
||||
|
||||
if [ -z "$MERGE_SHA" ]; then
|
||||
echo "::warning::PR #${PR_NUMBER} merged=true but no merge_commit_sha — cannot evaluate force-merge."
|
||||
@@ -75,7 +75,7 @@ STATUS=$(curl -sS -H "$AUTH" \
|
||||
declare -A CHECK_STATE
|
||||
while IFS=$'\t' read -r ctx state; do
|
||||
[ -n "$ctx" ] && CHECK_STATE[$ctx]="$state"
|
||||
done < <(echo "$STATUS" | jq -r '.statuses // [] | .[] | "\(.context)\t\(.status)"') || true
|
||||
done < <(echo "$STATUS" | jq -r '.statuses // [] | .[] | "\(.context)\t\(.status)"')
|
||||
|
||||
# 4. For each required check, was it green at merge? YAML block scalars
|
||||
# (`|`) leave a trailing newline; skip blank/whitespace-only lines.
|
||||
@@ -97,7 +97,7 @@ fi
|
||||
|
||||
# 5. Emit structured audit event.
|
||||
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
FAILED_JSON=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .) || true
|
||||
FAILED_JSON=$(printf '%s\n' "${FAILED_CHECKS[@]}" | jq -R . | jq -s .)
|
||||
|
||||
# Print as a single-line JSON so Vector's parse_json transform can pick
|
||||
# it up cleanly from docker_logs.
|
||||
|
||||
@@ -301,19 +301,7 @@ def expected_context(job_key: str, workflow_name: str = "ci") -> str:
|
||||
# Drift detection
|
||||
# --------------------------------------------------------------------------
|
||||
def detect_drift(branch: str) -> tuple[list[str], dict]:
|
||||
"""Returns (findings, debug). Empty findings == no drift.
|
||||
|
||||
Raises:
|
||||
ApiError: propagated from the protection fetch only when the
|
||||
failure is likely a transient Gitea outage (5xx).
|
||||
403/404 from the protection endpoint is treated as
|
||||
"cannot determine drift for this branch" — a token-
|
||||
scope issue (missing repo-admin on DRIFT_BOT_TOKEN) or
|
||||
a repo with no protection set should not turn the
|
||||
hourly cron red. The workflow continues to the next
|
||||
branch; no [ci-drift] issue is filed for a branch
|
||||
whose protection cannot be read.
|
||||
"""
|
||||
"""Returns (findings, debug). Empty findings == no drift."""
|
||||
findings: list[str] = []
|
||||
|
||||
ci_doc = load_yaml(CI_WORKFLOW_PATH)
|
||||
@@ -325,50 +313,9 @@ def detect_drift(branch: str) -> tuple[list[str], dict]:
|
||||
env_set = required_checks_env(audit_doc)
|
||||
|
||||
# Protection
|
||||
# api() raises ApiError on non-2xx. Transient 5xx should fail loud.
|
||||
# 403/404 means the token lacks repo-admin scope (Gitea 1.22.6's
|
||||
# branch_protections endpoint requires it — see DRIFT_BOT_TOKEN
|
||||
# provisioning trail in ci-required-drift.yml). Treat as
|
||||
# "cannot determine drift for this branch" — skip without turning
|
||||
# the workflow red. Surface a clear diagnostic so the operator
|
||||
# knows what to fix.
|
||||
contexts: set[str] = set()
|
||||
protection_path = f"/repos/{OWNER}/{NAME}/branch_protections/{branch}"
|
||||
try:
|
||||
_, protection = api("GET", protection_path)
|
||||
except ApiError as e:
|
||||
# Isolate the HTTP status from the error message.
|
||||
http_status: int | None = None
|
||||
msg = str(e)
|
||||
# ApiError message format: "{method} {path} → HTTP {status}: {body}"
|
||||
import re as _re
|
||||
|
||||
m = _re.search(r"HTTP (\d{3})", msg)
|
||||
if m:
|
||||
http_status = int(m.group(1))
|
||||
if http_status in (403, 404):
|
||||
# Token lacks scope OR branch has no protection. Cannot
|
||||
# determine drift — skip this branch. Do NOT exit non-zero;
|
||||
# the issue IS the alarm, not a red workflow.
|
||||
sys.stderr.write(
|
||||
f"::error::GET {protection_path} returned HTTP {http_status} — "
|
||||
f"DRIFT_BOT_TOKEN lacks repo-admin scope (Gitea 1.22.6 "
|
||||
f"requires it for this endpoint) OR branch has no protection "
|
||||
f"configured. Cannot determine drift for {branch}; "
|
||||
f"skipping. Fix: grant repo-admin to mc-drift-bot or "
|
||||
f"configure protection on {branch}.\n"
|
||||
)
|
||||
debug = {
|
||||
"branch": branch,
|
||||
"ci_jobs": sorted(jobs),
|
||||
"sentinel_needs": sorted(needs),
|
||||
"protection_contexts_skipped": True,
|
||||
"protection_http_status": http_status,
|
||||
"audit_env_checks": sorted(env_set),
|
||||
}
|
||||
return [], debug
|
||||
# 5xx — propagate (transient outage, fail loud per design).
|
||||
raise
|
||||
# api() raises ApiError on non-2xx; let it propagate so a transient
|
||||
# 500 fails the run loudly rather than producing a "no drift" lie.
|
||||
_, protection = api("GET", f"/repos/{OWNER}/{NAME}/branch_protections/{branch}")
|
||||
if not isinstance(protection, dict):
|
||||
sys.stderr.write(
|
||||
f"::error::protection response for {branch} not a JSON object\n"
|
||||
|
||||
@@ -1,404 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""lint-required-no-paths — structural enforcement of
|
||||
`feedback_path_filtered_workflow_cant_be_required`.
|
||||
|
||||
For every workflow whose status-check context appears in
|
||||
`branch_protections/<branch>.status_check_contexts`, assert that the
|
||||
workflow's `on:` block has NO `paths:` and NO `paths-ignore:` filter.
|
||||
|
||||
A required-check workflow with a paths filter silently degrades the
|
||||
merge gate:
|
||||
|
||||
- If the PR's diff doesn't match the `paths:` glob, the workflow
|
||||
never fires.
|
||||
- Gitea (1.22.6) reports the required context as `pending` (never as
|
||||
`skipped == success`), so the PR cannot merge.
|
||||
- For a docs-only PR against `paths: ['**.go']`, the PR is
|
||||
blocked forever — no human action can produce a green.
|
||||
|
||||
The class was previously prevented only by reviewer vigilance + the
|
||||
saved memory `feedback_path_filtered_workflow_cant_be_required`. This
|
||||
script makes it a hard CI gate so a future PR adding `paths:` to a
|
||||
required workflow fails fast at PR time, not after merge when the next
|
||||
docs PR wedges main.
|
||||
|
||||
The lint runs as `.gitea/workflows/lint-required-no-paths.yml` on every
|
||||
PR. The lint workflow ITSELF must not have a paths-filter (otherwise it
|
||||
could be circumvented by a paths-non-matching PR) — that's enforced by
|
||||
self-reference and by the workflow's own `on:` block deliberately
|
||||
omitting filters.
|
||||
|
||||
Sources of truth:
|
||||
- `branch_protections/<branch>` `status_check_contexts` (the merge gate)
|
||||
- `.gitea/workflows/*.yml` `name:` + `on:` (the workflow set)
|
||||
|
||||
Context-format note (Gitea 1.22.6):
|
||||
Status-check contexts are formatted `{workflow_name} / {job_name_or_key} ({event})`.
|
||||
We parse the workflow_name prefix and walk `.gitea/workflows/*.yml` for
|
||||
a file whose `name:` attr matches. (The filename is NOT the source of
|
||||
truth; `name:` is, because Gitea formats the context from `name:`.)
|
||||
|
||||
Exit codes:
|
||||
0 — no required workflow has a paths/paths-ignore filter (clean) OR
|
||||
branch_protections endpoint returned 403/404 (token-scope issue;
|
||||
surfaced via ::error:: but non-fatal so a missing scope doesn't
|
||||
red-X every PR — fix the token, not the lint).
|
||||
1 — at least one required workflow has a paths/paths-ignore filter
|
||||
(the gate-degrading defect class).
|
||||
2 — env contract violation (missing GITEA_TOKEN/HOST/REPO/BRANCH).
|
||||
3 — workflows directory missing or workflow YAML unparseable.
|
||||
4 — protection response shape unexpected (non-dict body on 2xx).
|
||||
|
||||
Auth note: `GET /repos/.../branch_protections/{branch}` requires
|
||||
repo-admin role in Gitea 1.22.6. The workflow-default `GITHUB_TOKEN`
|
||||
is non-admin; we re-use `DRIFT_BOT_TOKEN` (same persona that powers
|
||||
ci-required-drift.yml). If `DRIFT_BOT_TOKEN` is unavailable in a future
|
||||
context, the script falls through gracefully (exit 0 + ::error::).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml # PyYAML 6.0.2 — installed by the workflow before this runs.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Environment
|
||||
# --------------------------------------------------------------------------
|
||||
def _env(key: str, *, required: bool = True, default: str | None = None) -> str:
|
||||
val = os.environ.get(key, default)
|
||||
if required and not val:
|
||||
sys.stderr.write(f"::error::missing required env var: {key}\n")
|
||||
sys.exit(2)
|
||||
return val or ""
|
||||
|
||||
|
||||
GITEA_TOKEN = _env("GITEA_TOKEN", required=False)
|
||||
GITEA_HOST = _env("GITEA_HOST", required=False)
|
||||
REPO = _env("REPO", required=False)
|
||||
BRANCH = _env("BRANCH", required=False, default="main")
|
||||
WORKFLOWS_DIR = _env(
|
||||
"WORKFLOWS_DIR", required=False, default=".gitea/workflows"
|
||||
)
|
||||
|
||||
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
|
||||
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
||||
|
||||
|
||||
def _require_runtime_env() -> None:
|
||||
"""Enforce env contract — called from `run()` only. Tests import
|
||||
individual functions without setting the full env contract."""
|
||||
for key in ("GITEA_TOKEN", "GITEA_HOST", "REPO", "BRANCH"):
|
||||
if not os.environ.get(key):
|
||||
sys.stderr.write(f"::error::missing required env var: {key}\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tiny HTTP helper (mirrors ci-required-drift.py contract:
|
||||
# raise on non-2xx and on JSON-decode-fail when JSON expected, per
|
||||
# `feedback_api_helper_must_raise_not_return_dict`).
|
||||
# --------------------------------------------------------------------------
|
||||
class ApiError(RuntimeError):
|
||||
"""Raised when a Gitea API call cannot be trusted to have succeeded."""
|
||||
|
||||
|
||||
def api(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
body: dict | None = None,
|
||||
query: dict[str, str] | None = None,
|
||||
expect_json: bool = True,
|
||||
) -> tuple[int, Any]:
|
||||
url = f"{API}{path}"
|
||||
if query:
|
||||
url = f"{url}?{urllib.parse.urlencode(query)}"
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, method=method, data=data, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read()
|
||||
status = resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
status = e.code
|
||||
|
||||
if not (200 <= status < 300):
|
||||
snippet = raw[:500].decode("utf-8", errors="replace") if raw else ""
|
||||
raise ApiError(f"{method} {path} → HTTP {status}: {snippet}")
|
||||
|
||||
if not raw:
|
||||
return status, None
|
||||
try:
|
||||
return status, json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
if expect_json:
|
||||
raise ApiError(
|
||||
f"{method} {path} → HTTP {status} but body is not JSON: {e}"
|
||||
) from e
|
||||
return status, {"_raw": raw.decode("utf-8", errors="replace")}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Status-check context parser
|
||||
# --------------------------------------------------------------------------
|
||||
# Format: "<workflow_name> / <job_name_or_key> (<event>)"
|
||||
# Examples observed on molecule-core/main:
|
||||
# "Secret scan / Scan diff for credential-shaped strings (pull_request)"
|
||||
# "sop-tier-check / tier-check (pull_request)"
|
||||
#
|
||||
# Split strategy: peel off the trailing ` (<event>)` first, then split
|
||||
# the leading `<workflow> / <rest>` on the FIRST ` / ` (workflow names
|
||||
# come from `name:` attrs which conventionally don't embed ' / '; job
|
||||
# names CAN, so we keep the rest of the slash-divided text as the job
|
||||
# name). This matches Gitea's `name: ` semantics.
|
||||
_CONTEXT_RE = re.compile(r"^(?P<workflow>.+?) / (?P<job>.+) \((?P<event>[^)]+)\)$")
|
||||
|
||||
|
||||
def parse_context(ctx: str) -> tuple[str, str, str] | None:
|
||||
"""Parse `<workflow> / <job> (<event>)` → (workflow, job, event) or None."""
|
||||
if not ctx:
|
||||
return None
|
||||
m = _CONTEXT_RE.match(ctx)
|
||||
if not m:
|
||||
return None
|
||||
return m.group("workflow"), m.group("job"), m.group("event")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# workflow-name → file resolution
|
||||
# --------------------------------------------------------------------------
|
||||
def _iter_workflow_files() -> list[Path]:
|
||||
d = Path(WORKFLOWS_DIR)
|
||||
if not d.is_dir():
|
||||
sys.stderr.write(f"::error::workflows directory not found: {d}\n")
|
||||
sys.exit(3)
|
||||
# `.yml` and `.yaml` — Gitea accepts both (rarely used `.yaml`, but
|
||||
# don't silently miss it if a future port uses it).
|
||||
return sorted(list(d.glob("*.yml")) + list(d.glob("*.yaml")))
|
||||
|
||||
|
||||
def resolve_workflow_file(workflow_name: str) -> Path | None:
|
||||
"""Find the YAML file whose `name:` attr matches `workflow_name`.
|
||||
|
||||
Returns None if no match. Filename is NOT used as a fallback —
|
||||
Gitea's context format uses `name:`, so a `name:`-less workflow
|
||||
won't even appear in the protection list. (A YAML with no `name:`
|
||||
would default the context to the file basename, but our protection
|
||||
contexts on molecule-core are all `name:`-derived; we trust the
|
||||
same.)
|
||||
"""
|
||||
for f in _iter_workflow_files():
|
||||
try:
|
||||
doc = yaml.safe_load(f.read_text(encoding="utf-8"))
|
||||
except yaml.YAMLError as e:
|
||||
sys.stderr.write(f"::error::YAML parse error in {f}: {e}\n")
|
||||
sys.exit(3)
|
||||
if isinstance(doc, dict) and doc.get("name") == workflow_name:
|
||||
return f
|
||||
return None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# paths-filter detection
|
||||
# --------------------------------------------------------------------------
|
||||
# Triggers that accept `paths:` / `paths-ignore:` (per GitHub Actions /
|
||||
# Gitea Actions docs): pull_request, pull_request_target, push.
|
||||
# We don't enumerate — any sub-key named `paths` or `paths-ignore`
|
||||
# inside an event mapping is flagged.
|
||||
_PATHS_KEYS = ("paths", "paths-ignore")
|
||||
|
||||
|
||||
def detect_paths_filters(workflow_path: Path) -> list[str]:
|
||||
"""Walk the workflow's `on:` block and return a list of findings, one
|
||||
per offending `paths`/`paths-ignore` key.
|
||||
|
||||
Returns:
|
||||
Empty list if the workflow has no paths/paths-ignore filter
|
||||
anywhere in its `on:` block. Otherwise, a list of human-readable
|
||||
strings naming the event and filter key + the filter contents.
|
||||
"""
|
||||
try:
|
||||
doc = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
|
||||
except yaml.YAMLError as e:
|
||||
sys.stderr.write(f"::error::YAML parse error in {workflow_path}: {e}\n")
|
||||
sys.exit(3)
|
||||
if not isinstance(doc, dict):
|
||||
return []
|
||||
|
||||
on_block = doc.get("on") or doc.get(True) # PyYAML 6 quirk: `on:`
|
||||
# under default constructor sometimes becomes the bool key `True`
|
||||
# because YAML 1.1 treats `on` as a boolean. Tolerate both.
|
||||
if on_block is None:
|
||||
return []
|
||||
|
||||
findings: list[str] = []
|
||||
|
||||
# Shape A: `on: pull_request` (string shorthand) — cannot carry filters.
|
||||
if isinstance(on_block, str):
|
||||
return []
|
||||
# Shape B: `on: [pull_request, push]` (list shorthand) — cannot carry filters.
|
||||
if isinstance(on_block, list):
|
||||
return []
|
||||
# Shape C: `on: { event: { ... } }` — the standard mapping case.
|
||||
if isinstance(on_block, dict):
|
||||
# Defensive: top-level malformed `on.paths` (someone wrote
|
||||
# `on: { paths: ['x'] }` thinking it's a workflow-level filter).
|
||||
# This is invalid syntax, but if present, flag it — it might
|
||||
# not block the workflow from registering (Gitea may ignore the
|
||||
# unknown key) and would create a false sense of "filter exists"
|
||||
# the lint should still surface.
|
||||
for k in _PATHS_KEYS:
|
||||
if k in on_block:
|
||||
v = on_block[k]
|
||||
findings.append(
|
||||
f"top-level `on.{k}` filter (malformed but present): {v!r}"
|
||||
)
|
||||
for event, event_body in on_block.items():
|
||||
if event in _PATHS_KEYS:
|
||||
continue # already handled above
|
||||
if not isinstance(event_body, dict):
|
||||
# `pull_request: null` / `pull_request: [opened]` shapes —
|
||||
# no place for a paths filter to live; skip.
|
||||
continue
|
||||
for k in _PATHS_KEYS:
|
||||
if k in event_body:
|
||||
v = event_body[k]
|
||||
findings.append(
|
||||
f"`on.{event}.{k}` filter present: {v!r}"
|
||||
)
|
||||
return findings
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Driver
|
||||
# --------------------------------------------------------------------------
|
||||
def run() -> int:
|
||||
"""Main lint entrypoint. Returns the process exit code.
|
||||
|
||||
Exit semantics (see module docstring for full table):
|
||||
0 — clean (no offending paths-filter on any required workflow),
|
||||
OR protection unreadable (403/404) — surfaced as ::error::
|
||||
but treated as non-fatal so token-scope issues don't red-X
|
||||
every PR.
|
||||
1 — at least one required workflow carries a paths/paths-ignore
|
||||
filter — the regression class this lint exists to prevent.
|
||||
"""
|
||||
_require_runtime_env()
|
||||
|
||||
protection_path = f"/repos/{OWNER}/{NAME}/branch_protections/{BRANCH}"
|
||||
try:
|
||||
_, protection = api("GET", protection_path)
|
||||
except ApiError as e:
|
||||
msg = str(e)
|
||||
m = re.search(r"HTTP (\d{3})", msg)
|
||||
http_status = int(m.group(1)) if m else None
|
||||
if http_status in (403, 404):
|
||||
sys.stderr.write(
|
||||
f"::error::GET {protection_path} returned HTTP {http_status} — "
|
||||
f"DRIFT_BOT_TOKEN lacks repo-admin scope (Gitea 1.22.6 "
|
||||
f"requires it for this endpoint) OR branch '{BRANCH}' has "
|
||||
f"no protection configured. Cannot enumerate required "
|
||||
f"checks; skipping lint with exit 0 to avoid red-X on "
|
||||
f"every PR. Fix: grant repo-admin to mc-drift-bot.\n"
|
||||
)
|
||||
return 0
|
||||
raise
|
||||
|
||||
if not isinstance(protection, dict):
|
||||
sys.stderr.write(
|
||||
f"::error::protection response for {BRANCH} not a JSON object\n"
|
||||
)
|
||||
return 4
|
||||
|
||||
contexts: list[str] = list(protection.get("status_check_contexts") or [])
|
||||
if not contexts:
|
||||
print(
|
||||
f"::notice::branch_protections/{BRANCH} has 0 required "
|
||||
f"status_check_contexts; nothing to lint. (no required contexts)"
|
||||
)
|
||||
return 0
|
||||
|
||||
print(f"::notice::Linting {len(contexts)} required context(s) for paths-filter regressions:")
|
||||
for c in contexts:
|
||||
print(f" - {c}")
|
||||
|
||||
offenders: list[tuple[str, Path, list[str]]] = []
|
||||
unresolved: list[str] = []
|
||||
|
||||
for ctx in contexts:
|
||||
parsed = parse_context(ctx)
|
||||
if parsed is None:
|
||||
print(
|
||||
f"::warning::could not parse context '{ctx}' "
|
||||
f"(expected `<workflow> / <job> (<event>)`); skipping"
|
||||
)
|
||||
unresolved.append(ctx)
|
||||
continue
|
||||
workflow_name, _job, _event = parsed
|
||||
wf_path = resolve_workflow_file(workflow_name)
|
||||
if wf_path is None:
|
||||
print(
|
||||
f"::warning::no workflow file in {WORKFLOWS_DIR} has "
|
||||
f"`name: {workflow_name}` (required context '{ctx}'); "
|
||||
f"skipping paths-filter check. "
|
||||
f"(orphaned-context detection is ci-required-drift's job.)"
|
||||
)
|
||||
unresolved.append(ctx)
|
||||
continue
|
||||
findings = detect_paths_filters(wf_path)
|
||||
if findings:
|
||||
offenders.append((workflow_name, wf_path, findings))
|
||||
else:
|
||||
print(f"::notice::OK {wf_path.name} ({workflow_name}) — no paths filter")
|
||||
|
||||
if offenders:
|
||||
print("")
|
||||
print(f"::error::Found {len(offenders)} required workflow(s) with paths/paths-ignore filters:")
|
||||
for workflow_name, wf_path, findings in offenders:
|
||||
for finding in findings:
|
||||
# ::error file=... lets Gitea Actions surface a per-file
|
||||
# annotation in the PR UI (when annotations are wired).
|
||||
print(
|
||||
f"::error file={wf_path}::Required workflow "
|
||||
f"'{workflow_name}' ({wf_path.name}) has a paths "
|
||||
f"filter that would degrade the merge gate to a "
|
||||
f"silent indefinite pending: {finding}. "
|
||||
f"See feedback_path_filtered_workflow_cant_be_required. "
|
||||
f"Fix: remove the filter and instead gate per-step "
|
||||
f"inside the job with `if: contains(steps.changed.outputs.files, ...)` "
|
||||
f"or refactor to a single-job-with-per-step-if shape."
|
||||
)
|
||||
return 1
|
||||
|
||||
print("")
|
||||
print(
|
||||
f"::notice::OK — all {len(contexts) - len(unresolved)} resolvable "
|
||||
f"required workflow(s) clean (no paths/paths-ignore filters)."
|
||||
)
|
||||
if unresolved:
|
||||
print(
|
||||
f"::notice::{len(unresolved)} required context(s) were not "
|
||||
f"resolved to a workflow file (warn-not-fail); see warnings above."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run())
|
||||
@@ -1,369 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""lint-workflow-yaml — catch Gitea-1.22.6-hostile workflow YAML shapes.
|
||||
|
||||
This script enforces six structural rules that have historically caused
|
||||
silent CI failures on Gitea Actions (1.22.6) — workflows that the server's
|
||||
YAML parser rejects with `[W] ignore invalid workflow ...` and registers
|
||||
for zero events, or shape conventions that produce ambiguous status
|
||||
contexts. Each rule maps to a documented incident in saved memory.
|
||||
|
||||
Rules (4 fatal + 1 fatal cross-file + 1 heuristic-warn):
|
||||
1. `workflow_dispatch.inputs:` block — Gitea 1.22.6 mis-parses the
|
||||
`inputs` keys as sibling event types and rejects the whole file.
|
||||
Memory: feedback_gitea_workflow_dispatch_inputs_unsupported.
|
||||
Origin: 2026-05-11 PyPI freeze (publish-runtime).
|
||||
2. `on: workflow_run:` event — not enumerated in Gitea 1.22.6's
|
||||
supported event list (verified via modules/actions/workflows.go
|
||||
enumeration; task #81). Workflow registers, fires for 0 events.
|
||||
3. `name:` containing `/` — breaks the
|
||||
`<workflow> / <job> (<event>)` commit-status context convention;
|
||||
downstream parsers (sop-tier-check, status-reaper) tokenize on `/`.
|
||||
4. `name:` collision across files — Gitea routes commit-status updates
|
||||
by `name` and behavior on collision is undefined (status-reaper
|
||||
rev1 fail-loud).
|
||||
5. Cross-repo `uses: org/repo/path@ref` — blocked while
|
||||
`[actions].DEFAULT_ACTIONS_URL=github` is the server default;
|
||||
resolves to github.com/<org-suspended>/... and 404s.
|
||||
Memory: feedback_gitea_cross_repo_uses_blocked. Cross-link: task #109.
|
||||
6. (HEURISTIC, warn-not-fail) Steps reference `https://api.github.com`
|
||||
or `https://github.com/.../releases/download` without a
|
||||
workflow-level `env.GITHUB_SERVER_URL` set to the Gitea instance.
|
||||
Memory: feedback_act_runner_github_server_url.
|
||||
|
||||
Per `feedback_smoke_test_vendor_truth_not_shape_match`: fixtures used to
|
||||
validate this lint must mirror real Gitea 1.22.6 YAML semantics, not
|
||||
Python yaml-parser quirks. The test suite at tests/test_lint_workflow_yaml.py
|
||||
includes a vendor-truth fixture (the exact publish-runtime regression).
|
||||
|
||||
Usage:
|
||||
python3 .gitea/scripts/lint-workflow-yaml.py
|
||||
Lint every `*.yml` in `.gitea/workflows/`.
|
||||
|
||||
python3 .gitea/scripts/lint-workflow-yaml.py --workflow-dir <path>
|
||||
Lint a custom directory (used by tests/test_lint_workflow_yaml.py).
|
||||
|
||||
Exit codes:
|
||||
0 — clean OR only heuristic-warnings emitted.
|
||||
1 — at least one fatal rule (1-5) violated.
|
||||
2 — YAML parse error or argv usage error.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("::error::PyYAML is required. Install with: pip install PyYAML", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# YAML quirk: bare `on:` at the top level parses to the Python `True`
|
||||
# (because `on` is a YAML 1.1 boolean alias). Handle both keys.
|
||||
def _get_on(d: dict) -> Any:
|
||||
if not isinstance(d, dict):
|
||||
return None
|
||||
if "on" in d:
|
||||
return d["on"]
|
||||
if True in d:
|
||||
return d[True]
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 1 — workflow_dispatch.inputs block (Gitea 1.22.6 parser rejects)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_workflow_dispatch_inputs(filename: str, doc: Any) -> list[str]:
|
||||
"""Return per-violation error lines if `workflow_dispatch.inputs` is set."""
|
||||
errors: list[str] = []
|
||||
on = _get_on(doc)
|
||||
if not isinstance(on, dict):
|
||||
return errors
|
||||
wd = on.get("workflow_dispatch")
|
||||
if isinstance(wd, dict) and wd.get("inputs"):
|
||||
errors.append(
|
||||
f"::error file={filename}::Rule 1 (FATAL): "
|
||||
f"`on.workflow_dispatch.inputs:` block detected. Gitea 1.22.6 "
|
||||
f"silently rejects the entire workflow with `[W] ignore invalid "
|
||||
f"workflow: unknown on type: map[...]`. Drop the `inputs:` block "
|
||||
f"and derive parameters from tag name / env / external query. "
|
||||
f"Memory: feedback_gitea_workflow_dispatch_inputs_unsupported."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 2 — on: workflow_run (not supported on Gitea 1.22.6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_workflow_run_event(filename: str, doc: Any) -> list[str]:
|
||||
"""Return per-violation error lines if `on: workflow_run:` is used."""
|
||||
errors: list[str] = []
|
||||
on = _get_on(doc)
|
||||
if isinstance(on, dict) and "workflow_run" in on:
|
||||
errors.append(
|
||||
f"::error file={filename}::Rule 2 (FATAL): `on: workflow_run:` "
|
||||
f"event used. Gitea 1.22.6 does NOT support `workflow_run` "
|
||||
f"(verified via modules/actions/workflows.go enumeration; "
|
||||
f"task #81). Workflow will fire for zero events. Use a "
|
||||
f"`schedule:` cron OR a `push:` trigger with `paths:` filter "
|
||||
f"on the upstream workflow file as the cross-workflow gate."
|
||||
)
|
||||
elif isinstance(on, list) and "workflow_run" in on:
|
||||
errors.append(
|
||||
f"::error file={filename}::Rule 2 (FATAL): `on: workflow_run` "
|
||||
f"in event list. Not supported on Gitea 1.22.6 — task #81."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 3 — name: contains "/" (breaks status-context tokenization)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_name_with_slash(filename: str, doc: Any) -> list[str]:
|
||||
"""Return per-violation error lines if workflow `name:` contains a slash."""
|
||||
errors: list[str] = []
|
||||
if not isinstance(doc, dict):
|
||||
return errors
|
||||
name = doc.get("name")
|
||||
if isinstance(name, str) and "/" in name:
|
||||
errors.append(
|
||||
f"::error file={filename}::Rule 3 (FATAL): workflow `name: "
|
||||
f"{name!r}` contains `/`. The commit-status context convention "
|
||||
f"is `<workflow> / <job> (<event>)`; embedding `/` in the "
|
||||
f"workflow name makes downstream parsers (sop-tier-check, "
|
||||
f"status-reaper) tokenize ambiguously. Rename to use `-` or "
|
||||
f"` ` instead."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 4 — cross-file name collision
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_name_collision_across_files(
|
||||
docs_by_file: dict[str, Any],
|
||||
) -> list[str]:
|
||||
"""Return per-collision error lines if two files share the same `name:`."""
|
||||
errors: list[str] = []
|
||||
by_name: dict[str, list[str]] = collections.defaultdict(list)
|
||||
for filename, doc in docs_by_file.items():
|
||||
if isinstance(doc, dict):
|
||||
n = doc.get("name")
|
||||
if isinstance(n, str) and n:
|
||||
by_name[n].append(filename)
|
||||
for n, files in sorted(by_name.items()):
|
||||
if len(files) > 1:
|
||||
errors.append(
|
||||
f"::error::Rule 4 (FATAL): workflow `name: {n!r}` collision "
|
||||
f"across {len(files)} files: {files}. Gitea routes "
|
||||
f"commit-status updates by `name`; collision yields "
|
||||
f"undefined behavior. Give each workflow a unique `name:`."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 5 — cross-repo `uses: org/repo/path@ref`
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# `uses: <foo>@<ref>` — match the value form Gitea/act actually parse.
|
||||
# We need to distinguish:
|
||||
# - `actions/checkout@<sha>` OK (bare org/repo@ref, no subpath)
|
||||
# - `./.gitea/actions/foo` OK (local path)
|
||||
# - `docker://image:tag` OK (docker-image form)
|
||||
# - `molecule-ai/molecule-ci/.gitea/actions/audit-force-merge@main` BAD
|
||||
USES_CROSS_REPO_RE = re.compile(
|
||||
r"""^
|
||||
(?P<owner>[A-Za-z0-9_.\-]+)
|
||||
/
|
||||
(?P<repo>[A-Za-z0-9_.\-]+)
|
||||
/ # mandatory subpath separator => cross-repo composite/reusable
|
||||
(?P<path>[^@\s]+)
|
||||
@
|
||||
(?P<ref>\S+)
|
||||
$""",
|
||||
re.VERBOSE,
|
||||
)
|
||||
|
||||
|
||||
def _iter_uses(doc: Any) -> Iterable[str]:
|
||||
"""Yield every `uses:` string from job steps in a workflow document."""
|
||||
if not isinstance(doc, dict):
|
||||
return
|
||||
jobs = doc.get("jobs")
|
||||
if not isinstance(jobs, dict):
|
||||
return
|
||||
for job in jobs.values():
|
||||
if not isinstance(job, dict):
|
||||
continue
|
||||
# reusable workflow: `uses:` at the job level
|
||||
if isinstance(job.get("uses"), str):
|
||||
yield job["uses"]
|
||||
steps = job.get("steps")
|
||||
if not isinstance(steps, list):
|
||||
continue
|
||||
for step in steps:
|
||||
if isinstance(step, dict) and isinstance(step.get("uses"), str):
|
||||
yield step["uses"]
|
||||
|
||||
|
||||
def check_cross_repo_uses(filename: str, doc: Any) -> list[str]:
|
||||
"""Return per-violation error lines for cross-repo `uses:` references."""
|
||||
errors: list[str] = []
|
||||
for uses in _iter_uses(doc):
|
||||
# Skip docker:// and local ./
|
||||
if uses.startswith(("docker://", "./", "../")):
|
||||
continue
|
||||
m = USES_CROSS_REPO_RE.match(uses.strip())
|
||||
if m:
|
||||
errors.append(
|
||||
f"::error file={filename}::Rule 5 (FATAL): cross-repo "
|
||||
f"`uses: {uses}` detected. Gitea 1.22.6 with "
|
||||
f"`[actions].DEFAULT_ACTIONS_URL=github` resolves this to "
|
||||
f"github.com/{m.group('owner')}/{m.group('repo')} which "
|
||||
f"404s (org suspended 2026-05-06). Inline the shared bash "
|
||||
f"into `.gitea/scripts/` until task #109 (actions mirror) "
|
||||
f"ships. Memory: feedback_gitea_cross_repo_uses_blocked."
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 6 — heuristic: github.com/api refs without workflow-level
|
||||
# GITHUB_SERVER_URL (WARN-not-FAIL per halt-condition 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Match `https://api.github.com/...` (API call) — that's the actionable
|
||||
# pattern. We intentionally do NOT match `https://github.com/.../releases/
|
||||
# download/...` (jq-release pin) nor `https://github.com/${{ github.repository
|
||||
# }}` (OCI label) because those are documented benign references on current
|
||||
# main and would 100% false-positive (3 hits, per Phase 1 audit).
|
||||
GITHUB_API_REF_RE = re.compile(
|
||||
r"https://api\.github\.com\b|https://github\.com/api/",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _has_workflow_level_server_url(doc: Any) -> bool:
|
||||
if not isinstance(doc, dict):
|
||||
return False
|
||||
env = doc.get("env")
|
||||
if isinstance(env, dict) and "GITHUB_SERVER_URL" in env:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def check_github_server_url_missing(filename: str, doc: Any, raw: str) -> list[str]:
|
||||
"""Return warn-lines (NOT errors) if api.github.com is referenced without
|
||||
workflow-level GITHUB_SERVER_URL. Heuristic — false-positives possible.
|
||||
"""
|
||||
warns: list[str] = []
|
||||
if not GITHUB_API_REF_RE.search(raw):
|
||||
return warns
|
||||
if _has_workflow_level_server_url(doc):
|
||||
return warns
|
||||
warns.append(
|
||||
f"::warning file={filename}::Rule 6 (WARN, heuristic): file "
|
||||
f"references `https://api.github.com` without a workflow-level "
|
||||
f"`env.GITHUB_SERVER_URL: https://git.moleculesai.app`. The "
|
||||
f"act_runner default for `${{{{ github.server_url }}}}` is "
|
||||
f"github.com, which can break actions that auth-condition on "
|
||||
f"server_url (e.g. actions/setup-go). If this curl is "
|
||||
f"intentionally hitting GitHub (e.g. public release pin), ignore. "
|
||||
f"Memory: feedback_act_runner_github_server_url."
|
||||
)
|
||||
return warns
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Driver
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(
|
||||
description="Lint Gitea Actions workflow YAML for 1.22.6-hostile shapes."
|
||||
)
|
||||
p.add_argument(
|
||||
"--workflow-dir",
|
||||
default=".gitea/workflows",
|
||||
help="Directory of workflow *.yml files (default: .gitea/workflows).",
|
||||
)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
wf_dir = Path(args.workflow_dir)
|
||||
if not wf_dir.exists():
|
||||
# Empty / missing dir = nothing to lint, not a failure.
|
||||
print(f"::notice::No workflow directory at {wf_dir}; skipping.")
|
||||
return 0
|
||||
|
||||
yml_paths = sorted(
|
||||
glob.glob(str(wf_dir / "*.yml")) + glob.glob(str(wf_dir / "*.yaml"))
|
||||
)
|
||||
if not yml_paths:
|
||||
print(f"::notice::No workflow files in {wf_dir}; nothing to lint.")
|
||||
return 0
|
||||
|
||||
fatal_errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
docs_by_file: dict[str, Any] = {}
|
||||
|
||||
for path in yml_paths:
|
||||
rel = os.path.relpath(path)
|
||||
try:
|
||||
raw = Path(path).read_text()
|
||||
doc = yaml.safe_load(raw)
|
||||
except yaml.YAMLError as e:
|
||||
fatal_errors.append(
|
||||
f"::error file={rel}::YAML parse error: {e}. Cannot lint "
|
||||
f"a file the parser rejects."
|
||||
)
|
||||
continue
|
||||
docs_by_file[rel] = doc
|
||||
|
||||
# Per-file checks
|
||||
fatal_errors.extend(check_workflow_dispatch_inputs(rel, doc))
|
||||
fatal_errors.extend(check_workflow_run_event(rel, doc))
|
||||
fatal_errors.extend(check_name_with_slash(rel, doc))
|
||||
fatal_errors.extend(check_cross_repo_uses(rel, doc))
|
||||
warnings.extend(check_github_server_url_missing(rel, doc, raw))
|
||||
|
||||
# Cross-file checks
|
||||
fatal_errors.extend(check_name_collision_across_files(docs_by_file))
|
||||
|
||||
# Emit warnings first (non-blocking)
|
||||
for w in warnings:
|
||||
print(w)
|
||||
|
||||
if not fatal_errors:
|
||||
n = len(yml_paths)
|
||||
print(
|
||||
f"::notice::lint-workflow-yaml: {n} workflow file(s) checked, "
|
||||
f"no fatal Gitea-1.22.6-hostile shapes. "
|
||||
f"({len(warnings)} heuristic warning(s) emitted.)"
|
||||
)
|
||||
return 0
|
||||
|
||||
# Emit fatal errors
|
||||
print(
|
||||
f"::error::lint-workflow-yaml: {len(fatal_errors)} fatal violation(s) "
|
||||
f"across {len(yml_paths)} workflow file(s). See rule documentation "
|
||||
f"in .gitea/scripts/lint-workflow-yaml.py docstring."
|
||||
)
|
||||
for e in fatal_errors:
|
||||
print(e)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,361 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""lint_mask_pr_atomicity — Tier 2d structural enforcement per internal#350.
|
||||
|
||||
Rule
|
||||
----
|
||||
A PR whose diff touches `.gitea/workflows/ci.yml` AND modifies EITHER:
|
||||
|
||||
- any `continue-on-error:` value, OR
|
||||
- the `all-required` sentinel job's `needs:` block
|
||||
|
||||
must EITHER:
|
||||
|
||||
- Touch BOTH atomically in the same PR (preferred), OR
|
||||
- Cross-link the paired PR via a literal `Paired: #NNN` reference in
|
||||
the PR body OR in any commit message between BASE_SHA and HEAD_SHA.
|
||||
|
||||
The class this prevents
|
||||
-----------------------
|
||||
PR#665 (interim `continue-on-error: true` on `platform-build`) and
|
||||
PR#668 (sentinel-`needs` demotion of the same job) were designed as a
|
||||
pair but merged solo — #665 landed at 04:47Z 2026-05-12, #668 was still
|
||||
open at 05:07Z when the main-red watchdog (#674) fired. Result: ~20
|
||||
minutes of `main` red and a cascade of false-positives on unrelated PRs.
|
||||
|
||||
The lint operates on the YAML AST (PyYAML), not grep, per
|
||||
`feedback_behavior_based_ast_gates`: a refactor that moves `continue-on-error`
|
||||
between job keys, or renames the `all-required` job, would still be
|
||||
detected because we walk the parsed structure.
|
||||
|
||||
Why this works on Gitea 1.22.6
|
||||
------------------------------
|
||||
We don't use any 1.22.6-missing endpoints (no `/actions/runs/*`, no
|
||||
`branch_protections/*` — Tier 2f/g need those; Tier 2d does not). All
|
||||
required inputs come from the workflow `pull_request` event payload
|
||||
(BASE_SHA, HEAD_SHA, PR_BODY) and from local git via `git show`/`git log`.
|
||||
The auto-injected `GITHUB_TOKEN` is enough; we don't need
|
||||
DRIFT_BOT_TOKEN.
|
||||
|
||||
Exit codes
|
||||
----------
|
||||
0 — ci.yml not in diff, OR diff is no-op for the rule predicates,
|
||||
OR atomicity satisfied (both touched), OR a valid `Paired: #NNN`
|
||||
reference is present.
|
||||
1 — exactly ONE of {coe, sentinel-needs} touched AND no valid
|
||||
`Paired: #NNN` reference. The split-pair regression class.
|
||||
2 — env contract violation (BASE_SHA / HEAD_SHA missing) or YAML
|
||||
parse error on either side.
|
||||
|
||||
Env
|
||||
---
|
||||
BASE_SHA — PR base (pull_request.base.sha)
|
||||
HEAD_SHA — PR head (pull_request.head.sha)
|
||||
PR_BODY — pull_request.body (may be empty)
|
||||
CI_WORKFLOW_PATH — defaults to `.gitea/workflows/ci.yml`
|
||||
SENTINEL_JOB_KEY — defaults to `all-required`
|
||||
|
||||
Memory cross-links
|
||||
------------------
|
||||
- internal#350 (the RFC that specs this lint)
|
||||
- PR#665 / PR#668 (the empirical split-pair)
|
||||
- mc#664 (the main-red incident)
|
||||
- feedback_strict_root_only_after_class_a
|
||||
- feedback_behavior_based_ast_gates
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.stderr.write(
|
||||
"::error::PyYAML is required. Install with: pip install PyYAML\n"
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML quirk: bare `on:` at the top level becomes Python `True` because
|
||||
# `on` is a YAML 1.1 boolean. Not used here but documented for future
|
||||
# editors who copy from this module.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# `Paired: #NNN` reference. `#` is mandatory, NNN must be digits. Any
|
||||
# surrounding markdown/whitespace is fine. The match is case-sensitive
|
||||
# on `Paired:` because lower-case `paired:` collides with conversational
|
||||
# prose ("paired: see comment above") and the convention is the exact
|
||||
# capitalisation.
|
||||
PAIRED_RE = re.compile(r"\bPaired:\s*#(?P<num>\d+)\b")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Env contract
|
||||
# ---------------------------------------------------------------------------
|
||||
def _env(key: str, default: str | None = None) -> str:
|
||||
v = os.environ.get(key, default)
|
||||
return v if v is not None else ""
|
||||
|
||||
|
||||
def _require_env(key: str) -> str:
|
||||
v = os.environ.get(key)
|
||||
if not v:
|
||||
sys.stderr.write(f"::error::missing required env var: {key}\n")
|
||||
sys.exit(2)
|
||||
return v
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# git-show helper. Returns None when the path doesn't exist on that side
|
||||
# (new file, deleted file, or rename — git returns exit 128 with "fatal:
|
||||
# path not in tree"). We treat None as "no rule predicate triggered on
|
||||
# that side".
|
||||
# ---------------------------------------------------------------------------
|
||||
def git_show(sha: str, path: str) -> str | None:
|
||||
r = subprocess.run(
|
||||
["git", "show", f"{sha}:{path}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return None
|
||||
return r.stdout
|
||||
|
||||
|
||||
def git_log_messages(base_sha: str, head_sha: str) -> str:
|
||||
r = subprocess.run(
|
||||
["git", "log", "--format=%B", f"{base_sha}..{head_sha}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return ""
|
||||
return r.stdout
|
||||
|
||||
|
||||
def git_diff_paths(base_sha: str, head_sha: str) -> list[str]:
|
||||
r = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{base_sha}..{head_sha}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
return []
|
||||
return [p for p in r.stdout.splitlines() if p.strip()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Predicate 1 — any `continue-on-error` value changed between base and head
|
||||
# ---------------------------------------------------------------------------
|
||||
def _collect_coe(doc: Any) -> dict[str, Any]:
|
||||
"""Walk every job in `jobs.*` and collect its continue-on-error value.
|
||||
|
||||
Returns a dict {job_key: coe_value}. Missing keys are absent from
|
||||
the dict (NOT `False` — distinguishes "added the key" from
|
||||
"unchanged absent"). Job-step `continue-on-error` is NOT considered
|
||||
— only job-level, because that's the value that masks job status
|
||||
rollup, which is the class this lint targets.
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
if not isinstance(doc, dict):
|
||||
return out
|
||||
jobs = doc.get("jobs")
|
||||
if not isinstance(jobs, dict):
|
||||
return out
|
||||
for k, j in jobs.items():
|
||||
if not isinstance(j, dict):
|
||||
continue
|
||||
if "continue-on-error" in j:
|
||||
out[k] = j["continue-on-error"]
|
||||
return out
|
||||
|
||||
|
||||
def coe_changed(base_doc: Any, head_doc: Any) -> tuple[bool, list[str]]:
|
||||
"""Return (changed?, [reasons]) describing per-job coe diffs."""
|
||||
base = _collect_coe(base_doc)
|
||||
head = _collect_coe(head_doc)
|
||||
reasons: list[str] = []
|
||||
all_keys = set(base) | set(head)
|
||||
for k in sorted(all_keys):
|
||||
b = base.get(k, "<absent>")
|
||||
h = head.get(k, "<absent>")
|
||||
if b != h:
|
||||
reasons.append(f"job '{k}' continue-on-error: {b!r} → {h!r}")
|
||||
return (bool(reasons), reasons)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Predicate 2 — sentinel job's `needs:` changed
|
||||
# ---------------------------------------------------------------------------
|
||||
def _collect_needs(doc: Any, sentinel_key: str) -> list[str] | None:
|
||||
"""Return the sentinel job's needs list (sorted) or None if absent."""
|
||||
if not isinstance(doc, dict):
|
||||
return None
|
||||
jobs = doc.get("jobs")
|
||||
if not isinstance(jobs, dict):
|
||||
return None
|
||||
j = jobs.get(sentinel_key)
|
||||
if not isinstance(j, dict):
|
||||
return None
|
||||
needs = j.get("needs")
|
||||
if needs is None:
|
||||
return []
|
||||
if isinstance(needs, str):
|
||||
return [needs]
|
||||
if isinstance(needs, list):
|
||||
# Sort because `needs:` is order-insensitive at the engine
|
||||
# level; a reorder is not a semantic change and shouldn't
|
||||
# trip the lint.
|
||||
return sorted(str(x) for x in needs)
|
||||
return None
|
||||
|
||||
|
||||
def sentinel_needs_changed(
|
||||
base_doc: Any, head_doc: Any, sentinel_key: str
|
||||
) -> tuple[bool, str]:
|
||||
"""Return (changed?, reason)."""
|
||||
base = _collect_needs(base_doc, sentinel_key)
|
||||
head = _collect_needs(head_doc, sentinel_key)
|
||||
if base == head:
|
||||
return (False, "")
|
||||
return (
|
||||
True,
|
||||
f"sentinel '{sentinel_key}'.needs: {base!r} → {head!r}",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Predicate 3 — `Paired: #NNN` present in body or any commit message
|
||||
# ---------------------------------------------------------------------------
|
||||
def find_paired_refs(pr_body: str, commit_log: str) -> list[str]:
|
||||
"""Return list of `#NNN` strings found (deduped, sorted)."""
|
||||
found: set[str] = set()
|
||||
for src in (pr_body, commit_log):
|
||||
for m in PAIRED_RE.finditer(src or ""):
|
||||
found.add(m.group("num"))
|
||||
return sorted(found)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Driver
|
||||
# ---------------------------------------------------------------------------
|
||||
def _parse(content: str | None, label: str) -> Any:
|
||||
if content is None:
|
||||
return None
|
||||
try:
|
||||
return yaml.safe_load(content)
|
||||
except yaml.YAMLError as e:
|
||||
sys.stderr.write(f"::error::YAML parse error on {label}: {e}\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
def run() -> int:
|
||||
base_sha = _require_env("BASE_SHA")
|
||||
head_sha = _require_env("HEAD_SHA")
|
||||
pr_body = _env("PR_BODY", "")
|
||||
ci_path = _env("CI_WORKFLOW_PATH", ".gitea/workflows/ci.yml")
|
||||
sentinel_key = _env("SENTINEL_JOB_KEY", "all-required")
|
||||
|
||||
# Step 0 — is ci.yml even in the diff? If not, the lint doesn't apply.
|
||||
changed_paths = git_diff_paths(base_sha, head_sha)
|
||||
if ci_path not in changed_paths:
|
||||
print(
|
||||
f"::notice::{ci_path} not in PR diff; lint-mask-pr-atomicity "
|
||||
f"skipped (no atomicity risk)."
|
||||
)
|
||||
return 0
|
||||
|
||||
base_yml = git_show(base_sha, ci_path)
|
||||
head_yml = git_show(head_sha, ci_path)
|
||||
|
||||
base_doc = _parse(base_yml, f"{ci_path}@{base_sha}")
|
||||
head_doc = _parse(head_yml, f"{ci_path}@{head_sha}")
|
||||
|
||||
# If the file is newly added (no base), no flip is possible — every
|
||||
# value is "newly introduced", not "changed". Tier 2e covers the
|
||||
# tracking-issue check for new continue-on-error: true. Exit 0.
|
||||
if base_doc is None:
|
||||
print(
|
||||
f"::notice::{ci_path} newly added in this PR; no flip to "
|
||||
f"analyse — lint-mask-pr-atomicity skipped."
|
||||
)
|
||||
return 0
|
||||
|
||||
# If the file is deleted on head, ditto — no atomicity question.
|
||||
if head_doc is None:
|
||||
print(
|
||||
f"::notice::{ci_path} deleted in this PR; "
|
||||
f"lint-mask-pr-atomicity skipped."
|
||||
)
|
||||
return 0
|
||||
|
||||
coe_yes, coe_reasons = coe_changed(base_doc, head_doc)
|
||||
needs_yes, needs_reason = sentinel_needs_changed(
|
||||
base_doc, head_doc, sentinel_key
|
||||
)
|
||||
|
||||
if not coe_yes and not needs_yes:
|
||||
print(
|
||||
f"::notice::{ci_path} touched but neither continue-on-error "
|
||||
f"nor sentinel '{sentinel_key}'.needs changed — no atomicity "
|
||||
f"risk. OK."
|
||||
)
|
||||
return 0
|
||||
|
||||
if coe_yes and needs_yes:
|
||||
print(
|
||||
f"::notice::Atomic change detected: both continue-on-error "
|
||||
f"AND sentinel '{sentinel_key}'.needs touched in same PR. OK."
|
||||
)
|
||||
for r in coe_reasons:
|
||||
print(f" - {r}")
|
||||
print(f" - {needs_reason}")
|
||||
return 0
|
||||
|
||||
# Exactly one side touched — require Paired: #NNN reference.
|
||||
commit_log = git_log_messages(base_sha, head_sha)
|
||||
paired = find_paired_refs(pr_body, commit_log)
|
||||
|
||||
one_side = "continue-on-error" if coe_yes else f"sentinel '{sentinel_key}'.needs"
|
||||
other_side = (
|
||||
f"sentinel '{sentinel_key}'.needs" if coe_yes else "continue-on-error"
|
||||
)
|
||||
|
||||
if paired:
|
||||
print(
|
||||
f"::notice::Split-pair detected ({one_side} changed without "
|
||||
f"{other_side}), but Paired reference(s) present: "
|
||||
f"{', '.join('#' + n for n in paired)}. OK."
|
||||
)
|
||||
for r in coe_reasons:
|
||||
print(f" - {r}")
|
||||
if needs_reason:
|
||||
print(f" - {needs_reason}")
|
||||
return 0
|
||||
|
||||
# The failure mode this lint exists to prevent.
|
||||
print(
|
||||
f"::error file={ci_path}::lint-mask-pr-atomicity (Tier 2d): "
|
||||
f"PR touches {one_side} in {ci_path} but NOT {other_side}, "
|
||||
f"and no `Paired: #NNN` reference was found in the PR body or "
|
||||
f"in commit messages between {base_sha[:8]}..{head_sha[:8]}. "
|
||||
f"This is the PR#665+#668 split-pair regression class "
|
||||
f"(see internal#350, mc#664). FIX: either (a) include the "
|
||||
f"matching {other_side} change in the same PR (preferred), or "
|
||||
f"(b) add `Paired: #NNN` (literal, capital P, with `#`) to the "
|
||||
f"PR body or a commit message referencing the paired PR."
|
||||
)
|
||||
for r in coe_reasons:
|
||||
print(f" - {r}")
|
||||
if needs_reason:
|
||||
print(f" - {needs_reason}")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run())
|
||||
@@ -222,20 +222,9 @@ def is_red(status: dict) -> tuple[bool, list[dict]]:
|
||||
combined = status.get("state")
|
||||
statuses = status.get("statuses") or []
|
||||
red_states = {"failure", "error"}
|
||||
# Schema asymmetry: top-level combined uses `state`, but per-entry
|
||||
# items in `statuses[]` use `status` in Gitea 1.22.6. Prefer
|
||||
# `status`; fall back to `state` defensively. Verified empirically
|
||||
# 2026-05-12 03:42Z. Pre-rev4 code only read `state` from per-entry
|
||||
# items → failed[] always empty → render_body always showed the
|
||||
# "no per-context entries were in a red state" fallback even when
|
||||
# the combined-state correctly flagged red. See
|
||||
# `feedback_smoke_test_vendor_truth_not_shape_match`.
|
||||
def _entry_state(s: dict) -> str:
|
||||
return s.get("status") or s.get("state") or ""
|
||||
|
||||
failed = [
|
||||
s for s in statuses
|
||||
if isinstance(s, dict) and _entry_state(s) in red_states
|
||||
if isinstance(s, dict) and s.get("state") in red_states
|
||||
]
|
||||
return (combined in red_states or bool(failed), failed)
|
||||
|
||||
@@ -324,9 +313,7 @@ def render_body(sha: str, failed: list[dict], debug: dict) -> str:
|
||||
else:
|
||||
for s in failed:
|
||||
ctx = s.get("context", "(no context)")
|
||||
# Per-entry key is `status` in Gitea 1.22.6, not `state`
|
||||
# (see _entry_state in is_red). Fallback for forward-compat.
|
||||
state = s.get("status") or s.get("state") or "(no state)"
|
||||
state = s.get("state", "(no state)")
|
||||
url = s.get("target_url") or ""
|
||||
desc = (s.get("description") or "").strip()
|
||||
entry = f"- **{ctx}** — `{state}`"
|
||||
@@ -559,11 +546,7 @@ def run_once(*, dry_run: bool = False) -> int:
|
||||
"combined_state": status.get("state"),
|
||||
"failed_contexts": [s.get("context") for s in failed],
|
||||
"all_contexts": [
|
||||
# Per-entry key is `status` in Gitea 1.22.6, not `state`.
|
||||
# Pre-rev4 debug output reported `state: None` for every
|
||||
# context, making run logs useless for triage.
|
||||
{"context": s.get("context"),
|
||||
"state": s.get("status") or s.get("state")}
|
||||
{"context": s.get("context"), "state": s.get("state")}
|
||||
for s in (status.get("statuses") or [])
|
||||
if isinstance(s, dict)
|
||||
],
|
||||
|
||||
@@ -1,823 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# sop-checklist-gate — evaluate whether a PR has peer-acked each
|
||||
# SOP-checklist item. Posts a commit-status that branch protection
|
||||
# can require.
|
||||
#
|
||||
# RFC#351 Step 2 of 6 (implementation MVP).
|
||||
#
|
||||
# Invoked by .gitea/workflows/sop-checklist-gate.yml on:
|
||||
# - pull_request_target: [opened, edited, synchronize, reopened]
|
||||
# - issue_comment: [created, edited, deleted]
|
||||
#
|
||||
# Flow:
|
||||
# 1. Load .gitea/sop-checklist-config.yaml (from BASE ref — trusted).
|
||||
# 2. GET /repos/{R}/pulls/{N} — author, head.sha, tier label
|
||||
# 3. GET /repos/{R}/issues/{N}/comments — extract /sop-ack and /sop-revoke
|
||||
# 4. For each checklist item:
|
||||
# a. Is the section marker present in PR body? (author answered)
|
||||
# b. Is there ≥1 unrevoked /sop-ack from a non-author whose
|
||||
# team-membership matches required_teams?
|
||||
# 5. POST /repos/{R}/statuses/{sha} — context
|
||||
# `sop-checklist / all-items-acked (pull_request)`,
|
||||
# state=success | failure | pending, description=`acked: N/M …`.
|
||||
#
|
||||
# Trust boundary (mirrors RFC#324 §A4):
|
||||
# This script is loaded from the BASE branch. The workflow's
|
||||
# actions/checkout step pins ref=base.sha. PR-HEAD code is never
|
||||
# executed. We only HTTP-call the Gitea API.
|
||||
#
|
||||
# Token scope:
|
||||
# - read:repository / read:organization to enumerate PR + comments
|
||||
# + team membership (Gitea 1.22.6 quirk: team-membership endpoint
|
||||
# returns 403 if token owner is not in the team; see review-check.sh
|
||||
# for the same gotcha — we surface the same fail-closed message).
|
||||
# - write:repository for `POST /repos/{R}/statuses/{sha}`. Unlike
|
||||
# RFC#324's pattern (which uses the JOB's own pass/fail as the
|
||||
# status), we POST the status explicitly because the gate posts
|
||||
# a single multi-item status with a richer description than a
|
||||
# bare success/failure context can carry.
|
||||
#
|
||||
# Slug normalization rules (canonical form: kebab-case):
|
||||
# - Lowercase
|
||||
# - Whitespace + underscores → single dash
|
||||
# - Strip non [a-z0-9-] characters
|
||||
# - Collapse adjacent dashes
|
||||
# - Strip leading/trailing dashes
|
||||
# - If the result is a digit string (e.g. "1"), look up via
|
||||
# config.items[*].numeric_alias to get the kebab-case slug.
|
||||
#
|
||||
# Examples:
|
||||
# "Comprehensive_Testing" → "comprehensive-testing"
|
||||
# "comprehensive testing" → "comprehensive-testing"
|
||||
# "1" → "comprehensive-testing"
|
||||
# "Five-Axis-Review" → "five-axis-review"
|
||||
#
|
||||
# Revoke semantics:
|
||||
# /sop-revoke <slug> [reason] — most-recent comment per (slug, user)
|
||||
# wins. So if Alice posts /sop-ack X then later /sop-revoke X, her ack
|
||||
# for X is invalidated. Bob's prior /sop-ack X is unaffected. If Alice
|
||||
# posts /sop-revoke X then later /sop-ack X again, the ack is restored.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Slug normalization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_NORMALIZE_REPLACE_RE = re.compile(r"[\s_]+")
|
||||
_NORMALIZE_STRIP_RE = re.compile(r"[^a-z0-9-]")
|
||||
_NORMALIZE_DASH_RE = re.compile(r"-+")
|
||||
|
||||
|
||||
def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> str:
|
||||
"""Normalize a user-supplied slug to canonical kebab-case form.
|
||||
|
||||
See module header for the rules.
|
||||
|
||||
If the input is a pure digit string AND numeric_aliases is provided,
|
||||
the alias mapping is consulted. Unknown digits return "" so the caller
|
||||
can flag the comment as unparseable.
|
||||
"""
|
||||
if raw is None:
|
||||
return ""
|
||||
s = raw.strip().lower()
|
||||
s = _NORMALIZE_REPLACE_RE.sub("-", s)
|
||||
s = _NORMALIZE_STRIP_RE.sub("", s)
|
||||
s = _NORMALIZE_DASH_RE.sub("-", s)
|
||||
s = s.strip("-")
|
||||
if s.isdigit() and numeric_aliases is not None:
|
||||
return numeric_aliases.get(int(s), "")
|
||||
return s
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Comment parsing — /sop-ack and /sop-revoke
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# A directive must be on its own line. Permits leading whitespace.
|
||||
# Optional trailing note after the slug for /sop-ack and required reason
|
||||
# for /sop-revoke (RFC#351 open question 4 — reason is captured but not
|
||||
# yet validated; future iteration may require a min-length).
|
||||
_DIRECTIVE_RE = re.compile(
|
||||
r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def parse_directives(
|
||||
comment_body: str,
|
||||
numeric_aliases: dict[int, str],
|
||||
) -> list[tuple[str, str, str]]:
|
||||
"""Extract /sop-ack and /sop-revoke directives from a comment body.
|
||||
|
||||
Returns a list of (kind, canonical_slug, note) tuples where:
|
||||
kind is "sop-ack" or "sop-revoke"
|
||||
canonical_slug is the normalized form (or "" if unparseable)
|
||||
note is the trailing free-text (may be "")
|
||||
"""
|
||||
out: list[tuple[str, str, str]] = []
|
||||
if not comment_body:
|
||||
return out
|
||||
for m in _DIRECTIVE_RE.finditer(comment_body):
|
||||
kind = m.group(1)
|
||||
raw_slug = (m.group(2) or "").strip()
|
||||
# If the raw match included trailing words, the regex non-greedy
|
||||
# captured only the first token; strip again for safety.
|
||||
# We split on whitespace to keep the FIRST word as the slug, and
|
||||
# everything after as the note.
|
||||
parts = raw_slug.split()
|
||||
if not parts:
|
||||
continue
|
||||
first = parts[0]
|
||||
# If the slug-capture greedily matched multiple words (e.g.
|
||||
# "comprehensive testing"), preserve normalize behavior: join
|
||||
# the WHOLE first-word-token only; trailing words get appended to
|
||||
# the note. The regex limits group(2) to [A-Za-z0-9_\- ] so we
|
||||
# may have multi-word forms here — normalize handles them.
|
||||
if len(parts) > 1:
|
||||
# User wrote "/sop-ack comprehensive testing extra-note"
|
||||
# → treat "comprehensive testing" as the slug source if it
|
||||
# normalizes to a known item; otherwise treat "comprehensive"
|
||||
# as slug and "testing extra-note" as note. We defer the
|
||||
# disambiguation to the caller via the returned canonical
|
||||
# slug. For simplicity: try the WHOLE captured string first.
|
||||
canonical = normalize_slug(raw_slug, numeric_aliases)
|
||||
else:
|
||||
canonical = normalize_slug(first, numeric_aliases)
|
||||
note_from_group = (m.group(3) or "").strip()
|
||||
# If we collapsed multi-word slug into kebab and there's a
|
||||
# trailing-text group too, append it.
|
||||
out.append((kind, canonical, note_from_group))
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PR body section detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def section_marker_present(body: str, marker: str) -> bool:
|
||||
"""Return True if `marker` appears in `body` case-insensitively
|
||||
on a non-empty line (i.e. the author actually filled it in).
|
||||
|
||||
We require the marker substring AND non-whitespace content on the
|
||||
same line OR within the next line — this prevents trivially-empty
|
||||
checklists like:
|
||||
|
||||
## SOP-Checklist
|
||||
- [ ] **Comprehensive testing performed**:
|
||||
- [ ] **Local-postgres E2E run**:
|
||||
|
||||
from auto-passing the section-present check. The peer-ack is still
|
||||
required, but answering with empty content is captured as a soft
|
||||
finding via the section-present test alone.
|
||||
"""
|
||||
if not body or not marker:
|
||||
return False
|
||||
body_lower = body.lower()
|
||||
marker_lower = marker.lower()
|
||||
idx = body_lower.find(marker_lower)
|
||||
if idx < 0:
|
||||
return False
|
||||
# Walk to end of line.
|
||||
line_end = body.find("\n", idx)
|
||||
if line_end < 0:
|
||||
line_end = len(body)
|
||||
line = body[idx + len(marker):line_end]
|
||||
# Strip the colon + checkbox tail patterns; require at least one
|
||||
# non-whitespace, non-punctuation char.
|
||||
stripped = re.sub(r"[\s\*:\-\[\]]+", "", line)
|
||||
if stripped:
|
||||
return True
|
||||
# Fall through: check the NEXT line (multi-line answers).
|
||||
next_line_end = body.find("\n", line_end + 1)
|
||||
if next_line_end < 0:
|
||||
next_line_end = len(body)
|
||||
next_line = body[line_end + 1:next_line_end]
|
||||
stripped_next = re.sub(r"[\s\*:\-\[\]]+", "", next_line)
|
||||
return bool(stripped_next)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Ack-state computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_ack_state(
|
||||
comments: list[dict[str, Any]],
|
||||
pr_author: str,
|
||||
items_by_slug: dict[str, dict[str, Any]],
|
||||
numeric_aliases: dict[int, str],
|
||||
team_membership_probe: "callable[[str, list[str]], list[str]]",
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Compute per-item ack state.
|
||||
|
||||
Each comment is processed in chronological order. The most-recent
|
||||
directive per (commenter, slug) wins.
|
||||
|
||||
Returns a dict keyed by canonical slug:
|
||||
{
|
||||
"comprehensive-testing": {
|
||||
"ackers": ["bob"], # non-author, team-verified
|
||||
"rejected_ackers": { # debugging info
|
||||
"self_ack": ["alice"],
|
||||
"unknown_slug": [],
|
||||
"not_in_team": ["eve"],
|
||||
}
|
||||
},
|
||||
...
|
||||
}
|
||||
"""
|
||||
# Step 1: collapse directives per (commenter, slug) — most recent wins.
|
||||
# comments are expected to come in chronological order from the
|
||||
# API (Gitea returns oldest-first by default for issues/{N}/comments).
|
||||
latest_directive: dict[tuple[str, str], str] = {} # (user, slug) → kind
|
||||
unparseable_per_user: dict[str, int] = {}
|
||||
for c in comments:
|
||||
body = c.get("body", "") or ""
|
||||
user = (c.get("user") or {}).get("login", "")
|
||||
if not user:
|
||||
continue
|
||||
for kind, slug, _note in parse_directives(body, numeric_aliases):
|
||||
if not slug:
|
||||
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
|
||||
continue
|
||||
latest_directive[(user, slug)] = kind
|
||||
|
||||
# Step 2: build candidate ackers per slug.
|
||||
# Filter out self-acks and unknown slugs.
|
||||
ackers_per_slug: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
rejected_self: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
rejected_unknown: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
pending_team_check: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
|
||||
for (user, slug), kind in latest_directive.items():
|
||||
if kind != "sop-ack":
|
||||
continue # revokes leave the (user,slug) state as "no ack"
|
||||
if slug not in items_by_slug:
|
||||
# Slug normalized to something not in our config — store
|
||||
# under a synthetic key for diagnostic surfacing. Don't add
|
||||
# to any item.
|
||||
continue
|
||||
if user == pr_author:
|
||||
rejected_self[slug].append(user)
|
||||
continue
|
||||
pending_team_check[slug].append(user)
|
||||
|
||||
# Step 3: team membership probe per slug (batched per slug to keep
|
||||
# API call count down — same user may ack multiple items but the
|
||||
# required_teams differ per item, so we MUST probe per (user, item)).
|
||||
rejected_not_in_team: dict[str, list[str]] = {s: [] for s in items_by_slug}
|
||||
for slug, candidates in pending_team_check.items():
|
||||
if not candidates:
|
||||
continue
|
||||
required = items_by_slug[slug]["required_teams"]
|
||||
approved = team_membership_probe(slug, candidates) # returns subset
|
||||
rejected_not_in_team[slug] = [u for u in candidates if u not in approved]
|
||||
ackers_per_slug[slug] = approved
|
||||
# Stash required teams for description rendering.
|
||||
items_by_slug[slug]["_required_resolved"] = required
|
||||
|
||||
return {
|
||||
slug: {
|
||||
"ackers": ackers_per_slug[slug],
|
||||
"rejected": {
|
||||
"self_ack": rejected_self[slug],
|
||||
"not_in_team": rejected_not_in_team[slug],
|
||||
},
|
||||
}
|
||||
for slug in items_by_slug
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea API client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
def __init__(self, host: str, token: str):
|
||||
self.base = f"https://{host}/api/v1"
|
||||
self.token = token
|
||||
# Cache team-name → team-id resolutions per org.
|
||||
self._team_id_cache: dict[tuple[str, str], int | None] = {}
|
||||
|
||||
def _req(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
body: dict[str, Any] | None = None,
|
||||
ok_codes: tuple[int, ...] = (200, 201, 204),
|
||||
) -> tuple[int, Any]:
|
||||
url = self.base + path
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": f"token {self.token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, method=method, data=data, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
raw = r.read()
|
||||
code = r.getcode()
|
||||
except urllib.error.HTTPError as e:
|
||||
code = e.code
|
||||
raw = e.read()
|
||||
try:
|
||||
parsed = json.loads(raw.decode("utf-8")) if raw else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = raw.decode("utf-8", errors="replace") if raw else None
|
||||
return code, parsed
|
||||
|
||||
def get_pr(self, owner: str, repo: str, pr: int) -> dict[str, Any]:
|
||||
code, data = self._req("GET", f"/repos/{owner}/{repo}/pulls/{pr}")
|
||||
if code != 200:
|
||||
raise RuntimeError(f"GET pulls/{pr} → HTTP {code}: {data!r}")
|
||||
return data
|
||||
|
||||
def get_issue_comments(
|
||||
self, owner: str, repo: str, issue: int
|
||||
) -> list[dict[str, Any]]:
|
||||
# Paginate. Gitea default page size 50.
|
||||
out: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
while True:
|
||||
code, data = self._req(
|
||||
"GET",
|
||||
f"/repos/{owner}/{repo}/issues/{issue}/comments?limit=50&page={page}",
|
||||
)
|
||||
if code != 200:
|
||||
raise RuntimeError(
|
||||
f"GET issues/{issue}/comments page={page} → HTTP {code}: {data!r}"
|
||||
)
|
||||
if not data:
|
||||
break
|
||||
out.extend(data)
|
||||
if len(data) < 50:
|
||||
break
|
||||
page += 1
|
||||
return out
|
||||
|
||||
def resolve_team_id(self, org: str, team_name: str) -> int | None:
|
||||
key = (org, team_name)
|
||||
if key in self._team_id_cache:
|
||||
return self._team_id_cache[key]
|
||||
code, data = self._req("GET", f"/orgs/{org}/teams/search?q={urllib.parse.quote(team_name)}")
|
||||
team_id = None
|
||||
if code == 200 and isinstance(data, dict):
|
||||
for t in data.get("data", []):
|
||||
if t.get("name") == team_name:
|
||||
team_id = t.get("id")
|
||||
break
|
||||
if team_id is None and code == 200 and isinstance(data, list):
|
||||
for t in data:
|
||||
if t.get("name") == team_name:
|
||||
team_id = t.get("id")
|
||||
break
|
||||
self._team_id_cache[key] = team_id
|
||||
return team_id
|
||||
|
||||
def is_team_member(self, team_id: int, login: str) -> bool | None:
|
||||
"""Return True / False / None (unknown — 403 from API)."""
|
||||
code, _ = self._req(
|
||||
"GET", f"/teams/{team_id}/members/{urllib.parse.quote(login)}"
|
||||
)
|
||||
if code in (200, 204):
|
||||
return True
|
||||
if code == 404:
|
||||
return False
|
||||
# 403 means the token owner isn't in this team, so the API
|
||||
# refuses to confirm membership. Fail-closed at the caller.
|
||||
return None
|
||||
|
||||
def post_status(
|
||||
self,
|
||||
owner: str,
|
||||
repo: str,
|
||||
sha: str,
|
||||
state: str,
|
||||
context: str,
|
||||
description: str,
|
||||
target_url: str = "",
|
||||
) -> None:
|
||||
body = {
|
||||
"state": state,
|
||||
"context": context,
|
||||
"description": description[:140], # Gitea truncates to 255 but be safe
|
||||
"target_url": target_url or "",
|
||||
}
|
||||
code, data = self._req(
|
||||
"POST",
|
||||
f"/repos/{owner}/{repo}/statuses/{sha}",
|
||||
body=body,
|
||||
ok_codes=(201,),
|
||||
)
|
||||
if code not in (200, 201):
|
||||
raise RuntimeError(
|
||||
f"POST statuses/{sha} → HTTP {code}: {data!r}"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config loader (PyYAML-free — config file is intentionally tiny + flat)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_config(path: str) -> dict[str, Any]:
|
||||
"""Load .gitea/sop-checklist-config.yaml.
|
||||
|
||||
Uses PyYAML if available, otherwise falls back to a built-in
|
||||
minimal parser sufficient for our flat config shape. Bundling
|
||||
PyYAML on the runner is one apt install away but we avoid the
|
||||
dep by keeping the config shape constrained.
|
||||
"""
|
||||
try:
|
||||
import yaml # type: ignore[import-not-found]
|
||||
with open(path) as f:
|
||||
return yaml.safe_load(f)
|
||||
except ImportError:
|
||||
return _load_config_minimal(path)
|
||||
|
||||
|
||||
def _load_config_minimal(path: str) -> dict[str, Any]:
|
||||
"""Minimal YAML subset parser for our config shape.
|
||||
|
||||
Supports: top-level scalar:value, top-level map-of-map (e.g.
|
||||
tier_failure_mode), top-level list of maps (items:), and within an
|
||||
item map: scalars + lists of scalars. Does NOT support nested lists,
|
||||
YAML anchors, multi-doc, or flow style.
|
||||
"""
|
||||
with open(path) as f:
|
||||
lines = f.readlines()
|
||||
return _parse_minimal_yaml(lines)
|
||||
|
||||
|
||||
def _parse_minimal_yaml(lines: list[str]) -> dict[str, Any]: # noqa: C901
|
||||
"""Hand-rolled subset parser. See _load_config_minimal docstring."""
|
||||
# Strip comments + blank lines but preserve indentation.
|
||||
cleaned: list[tuple[int, str]] = []
|
||||
for raw in lines:
|
||||
# Don't strip a "#" that is inside a quoted value.
|
||||
body = raw.rstrip("\n")
|
||||
# Remove trailing comment.
|
||||
idx = body.find("#")
|
||||
if idx >= 0 and (idx == 0 or body[idx - 1] in " \t"):
|
||||
body = body[:idx].rstrip()
|
||||
if not body.strip():
|
||||
continue
|
||||
indent = len(body) - len(body.lstrip(" "))
|
||||
cleaned.append((indent, body.strip()))
|
||||
|
||||
root: dict[str, Any] = {}
|
||||
i = 0
|
||||
n = len(cleaned)
|
||||
|
||||
def parse_scalar(s: str) -> Any:
|
||||
s = s.strip()
|
||||
if s.startswith('"') and s.endswith('"'):
|
||||
return s[1:-1]
|
||||
if s.startswith("'") and s.endswith("'"):
|
||||
return s[1:-1]
|
||||
if s.lower() in ("true", "yes"):
|
||||
return True
|
||||
if s.lower() in ("false", "no"):
|
||||
return False
|
||||
try:
|
||||
return int(s)
|
||||
except ValueError:
|
||||
pass
|
||||
return s
|
||||
|
||||
def parse_inline_list(s: str) -> list[Any]:
|
||||
s = s.strip()
|
||||
if not (s.startswith("[") and s.endswith("]")):
|
||||
return [parse_scalar(s)]
|
||||
inner = s[1:-1]
|
||||
if not inner.strip():
|
||||
return []
|
||||
return [parse_scalar(x.strip()) for x in inner.split(",")]
|
||||
|
||||
while i < n:
|
||||
indent, line = cleaned[i]
|
||||
if indent != 0:
|
||||
i += 1
|
||||
continue
|
||||
if ":" not in line:
|
||||
i += 1
|
||||
continue
|
||||
key, _, rest = line.partition(":")
|
||||
key = key.strip()
|
||||
rest = rest.strip()
|
||||
if rest == "":
|
||||
# Block — could be map or list.
|
||||
i += 1
|
||||
# Look ahead for first child.
|
||||
if i < n and cleaned[i][1].startswith("- "):
|
||||
# List of items.
|
||||
items: list[Any] = []
|
||||
while i < n and cleaned[i][0] > indent and cleaned[i][1].startswith("- "):
|
||||
item_indent = cleaned[i][0]
|
||||
first_kv = cleaned[i][1][2:].strip() # strip "- "
|
||||
item: dict[str, Any] = {}
|
||||
if ":" in first_kv:
|
||||
k, _, v = first_kv.partition(":")
|
||||
k = k.strip()
|
||||
v = v.strip()
|
||||
if v == "":
|
||||
item[k] = ""
|
||||
elif v.startswith(">-") or v.startswith(">"):
|
||||
# Folded scalar continues on subsequent indented lines
|
||||
collected: list[str] = []
|
||||
i += 1
|
||||
while i < n and cleaned[i][0] > item_indent:
|
||||
collected.append(cleaned[i][1])
|
||||
i += 1
|
||||
item[k] = " ".join(collected)
|
||||
items.append(item)
|
||||
continue
|
||||
elif v.startswith("["):
|
||||
item[k] = parse_inline_list(v)
|
||||
else:
|
||||
item[k] = parse_scalar(v)
|
||||
i += 1
|
||||
# Subsequent k:v lines at deeper indent belong to this item.
|
||||
while i < n and cleaned[i][0] > item_indent and not cleaned[i][1].startswith("- "):
|
||||
sub_indent, sub_line = cleaned[i]
|
||||
if ":" in sub_line:
|
||||
k, _, v = sub_line.partition(":")
|
||||
k = k.strip()
|
||||
v = v.strip()
|
||||
if v == "":
|
||||
item[k] = ""
|
||||
i += 1
|
||||
elif v.startswith(">-") or v.startswith(">"):
|
||||
collected = []
|
||||
i += 1
|
||||
while i < n and cleaned[i][0] > sub_indent:
|
||||
collected.append(cleaned[i][1])
|
||||
i += 1
|
||||
item[k] = " ".join(collected)
|
||||
elif v.startswith("["):
|
||||
item[k] = parse_inline_list(v)
|
||||
i += 1
|
||||
else:
|
||||
item[k] = parse_scalar(v)
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
items.append(item)
|
||||
root[key] = items
|
||||
else:
|
||||
# Sub-map.
|
||||
submap: dict[str, Any] = {}
|
||||
while i < n and cleaned[i][0] > indent:
|
||||
sub_indent, sub_line = cleaned[i]
|
||||
if ":" in sub_line:
|
||||
k, _, v = sub_line.partition(":")
|
||||
k = k.strip().strip('"').strip("'")
|
||||
v = v.strip()
|
||||
if v.startswith("[") and v.endswith("]"):
|
||||
submap[k] = parse_inline_list(v)
|
||||
else:
|
||||
submap[k] = parse_scalar(v)
|
||||
i += 1
|
||||
root[key] = submap
|
||||
else:
|
||||
# Inline scalar or list.
|
||||
if rest.startswith("[") and rest.endswith("]"):
|
||||
root[key] = parse_inline_list(rest)
|
||||
else:
|
||||
root[key] = parse_scalar(rest)
|
||||
i += 1
|
||||
return root
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_status(
|
||||
items: list[dict[str, Any]],
|
||||
ack_state: dict[str, dict[str, Any]],
|
||||
body_state: dict[str, bool],
|
||||
) -> tuple[str, str]:
|
||||
"""Return (state, description) for the commit-status post.
|
||||
|
||||
state is "success" if every item has at least one valid ack
|
||||
(body section presence is informational only — peer-ack is the
|
||||
real gate). "pending" is reserved for the soft-fail path
|
||||
(tier:low) and is set by the caller.
|
||||
"""
|
||||
n = len(items)
|
||||
fully_acked = [
|
||||
it["slug"] for it in items if ack_state[it["slug"]]["ackers"]
|
||||
]
|
||||
missing = [
|
||||
it["slug"] for it in items if not ack_state[it["slug"]]["ackers"]
|
||||
]
|
||||
missing_body = [it["slug"] for it in items if not body_state.get(it["slug"], False)]
|
||||
|
||||
desc_parts = [f"acked: {len(fully_acked)}/{n}"]
|
||||
if missing:
|
||||
# Show up to 3 missing slugs to stay inside the 140-char budget.
|
||||
shown = ", ".join(missing[:3])
|
||||
if len(missing) > 3:
|
||||
shown += f", +{len(missing) - 3}"
|
||||
desc_parts.append(f"missing: {shown}")
|
||||
if missing_body:
|
||||
desc_parts.append(f"body-unfilled: {len(missing_body)}")
|
||||
state = "success" if not missing else "failure"
|
||||
return state, " — ".join(desc_parts)
|
||||
|
||||
|
||||
def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
|
||||
"""Read tier label, return 'hard' or 'soft' per cfg.tier_failure_mode."""
|
||||
labels = pr.get("labels") or []
|
||||
tier_labels = [l.get("name", "") for l in labels if (l.get("name", "") or "").startswith("tier:")]
|
||||
mode_map = cfg.get("tier_failure_mode") or {}
|
||||
default_mode = cfg.get("default_mode", "hard")
|
||||
for tl in tier_labels:
|
||||
if tl in mode_map:
|
||||
return mode_map[tl]
|
||||
return default_mode
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--owner", required=True)
|
||||
p.add_argument("--repo", required=True)
|
||||
p.add_argument("--pr", type=int, required=True)
|
||||
p.add_argument("--config", default=".gitea/sop-checklist-config.yaml")
|
||||
p.add_argument("--gitea-host", default="git.moleculesai.app")
|
||||
p.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Compute state but do not POST the status.",
|
||||
)
|
||||
p.add_argument(
|
||||
"--status-context",
|
||||
default="sop-checklist / all-items-acked (pull_request)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--exit-on-state",
|
||||
action="store_true",
|
||||
help=(
|
||||
"If set, exit non-zero when state=failure. Default OFF so the "
|
||||
"job-level conclusion is independent of ack-state — the only "
|
||||
"thing BP sees is the POSTed status. Useful for local debugging."
|
||||
),
|
||||
)
|
||||
args = p.parse_args(argv)
|
||||
|
||||
token = os.environ.get("GITEA_TOKEN", "")
|
||||
if not token and not args.dry_run:
|
||||
print("::error::GITEA_TOKEN env required", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
cfg = load_config(args.config)
|
||||
items: list[dict[str, Any]] = cfg["items"]
|
||||
items_by_slug = {it["slug"]: it for it in items}
|
||||
numeric_aliases = {
|
||||
int(it["numeric_alias"]): it["slug"] for it in items if it.get("numeric_alias")
|
||||
}
|
||||
|
||||
client = GiteaClient(args.gitea_host, token) if token else None
|
||||
if not client:
|
||||
print("::error::No client (dry-run without token has nothing to do)", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
pr = client.get_pr(args.owner, args.repo, args.pr)
|
||||
if pr.get("state") != "open":
|
||||
print(f"::notice::PR #{args.pr} is {pr.get('state')} — gate is a no-op")
|
||||
return 0
|
||||
|
||||
author = (pr.get("user") or {}).get("login", "")
|
||||
head_sha = (pr.get("head") or {}).get("sha", "")
|
||||
body = pr.get("body", "") or ""
|
||||
|
||||
if not author or not head_sha:
|
||||
print("::error::PR payload missing user.login or head.sha", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
comments = client.get_issue_comments(args.owner, args.repo, args.pr)
|
||||
|
||||
# Build team-membership probe closure that caches results per
|
||||
# (user, team-id) so a user acking multiple items only triggers
|
||||
# one membership lookup per team.
|
||||
team_member_cache: dict[tuple[str, int], bool | None] = {}
|
||||
|
||||
def probe(slug: str, users: list[str]) -> list[str]:
|
||||
item = items_by_slug[slug]
|
||||
team_names: list[str] = item["required_teams"]
|
||||
# Resolve names → ids. NOTE: orgs/{org}/teams/search may not be
|
||||
# available — fall back to the list endpoint.
|
||||
team_ids: list[int] = []
|
||||
for tn in team_names:
|
||||
tid = client.resolve_team_id(args.owner, tn)
|
||||
if tid is None:
|
||||
# Try the list endpoint as a fallback.
|
||||
code, data = client._req( # noqa: SLF001
|
||||
"GET", f"/orgs/{args.owner}/teams"
|
||||
)
|
||||
if code == 200 and isinstance(data, list):
|
||||
for t in data:
|
||||
if t.get("name") == tn:
|
||||
tid = t.get("id")
|
||||
client._team_id_cache[(args.owner, tn)] = tid # noqa: SLF001
|
||||
break
|
||||
if tid is not None:
|
||||
team_ids.append(tid)
|
||||
else:
|
||||
print(
|
||||
f"::warning::could not resolve team-id for '{tn}' "
|
||||
f"in org '{args.owner}' — item '{slug}' will fail closed",
|
||||
file=sys.stderr,
|
||||
)
|
||||
approved: list[str] = []
|
||||
for u in users:
|
||||
for tid in team_ids:
|
||||
cache_key = (u, tid)
|
||||
if cache_key not in team_member_cache:
|
||||
team_member_cache[cache_key] = client.is_team_member(tid, u)
|
||||
result = team_member_cache[cache_key]
|
||||
if result is True:
|
||||
approved.append(u)
|
||||
break
|
||||
if result is None:
|
||||
print(
|
||||
f"::warning::team-probe for {u} in team-id {tid} returned 403 "
|
||||
"(token owner not in that team — fail-closed per RFC#324)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
# Treat as not-in-team for this user/team pair; loop
|
||||
# may still find membership in another team.
|
||||
return approved
|
||||
|
||||
ack_state = compute_ack_state(comments, author, items_by_slug, numeric_aliases, probe)
|
||||
body_state = {it["slug"]: section_marker_present(body, it["pr_section_marker"]) for it in items}
|
||||
|
||||
state, description = render_status(items, ack_state, body_state)
|
||||
mode = get_tier_mode(pr, cfg)
|
||||
if state == "failure" and mode == "soft":
|
||||
state = "pending"
|
||||
description = f"[soft-fail tier:low] {description}"
|
||||
|
||||
# Diagnostics to job log.
|
||||
print(f"::notice::PR #{args.pr} author={author} head={head_sha[:7]} mode={mode}")
|
||||
for it in items:
|
||||
slug = it["slug"]
|
||||
ackers = ack_state[slug]["ackers"]
|
||||
if ackers:
|
||||
print(f"::notice:: [PASS] {slug} — acked by {','.join(ackers)}")
|
||||
else:
|
||||
r = ack_state[slug]["rejected"]
|
||||
extras: list[str] = []
|
||||
if r["self_ack"]:
|
||||
extras.append(f"self-acks-rejected:{','.join(r['self_ack'])}")
|
||||
if r["not_in_team"]:
|
||||
extras.append(f"not-in-team:{','.join(r['not_in_team'])}")
|
||||
extra = " (" + "; ".join(extras) + ")" if extras else ""
|
||||
print(f"::notice:: [WAIT] {slug} — no valid peer-ack yet{extra}")
|
||||
|
||||
print(f"::notice::posting status: state={state} desc={description!r}")
|
||||
|
||||
if args.dry_run:
|
||||
print("::notice::--dry-run: not posting status")
|
||||
if args.exit_on_state:
|
||||
return 0 if state in ("success", "pending") else 1
|
||||
return 0
|
||||
|
||||
target_url = f"https://{args.gitea_host}/{args.owner}/{args.repo}/pulls/{args.pr}"
|
||||
client.post_status(
|
||||
args.owner, args.repo, head_sha,
|
||||
state=state, context=args.status_context,
|
||||
description=description, target_url=target_url,
|
||||
)
|
||||
print(f"::notice::status posted: {args.status_context} → {state}")
|
||||
# By default exit 0 — the POSTed status IS the gate, NOT the job
|
||||
# conclusion. If the job exits 1 BP will see TWO failure signals
|
||||
# (one from the job's auto-status, one from our POST), making the
|
||||
# description less actionable. --exit-on-state restores the old
|
||||
# behavior for local debugging.
|
||||
if args.exit_on_state:
|
||||
return 0 if state in ("success", "pending") else 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -96,27 +96,16 @@ API="https://${GITEA_HOST}/api/v1"
|
||||
AUTH="Authorization: token ${GITEA_TOKEN}"
|
||||
echo "::notice::tier-check start: repo=$OWNER/$NAME pr=$PR_NUMBER author=$PR_AUTHOR"
|
||||
|
||||
# Sanity: token resolves to a user.
|
||||
# Use || true on the jq pipeline so that set -euo pipefail (line 45) does not
|
||||
# cause the script to exit prematurely when the token is empty/invalid — the
|
||||
# if check below handles that case gracefully. Without || true, a 401 from an
|
||||
# empty/invalid token causes jq to exit 1, triggering set -e and exiting the
|
||||
# entire script before SOP_FAIL_OPEN can be evaluated (the check is in the jq-
|
||||
# install block; if jq is already on PATH, that block is skipped entirely).
|
||||
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""') || true
|
||||
# Sanity: token resolves to a user
|
||||
WHOAMI=$(curl -sS -H "$AUTH" "${API}/user" | jq -r '.login // ""')
|
||||
if [ -z "$WHOAMI" ]; then
|
||||
echo "::error::GITEA_TOKEN cannot resolve a user via /api/v1/user — check the token scope and that the secret is wired correctly."
|
||||
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
|
||||
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::token resolves to user: $WHOAMI"
|
||||
|
||||
# 1. Read tier label. || true ensures set -euo pipefail does not abort the
|
||||
# script if curl or jq fails (e.g. 401 from empty token).
|
||||
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name') || true
|
||||
# 1. Read tier label
|
||||
LABELS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/labels" | jq -r '.[].name')
|
||||
TIER=""
|
||||
for L in $LABELS; do
|
||||
case "$L" in
|
||||
@@ -187,25 +176,17 @@ fi
|
||||
# 4. Resolve all team names → IDs
|
||||
# /orgs/{org}/teams/{slug}/... endpoints don't exist on Gitea 1.22;
|
||||
# we use /teams/{id}.
|
||||
# set +e prevents set -e from aborting the script if curl fails (e.g. empty token).
|
||||
ORG_TEAMS_FILE=$(mktemp)
|
||||
trap 'rm -f "$ORG_TEAMS_FILE"' EXIT
|
||||
set +e
|
||||
HTTP_CODE=$(curl -sS -o "$ORG_TEAMS_FILE" -w '%{http_code}' -H "$AUTH" \
|
||||
"${API}/orgs/${OWNER}/teams")
|
||||
_HTTP_EXIT=$?
|
||||
set -e
|
||||
debug "teams-list HTTP=$HTTP_CODE (curl exit=$_HTTP_EXIT) size=$(wc -c <"$ORG_TEAMS_FILE")"
|
||||
debug "teams-list HTTP=$HTTP_CODE size=$(wc -c <"$ORG_TEAMS_FILE")"
|
||||
if [ "${SOP_DEBUG:-}" = "1" ]; then
|
||||
echo " [debug] teams-list body (first 300 chars):" >&2
|
||||
head -c 300 "$ORG_TEAMS_FILE" >&2; echo >&2
|
||||
fi
|
||||
if [ "$_HTTP_EXIT" -ne 0 ] || [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::error::GET /orgs/${OWNER}/teams failed (curl exit=$_HTTP_EXIT HTTP=$HTTP_CODE) — token may lack read:org scope or be invalid."
|
||||
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
|
||||
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
|
||||
exit 0
|
||||
fi
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "::error::GET /orgs/${OWNER}/teams returned HTTP $HTTP_CODE — token likely lacks read:org scope."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -250,22 +231,9 @@ for _t in $_all_teams; do
|
||||
debug "team-id: $_t → $_id"
|
||||
done
|
||||
|
||||
# 5. Read approving reviewers. set +e disables set -e temporarily so that curl
|
||||
# failures (e.g. empty/invalid token → HTTP 401) do not abort the script before
|
||||
# SOP_FAIL_OPEN is evaluated. set -e is restored immediately after.
|
||||
set +e
|
||||
# 5. Read approving reviewers
|
||||
REVIEWS=$(curl -sS -H "$AUTH" "${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews")
|
||||
_REVIEWS_EXIT=$?
|
||||
set -e
|
||||
if [ $_REVIEWS_EXIT -ne 0 ] || [ -z "$REVIEWS" ]; then
|
||||
echo "::error::Failed to fetch reviews (curl exit=$_REVIEWS_EXIT) — token may be invalid or unreachable."
|
||||
if [ "${SOP_FAIL_OPEN:-}" = "1" ]; then
|
||||
echo "::warning::SOP_FAIL_OPEN=1 — exiting 0 so CI does not block."
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]') || true
|
||||
APPROVERS=$(echo "$REVIEWS" | jq -r '[.[] | select(.state=="APPROVED") | .user.login] | unique | .[]')
|
||||
if [ -z "$APPROVERS" ]; then
|
||||
echo "::error::No approving reviews on this PR. Set SOP_DEBUG=1 and re-run for diagnostics."
|
||||
exit 1
|
||||
|
||||
@@ -1,699 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""status-reaper — Option B compensating-status POST for Gitea 1.22.6's
|
||||
hardcoded `(push)` suffix on default-branch commit statuses.
|
||||
|
||||
Tracking: this PR (workflow + script + tests + audit issue). Sibling
|
||||
bots: internal#327 (publish-runtime-bot), internal#328 (mc-drift-bot).
|
||||
Upstream RFC: internal#80. Persona provisioned by sub-agent aefaac1b
|
||||
(2026-05-11 21:39Z; Gitea uid 94, scope=write:repository).
|
||||
|
||||
What this script does, per `.gitea/workflows/status-reaper.yml` invocation:
|
||||
|
||||
1. Walk `.gitea/workflows/*.yml`. For each file, build the workflow_id
|
||||
using this resolution (per hongming-pc 22:08Z review):
|
||||
- If YAML has top-level `name:` → use that.
|
||||
- Else → use filename stem (basename minus `.yml`).
|
||||
Fail-LOUD on:
|
||||
- Two workflows resolving to the SAME identifier (collision).
|
||||
- Any identifier containing `/` (it would break context parsing
|
||||
downstream — Gitea uses ` / ` as the workflow/job separator).
|
||||
Classify each by whether `on:` contains a `push:` trigger.
|
||||
|
||||
2. List the last N (=30, rev3 — widened from 10) commits on
|
||||
WATCH_BRANCH via GET /repos/{o}/{r}/commits?sha={branch}&limit={N}.
|
||||
rev2 sweeps N commits per tick instead of HEAD only — schedule
|
||||
workflows post `failure` to whatever SHA was HEAD when they
|
||||
COMPLETED, so by the next */5 tick main has often moved forward
|
||||
and the red gets stranded on a stale commit. rev3 widens the
|
||||
window from 10 → 30 because schedule workflows post `failure`
|
||||
RETROACTIVELY (5-15 min after their merge); a 10-commit window
|
||||
is narrower than the merge-cadence during a burst, so reds land
|
||||
OUTSIDE the window before reaper sees them (Phase 1+2 evidence:
|
||||
rev2 run 17057 at 02:46Z saw 185/0 contexts on 10 SHAs; direct
|
||||
probe ~30min later showed ~25 fails on those same 10 SHAs).
|
||||
|
||||
3. For EACH SHA in the list:
|
||||
- GET combined commit status. Per-SHA error isolation
|
||||
(refinement #7): if this call raises ApiError or any 5xx,
|
||||
LOG `::warning::` + continue to the next SHA. Different from
|
||||
the single-HEAD pre-rev2 path where fail-loud was correct;
|
||||
the sweep is best-effort across historical commits, so one
|
||||
transient blip on a stale SHA must not strand reds on the
|
||||
OTHER stale SHAs.
|
||||
- If combined.state == "success": skip — cost optimization
|
||||
(refinement #2), common case (most commits are green).
|
||||
- Otherwise iterate per-context entries. For each entry where:
|
||||
state == "failure" AND context.endswith(" (push)")
|
||||
Parse context as `<workflow_name> / <job_name> (push)`.
|
||||
Look up workflow_name in the trigger map:
|
||||
- missing → log ::notice:: and skip (conservative).
|
||||
- has_push_trigger=True → preserve (real defect signal).
|
||||
- has_push_trigger=False → POST a compensating
|
||||
`state=success` status to /statuses/{sha} with the same
|
||||
context (Gitea de-dups by context) and a description
|
||||
documenting the workaround + this script's path.
|
||||
|
||||
4. Exit 0. Re-running is idempotent — Gitea's commit-status table
|
||||
stores the LATEST state-per-context, so the success POST sticks
|
||||
even if another tick happens before the runner finishes.
|
||||
|
||||
What it does NOT do:
|
||||
- Touch any context NOT ending in ` (push)`. The required-checks on
|
||||
main (verified 2026-05-11) all have ` (pull_request)` suffixes;
|
||||
they CANNOT be reached by this code path.
|
||||
- Compensate `error`/`pending` states. Only `failure` — the only one
|
||||
Gitea emits for the hardcoded-suffix bug.
|
||||
- Write to non-default branches. WATCH_BRANCH is sourced from
|
||||
`github.event.repository.default_branch` in the workflow.
|
||||
- Mutate workflows or runs. The Actions UI still shows the
|
||||
underlying schedule-triggered run as failed; this script edits
|
||||
the commit-status surface only.
|
||||
|
||||
Halt conditions (script-level — orchestrator-level halts are in the
|
||||
workflow comments):
|
||||
- PyYAML missing → fail-loud at import (no fallback parse).
|
||||
- Workflow `name:` collision → exit 1 with ::error:: message.
|
||||
- Workflow `name:` containing `/` → exit 1 with ::error:: message.
|
||||
- Ambiguous `on:` shape (e.g. neither str/list/dict) → treat as
|
||||
"has_push_trigger=True" and log ::notice:: (preserve, never
|
||||
compensate the unknown).
|
||||
- api() non-2xx → raise ApiError, fail the workflow run loudly so
|
||||
a subsequent tick retries (per
|
||||
`feedback_api_helper_must_raise_not_return_dict`).
|
||||
|
||||
Local dry-run (no network):
|
||||
GITEA_TOKEN=... GITEA_HOST=git.moleculesai.app REPO=owner/repo \\
|
||||
WATCH_BRANCH=main WORKFLOWS_DIR=.gitea/workflows \\
|
||||
python3 .gitea/scripts/status-reaper.py --dry-run
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml # PyYAML 6.0.2 — installed by the workflow before this runs.
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Environment
|
||||
# --------------------------------------------------------------------------
|
||||
def _env(key: str, *, default: str = "") -> str:
|
||||
"""Read an env var with a default. Module-import-safe — tests can
|
||||
import this script without setting the full env contract."""
|
||||
return os.environ.get(key, default)
|
||||
|
||||
|
||||
GITEA_TOKEN = _env("GITEA_TOKEN")
|
||||
GITEA_HOST = _env("GITEA_HOST")
|
||||
REPO = _env("REPO")
|
||||
WATCH_BRANCH = _env("WATCH_BRANCH", default="main")
|
||||
WORKFLOWS_DIR = _env("WORKFLOWS_DIR", default=".gitea/workflows")
|
||||
|
||||
OWNER, NAME = (REPO.split("/", 1) + [""])[:2] if REPO else ("", "")
|
||||
API = f"https://{GITEA_HOST}/api/v1" if GITEA_HOST else ""
|
||||
|
||||
# Compensating-status description prefix. Used as the marker so a human
|
||||
# auditing commit statuses can tell at a glance that the green was
|
||||
# synthetic, not a real CI pass. Kept stable; downstream tooling
|
||||
# (e.g. main-red-watchdog visual diff) MAY key on it.
|
||||
COMPENSATION_DESCRIPTION = (
|
||||
"Compensated by status-reaper (workflow has no push: trigger; "
|
||||
"Gitea 1.22.6 hardcoded-suffix bug — see .gitea/scripts/status-reaper.py)"
|
||||
)
|
||||
|
||||
# Context suffix the reaper acts on. Gitea hardcodes this for ALL
|
||||
# default-branch workflow runs.
|
||||
PUSH_SUFFIX = " (push)"
|
||||
|
||||
|
||||
def _require_runtime_env() -> None:
|
||||
"""Enforce env contract — called from `main()` only.
|
||||
|
||||
Tests import individual functions without setting the full env
|
||||
contract. Mirrors `main-red-watchdog.py`/`ci-required-drift.py`.
|
||||
"""
|
||||
for key in ("GITEA_TOKEN", "GITEA_HOST", "REPO", "WATCH_BRANCH", "WORKFLOWS_DIR"):
|
||||
if not os.environ.get(key):
|
||||
sys.stderr.write(f"::error::missing required env var: {key}\n")
|
||||
sys.exit(2)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tiny HTTP helper — raises on non-2xx + on JSON-decode-of-expected-JSON.
|
||||
# --------------------------------------------------------------------------
|
||||
class ApiError(RuntimeError):
|
||||
"""Raised when a Gitea API call cannot be trusted to have succeeded.
|
||||
|
||||
Per `feedback_api_helper_must_raise_not_return_dict`: soft-failure is
|
||||
opt-in via `expect_json=False`, never the default. A pre-fix
|
||||
implementation that returned `{}` on non-2xx would skip the
|
||||
compensating POST on a transient outage AND silently lose the
|
||||
failed-status enumeration, painting main green via omission.
|
||||
"""
|
||||
|
||||
|
||||
def api(
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
body: dict | None = None,
|
||||
query: dict[str, str] | None = None,
|
||||
expect_json: bool = True,
|
||||
) -> tuple[int, Any]:
|
||||
"""Tiny HTTP helper around urllib. Same contract as
|
||||
`main-red-watchdog.py` and `ci-required-drift.py` so behaviour
|
||||
is cross-checkable."""
|
||||
url = f"{API}{path}"
|
||||
if query:
|
||||
url = f"{url}?{urllib.parse.urlencode(query)}"
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": f"token {GITEA_TOKEN}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if body is not None:
|
||||
data = json.dumps(body).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
req = urllib.request.Request(url, method=method, data=data, headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
raw = resp.read()
|
||||
status = resp.status
|
||||
except urllib.error.HTTPError as e:
|
||||
raw = e.read()
|
||||
status = e.code
|
||||
|
||||
if not (200 <= status < 300):
|
||||
snippet = raw[:500].decode("utf-8", errors="replace") if raw else ""
|
||||
raise ApiError(f"{method} {path} -> HTTP {status}: {snippet}")
|
||||
|
||||
if not raw:
|
||||
return status, None
|
||||
try:
|
||||
return status, json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
if expect_json:
|
||||
raise ApiError(
|
||||
f"{method} {path} -> HTTP {status} but body is not JSON: {e}"
|
||||
) from e
|
||||
return status, {"_raw": raw.decode("utf-8", errors="replace")}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Workflow scan + classification
|
||||
# --------------------------------------------------------------------------
|
||||
def _on_block(doc: dict) -> Any:
|
||||
"""Extract the `on:` block from a parsed YAML doc.
|
||||
|
||||
PyYAML parses bareword `on:` as Python `True` (YAML 1.1 boolean
|
||||
spec — `on/off/yes/no` are booleans). The actual key in the dict
|
||||
is therefore `True`, NOT the string `"on"`. We accept both for
|
||||
forward-compat with YAML 1.2 loaders (which keep it as `"on"`).
|
||||
"""
|
||||
if True in doc:
|
||||
return doc[True]
|
||||
return doc.get("on")
|
||||
|
||||
|
||||
def _has_push_trigger(on_block: Any, workflow_id: str) -> bool:
|
||||
"""Return True if `on:` block declares a `push` trigger.
|
||||
|
||||
Accepts the three common shapes:
|
||||
- str: `on: push` → True only if == "push"
|
||||
- list: `on: [push, pull_request]` → True if "push" in list
|
||||
- dict: `on: { push: {...}, schedule: ... }` → True if "push" key
|
||||
|
||||
Defensive: for anything else (including None/empty), return True
|
||||
so we preserve rather than over-compensate. Logged via ::notice::.
|
||||
"""
|
||||
if isinstance(on_block, str):
|
||||
return on_block == "push"
|
||||
if isinstance(on_block, list):
|
||||
return "push" in on_block
|
||||
if isinstance(on_block, dict):
|
||||
return "push" in on_block
|
||||
# None or unexpected shape — preserve, log.
|
||||
print(
|
||||
f"::notice::ambiguous on: for {workflow_id}; preserving "
|
||||
f"(value={on_block!r}, type={type(on_block).__name__})"
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def scan_workflows(workflows_dir: str) -> dict[str, bool]:
|
||||
"""Walk `workflows_dir` and return `{workflow_id: has_push_trigger}`.
|
||||
|
||||
Workflow ID resolution (per hongming-pc 22:08Z review):
|
||||
- Top-level `name:` if present.
|
||||
- Else filename stem (basename minus `.yml`).
|
||||
|
||||
Fail-LOUD on:
|
||||
- Two workflows resolving to the same ID (collision).
|
||||
- Any ID containing `/` (would break ` / `-separated context
|
||||
parsing on the downstream side).
|
||||
|
||||
Returns a dict for O(1) lookup in the per-status loop.
|
||||
"""
|
||||
path = Path(workflows_dir)
|
||||
if not path.is_dir():
|
||||
# Workflow dir missing → no workflows to classify. Empty map is
|
||||
# safe: per-status loop will hit "unknown workflow; skip" for
|
||||
# every entry, which is correct (we cannot tell if a push
|
||||
# trigger exists, so we preserve).
|
||||
print(f"::warning::workflows dir not found: {workflows_dir}")
|
||||
return {}
|
||||
|
||||
out: dict[str, bool] = {}
|
||||
sources: dict[str, str] = {} # workflow_id -> source file (for collision msg)
|
||||
|
||||
for yml in sorted(path.glob("*.yml")):
|
||||
try:
|
||||
with yml.open() as f:
|
||||
doc = yaml.safe_load(f)
|
||||
except yaml.YAMLError as e:
|
||||
# A malformed YAML in the workflows dir is a real defect
|
||||
# (the workflow wouldn't load on Gitea either). Surface it
|
||||
# and keep going — the reaper's job is to compensate the
|
||||
# OTHER workflows even if one is broken.
|
||||
print(f"::warning::yaml parse failed for {yml.name}: {e}; skip")
|
||||
continue
|
||||
if not isinstance(doc, dict):
|
||||
print(f"::warning::workflow {yml.name} not a dict; skip")
|
||||
continue
|
||||
|
||||
# Resolve workflow_id.
|
||||
name_field = doc.get("name")
|
||||
if isinstance(name_field, str) and name_field.strip():
|
||||
workflow_id = name_field.strip()
|
||||
else:
|
||||
workflow_id = yml.stem # basename minus .yml
|
||||
|
||||
# Halt-loud: `/` in workflow_id breaks ` / ` context parsing.
|
||||
if "/" in workflow_id:
|
||||
sys.stderr.write(
|
||||
f"::error::workflow name contains '/' which breaks "
|
||||
f"context parsing: {workflow_id} (file={yml.name})\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Halt-loud: ID collision.
|
||||
if workflow_id in out:
|
||||
sys.stderr.write(
|
||||
f"::error::workflow name collision detected: {workflow_id} "
|
||||
f"(files: {sources[workflow_id]} + {yml.name})\n"
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
on_block = _on_block(doc)
|
||||
out[workflow_id] = _has_push_trigger(on_block, workflow_id)
|
||||
sources[workflow_id] = yml.name
|
||||
|
||||
return out
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gitea reads
|
||||
# --------------------------------------------------------------------------
|
||||
def get_head_sha(branch: str) -> str:
|
||||
"""HEAD SHA of `branch`. Raises ApiError on non-2xx."""
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/branches/{branch}")
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(f"branch {branch} response not a JSON object")
|
||||
commit = body.get("commit")
|
||||
if not isinstance(commit, dict):
|
||||
raise ApiError(f"branch {branch} response missing `commit` object")
|
||||
sha = commit.get("id") or commit.get("sha")
|
||||
if not isinstance(sha, str) or len(sha) < 7:
|
||||
raise ApiError(f"branch {branch} response has no usable commit SHA")
|
||||
return sha
|
||||
|
||||
|
||||
def get_combined_status(sha: str) -> dict:
|
||||
"""Combined commit status for `sha`. Gitea returns:
|
||||
{
|
||||
"state": "success" | "failure" | "pending" | "error",
|
||||
"statuses": [
|
||||
{"context": "...", "state": "...", "target_url": "...",
|
||||
"description": "..."},
|
||||
...
|
||||
],
|
||||
...
|
||||
}
|
||||
Raises ApiError on non-2xx.
|
||||
"""
|
||||
_, body = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
|
||||
if not isinstance(body, dict):
|
||||
raise ApiError(f"status for {sha} response not a JSON object")
|
||||
return body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Context parsing
|
||||
# --------------------------------------------------------------------------
|
||||
def parse_push_context(context: str) -> tuple[str, str] | None:
|
||||
"""Parse `<workflow_name> / <job_name> (push)` into
|
||||
(workflow_name, job_name).
|
||||
|
||||
Returns None if the context doesn't match the shape (caller skips).
|
||||
Strict: requires the trailing ` (push)` and at least one ` / `
|
||||
separator. Anything else is left alone.
|
||||
"""
|
||||
if not context.endswith(PUSH_SUFFIX):
|
||||
return None
|
||||
head = context[: -len(PUSH_SUFFIX)] # strip " (push)"
|
||||
if " / " not in head:
|
||||
# No workflow/job separator — not the bug shape we compensate.
|
||||
return None
|
||||
workflow_name, job_name = head.split(" / ", 1)
|
||||
return workflow_name, job_name
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Compensating POST
|
||||
# --------------------------------------------------------------------------
|
||||
def post_compensating_status(
|
||||
sha: str,
|
||||
context: str,
|
||||
target_url: str | None,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> None:
|
||||
"""POST a `state=success` to /repos/{o}/{r}/statuses/{sha} with the
|
||||
given context. Gitea de-dups by context (latest write wins).
|
||||
|
||||
Description references this script so the compensation is
|
||||
self-documenting on the commit's status view.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"context": context,
|
||||
"state": "success",
|
||||
"description": COMPENSATION_DESCRIPTION,
|
||||
}
|
||||
# Echo the original target_url when present so a human auditing
|
||||
# the (now-green) compensated status can still reach the run logs
|
||||
# that produced the original red.
|
||||
if target_url:
|
||||
payload["target_url"] = target_url
|
||||
|
||||
if dry_run:
|
||||
print(
|
||||
f"::notice::[dry-run] would compensate {context!r} on {sha[:10]} "
|
||||
f"with state=success"
|
||||
)
|
||||
return
|
||||
|
||||
api("POST", f"/repos/{OWNER}/{NAME}/statuses/{sha}", body=payload)
|
||||
print(f"::notice::compensated {context!r} on {sha[:10]} (state=success)")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Main reap loop
|
||||
# --------------------------------------------------------------------------
|
||||
def reap(
|
||||
workflow_trigger_map: dict[str, bool],
|
||||
combined: dict,
|
||||
sha: str,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Walk `combined.statuses[]` and compensate where appropriate.
|
||||
|
||||
Per-SHA worker. The multi-SHA orchestrator (`reap_branch`) calls
|
||||
this once per stale main commit each tick.
|
||||
|
||||
Returns counters for observability:
|
||||
{compensated, preserved_real_push, preserved_unknown,
|
||||
preserved_non_failure, preserved_non_push_suffix,
|
||||
preserved_unparseable,
|
||||
compensated_contexts: [<context>, ...]}
|
||||
|
||||
`compensated_contexts` is rev2-added so `reap_branch` can build
|
||||
`compensated_per_sha` without re-deriving it from the POST stream.
|
||||
"""
|
||||
counters: dict[str, Any] = {
|
||||
"compensated": 0,
|
||||
"preserved_real_push": 0,
|
||||
"preserved_unknown": 0,
|
||||
"preserved_non_failure": 0,
|
||||
"preserved_non_push_suffix": 0,
|
||||
"preserved_unparseable": 0,
|
||||
"compensated_contexts": [],
|
||||
}
|
||||
|
||||
statuses = combined.get("statuses") or []
|
||||
for s in statuses:
|
||||
if not isinstance(s, dict):
|
||||
continue
|
||||
context = s.get("context") or ""
|
||||
# Schema asymmetry: Gitea 1.22.6 returns the TOP-LEVEL combined
|
||||
# aggregate as `combined.state` but each per-context entry in
|
||||
# `combined.statuses[]` uses the key `status`, NOT `state`.
|
||||
# Prefer `status`; fall back to `state` so a future Gitea
|
||||
# version (or a test fixture written against the wrong key)
|
||||
# still flows through the compensation path. Verified empirically
|
||||
# via direct API probe 2026-05-12 03:42Z:
|
||||
# /repos/.../commits/{sha}/status entries → key is "status".
|
||||
# Pre-rev4 code read "state" only → returned "" → bypassed the
|
||||
# `state != "failure"` guard → compensation path unreachable.
|
||||
# See `feedback_smoke_test_vendor_truth_not_shape_match`.
|
||||
state = s.get("status") or s.get("state") or ""
|
||||
|
||||
# Only `failure` is the bug shape. `error`/`pending`/`success`
|
||||
# left alone — they have other meanings.
|
||||
if state != "failure":
|
||||
counters["preserved_non_failure"] += 1
|
||||
continue
|
||||
|
||||
# Only `(push)`-suffix contexts hit the hardcoded-suffix bug.
|
||||
# Branch-protection required checks (e.g. `Secret scan / Scan
|
||||
# diff (pull_request)`) are NOT reachable from this path.
|
||||
if not context.endswith(PUSH_SUFFIX):
|
||||
counters["preserved_non_push_suffix"] += 1
|
||||
continue
|
||||
|
||||
parsed = parse_push_context(context)
|
||||
if parsed is None:
|
||||
# Has ` (push)` suffix but missing ` / ` separator — not
|
||||
# the bug shape. Preserve.
|
||||
counters["preserved_unparseable"] += 1
|
||||
continue
|
||||
workflow_name, _job_name = parsed
|
||||
|
||||
if workflow_name not in workflow_trigger_map:
|
||||
# Real workflow but renamed/deleted/external — we can't
|
||||
# tell if it has push trigger. Conservative: preserve.
|
||||
print(f"::notice::unknown workflow {workflow_name!r}; skip")
|
||||
counters["preserved_unknown"] += 1
|
||||
continue
|
||||
|
||||
if workflow_trigger_map[workflow_name]:
|
||||
# Real push trigger → real defect signal. Preserve.
|
||||
counters["preserved_real_push"] += 1
|
||||
continue
|
||||
|
||||
# Class-O: schedule/dispatch/etc.-only workflow with a fake
|
||||
# (push) status from Gitea's hardcoded-suffix bug. Compensate.
|
||||
post_compensating_status(
|
||||
sha, context, s.get("target_url"), dry_run=dry_run
|
||||
)
|
||||
counters["compensated"] += 1
|
||||
counters["compensated_contexts"].append(context)
|
||||
|
||||
return counters
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# rev2: multi-SHA sweep over the last N commits on WATCH_BRANCH
|
||||
# --------------------------------------------------------------------------
|
||||
# How many main commits to sweep per tick. Sized to cover a burst-merge
|
||||
# window where multiple PRs land in the 5-min interval between reaper
|
||||
# ticks. Older reds falling off the window is acceptable — they were
|
||||
# already stale enough that the schedule-run that posted them has long
|
||||
# since been overwritten by a real push trigger. See `reference_post_
|
||||
# suspension_pipeline` for the merge-cadence baseline.
|
||||
#
|
||||
# rev3 (2026-05-12, hongming-pc2 GO 03:25Z): widened from 10 → 30.
|
||||
# rev2 (limit=10) shipped 01:48Z and ran 6/6 ticks post-merge with
|
||||
# `compensated:0` despite ~25 stranded reds visible on those same 10
|
||||
# SHAs ~30min later. Root cause: schedule workflows post `failure`
|
||||
# RETROACTIVELY 5-15 min after their merge, so by the time reaper's
|
||||
# next */5 tick lands, the stranded red is on a SHA that has already
|
||||
# fallen out of a 10-commit window during a burst-merge period.
|
||||
# Trades window-width-cheap for cadence-loady (per hongming-pc2):
|
||||
# kept `*/5` cron unchanged; only the window-N is widened.
|
||||
DEFAULT_SWEEP_LIMIT = 30
|
||||
|
||||
|
||||
def list_recent_commit_shas(branch: str, limit: int) -> list[str]:
|
||||
"""List the most recent `limit` commit SHAs on `branch`, newest
|
||||
first.
|
||||
|
||||
Wraps GET /repos/{o}/{r}/commits?sha={branch}&limit={limit}. Gitea
|
||||
1.22.6 returns a JSON list of commit objects each with a `sha` key
|
||||
(verified via vendor-truth probe 2026-05-11 against
|
||||
git.moleculesai.app — `feedback_smoke_test_vendor_truth_not_shape_match`).
|
||||
|
||||
Raises ApiError on non-2xx OR on unexpected response shape. This is
|
||||
a HARD halt — without the commit list the sweep can't proceed. (The
|
||||
per-SHA error isolation downstream is a different concern: tolerating
|
||||
a transient 5xx on ONE commit's status is best-effort; losing the
|
||||
commit list itself means we don't even know which commits to try.)
|
||||
"""
|
||||
_, body = api(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/commits",
|
||||
query={"sha": branch, "limit": str(limit)},
|
||||
)
|
||||
if not isinstance(body, list):
|
||||
raise ApiError(
|
||||
f"commits listing for {branch} not a JSON array "
|
||||
f"(got {type(body).__name__})"
|
||||
)
|
||||
shas: list[str] = []
|
||||
for entry in body:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
sha = entry.get("sha")
|
||||
if isinstance(sha, str) and len(sha) >= 7:
|
||||
shas.append(sha)
|
||||
if not shas:
|
||||
raise ApiError(
|
||||
f"commits listing for {branch} returned no usable SHAs"
|
||||
)
|
||||
return shas
|
||||
|
||||
|
||||
def reap_branch(
|
||||
workflow_trigger_map: dict[str, bool],
|
||||
branch: str,
|
||||
*,
|
||||
limit: int = DEFAULT_SWEEP_LIMIT,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Sweep the last `limit` commits on `branch`, applying `reap()`
|
||||
to each (with per-SHA error isolation).
|
||||
|
||||
Returns aggregated counters PLUS rev2 observability fields:
|
||||
- scanned_shas: how many SHAs we actually iterated
|
||||
- compensated_per_sha: {<sha_full>: [<context>, ...]} — only
|
||||
SHAs that actually got at least one compensation are included
|
||||
"""
|
||||
shas = list_recent_commit_shas(branch, limit)
|
||||
|
||||
aggregate: dict[str, Any] = {
|
||||
"scanned_shas": 0,
|
||||
"compensated": 0,
|
||||
"preserved_real_push": 0,
|
||||
"preserved_unknown": 0,
|
||||
"preserved_non_failure": 0,
|
||||
"preserved_non_push_suffix": 0,
|
||||
"preserved_unparseable": 0,
|
||||
"compensated_per_sha": {},
|
||||
}
|
||||
|
||||
for sha in shas:
|
||||
aggregate["scanned_shas"] += 1
|
||||
|
||||
# Per-SHA error isolation (refinement #7). One transient blip
|
||||
# on a historical commit must NOT abort the whole tick — the
|
||||
# OTHER stale SHAs may still hold strandable reds.
|
||||
try:
|
||||
combined = get_combined_status(sha)
|
||||
except ApiError as e:
|
||||
print(
|
||||
f"::warning::get_combined_status({sha[:10]}) failed; "
|
||||
f"skipping this SHA: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Cost optimization (refinement #2): the common case is a green
|
||||
# commit. Skip the per-context loop entirely when combined is
|
||||
# already success — saves a tight loop over ~20 statuses per SHA
|
||||
# on green commits, the dominant majority.
|
||||
if combined.get("state") == "success":
|
||||
continue
|
||||
|
||||
per_sha = reap(
|
||||
workflow_trigger_map, combined, sha, dry_run=dry_run
|
||||
)
|
||||
|
||||
# Aggregate scalar counters.
|
||||
for key in (
|
||||
"compensated",
|
||||
"preserved_real_push",
|
||||
"preserved_unknown",
|
||||
"preserved_non_failure",
|
||||
"preserved_non_push_suffix",
|
||||
"preserved_unparseable",
|
||||
):
|
||||
aggregate[key] += per_sha[key]
|
||||
|
||||
# Record per-SHA compensated contexts (only when non-empty —
|
||||
# keep the summary readable when most SHAs are no-ops).
|
||||
contexts = per_sha.get("compensated_contexts") or []
|
||||
if contexts:
|
||||
aggregate["compensated_per_sha"][sha] = list(contexts)
|
||||
|
||||
return aggregate
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Skip the compensating POST; print what would be done.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--limit",
|
||||
type=int,
|
||||
default=DEFAULT_SWEEP_LIMIT,
|
||||
help=(
|
||||
"How many recent commits on WATCH_BRANCH to sweep per tick "
|
||||
f"(default: {DEFAULT_SWEEP_LIMIT})."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
_require_runtime_env()
|
||||
|
||||
workflow_trigger_map = scan_workflows(WORKFLOWS_DIR)
|
||||
print(
|
||||
f"::notice::scanned {len(workflow_trigger_map)} workflows; "
|
||||
f"push-triggered={sum(1 for v in workflow_trigger_map.values() if v)}, "
|
||||
f"class-O candidates={sum(1 for v in workflow_trigger_map.values() if not v)}"
|
||||
)
|
||||
|
||||
counters = reap_branch(
|
||||
workflow_trigger_map,
|
||||
WATCH_BRANCH,
|
||||
limit=args.limit,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
# Observability: print one JSON line summarising the tick. Loki
|
||||
# ingestion via the runner's stdout (`source="gitea-actions"`).
|
||||
print(
|
||||
"status-reaper summary: "
|
||||
+ json.dumps(
|
||||
{
|
||||
"branch": WATCH_BRANCH,
|
||||
"dry_run": args.dry_run,
|
||||
"limit": args.limit,
|
||||
**counters,
|
||||
},
|
||||
sort_keys=True,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -317,8 +317,7 @@ JQ_FILTER='.[]
|
||||
|
||||
T12_INPUT='[{"state":"APPROVED","dismissed":false,"user":{"login":"core-devops"}},{"state":"CHANGES_REQUESTED","dismissed":false,"user":{"login":"bob"}},{"state":"APPROVED","dismissed":false,"user":{"login":"alice"}},{"state":"APPROVED","dismissed":true,"user":{"login":"carol"}}]'
|
||||
|
||||
JQ_CMD=$(command -v jq 2>/dev/null || echo /tmp/jq)
|
||||
T12_CANDIDATES=$(echo "$T12_INPUT" | "$JQ_CMD" -r "$JQ_FILTER" 2>/dev/null | sort -u)
|
||||
T12_CANDIDATES=$(echo "$T12_INPUT" | /tmp/jq -r "$JQ_FILTER" 2>/dev/null | sort -u)
|
||||
assert_contains "T12 jq: core-devops (non-author APPROVED) in candidates" "core-devops" "$T12_CANDIDATES"
|
||||
assert_eq "T12 jq: alice (author) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^alice$' || true)"
|
||||
assert_eq "T12 jq: carol (dismissed) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^carol$' || true)"
|
||||
|
||||
@@ -1,524 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
# Unit tests for sop-checklist-gate.py
|
||||
#
|
||||
# Run: python3 .gitea/scripts/tests/test_sop_checklist_gate.py
|
||||
# or: pytest .gitea/scripts/tests/test_sop_checklist_gate.py
|
||||
#
|
||||
# RFC#351 Step 2 of 6 — implementation MVP. Tests cover:
|
||||
# - slug normalization (the 4 example variants in the script header)
|
||||
# - parse_directives (ack, revoke, with/without note, mid-comment, etc.)
|
||||
# - section_marker_present (empty answer rejected, filled answer ok)
|
||||
# - compute_ack_state (self-ack rejected, team probe applied, revoke
|
||||
# invalidates own prior ack, peer's ack survives unrevoked)
|
||||
# - render_status (state + description format)
|
||||
# - get_tier_mode (label-driven, default fallback)
|
||||
# - load_config (default config parses cleanly with both PyYAML and
|
||||
# the bundled minimal parser)
|
||||
#
|
||||
# All tests run WITHOUT touching the Gitea API — the team-probe
|
||||
# callable is dependency-injected.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
# Resolve sibling script regardless of where pytest is invoked from.
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
PARENT = os.path.dirname(HERE) # .gitea/scripts
|
||||
sys.path.insert(0, PARENT)
|
||||
|
||||
import importlib.util # noqa: E402
|
||||
|
||||
_spec = importlib.util.spec_from_file_location(
|
||||
"sop_checklist_gate", os.path.join(PARENT, "sop-checklist-gate.py")
|
||||
)
|
||||
sop = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(sop) # type: ignore[union-attr]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CONFIG_PATH = os.path.join(PARENT, "..", "sop-checklist-config.yaml")
|
||||
|
||||
|
||||
def _items() -> list[dict]:
|
||||
cfg = sop.load_config(CONFIG_PATH)
|
||||
return cfg["items"]
|
||||
|
||||
|
||||
def _items_by_slug() -> dict[str, dict]:
|
||||
return {it["slug"]: it for it in _items()}
|
||||
|
||||
|
||||
def _numeric_aliases() -> dict[int, str]:
|
||||
return {
|
||||
int(it["numeric_alias"]): it["slug"]
|
||||
for it in _items()
|
||||
if it.get("numeric_alias")
|
||||
}
|
||||
|
||||
|
||||
def _comment(user: str, body: str) -> dict:
|
||||
return {"user": {"login": user}, "body": body}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_slug
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalizeSlug(unittest.TestCase):
|
||||
def test_kebab_already(self):
|
||||
self.assertEqual(sop.normalize_slug("comprehensive-testing"), "comprehensive-testing")
|
||||
|
||||
def test_underscore_to_dash(self):
|
||||
self.assertEqual(sop.normalize_slug("comprehensive_testing"), "comprehensive-testing")
|
||||
|
||||
def test_space_to_dash(self):
|
||||
self.assertEqual(sop.normalize_slug("comprehensive testing"), "comprehensive-testing")
|
||||
|
||||
def test_uppercase_to_lower(self):
|
||||
self.assertEqual(sop.normalize_slug("Comprehensive-Testing"), "comprehensive-testing")
|
||||
|
||||
def test_mixed_separators(self):
|
||||
self.assertEqual(sop.normalize_slug("Comprehensive_Testing"), "comprehensive-testing")
|
||||
self.assertEqual(sop.normalize_slug("FIVE_axis review"), "five-axis-review")
|
||||
|
||||
def test_collapse_repeated_dashes(self):
|
||||
self.assertEqual(sop.normalize_slug("comprehensive--testing"), "comprehensive-testing")
|
||||
self.assertEqual(sop.normalize_slug("comprehensive testing"), "comprehensive-testing")
|
||||
|
||||
def test_strip_trailing_punctuation(self):
|
||||
self.assertEqual(sop.normalize_slug("comprehensive-testing."), "comprehensive-testing")
|
||||
self.assertEqual(sop.normalize_slug("comprehensive-testing!"), "comprehensive-testing")
|
||||
|
||||
def test_numeric_shorthand_known(self):
|
||||
self.assertEqual(
|
||||
sop.normalize_slug("1", _numeric_aliases()),
|
||||
"comprehensive-testing",
|
||||
)
|
||||
self.assertEqual(
|
||||
sop.normalize_slug("3", _numeric_aliases()),
|
||||
"staging-smoke",
|
||||
)
|
||||
self.assertEqual(
|
||||
sop.normalize_slug("7", _numeric_aliases()),
|
||||
"memory-consulted",
|
||||
)
|
||||
|
||||
def test_numeric_shorthand_unknown_returns_empty(self):
|
||||
# "8" is out of range → empty so caller can flag as unparseable.
|
||||
self.assertEqual(sop.normalize_slug("8", _numeric_aliases()), "")
|
||||
|
||||
def test_numeric_without_alias_table_keeps_digits(self):
|
||||
# No alias table → return the digits as-is.
|
||||
self.assertEqual(sop.normalize_slug("1"), "1")
|
||||
|
||||
def test_empty_input(self):
|
||||
self.assertEqual(sop.normalize_slug(""), "")
|
||||
self.assertEqual(sop.normalize_slug(" "), "")
|
||||
self.assertEqual(sop.normalize_slug(None), "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# parse_directives
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseDirectives(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.aliases = _numeric_aliases()
|
||||
|
||||
def test_simple_ack(self):
|
||||
d = sop.parse_directives("/sop-ack comprehensive-testing", self.aliases)
|
||||
self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")])
|
||||
|
||||
def test_simple_revoke(self):
|
||||
d = sop.parse_directives("/sop-revoke staging-smoke", self.aliases)
|
||||
self.assertEqual(d, [("sop-revoke", "staging-smoke", "")])
|
||||
|
||||
def test_ack_with_note(self):
|
||||
d = sop.parse_directives(
|
||||
"/sop-ack comprehensive-testing LGTM the test covers all edge cases",
|
||||
self.aliases,
|
||||
)
|
||||
self.assertEqual(len(d), 1)
|
||||
self.assertEqual(d[0][0], "sop-ack")
|
||||
self.assertEqual(d[0][1], "comprehensive-testing")
|
||||
self.assertIn("LGTM", d[0][2])
|
||||
|
||||
def test_numeric_shorthand(self):
|
||||
d = sop.parse_directives("/sop-ack 1", self.aliases)
|
||||
self.assertEqual(d, [("sop-ack", "comprehensive-testing", "")])
|
||||
|
||||
def test_revoke_with_reason(self):
|
||||
d = sop.parse_directives(
|
||||
"/sop-revoke comprehensive-testing realized the e2e was mocking the DB",
|
||||
self.aliases,
|
||||
)
|
||||
self.assertEqual(d[0][0], "sop-revoke")
|
||||
self.assertEqual(d[0][1], "comprehensive-testing")
|
||||
self.assertIn("mocking", d[0][2])
|
||||
|
||||
def test_directive_in_middle_of_comment(self):
|
||||
body = (
|
||||
"Reviewed the PR, looks good overall.\n"
|
||||
"/sop-ack comprehensive-testing\n"
|
||||
"Will follow up on the doc nit separately."
|
||||
)
|
||||
d = sop.parse_directives(body, self.aliases)
|
||||
self.assertEqual(len(d), 1)
|
||||
self.assertEqual(d[0][1], "comprehensive-testing")
|
||||
|
||||
def test_multiple_directives_in_one_comment(self):
|
||||
body = (
|
||||
"/sop-ack comprehensive-testing\n"
|
||||
"/sop-ack local-postgres-e2e\n"
|
||||
)
|
||||
d = sop.parse_directives(body, self.aliases)
|
||||
self.assertEqual(len(d), 2)
|
||||
slugs = {x[1] for x in d}
|
||||
self.assertEqual(slugs, {"comprehensive-testing", "local-postgres-e2e"})
|
||||
|
||||
def test_must_be_at_line_start(self):
|
||||
# A directive embedded mid-line is not honored (prevents review
|
||||
# comments like "to /sop-ack you need..." from acting as acks).
|
||||
body = "If you want to /sop-ack comprehensive-testing reply in this thread"
|
||||
d = sop.parse_directives(body, self.aliases)
|
||||
self.assertEqual(d, [])
|
||||
|
||||
def test_leading_whitespace_allowed(self):
|
||||
body = " /sop-ack comprehensive-testing"
|
||||
d = sop.parse_directives(body, self.aliases)
|
||||
self.assertEqual(len(d), 1)
|
||||
|
||||
def test_empty_body(self):
|
||||
self.assertEqual(sop.parse_directives("", self.aliases), [])
|
||||
self.assertEqual(sop.parse_directives(None, self.aliases), [])
|
||||
|
||||
def test_normalization_applied(self):
|
||||
# /sop-ack Comprehensive_Testing → canonical comprehensive-testing
|
||||
d = sop.parse_directives("/sop-ack Comprehensive_Testing", self.aliases)
|
||||
self.assertEqual(d[0][1], "comprehensive-testing")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# section_marker_present
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSectionMarkerPresent(unittest.TestCase):
|
||||
def test_marker_with_inline_answer(self):
|
||||
body = "- [ ] **Comprehensive testing performed**: Added 12 new tests covering null/empty/giant inputs."
|
||||
self.assertTrue(sop.section_marker_present(body, "Comprehensive testing performed"))
|
||||
|
||||
def test_marker_with_empty_answer(self):
|
||||
body = "- [ ] **Comprehensive testing performed**:"
|
||||
self.assertFalse(sop.section_marker_present(body, "Comprehensive testing performed"))
|
||||
|
||||
def test_marker_with_only_whitespace_answer(self):
|
||||
body = "- [ ] **Comprehensive testing performed**: \n"
|
||||
self.assertFalse(sop.section_marker_present(body, "Comprehensive testing performed"))
|
||||
|
||||
def test_marker_with_next_line_answer(self):
|
||||
body = (
|
||||
"- [ ] **Comprehensive testing performed**:\n"
|
||||
" Yes — see attached log + 12 new unit tests in foo_test.py.\n"
|
||||
)
|
||||
self.assertTrue(sop.section_marker_present(body, "Comprehensive testing performed"))
|
||||
|
||||
def test_marker_missing(self):
|
||||
body = "- [ ] **Local-postgres E2E run**: N/A — pure-frontend\n"
|
||||
self.assertFalse(sop.section_marker_present(body, "Comprehensive testing performed"))
|
||||
|
||||
def test_case_insensitive_marker_match(self):
|
||||
body = "- [ ] **comprehensive TESTING performed**: yes"
|
||||
self.assertTrue(sop.section_marker_present(body, "Comprehensive testing performed"))
|
||||
|
||||
def test_empty_body(self):
|
||||
self.assertFalse(sop.section_marker_present("", "X"))
|
||||
self.assertFalse(sop.section_marker_present(None, "X"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_ack_state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestComputeAckState(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.items = _items_by_slug()
|
||||
self.aliases = _numeric_aliases()
|
||||
|
||||
@staticmethod
|
||||
def _approve_all(slug, users):
|
||||
return list(users)
|
||||
|
||||
@staticmethod
|
||||
def _approve_none(slug, users):
|
||||
return []
|
||||
|
||||
def _approve_only(self, allowed_users):
|
||||
return lambda slug, users: [u for u in users if u in allowed_users]
|
||||
|
||||
def test_peer_ack_passes(self):
|
||||
comments = [_comment("bob", "/sop-ack comprehensive-testing")]
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, self._approve_all
|
||||
)
|
||||
self.assertEqual(state["comprehensive-testing"]["ackers"], ["bob"])
|
||||
|
||||
def test_self_ack_rejected(self):
|
||||
comments = [_comment("alice", "/sop-ack comprehensive-testing")]
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, self._approve_all
|
||||
)
|
||||
self.assertEqual(state["comprehensive-testing"]["ackers"], [])
|
||||
self.assertEqual(state["comprehensive-testing"]["rejected"]["self_ack"], ["alice"])
|
||||
|
||||
def test_not_in_team_rejected(self):
|
||||
comments = [_comment("eve", "/sop-ack comprehensive-testing")]
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, self._approve_none
|
||||
)
|
||||
self.assertEqual(state["comprehensive-testing"]["ackers"], [])
|
||||
self.assertEqual(state["comprehensive-testing"]["rejected"]["not_in_team"], ["eve"])
|
||||
|
||||
def test_revoke_invalidates_own_prior_ack(self):
|
||||
# Bob acks then later revokes — Bob no longer counts.
|
||||
comments = [
|
||||
_comment("bob", "/sop-ack comprehensive-testing"),
|
||||
_comment("bob", "/sop-revoke comprehensive-testing realized e2e was mocked"),
|
||||
]
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, self._approve_all
|
||||
)
|
||||
self.assertEqual(state["comprehensive-testing"]["ackers"], [])
|
||||
|
||||
def test_revoke_does_not_affect_others_acks(self):
|
||||
# Bob revokes his own ack; Carol's still counts.
|
||||
comments = [
|
||||
_comment("bob", "/sop-ack comprehensive-testing"),
|
||||
_comment("carol", "/sop-ack comprehensive-testing"),
|
||||
_comment("bob", "/sop-revoke comprehensive-testing"),
|
||||
]
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, self._approve_all
|
||||
)
|
||||
self.assertEqual(state["comprehensive-testing"]["ackers"], ["carol"])
|
||||
|
||||
def test_ack_after_revoke_restored(self):
|
||||
# Bob revokes then re-acks (e.g. after re-reviewing).
|
||||
comments = [
|
||||
_comment("bob", "/sop-ack comprehensive-testing"),
|
||||
_comment("bob", "/sop-revoke comprehensive-testing"),
|
||||
_comment("bob", "/sop-ack comprehensive-testing"),
|
||||
]
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, self._approve_all
|
||||
)
|
||||
self.assertEqual(state["comprehensive-testing"]["ackers"], ["bob"])
|
||||
|
||||
def test_numeric_shorthand_ack(self):
|
||||
# /sop-ack 1 → comprehensive-testing
|
||||
comments = [_comment("bob", "/sop-ack 1")]
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, self._approve_all
|
||||
)
|
||||
self.assertEqual(state["comprehensive-testing"]["ackers"], ["bob"])
|
||||
|
||||
def test_ack_for_unknown_slug_ignored(self):
|
||||
# Some other slug not in config — silently drop (doesn't crash).
|
||||
comments = [_comment("bob", "/sop-ack does-not-exist")]
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, self._approve_all
|
||||
)
|
||||
for slug in self.items:
|
||||
self.assertEqual(state[slug]["ackers"], [])
|
||||
|
||||
def test_multi_item_multi_user(self):
|
||||
comments = [
|
||||
_comment("bob", "/sop-ack comprehensive-testing\n/sop-ack staging-smoke"),
|
||||
_comment("carol", "/sop-ack five-axis-review"),
|
||||
]
|
||||
state = sop.compute_ack_state(
|
||||
comments, "alice", self.items, self.aliases, self._approve_all
|
||||
)
|
||||
self.assertEqual(state["comprehensive-testing"]["ackers"], ["bob"])
|
||||
self.assertEqual(state["staging-smoke"]["ackers"], ["bob"])
|
||||
self.assertEqual(state["five-axis-review"]["ackers"], ["carol"])
|
||||
self.assertEqual(state["root-cause"]["ackers"], [])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# render_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRenderStatus(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.items = _items()
|
||||
self.items_by_slug = _items_by_slug()
|
||||
|
||||
def _state_with(self, acked: list[str]) -> dict:
|
||||
return {
|
||||
it["slug"]: {
|
||||
"ackers": ["peer"] if it["slug"] in acked else [],
|
||||
"rejected": {"self_ack": [], "not_in_team": []},
|
||||
}
|
||||
for it in self.items
|
||||
}
|
||||
|
||||
def test_all_acked_returns_success(self):
|
||||
all_slugs = [it["slug"] for it in self.items]
|
||||
state, desc = sop.render_status(
|
||||
self.items, self._state_with(all_slugs), {s: True for s in all_slugs}
|
||||
)
|
||||
self.assertEqual(state, "success")
|
||||
self.assertIn("7/7", desc)
|
||||
|
||||
def test_partial_acked_returns_failure(self):
|
||||
state, desc = sop.render_status(
|
||||
self.items,
|
||||
self._state_with(["comprehensive-testing", "staging-smoke"]),
|
||||
{it["slug"]: True for it in self.items},
|
||||
)
|
||||
self.assertEqual(state, "failure")
|
||||
self.assertIn("2/7", desc)
|
||||
self.assertIn("missing", desc)
|
||||
|
||||
def test_description_truncates_long_missing_list(self):
|
||||
# Only ack one — 6 missing should be summarized as "+N".
|
||||
state, desc = sop.render_status(
|
||||
self.items,
|
||||
self._state_with(["comprehensive-testing"]),
|
||||
{it["slug"]: True for it in self.items},
|
||||
)
|
||||
# Length budget: under 140 chars.
|
||||
self.assertLessEqual(len(desc), 140)
|
||||
self.assertIn("+", desc) # +N elision marker
|
||||
|
||||
def test_body_unfilled_surfaced(self):
|
||||
all_slugs = [it["slug"] for it in self.items]
|
||||
state, desc = sop.render_status(
|
||||
self.items,
|
||||
self._state_with(all_slugs),
|
||||
{it["slug"]: False for it in self.items},
|
||||
)
|
||||
self.assertIn("body-unfilled", desc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_tier_mode
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGetTierMode(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.cfg = sop.load_config(CONFIG_PATH)
|
||||
|
||||
def test_tier_high_is_hard(self):
|
||||
pr = {"labels": [{"name": "tier:high"}, {"name": "area:ci"}]}
|
||||
self.assertEqual(sop.get_tier_mode(pr, self.cfg), "hard")
|
||||
|
||||
def test_tier_medium_is_hard(self):
|
||||
pr = {"labels": [{"name": "tier:medium"}]}
|
||||
self.assertEqual(sop.get_tier_mode(pr, self.cfg), "hard")
|
||||
|
||||
def test_tier_low_is_soft(self):
|
||||
pr = {"labels": [{"name": "tier:low"}]}
|
||||
self.assertEqual(sop.get_tier_mode(pr, self.cfg), "soft")
|
||||
|
||||
def test_no_tier_label_defaults_to_hard(self):
|
||||
# Per feedback_fix_root_not_symptom — never silently lower the bar.
|
||||
pr = {"labels": [{"name": "area:ci"}]}
|
||||
self.assertEqual(sop.get_tier_mode(pr, self.cfg), "hard")
|
||||
|
||||
def test_no_labels_defaults_to_hard(self):
|
||||
self.assertEqual(sop.get_tier_mode({"labels": []}, self.cfg), "hard")
|
||||
self.assertEqual(sop.get_tier_mode({}, self.cfg), "hard")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# load_config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLoadConfig(unittest.TestCase):
|
||||
def test_default_config_parses(self):
|
||||
cfg = sop.load_config(CONFIG_PATH)
|
||||
self.assertIn("items", cfg)
|
||||
self.assertEqual(len(cfg["items"]), 7)
|
||||
slugs = {it["slug"] for it in cfg["items"]}
|
||||
self.assertEqual(
|
||||
slugs,
|
||||
{
|
||||
"comprehensive-testing",
|
||||
"local-postgres-e2e",
|
||||
"staging-smoke",
|
||||
"root-cause",
|
||||
"five-axis-review",
|
||||
"no-backwards-compat",
|
||||
"memory-consulted",
|
||||
},
|
||||
)
|
||||
|
||||
def test_default_config_tier_mode_shape(self):
|
||||
cfg = sop.load_config(CONFIG_PATH)
|
||||
self.assertEqual(cfg["tier_failure_mode"]["tier:high"], "hard")
|
||||
self.assertEqual(cfg["tier_failure_mode"]["tier:medium"], "hard")
|
||||
self.assertEqual(cfg["tier_failure_mode"]["tier:low"], "soft")
|
||||
self.assertEqual(cfg["default_mode"], "hard")
|
||||
|
||||
def test_each_item_has_required_fields(self):
|
||||
cfg = sop.load_config(CONFIG_PATH)
|
||||
for it in cfg["items"]:
|
||||
self.assertIn("slug", it)
|
||||
self.assertIn("numeric_alias", it)
|
||||
self.assertIn("pr_section_marker", it)
|
||||
self.assertIn("required_teams", it)
|
||||
self.assertIsInstance(it["required_teams"], list)
|
||||
self.assertGreater(len(it["required_teams"]), 0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Edge case: full integration without team probe (dependency-injected)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEndToEndAckFlow(unittest.TestCase):
|
||||
"""All-7-items happy path with synthetic comments. Verifies the
|
||||
full pipeline minus the Gitea API."""
|
||||
|
||||
def test_all_seven_acked_by_proper_teams(self):
|
||||
items = _items_by_slug()
|
||||
aliases = _numeric_aliases()
|
||||
comments = [
|
||||
_comment("qa-bot", "/sop-ack comprehensive-testing"),
|
||||
_comment("eng-bot", "/sop-ack local-postgres-e2e"),
|
||||
_comment("eng-bot", "/sop-ack staging-smoke"),
|
||||
_comment("mgr-bot", "/sop-ack root-cause"),
|
||||
_comment("eng-bot", "/sop-ack five-axis-review"),
|
||||
_comment("mgr-bot", "/sop-ack no-backwards-compat"),
|
||||
_comment("eng-bot", "/sop-ack memory-consulted"),
|
||||
]
|
||||
|
||||
def probe(slug, users):
|
||||
# Pretend every user is in every team.
|
||||
return list(users)
|
||||
|
||||
state = sop.compute_ack_state(comments, "alice-author", items, aliases, probe)
|
||||
body = {it["slug"]: True for it in items.values()}
|
||||
items_list = list(items.values())
|
||||
result_state, desc = sop.render_status(items_list, state, body)
|
||||
self.assertEqual(result_state, "success")
|
||||
self.assertIn("7/7", desc)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -1,109 +0,0 @@
|
||||
# SOP-Checklist gate — per-item required reviewer teams.
|
||||
#
|
||||
# RFC#351 v1 starter set. Each item lists:
|
||||
# slug — canonical kebab-case form used in /sop-ack <slug>
|
||||
# pr_section_marker — substring matched in the PR body to detect that
|
||||
# the author filled in this item (case-insensitive)
|
||||
# required_teams — list of Gitea team names; an ack from ANY one of
|
||||
# these teams (logical OR) satisfies the item.
|
||||
# Membership is probed at gate-time via
|
||||
# GET /api/v1/teams/{id}/members/{login}.
|
||||
# Team-id resolution happens at script start via
|
||||
# GET /api/v1/orgs/{org}/teams (cheap, one call).
|
||||
# numeric_alias — 1..7; lets reviewers type `/sop-ack 3` as a
|
||||
# shortcut for `/sop-ack staging-smoke`.
|
||||
#
|
||||
# WHY THESE TEAM MAPPINGS:
|
||||
# The RFC table referenced persona-role names like `core-qa`,
|
||||
# `core-be`, `core-devops` — these are individual Gitea user logins,
|
||||
# not teams. The Gitea team-membership API is /teams/{id}/members/{u},
|
||||
# so we need actual teams. Orchestrator preflight 2026-05-12 verified
|
||||
# only these teams exist on molecule-ai: ceo(5), engineers(2),
|
||||
# managers(6), qa(20), security(21), Owners(1), and bot teams. We
|
||||
# map the RFC roles to the closest existing team and surface the
|
||||
# mapping explicitly so it's reviewable.
|
||||
#
|
||||
# HOW TO EDIT:
|
||||
# - Tightening: replace `engineers` with a smaller team after creating
|
||||
# it (e.g. a new `senior-engineers` team if needed).
|
||||
# - Loosening: add another team to required_teams (OR semantics).
|
||||
# - Add an item: append to items list and document the slug below.
|
||||
#
|
||||
# AUTHOR SELF-ACK IS FORBIDDEN regardless of which team contains them
|
||||
# — the gate script enforces commenter != PR author before checking
|
||||
# team membership.
|
||||
|
||||
version: 1
|
||||
|
||||
# Tier-aware failure mode (RFC#351 open question 2):
|
||||
# For tier:high — hard-fail (status `failure`, blocks merge via BP).
|
||||
# For tier:medium — hard-fail (same as high; medium is non-trivial).
|
||||
# For tier:low — soft-fail (status `pending` with `acked: N/M` in the
|
||||
# description). BP can choose to require the context
|
||||
# or not for low-tier PRs.
|
||||
# If no tier label is present, default to medium (hard-fail) — every PR
|
||||
# should have a tier label per sop-tier-check, and absence indicates
|
||||
# a missing-tier defect we should surface, not silently lower the bar.
|
||||
tier_failure_mode:
|
||||
"tier:high": hard
|
||||
"tier:medium": hard
|
||||
"tier:low": soft
|
||||
default_mode: hard # used when no tier:* label is present
|
||||
|
||||
items:
|
||||
- slug: comprehensive-testing
|
||||
numeric_alias: 1
|
||||
pr_section_marker: "Comprehensive testing performed"
|
||||
required_teams: [qa, engineers]
|
||||
description: >-
|
||||
What was tested, how, edge cases covered. Ack from any qa-team
|
||||
member (or engineers fallback while qa is small).
|
||||
|
||||
- slug: local-postgres-e2e
|
||||
numeric_alias: 2
|
||||
pr_section_marker: "Local-postgres E2E run"
|
||||
required_teams: [engineers]
|
||||
description: >-
|
||||
Link to local CI artifact, or "N/A: pure-frontend change". Ack
|
||||
from any engineer who can verify the local DB test actually ran.
|
||||
|
||||
- slug: staging-smoke
|
||||
numeric_alias: 3
|
||||
pr_section_marker: "Staging-smoke verified or pending"
|
||||
required_teams: [engineers]
|
||||
description: >-
|
||||
Link to canary run, or "scheduled post-merge". Ack from any
|
||||
engineer (core-devops/infra-sre are members of engineers team).
|
||||
|
||||
- slug: root-cause
|
||||
numeric_alias: 4
|
||||
pr_section_marker: "Root-cause not symptom"
|
||||
required_teams: [managers, ceo]
|
||||
description: >-
|
||||
One-sentence root-cause statement. Ack from managers tier
|
||||
(team-leads) or ceo. Senior judgment required to attest
|
||||
root-cause-versus-symptom.
|
||||
|
||||
- slug: five-axis-review
|
||||
numeric_alias: 5
|
||||
pr_section_marker: "Five-Axis review walked"
|
||||
required_teams: [engineers]
|
||||
description: >-
|
||||
Correctness / readability / architecture / security / performance.
|
||||
Ack from any non-author engineer.
|
||||
|
||||
- slug: no-backwards-compat
|
||||
numeric_alias: 6
|
||||
pr_section_marker: "No backwards-compat shim / dead code added"
|
||||
required_teams: [managers, ceo]
|
||||
description: >-
|
||||
Yes/no + justification if no. Senior ack required because
|
||||
backward-compat shims are how dead-code accretes.
|
||||
|
||||
- slug: memory-consulted
|
||||
numeric_alias: 7
|
||||
pr_section_marker: "Memory/saved-feedback consulted"
|
||||
required_teams: [engineers]
|
||||
description: >-
|
||||
List of feedback memories applicable to this change. Ack from
|
||||
any engineer who has the same memory access.
|
||||
@@ -85,5 +85,4 @@ jobs:
|
||||
REQUIRED_CHECKS: |
|
||||
Secret scan / Scan diff for credential-shaped strings (pull_request)
|
||||
sop-tier-check / tier-check (pull_request)
|
||||
CI / all-required (pull_request)
|
||||
run: bash .gitea/scripts/audit-force-merge.sh
|
||||
|
||||
@@ -23,11 +23,11 @@
|
||||
# `feedback_behavior_based_ast_gates` — NOT grep-by-name. That way
|
||||
# job renames or matrix-expansion-induced churn produce honest signal.
|
||||
#
|
||||
# NOTE on protection endpoint scope: `GET /repos/.../branch_protections/{branch}`
|
||||
# requires repo-admin role in Gitea 1.22.6. If DRIFT_BOT_TOKEN lacks it,
|
||||
# the script skips that branch with a clear ::error:: diagnostic and exits 0
|
||||
# (the issue IS the alarm, not a red workflow). See provisioning trail in
|
||||
# the run step's GITEA_TOKEN env comment.
|
||||
# IMPORTANT — TRANSITIONAL STATE: molecule-core's ci.yml does NOT yet
|
||||
# contain the `all-required` sentinel job (RFC §4 Phase 4 adds it).
|
||||
# Until Phase 4 lands the detector will hard-fail with exit 3 on the
|
||||
# missing sentinel. That's intentional: a red workflow on a 5-min cron
|
||||
# is louder than a silent issue and forces Phase 4 to land soon.
|
||||
|
||||
name: ci-required-drift
|
||||
|
||||
|
||||
+8
-50
@@ -70,12 +70,10 @@ jobs:
|
||||
changes:
|
||||
name: Detect changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): all required jobs >=98% green on main.
|
||||
# Flip confirmed 2026-05-12 via combined-status check of latest main
|
||||
# commit (all CI jobs green). `all-required` sentinel hard-fails
|
||||
# when this job fails; no Phase 3 suppression needed.
|
||||
# revert: add `continue-on-error: true` back if regressions appear.
|
||||
continue-on-error: false
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking
|
||||
# the PR. Follow-up PR flips this off after the surfaced defects
|
||||
# (if any) are triaged.
|
||||
continue-on-error: true
|
||||
outputs:
|
||||
platform: ${{ steps.check.outputs.platform }}
|
||||
canvas: ${{ steps.check.outputs.canvas }}
|
||||
@@ -126,29 +124,7 @@ jobs:
|
||||
name: Platform (Go)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# mc#664 (interim): re-mask platform-build pending fix-forward. Phase 4
|
||||
# (#656) flipped this to continue-on-error: false based on a Phase-3-masked
|
||||
# "green on main 2026-05-12" — the prior continue-on-error: true had
|
||||
# been hiding failing tests in workspace-server/internal/handlers/.
|
||||
# Two distinct failure classes surfaced on 0e5152c3:
|
||||
# (1) 4x delegation_test.go (lines 1110/1176/1228/1271): helpers
|
||||
# expectExecuteDelegationBase/Success/Failed are missing sqlmock
|
||||
# expectations for queries production has issued since ~2026-04-21
|
||||
# (last_outbound_at UPDATE, lookupDeliveryMode/Runtime SELECTs,
|
||||
# a2a_receive INSERT activity_logs, recordLedgerStatus writes).
|
||||
# Halt cond #3 applies (regression > 7 days → broader sweep).
|
||||
# (2) 1x mcp_test.go:433 (TestMCPHandler_CommitMemory_GlobalScope_Blocked):
|
||||
# commit 7d1a189f (2026-05-10) hardened mcp.go to scrub err.Error()
|
||||
# from JSON-RPC responses (OFFSEC-001), but the test asserts the
|
||||
# error message contains "GLOBAL". Production-vs-test contract
|
||||
# collision — needs design call, not mock update.
|
||||
# Time-boxed Option A (90 min) did not fit the cross-cutting scope.
|
||||
# This is a sequenced revert→fix→reflip per
|
||||
# feedback_strict_root_only_after_class_a emergency clause — NOT
|
||||
# a permanent re-mask. Re-flip blocked on mc#664 fix-forward landing.
|
||||
# Other 4 #656 flips (changes, canvas-build, shellcheck, python-lint)
|
||||
# retain continue-on-error: false; only platform-build regresses.
|
||||
continue-on-error: true # mc#664 fix-forward in flight; re-flip when tests pass
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
@@ -172,21 +148,6 @@ jobs:
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Run golangci-lint
|
||||
run: golangci-lint run --timeout 3m ./... || true
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Diagnostic — per-package verbose 60s
|
||||
run: |
|
||||
set +e
|
||||
go test -race -v -timeout 60s ./internal/handlers/... 2>&1 | tee /tmp/test-handlers.log
|
||||
handlers_exit=$?
|
||||
go test -race -v -timeout 60s ./internal/pendinguploads/... 2>&1 | tee /tmp/test-pu.log
|
||||
pu_exit=$?
|
||||
echo "::group::handlers exit=$handlers_exit (last 100 lines)"
|
||||
tail -100 /tmp/test-handlers.log
|
||||
echo "::endgroup::"
|
||||
echo "::group::pendinguploads exit=$pu_exit (last 100 lines)"
|
||||
tail -100 /tmp/test-pu.log
|
||||
echo "::endgroup::"
|
||||
continue-on-error: true
|
||||
- if: needs.changes.outputs.platform == 'true'
|
||||
name: Run tests with race detection and coverage
|
||||
run: go test -race -coverprofile=coverage.out ./...
|
||||
@@ -295,8 +256,7 @@ jobs:
|
||||
name: Canvas (Next.js)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
run:
|
||||
working-directory: canvas
|
||||
@@ -342,8 +302,7 @@ jobs:
|
||||
name: Shellcheck (E2E scripts)
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- if: needs.changes.outputs.scripts != 'true'
|
||||
run: echo "No tests/e2e/ or infra/scripts/ changes — skipping real shellcheck; this job always runs to satisfy the required-check name on branch protection."
|
||||
@@ -418,8 +377,7 @@ jobs:
|
||||
name: Python Lint & Test
|
||||
needs: changes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 4 (RFC #219 §1): confirmed green on main 2026-05-12.
|
||||
continue-on-error: false
|
||||
continue-on-error: true
|
||||
env:
|
||||
WORKSPACE_ID: test
|
||||
defaults:
|
||||
|
||||
@@ -71,12 +71,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Fetch all open PRs and run gate-check on each
|
||||
# socket.setdefaulttimeout(15): defence-in-depth for missing SOP_TIER_CHECK_TOKEN.
|
||||
# gate_check.py uses timeout=15 on every urlopen call; this catches the
|
||||
# inline Python polling loop too (issue #603).
|
||||
pr_numbers=$(python3 -c "
|
||||
import socket, urllib.request, json, os
|
||||
socket.setdefaulttimeout(15)
|
||||
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',
|
||||
|
||||
@@ -220,14 +220,12 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -z "${MOLECULE_GITEA_TOKEN}" ]; then
|
||||
echo "::warning::AUTO_SYNC_TOKEN not set — using anonymous clone (repos are public per manifest.json OSS contract)"
|
||||
echo "::error::AUTO_SYNC_TOKEN secret is empty — register the devops-engineer persona PAT in repo Actions secrets"
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p .tenant-bundle-deps
|
||||
# Strip JSON5 comments before jq parsing — Integration Tester appends
|
||||
# `// Triggered by ...` which breaks `jq` in clone-manifest.sh.
|
||||
sed '/^[[:space:]]*\/\//d' manifest.json > .manifest-stripped.json
|
||||
bash scripts/clone-manifest.sh \
|
||||
.manifest-stripped.json \
|
||||
manifest.json \
|
||||
.tenant-bundle-deps/workspace-configs-templates \
|
||||
.tenant-bundle-deps/org-templates \
|
||||
.tenant-bundle-deps/plugins
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
name: lint-mask-pr-atomicity
|
||||
|
||||
# Tier 2d hard-gate lint (per internal#350) — blocks PRs that touch
|
||||
# `.gitea/workflows/ci.yml` and modify ONLY ONE of {continue-on-error,
|
||||
# all-required.sentinel.needs} without a `Paired: #NNN` reference in
|
||||
# the PR body or in a commit message.
|
||||
#
|
||||
# Why this exists
|
||||
# ---------------
|
||||
# PR#665 (interim `continue-on-error: true` on `platform-build`) and
|
||||
# PR#668 (sentinel-`needs` demotion of the same job) were designed as a
|
||||
# pair but merged solo — #665 landed at 04:47Z 2026-05-12, #668 was
|
||||
# still open at 05:07Z when the main-red watchdog (#674) fired. Result:
|
||||
# ~20 minutes of `main` red and a cascade of false-positives on
|
||||
# unrelated PRs. This lint structurally prevents that class.
|
||||
#
|
||||
# How the gate works
|
||||
# ------------------
|
||||
# 1. The workflow runs on every PR whose diff touches ci.yml (paths
|
||||
# filter). It is NOT a required check on `main` because the rule is
|
||||
# diff-based — running it on PRs that don't touch ci.yml would
|
||||
# produce a `pending` status forever (per
|
||||
# `feedback_path_filtered_workflow_cant_be_required`).
|
||||
# 2. The script reads `BASE_SHA:ci.yml` and `HEAD_SHA:ci.yml`, parses
|
||||
# both via PyYAML AST (per `feedback_behavior_based_ast_gates` — no
|
||||
# grep, no regex on the raw text — so a YAML-shape refactor still
|
||||
# detects).
|
||||
# 3. Walks `jobs.*.continue-on-error` on each side; flags any value
|
||||
# diff. Reads `jobs.all-required.needs` on each side; flags any
|
||||
# set diff (order-insensitive — `needs:` is engine-unordered).
|
||||
# 4. If both predicates fired → atomic, OK. If neither → no risk, OK.
|
||||
# If exactly one fired → require `Paired: #NNN` in PR body OR in
|
||||
# any commit message between base..head; else fail.
|
||||
#
|
||||
# Phase contract (RFC internal#219 §1 ladder)
|
||||
# -------------------------------------------
|
||||
# This workflow lands at `continue-on-error: true` (Phase 3 — surface
|
||||
# regressions without blocking PRs while the rule beds in).
|
||||
# Follow-up PR flips to `false` once we have ≥3 days of clean runs on
|
||||
# `main` and no false-positives. Tracking issue: internal#350.
|
||||
#
|
||||
# Cross-links
|
||||
# -----------
|
||||
# - internal#350 (the RFC that specs this lint)
|
||||
# - PR#665 / PR#668 (the empirical split-pair)
|
||||
# - mc#664 (the main-red incident the split caused)
|
||||
# - feedback_strict_root_only_after_class_a
|
||||
# - feedback_behavior_based_ast_gates
|
||||
#
|
||||
# Auth: only needs the auto-injected GITHUB_TOKEN (read-only, repo
|
||||
# scope). No DRIFT_BOT_TOKEN needed — Tier 2d does NOT call
|
||||
# branch_protections (Tier 2g/f do).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
# `edited` is included because the rule depends on PR_BODY: a user
|
||||
# may add `Paired: #NNN` after first push to satisfy the lint. The
|
||||
# rerun on `edited` lets the PR turn green without an empty
|
||||
# commit. Gitea 1.22.6 fires `edited` on body changes — verified
|
||||
# via gitea-source/models/issues/pull_list.go::triggerNewPRWebhook.
|
||||
paths:
|
||||
- '.gitea/workflows/ci.yml'
|
||||
- '.gitea/scripts/lint_mask_pr_atomicity.py'
|
||||
- '.gitea/workflows/lint-mask-pr-atomicity.yml'
|
||||
- 'tests/test_lint_mask_pr_atomicity.py'
|
||||
|
||||
env:
|
||||
# Belt-and-suspenders against the runner-default trap
|
||||
# (feedback_act_runner_github_server_url). Runners are configured
|
||||
# with this env via /opt/molecule/runners/config.yaml, but pinning
|
||||
# at the workflow level protects against a runner regenerated
|
||||
# without the config file.
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
# Per-PR concurrency — re-pushes cancel previous runs to keep the
|
||||
# queue short. The lint is cheap (one git show + log + a YAML parse).
|
||||
concurrency:
|
||||
group: lint-mask-pr-atomicity-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: lint-mask-pr-atomicity
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
# Phase 3 (RFC #219 §1): surface broken shapes without blocking
|
||||
# PRs. Follow-up PR flips this to `false` once recent runs on main
|
||||
# are confirmed clean (eat-our-own-dogfood discipline mirrors
|
||||
# PR#673's same-shape comment). Tracking: internal#350.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Check out PR head with full history (need base SHA blobs)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# `git show <base-sha>:<path>` needs the base SHA's blobs.
|
||||
# Shallow=1 would miss it. Same rationale as PR#673 and
|
||||
# check-migration-collisions.yml.
|
||||
fetch-depth: 0
|
||||
- name: Set up Python (PyYAML for AST parsing)
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install PyYAML
|
||||
# Same pin as ci-required-drift.yml + the rest of the Tier 2
|
||||
# lint family — keep runner-cache hits uniform.
|
||||
run: python -m pip install --quiet 'PyYAML==6.0.2'
|
||||
- name: Ensure base ref is reachable locally
|
||||
# fetch-depth=0 usually pulls the base too, but explicit-fetch
|
||||
# is cheap insurance against runner-version drift (matches the
|
||||
# comment in check-migration-collisions.yml and PR#673).
|
||||
run: |
|
||||
git fetch origin "${{ github.event.pull_request.base.ref }}" || true
|
||||
- name: Run lint-mask-pr-atomicity
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
# PR body — the script greps for `Paired: #NNN`.
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
CI_WORKFLOW_PATH: .gitea/workflows/ci.yml
|
||||
SENTINEL_JOB_KEY: all-required
|
||||
run: python3 .gitea/scripts/lint_mask_pr_atomicity.py
|
||||
- name: Run lint-mask-pr-atomicity unit tests
|
||||
# Run the test suite in-CI so the lint's own behaviour is
|
||||
# verified on every change. Matches lint-workflow-yaml.yml.
|
||||
run: |
|
||||
python -m pip install --quiet pytest
|
||||
python3 -m pytest tests/test_lint_mask_pr_atomicity.py -v
|
||||
@@ -1,96 +0,0 @@
|
||||
# lint-required-no-paths — structural enforcement of
|
||||
# `feedback_path_filtered_workflow_cant_be_required`.
|
||||
#
|
||||
# Fails the PR if ANY workflow whose status-check context appears in
|
||||
# `branch_protections/main.status_check_contexts` carries a
|
||||
# `paths:` or `paths-ignore:` filter in its `on:` block.
|
||||
#
|
||||
# Why this exists:
|
||||
# A required-check workflow with a paths filter silently degrades the
|
||||
# merge gate. If a PR's diff doesn't touch the filter, the workflow
|
||||
# never fires; Gitea (1.22.6) reports the required context as
|
||||
# `pending` (NOT `skipped == success`), so the PR cannot merge. For a
|
||||
# docs-only PR against `paths: ['**.go']`, the PR is wedged forever.
|
||||
#
|
||||
# Previously prevented only by reviewer vigilance + the saved memory
|
||||
# `feedback_path_filtered_workflow_cant_be_required`. This workflow
|
||||
# makes it a hard CI gate.
|
||||
#
|
||||
# Forward-compat scope:
|
||||
# Today (2026-05-11) molecule-core/main protects 3 contexts:
|
||||
# - "Secret scan / Scan diff for credential-shaped strings (pull_request)"
|
||||
# - "sop-tier-check / tier-check (pull_request)"
|
||||
# - "CI / all-required (pull_request)"
|
||||
# Per RFC#324 Step 2 the required-list expands to ~5 contexts
|
||||
# (qa-review, security-review added). Each new required context's
|
||||
# workflow must remain unconditional. This lint pins that contract.
|
||||
#
|
||||
# Meta-required-check:
|
||||
# This workflow ITSELF deliberately has NO `paths:` filter on its `on:`
|
||||
# block — otherwise a paths-non-matching PR could bypass the check.
|
||||
# Self-evident from this file: only `pull_request` types + no paths.
|
||||
#
|
||||
# Auth:
|
||||
# `GET /repos/.../branch_protections/{branch}` requires repo-admin
|
||||
# role in Gitea 1.22.6. The workflow-default `GITHUB_TOKEN` is
|
||||
# non-admin (read-only), so we re-use `DRIFT_BOT_TOKEN` (same persona
|
||||
# that powers `ci-required-drift.yml` — verified working there).
|
||||
# If `DRIFT_BOT_TOKEN` becomes unavailable, the script exits 0 with a
|
||||
# loud `::error::` rather than red-X every PR — token-scope issues
|
||||
# should be fixed at the token, not surfaced as a gate failure on
|
||||
# every unrelated PR.
|
||||
#
|
||||
# Behavior-based gate per `feedback_behavior_based_ast_gates`:
|
||||
# YAML AST walk (PyYAML), NOT grep. Workflow renames, formatting
|
||||
# changes (block-scalar vs flow-style), or moving `paths:` between
|
||||
# `pull_request:` and `pull_request_target:` all still detect.
|
||||
#
|
||||
# IMPORTANT — Gitea 1.22.6 parser quirk per
|
||||
# `feedback_gitea_workflow_dispatch_inputs_unsupported`: do NOT add an
|
||||
# `inputs:` block to `workflow_dispatch:` — Gitea 1.22.6 rejects the
|
||||
# entire workflow as "unknown on type" and it registers for ZERO events.
|
||||
|
||||
name: lint-required-no-paths
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
workflow_dispatch:
|
||||
|
||||
# Read protection + read local YAML. No writes.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Only one in-flight run per PR — re-pushes cancel the previous run to
|
||||
# keep the queue short. Required-list reads are cheap (one GET); the
|
||||
# cancellation is just hygiene.
|
||||
concurrency:
|
||||
group: lint-required-no-paths-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: lint-required-no-paths
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out repo (we read the workflow YAML files locally)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Set up Python (PyYAML for AST parsing)
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install PyYAML
|
||||
run: python -m pip install --quiet 'PyYAML==6.0.2'
|
||||
- name: Run lint-required-no-paths
|
||||
env:
|
||||
# DRIFT_BOT_TOKEN is owned by mc-drift-bot, a least-privilege
|
||||
# Gitea persona with repo-admin role for branch_protections
|
||||
# read. Same secret used by ci-required-drift.yml — see that
|
||||
# workflow's header for provisioning trail (internal#329).
|
||||
GITEA_TOKEN: ${{ secrets.DRIFT_BOT_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
BRANCH: main
|
||||
WORKFLOWS_DIR: .gitea/workflows
|
||||
run: python3 .gitea/scripts/lint-required-no-paths.py
|
||||
@@ -1,75 +0,0 @@
|
||||
name: Lint workflow YAML (Gitea-1.22.6-hostile shapes)
|
||||
|
||||
# Tier-2 hard-gate lint (RFC internal#219 §1, charter §SOP-N rule (m)).
|
||||
# Catches six Gitea-1.22.6-hostile workflow-YAML shapes BEFORE they reach
|
||||
# `main`. Each rule maps to a documented incident in saved memory:
|
||||
#
|
||||
# 1. workflow_dispatch.inputs — feedback_gitea_workflow_dispatch_inputs_unsupported
|
||||
# (2026-05-11 PyPI freeze 24h)
|
||||
# 2. on: workflow_run — task #81 (Gitea 1.22.6 lacks the event)
|
||||
# 3. name: containing "/" — breaks status-context tokenization
|
||||
# 4. cross-file name collision — status-reaper rev1 fail-loud class
|
||||
# 5. cross-repo uses: org/r/p@r — feedback_gitea_cross_repo_uses_blocked
|
||||
# (DEFAULT_ACTIONS_URL=github → 404)
|
||||
# 6. (WARN) api.github.com refs — feedback_act_runner_github_server_url
|
||||
# without workflow-level GITHUB_SERVER_URL
|
||||
#
|
||||
# Empirical history this hardens against:
|
||||
# - status-reaper rev1 caught rule-4 (name-collision) class
|
||||
# - sop-tier-refire DOA'd on rule-2 (workflow_run partial)
|
||||
# - #319 bootstrap-paradox (chained-defect class, related)
|
||||
# - internal#329 dispatcher race (adjacent)
|
||||
# - 2026-05-11 publish-runtime: rule-1, 24h PyPI freeze
|
||||
#
|
||||
# Triggers:
|
||||
# - pull_request: pre-merge gate — block hostile shapes before they land
|
||||
# - push: post-merge regression detection — catch direct-to-main edits
|
||||
#
|
||||
# Per RFC internal#219 §1 contract: continue-on-error: true during the
|
||||
# surface-broken-shapes phase. Follow-up PR flips off after surfaced
|
||||
# defects are triaged. The push-trigger ensures we catch regressions
|
||||
# even if the pull_request gate is bypassed by branch-protection drift.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '.gitea/workflows/**'
|
||||
- '.gitea/scripts/lint-workflow-yaml.py'
|
||||
- 'tests/test_lint_workflow_yaml.py'
|
||||
push:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- '.gitea/workflows/**'
|
||||
- '.gitea/scripts/lint-workflow-yaml.py'
|
||||
- 'tests/test_lint_workflow_yaml.py'
|
||||
|
||||
# Belt-and-suspenders against runner default
|
||||
# (feedback_act_runner_github_server_url).
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
name: Lint workflow YAML for Gitea-1.22.6-hostile shapes
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken shapes without blocking PRs.
|
||||
# Follow-up PR flips this off after the 4 existing-on-main rule-2
|
||||
# (workflow_run) violations are migrated to a supported trigger.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PyYAML
|
||||
run: pip install --quiet 'PyYAML>=6.0'
|
||||
|
||||
- name: Lint .gitea/workflows/*.yml
|
||||
run: python3 .gitea/scripts/lint-workflow-yaml.py
|
||||
|
||||
- name: Run lint-workflow-yaml unit tests
|
||||
run: |
|
||||
pip install --quiet pytest
|
||||
python3 -m pytest tests/test_lint_workflow_yaml.py -v
|
||||
@@ -37,11 +37,6 @@ name: main-red-watchdog
|
||||
# "unknown on type" when `workflow_dispatch.inputs.X` is present. Revisit
|
||||
# when Gitea ≥ 1.23 is fleet-wide.
|
||||
on:
|
||||
# SCHEDULE RE-ENABLED 2026-05-12 rev3 — interim disable (mc#645) reverted alongside
|
||||
# status-reaper rev3 (widen-window). Job-level timeout-minutes raised 5 → 15 below
|
||||
# to absorb runner-saturation latency without spurious cancels (the original cascade
|
||||
# cause). If runner-saturation root persists, the dedicated-runner-label split
|
||||
# remains the structural next step (tracked separately).
|
||||
schedule:
|
||||
# Hourly at :05 — task spec calls for "off-zero" (`5 * * * *`),
|
||||
# offset from :17 (ci-required-drift) and :00 (peak cron load).
|
||||
@@ -63,12 +58,7 @@ concurrency:
|
||||
jobs:
|
||||
watchdog:
|
||||
runs-on: ubuntu-latest
|
||||
# rev3 (2026-05-12, mc#645 revert): raised 5 → 15 to absorb runner-saturation
|
||||
# latency. Original 5min cap was producing 124-style cancels under load,
|
||||
# which fed the very `[main-red]` issues this workflow files (self-poisoning).
|
||||
# 15min is still well below Gitea-default 6h job ceiling; if a real hang
|
||||
# occurs the issue-file path is still the alarm surface.
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out repo (script lives at .gitea/scripts/)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -54,12 +54,7 @@ env:
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: Build & push canvas image
|
||||
# infra/docker-label-registration (molecule-ai/operator-config PR #30): `docker` label
|
||||
# is now registered on all act_runners that mount /var/run/docker.sock. This change
|
||||
# routes publish jobs exclusively to Docker-capable runners (no more coin-flip failures).
|
||||
# Prerequisite: operator host must be rolled to pick up new runner config. See
|
||||
# molecule-ai/molecule-core issue #711.
|
||||
runs-on: [ubuntu-latest, docker]
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
@@ -84,10 +79,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Docker daemon health check"
|
||||
echo "Runner: ${HOSTNAME:-unknown}"
|
||||
docker info 2>&1 | head -5 || {
|
||||
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
|
||||
echo "::error::Runner: ${HOSTNAME:-unknown}"
|
||||
echo "::error::Check: (1) daemon running, (2) runner user in docker group, (3) sock perms 660+"
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -52,12 +52,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
# infra/docker-label-registration (molecule-ai/operator-config PR #30): `docker` label
|
||||
# is now registered on all act_runners that mount /var/run/docker.sock. This change
|
||||
# routes publish jobs exclusively to Docker-capable runners (no more coin-flip failures).
|
||||
# Prerequisite: operator host must be rolled to pick up new runner config. See
|
||||
# molecule-ai/molecule-core issue #711.
|
||||
runs-on: [ubuntu-latest, docker]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -73,10 +68,8 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "::group::Docker daemon health check"
|
||||
echo "Runner: ${HOSTNAME:-unknown}"
|
||||
docker info 2>&1 | head -5 || {
|
||||
echo "::error::Docker daemon is not accessible at /var/run/docker.sock"
|
||||
echo "::error::Runner: ${HOSTNAME:-unknown}"
|
||||
echo "::error::Check: (1) daemon is running, (2) runner user is in docker group, (3) sock permissions are 660+"
|
||||
exit 1
|
||||
}
|
||||
@@ -103,11 +96,8 @@ jobs:
|
||||
# 2026-05-08 migration). The token is only needed for private repos.
|
||||
# Do NOT require it — a missing secret would fail the build unnecessarily.
|
||||
mkdir -p .tenant-bundle-deps
|
||||
# Strip JSON5 comments before jq parsing — Integration Tester appends
|
||||
# `// Triggered by ...` which breaks `jq` in clone-manifest.sh.
|
||||
sed '/^[[:space:]]*\/\//d' manifest.json > .manifest-stripped.json
|
||||
bash scripts/clone-manifest.sh \
|
||||
.manifest-stripped.json \
|
||||
manifest.json \
|
||||
.tenant-bundle-deps/workspace-configs-templates \
|
||||
.tenant-bundle-deps/org-templates \
|
||||
.tenant-bundle-deps/plugins
|
||||
|
||||
@@ -50,10 +50,10 @@ name: redeploy-tenants-on-main
|
||||
# target_tag=<sha>, re-pulling the older image on every tenant.
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows: ['publish-workspace-server-image']
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
permissions:
|
||||
contents: read
|
||||
# No write scopes needed — the workflow hits an external CP endpoint,
|
||||
@@ -79,11 +79,11 @@ env:
|
||||
jobs:
|
||||
redeploy:
|
||||
# Skip the auto-trigger if publish-workspace-server-image didn't
|
||||
# actually succeed. The push trigger fires when the workflow file
|
||||
# is updated (post-merge of publish-workspace-server-image). This is
|
||||
# the best-available proxy for "publish succeeded" without workflow_run.
|
||||
# If the push was from a revert or a partial publish, continue-on-error
|
||||
# on the individual job means the redeploy failure won't block merges.
|
||||
# actually succeed. workflow_run fires on any completion state; we
|
||||
# don't want to redeploy against a half-built image.
|
||||
# NOTE (Gitea port): workflow_dispatch trigger dropped; only the
|
||||
# workflow_run path remains.
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
continue-on-error: true
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
# dispatch with no input falls through to github.sha.
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.target_tag }}
|
||||
HEAD_SHA: ${{ github.sha }}
|
||||
HEAD_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${INPUT_TAG:-}" ]; then
|
||||
@@ -251,7 +251,7 @@ jobs:
|
||||
# GHCR's manifest. For workflow_run (default :latest) the
|
||||
# workflow_run.head_sha is the SHA that just published.
|
||||
env:
|
||||
EXPECTED_SHA: ${{ github.sha }}
|
||||
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
TARGET_TAG: ${{ steps.tag.outputs.target_tag }}
|
||||
# Tenant subdomain template — slugs from the response are
|
||||
# appended. Production CP issues `<slug>.moleculesai.app`;
|
||||
|
||||
@@ -50,10 +50,10 @@ name: redeploy-tenants-on-staging
|
||||
# of a known-good build.
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows: ['publish-workspace-server-image']
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
permissions:
|
||||
contents: read
|
||||
# No write scopes needed — the workflow hits an external CP endpoint,
|
||||
@@ -72,12 +72,12 @@ env:
|
||||
|
||||
jobs:
|
||||
redeploy:
|
||||
# The push trigger fires when publish-workspace-server-image.yml is updated
|
||||
# (post-merge of the publish workflow). This is the best-available proxy
|
||||
# for "publish succeeded" without workflow_run. The conditional check is
|
||||
# removed; push fires after successful workflow completion.
|
||||
# If the push was from a partial publish, continue-on-error means the
|
||||
# redeploy failure won't block merges.
|
||||
# Skip the auto-trigger if publish-workspace-server-image didn't
|
||||
# actually succeed. workflow_run fires on any completion state; we
|
||||
# don't want to redeploy against a half-built image.
|
||||
# NOTE (Gitea port): workflow_dispatch trigger dropped; only the
|
||||
# workflow_run path remains.
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
continue-on-error: true
|
||||
@@ -237,7 +237,7 @@ jobs:
|
||||
# ssm_status-success-but-stale-image hazard and benefits from the
|
||||
# same gate. Diff: TENANT_DOMAIN includes the `staging.` infix.
|
||||
env:
|
||||
EXPECTED_SHA: ${{ github.sha }}
|
||||
EXPECTED_SHA: ${{ github.event.workflow_run.head_sha || github.sha }}
|
||||
TARGET_TAG: ${{ inputs.target_tag || 'staging-latest' }}
|
||||
TENANT_DOMAIN: 'staging.moleculesai.app'
|
||||
run: |
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
name: review-check-tests
|
||||
|
||||
# Runs review-check.sh regression tests on every PR + push that touches
|
||||
# the evaluator script or its test fixtures.
|
||||
#
|
||||
# Follows RFC#324 follow-up (issue #540):
|
||||
# .gitea/scripts/review-check.sh is load-bearing for PR merge gates.
|
||||
# It has ZERO production CI coverage. This workflow closes that gap.
|
||||
#
|
||||
# Design choices:
|
||||
# - Bash test harness (not bats). The existing test_review_check.sh
|
||||
# uses a custom assert_eq/assert_contains framework that is already
|
||||
# working and covers all 13 acceptance criteria (issue #540 §Acceptance).
|
||||
# Converting to bats would be refactoring, not closing the gap.
|
||||
# - No bats dependency: the runner-base image needs no extra tooling.
|
||||
# - continue-on-error: false — these tests must pass; a failure means
|
||||
# the review-gate evaluator is broken and must not be merged.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- '.gitea/scripts/review-check.sh'
|
||||
- '.gitea/scripts/tests/test_review_check.sh'
|
||||
- '.gitea/scripts/tests/_review_check_fixture.py'
|
||||
- '.gitea/workflows/review-check-tests.yml'
|
||||
pull_request:
|
||||
branches: [main, staging]
|
||||
paths:
|
||||
- '.gitea/scripts/review-check.sh'
|
||||
- '.gitea/scripts/tests/test_review_check.sh'
|
||||
- '.gitea/scripts/tests/_review_check_fixture.py'
|
||||
- '.gitea/workflows/review-check-tests.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: review-check.sh regression tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Install jq
|
||||
# Required for T12 jq-filter test case. Gitea Actions runners (ubuntu-latest
|
||||
# label) do not bundle jq. Install via apt-get first (reliable for Ubuntu
|
||||
# runners with internet access to package mirrors). Falls back to GitHub
|
||||
# binary download. GitHub releases may be blocked on some runner networks
|
||||
# (infra#241 follow-up).
|
||||
continue-on-error: true
|
||||
run: |
|
||||
if apt-get update -qq && apt-get install -y -qq jq; then
|
||||
echo "::notice::jq installed via apt-get: $(jq --version)"
|
||||
elif timeout 120 curl -sSL \
|
||||
"https://github.com/jqlang/jq/releases/download/jq-1.7.1/jq-linux-amd64" \
|
||||
-o /usr/local/bin/jq && chmod +x /usr/local/bin/jq; then
|
||||
echo "::notice::jq binary downloaded: $(/usr/local/bin/jq --version)"
|
||||
else
|
||||
echo "::warning::jq install failed — apt-get and GitHub download both failed."
|
||||
fi
|
||||
jq --version 2>/dev/null || echo "::notice::jq not yet available — continuing"
|
||||
|
||||
- name: Run review-check.sh regression suite
|
||||
run: bash .gitea/scripts/tests/test_review_check.sh
|
||||
@@ -1,121 +0,0 @@
|
||||
# sop-checklist-gate — peer-ack merge gate for SOP-checklist items.
|
||||
#
|
||||
# RFC#351 Step 2 of 6 (implementation MVP).
|
||||
#
|
||||
# === DESIGN ===
|
||||
#
|
||||
# Goal: each PR must answer 7 SOP-checklist questions in its body,
|
||||
# and each item must have at least one /sop-ack <slug> comment from
|
||||
# a non-author peer in the required team. BP requires the
|
||||
# `sop-checklist / all-items-acked (pull_request)` status to merge.
|
||||
#
|
||||
# Triggers:
|
||||
# - `pull_request_target`: opened, edited, synchronize, reopened
|
||||
# → fires when PR opens, body is edited (refire — RFC#351 §4),
|
||||
# or new code is pushed (head.sha changes → stale status would
|
||||
# be auto-discarded by BP via dismiss_stale_reviews, but the
|
||||
# status itself is per-SHA so we re-post on the new head).
|
||||
# - `issue_comment`: created, edited, deleted
|
||||
# → fires on any new comment so /sop-ack / /sop-revoke take
|
||||
# effect immediately (Gitea 1.22.6 doesn't refire on
|
||||
# pull_request_review per feedback_pull_request_review_no_refire,
|
||||
# so issue_comment is the canonical refire channel).
|
||||
#
|
||||
# Trust boundary (mirrors RFC#324 §A4 + sop-tier-check security note):
|
||||
# `pull_request_target` (not `pull_request`) — workflow def is loaded
|
||||
# from BASE branch, so a PR cannot rewrite this workflow to exfiltrate
|
||||
# the token. The `actions/checkout` step pins `ref: base.sha` so the
|
||||
# script ALSO comes from BASE. PR-HEAD code is never executed in the
|
||||
# runner.
|
||||
#
|
||||
# Token scope:
|
||||
# - read:repository, read:organization for PR + comments + team probes
|
||||
# - write:repository for POST /statuses/{sha}
|
||||
# - The token owner MUST be a member of every team referenced by the
|
||||
# config's required_teams (else /teams/{id}/members/{login} returns
|
||||
# 403 — see review-check.sh same-gotcha doc). For the MVP we use
|
||||
# the dev-lead token (a member of engineers, managers, qa, security)
|
||||
# via a repo secret `SOP_CHECKLIST_GATE_TOKEN`. Provisioning of that
|
||||
# secret is a follow-up authorization step (separate from this PR).
|
||||
#
|
||||
# Failure mode: tier-aware (RFC#351 open question 2):
|
||||
# - tier:high → state=failure (hard-fail; BP blocks merge)
|
||||
# - tier:medium → state=failure (hard-fail; same)
|
||||
# - tier:low → state=pending (soft-fail; BP can choose to require
|
||||
# this context or skip for low-tier PRs)
|
||||
# - missing/no-tier → state=failure (default-mode: hard — never lower
|
||||
# the bar per feedback_fix_root_not_symptom)
|
||||
#
|
||||
# Slash-command contract (RFC#351 v1 + §A1.1-style notes from RFC#324):
|
||||
#
|
||||
# /sop-ack <slug-or-numeric-alias> [optional note]
|
||||
# — register a peer-ack for one checklist item.
|
||||
# — slug accepts kebab-case, snake_case, or natural-spaces
|
||||
# (all normalize to canonical kebab-case).
|
||||
# — numeric 1..7 maps via config.items[*].numeric_alias.
|
||||
# — most-recent (user, slug) directive wins.
|
||||
#
|
||||
# /sop-revoke <slug-or-numeric-alias> [reason]
|
||||
# — invalidate the commenter's own prior /sop-ack for this slug.
|
||||
# — does NOT affect other peers' acks on the same slug.
|
||||
# — most-recent (user, slug) directive wins, so a later /sop-ack
|
||||
# re-restores the ack.
|
||||
#
|
||||
# The eval is read-only + idempotent (read PR + comments + team
|
||||
# membership, compute, post status). Re-running on any event is safe —
|
||||
# the new status overwrites the previous one for the same context.
|
||||
|
||||
name: sop-checklist-gate
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
issue_comment:
|
||||
types: [created, edited, deleted]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
# NOTE: `statuses: write` is the GitHub-Actions name for POST /statuses.
|
||||
# Gitea 1.22.6 may not gate on this permission key (it just checks the
|
||||
# token), but listing it explicitly documents intent for the next
|
||||
# platform-version upgrade.
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
# Run on pull_request_target events always. On issue_comment events,
|
||||
# only when the comment is on a PR (issue_comment fires for issues
|
||||
# too) and the body contains one of the slash-commands.
|
||||
if: |
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request != null &&
|
||||
(contains(github.event.comment.body, '/sop-ack') ||
|
||||
contains(github.event.comment.body, '/sop-revoke')))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out BASE ref (trust boundary — never PR-head)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
# For pull_request_target, the default branch is the trust
|
||||
# anchor. For issue_comment the PR base may differ from the
|
||||
# default branch (PR targeting `staging`), so we use the
|
||||
# default-branch ref explicitly — same approach as
|
||||
# qa-review.yml so the script source is always trusted.
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Run sop-checklist-gate
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
OWNER: ${{ github.repository_owner }}
|
||||
REPO_NAME: ${{ github.event.repository.name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 .gitea/scripts/sop-checklist-gate.py \
|
||||
--owner "$OWNER" \
|
||||
--repo "$REPO_NAME" \
|
||||
--pr "$PR_NUMBER" \
|
||||
--config .gitea/sop-checklist-config.yaml \
|
||||
--gitea-host git.moleculesai.app
|
||||
@@ -59,10 +59,9 @@ name: Staging verify
|
||||
# are populated.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- '.gitea/workflows/publish-workspace-server-image.yml'
|
||||
workflow_run:
|
||||
workflows: ["publish-workspace-server-image"]
|
||||
types: [completed]
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -79,10 +78,10 @@ env:
|
||||
|
||||
jobs:
|
||||
staging-smoke:
|
||||
# The push trigger fires when publish-workspace-server-image.yml is updated
|
||||
# (post-merge of the publish workflow). This is the best-available proxy
|
||||
# for "publish succeeded" without workflow_run. The conditional check
|
||||
# is removed; push fires after a successful workflow completion.
|
||||
# Skip when the upstream workflow failed — no image to test against.
|
||||
# workflow_dispatch trigger dropped in this Gitea port; only the
|
||||
# workflow_run path remains.
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
continue-on-error: true
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
# status-reaper — Option B (compensating-status POST) for Gitea 1.22.6's
|
||||
# hardcoded `(push)` suffix on default-branch commit statuses.
|
||||
#
|
||||
# Tracking: molecule-core#? (this PR), internal#327 (sibling publish-runtime-bot),
|
||||
# internal#328 (sibling mc-drift-bot), internal#80 (upstream RFC). Sister
|
||||
# bots already deployed under the same per-persona-identity contract
|
||||
# (`feedback_per_agent_gitea_identity_default`).
|
||||
#
|
||||
# Root cause:
|
||||
# Gitea 1.22.6 emits commit-status context as
|
||||
# `<workflow_name> / <job_name> (push)`
|
||||
# for ANY workflow run on the default branch's HEAD commit, REGARDLESS
|
||||
# of the trigger event. Schedule- and workflow_dispatch-triggered runs
|
||||
# on `main` therefore appear as `(push)` failures on the latest main
|
||||
# commit, painting main red via a fake-push status. Verified on runs
|
||||
# 14525 + 14526 via Phase 1 evidence (3 sub-agents). No upstream fix
|
||||
# in 1.23-1.26.1 (sibling a6f20db1 research).
|
||||
#
|
||||
# Why a cron-driven reaper, not workflow_run:
|
||||
# Gitea 1.22.6 does NOT support `on: workflow_run` (verified via
|
||||
# modules/actions/workflows.go enumeration; sister a6f20db1). The
|
||||
# only event-shaped option that fires is cron. 5min is chosen to
|
||||
# sit BETWEEN ci-required-drift (`:17` hourly) and main-red-watchdog
|
||||
# (`:05` hourly) so the reaper sweeps red before the watchdog files
|
||||
# a `[main-red]` issue (would-be false-positive).
|
||||
#
|
||||
# What the reaper does each tick:
|
||||
# 1. Parse `.gitea/workflows/*.yml`, classify each by whether `on:`
|
||||
# contains a `push:` trigger (see script for workflow_id resolution
|
||||
# including `name:` collision and `/`-in-name fail-loud lints).
|
||||
# 2. GET combined status for main HEAD.
|
||||
# 3. For each `failure` status whose context ends ` (push)`:
|
||||
# - if workflow has push trigger: PRESERVE (real defect signal).
|
||||
# - if workflow has no push trigger: POST a compensating
|
||||
# `state=success` with the same context and a description that
|
||||
# documents the workaround.
|
||||
#
|
||||
# What it does NOT do:
|
||||
# - Mutate non-`(push)`-suffix statuses (e.g. `(pull_request)` from
|
||||
# branch_protections required-checks — verified safe 2026-05-11).
|
||||
# - Auto-revert. Same reasoning as main-red-watchdog.
|
||||
# - Cancel runs. The runs themselves stay visible in Actions UI; the
|
||||
# fix is at the commit-status surface only.
|
||||
#
|
||||
# Removal path: drop this workflow when Gitea ≥ 1.24 ships with a
|
||||
# real fix for the hardcoded-suffix bug. Audit issue (filed post-merge)
|
||||
# tracks the deletion as a follow-up sweep.
|
||||
|
||||
name: status-reaper
|
||||
|
||||
# IMPORTANT — Gitea 1.22.6 parser quirk per
|
||||
# `feedback_gitea_workflow_dispatch_inputs_unsupported`: do NOT add an
|
||||
# `inputs:` block here. Gitea 1.22.6 rejects the whole workflow as
|
||||
# "unknown on type" when `workflow_dispatch.inputs.X` is present.
|
||||
on:
|
||||
# SCHEDULE RE-ENABLED 2026-05-12 rev3 — interim disable (mc#645) reverted now that
|
||||
# rev3 widens DEFAULT_SWEEP_LIMIT 10 → 30 (covers retroactive-failure timing window).
|
||||
# Sibling watchdog re-enabled in the same PR with timeout-minutes raised 5 → 15.
|
||||
schedule:
|
||||
# Every 5 minutes. Off-zero alignment with sibling cron workflows:
|
||||
# ci-required-drift (`:17`), main-red-watchdog (`:05`),
|
||||
# railway-pin-audit (`:23`). 5-min cadence gives a tight enough
|
||||
# close on schedule-triggered false-reds that main-red-watchdog
|
||||
# (hourly :05) almost never files an issue on the false case.
|
||||
# rev3 keeps `*/5` unchanged per hongming-pc2 03:25Z review:
|
||||
# "trades window-width-cheap for cadence-loady" — N=30 widens
|
||||
# the lookback cheaply without doubling runner load via `*/2`.
|
||||
- cron: '*/5 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
# Compensating-status POST needs write on repo statuses; no other
|
||||
# write surface is touched. checkout still needs `contents: read`.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# NOTE: NO `concurrency:` block is intentional.
|
||||
# Gitea 1.22.6 doesn't honor `cancel-in-progress: false`: queued ticks
|
||||
# of the same group get cancelled-with-started=0 instead of waiting
|
||||
# (DB-verified 2026-05-12, runs 16053/16085 of status-reaper.yml).
|
||||
# The reaper's POST /statuses/{sha} is idempotent — Gitea de-dups by
|
||||
# context — so concurrent ticks are safe; accept them rather than
|
||||
# serialise via the broken mechanism.
|
||||
|
||||
jobs:
|
||||
reap:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 3
|
||||
steps:
|
||||
- name: Check out repo at default-branch HEAD
|
||||
# BASE checkout per `feedback_pull_request_target_workflow_from_base`.
|
||||
# The script reads .gitea/workflows/*.yml from the working tree to
|
||||
# classify trigger sets; we must read main's CURRENT state, not
|
||||
# the SHA a stale schedule fired against.
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Set up Python (PyYAML for workflow `on:` parse)
|
||||
# Pinned to 3.12 to match sibling watchdog / ci-required-drift.
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install PyYAML
|
||||
# PyYAML is needed because shell-grep on `on:` misses list/string
|
||||
# forms and nested `push: { paths: ... }`. Same install pattern
|
||||
# as ci-required-drift.yml (sub-2s install, no wheel cache).
|
||||
run: python -m pip install --quiet 'PyYAML==6.0.2'
|
||||
|
||||
- name: Compensate operational push-suffix failures on main
|
||||
env:
|
||||
# claude-status-reaper persona token; provisioned by sibling
|
||||
# aefaac1b 2026-05-11. Owns write:repository scope to POST
|
||||
# /statuses/{sha} but NOTHING ELSE
|
||||
# (`feedback_per_agent_gitea_identity_default`).
|
||||
GITEA_TOKEN: ${{ secrets.STATUS_REAPER_TOKEN }}
|
||||
GITEA_HOST: git.moleculesai.app
|
||||
REPO: ${{ github.repository }}
|
||||
WATCH_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
WORKFLOWS_DIR: .gitea/workflows
|
||||
run: python3 .gitea/scripts/status-reaper.py
|
||||
@@ -1,120 +0,0 @@
|
||||
name: Weekly Platform-Go Surface
|
||||
|
||||
# Surface latent vet/test errors on main by running the full Platform-Go
|
||||
# suite on a weekly cron regardless of whether the last push touched
|
||||
# workspace-server/.
|
||||
#
|
||||
# Background: ci.yml's `platform-build` job gates real work on
|
||||
# `if: needs.changes.outputs.platform == 'true'`. When no push touches
|
||||
# workspace-server/, the skip fires and the suite never executes on main.
|
||||
# Latent vet errors and test flakes can sit for weeks undetected.
|
||||
#
|
||||
# This workflow runs the full suite (build, vet, golangci-lint, tests with
|
||||
# coverage) every Monday at 04:17 UTC. Results are posted as commit statuses
|
||||
# but continue-on-error: true means they never block anything — they're
|
||||
# purely a noise-reduction signal for when the next workspace-server push
|
||||
# lands and would otherwise trigger the first real suite run.
|
||||
#
|
||||
# Why 04:17 UTC on Monday: off-peak, before the weekly sprint cycle starts.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 4 * * 1' # Mondays at 04:17 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
|
||||
jobs:
|
||||
weekly-platform-go:
|
||||
name: Weekly Platform-Go Surface
|
||||
runs-on: ubuntu-latest
|
||||
# continue-on-error: surface only, never block
|
||||
continue-on-error: true
|
||||
defaults:
|
||||
run:
|
||||
working-directory: workspace-server
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version: stable
|
||||
|
||||
- name: Go mod download
|
||||
run: go mod download
|
||||
|
||||
- name: Build
|
||||
run: go build ./cmd/server
|
||||
|
||||
# `go vet` is NOT `|| true`-guarded: surfacing latent vet errors on main is
|
||||
# the whole point of this workflow (issue #567 — the motivating case was a
|
||||
# `go vet` error in org_external.go that sat undetected on main for weeks).
|
||||
# A vet error here fails the step → fails the job → shows red on the weekly
|
||||
# commit. Per Gitea quirk #10 (job-level continue-on-error is ignored), that
|
||||
# red surfaces on main — which is the intended signal, not a regression.
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
# golangci-lint stays `|| true`-guarded: lint is noisier (more false-
|
||||
# positives than vet) and golangci-lint may not be pre-installed on every
|
||||
# runner image — a `|| true` here keeps a missing-binary or lint-noise case
|
||||
# from masking the vet/test signal above. Tighten to match ci.yml's lint
|
||||
# gate if/when ci.yml's lint step becomes hard-failing.
|
||||
- name: golangci-lint
|
||||
run: golangci-lint run --timeout 3m ./... || true
|
||||
|
||||
- name: Tests with race detection + coverage
|
||||
run: go test -race -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Check coverage thresholds
|
||||
run: |
|
||||
set -e
|
||||
TOTAL_FLOOR=25
|
||||
CRITICAL_PATHS=(
|
||||
"internal/handlers/tokens"
|
||||
"internal/handlers/workspace_provision"
|
||||
"internal/handlers/a2a_proxy"
|
||||
"internal/handlers/registry"
|
||||
"internal/handlers/secrets"
|
||||
"internal/middleware/wsauth"
|
||||
"internal/crypto"
|
||||
)
|
||||
|
||||
TOTAL=$(go tool cover -func=coverage.out | grep '^total:' | awk '{print $3}' | sed 's/%//')
|
||||
echo "Total coverage: ${TOTAL}%"
|
||||
if awk "BEGIN{exit !(\$TOTAL < \$TOTAL_FLOOR)}"; then
|
||||
echo "::error::Total coverage \${TOTAL}% is below the \${TOTAL_FLOOR}% floor."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ALLOWLIST=""
|
||||
if [ -f ../.coverage-allowlist.txt ]; then
|
||||
ALLOWLIST=$(grep -vE '^(#|[[:space:]]*$)' ../.coverage-allowlist.txt || true)
|
||||
fi
|
||||
|
||||
FAILED=0
|
||||
for path in "\${CRITICAL_PATHS[@]}"; do
|
||||
while read -r file pct; do
|
||||
[[ "$file" == *_test.go ]] && continue
|
||||
[[ "$file" == *"$path"* ]] || continue
|
||||
awk "BEGIN{exit !(\$pct < 10)}" || continue
|
||||
rel=$(echo "$file" | sed 's|^github.com/molecule-ai/molecule-monorepo/platform/workspace-server/||; s|^github.com/molecule-ai/molecule-monorepo/platform/||')
|
||||
if echo "$ALLOWLIST" | grep -qxF "$rel"; then
|
||||
continue
|
||||
fi
|
||||
echo "::error::Low coverage \${pct}% on \${rel} (below 10% in critical path \${path})"
|
||||
FAILED=$((FAILED + 1))
|
||||
done < <(go tool cover -func=coverage.out | grep -v '^total:' | awk '{file=$1; sub(/:[0-9][0-9.]*:.*/, "", file); pct=$NF; gsub(/%/,"",pct); s[file]+=pct; c[file]++} END {for (f in s) printf "%s %.1f\n", f, s[f]/c[f]}' | sort)
|
||||
done
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "::error::\${FAILED} critical paths below 10% coverage — see above."
|
||||
exit 1
|
||||
fi
|
||||
echo "Coverage thresholds: OK"
|
||||
@@ -156,16 +156,6 @@ and run CI manually.
|
||||
| python-lint | pytest with coverage |
|
||||
| e2e-api | Full API test suite (62 tests) |
|
||||
| shellcheck | Shell script linting |
|
||||
| review-check-tests | `review-check.sh` evaluator regression suite (13 scenarios) |
|
||||
| ops-scripts | Python unittest suite for `scripts/*.py` |
|
||||
|
||||
## Local Testing
|
||||
|
||||
### review-check.sh
|
||||
```bash
|
||||
bash .gitea/scripts/tests/test_review_check.sh
|
||||
```
|
||||
Runs the full regression suite against a fixture HTTP server. No network access required.
|
||||
|
||||
## Code Style
|
||||
|
||||
|
||||
@@ -63,7 +63,6 @@ export function DropTargetBadge() {
|
||||
<>
|
||||
{ghostVisible && (
|
||||
<div
|
||||
data-testid="ghost-slot"
|
||||
className="pointer-events-none absolute z-40 rounded-lg border-2 border-dashed border-emerald-400/70 bg-emerald-500/10"
|
||||
style={{
|
||||
left: slotTL.x,
|
||||
@@ -74,7 +73,6 @@ export function DropTargetBadge() {
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
data-testid="drop-badge"
|
||||
className="pointer-events-none absolute z-50 -translate-x-1/2 -translate-y-full rounded-md bg-emerald-500 px-2 py-0.5 text-[11px] font-medium text-emerald-50 shadow-lg shadow-emerald-950/40"
|
||||
style={{ left: badge.x, top: badge.y - 6 }}
|
||||
>
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for DropTargetBadge — floating drag affordance rendered over the
|
||||
* ReactFlow canvas while a workspace node is being dragged onto a parent.
|
||||
*
|
||||
* Covers:
|
||||
* - Renders nothing when dragOverNodeId is null
|
||||
* - Renders nothing when target node not found in store
|
||||
* - Renders nothing when getInternalNode returns null
|
||||
* - Renders ghost slot + badge when valid target is found
|
||||
* - Ghost hidden when slot falls outside parent bounds
|
||||
* - Badge text includes the target workspace name
|
||||
* - Badge positioned via screen-space coordinates from flowToScreenPosition
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DropTargetBadge } from "../DropTargetBadge";
|
||||
|
||||
// ─── Mutable store state — hoisted so vi.mock factory closures capture the ref ─
|
||||
|
||||
let _storeState: {
|
||||
dragOverNodeId: string | null;
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
data: Record<string, unknown>;
|
||||
parentId: string | null;
|
||||
measured?: { width: number; height: number };
|
||||
}>;
|
||||
} = {
|
||||
dragOverNodeId: null,
|
||||
nodes: [],
|
||||
};
|
||||
|
||||
const _subscribers = new Set<() => void>();
|
||||
function _notifySubscribers() {
|
||||
for (const fn of _subscribers) fn();
|
||||
}
|
||||
|
||||
const _mockUseCanvasStore = vi.hoisted(() => {
|
||||
const impl = (selector: (s: typeof _storeState) => unknown) => selector(_storeState);
|
||||
return impl;
|
||||
});
|
||||
|
||||
// Module-level mutable impl — setFlowMock() swaps it out per test.
|
||||
let _flowImpl: (arg: { x: number; y: number }) => { x: number; y: number } =
|
||||
({ x, y }) => ({ x: x * 2, y: y * 2 });
|
||||
|
||||
let _flowToScreenPosition = vi.hoisted(() =>
|
||||
vi.fn((arg: { x: number; y: number }) => _flowImpl(arg)),
|
||||
);
|
||||
|
||||
let _getInternalNode = vi.hoisted(() =>
|
||||
vi.fn<(id: string) => {
|
||||
internals: { positionAbsolute: { x: number; y: number } };
|
||||
measured?: { width: number; height: number };
|
||||
} | null>(() => null),
|
||||
);
|
||||
|
||||
const _mockUseReactFlow = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
getInternalNode: _getInternalNode,
|
||||
flowToScreenPosition: _flowToScreenPosition,
|
||||
})),
|
||||
);
|
||||
|
||||
// ─── Module mocks ─────────────────────────────────────────────────────────────
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: _mockUseCanvasStore,
|
||||
}));
|
||||
|
||||
vi.mock("@xyflow/react", () => ({
|
||||
useReactFlow: _mockUseReactFlow,
|
||||
}));
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function setStore(state: Partial<typeof _storeState>) {
|
||||
_storeState = { ..._storeState, ...state };
|
||||
_notifySubscribers();
|
||||
}
|
||||
|
||||
// Helper to set per-test flowToScreenPosition mock — replaces _flowImpl.
|
||||
function setFlowMock(impl: (arg: { x: number; y: number }) => { x: number; y: number }) {
|
||||
_flowImpl = impl;
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DropTargetBadge — renders nothing when not dragging", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_storeState = { dragOverNodeId: null, nodes: [] };
|
||||
_getInternalNode.mockReset().mockReturnValue(null);
|
||||
_flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 });
|
||||
});
|
||||
|
||||
it("returns null when dragOverNodeId is null", () => {
|
||||
setStore({ dragOverNodeId: null });
|
||||
render(<DropTargetBadge />);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
|
||||
it("returns null when target node not found in store nodes array", () => {
|
||||
setStore({ dragOverNodeId: "ws-target", nodes: [] });
|
||||
render(<DropTargetBadge />);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropTargetBadge — renders nothing when getInternalNode is null", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_storeState = { dragOverNodeId: null, nodes: [] };
|
||||
_getInternalNode.mockReset().mockReturnValue(null);
|
||||
_flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 });
|
||||
});
|
||||
|
||||
it("returns null when getInternalNode returns null (node not in RF viewport)", () => {
|
||||
_getInternalNode.mockReturnValue(null);
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [{ id: "ws-target", data: { name: "Target WS" }, parentId: null }],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DropTargetBadge — renders ghost slot + badge for valid drag target", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_storeState = { dragOverNodeId: null, nodes: [] };
|
||||
_getInternalNode.mockReset().mockReturnValue(null);
|
||||
_flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 });
|
||||
});
|
||||
|
||||
it("renders the drop badge with target name", () => {
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
measured: { width: 220, height: 120 },
|
||||
});
|
||||
_flowToScreenPosition
|
||||
.mockReturnValueOnce({ x: 500, y: 400 }) // slotTL
|
||||
.mockReturnValueOnce({ x: 900, y: 600 }) // slotBR
|
||||
.mockReturnValueOnce({ x: 700, y: 200 }); // badge
|
||||
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [
|
||||
{ id: "ws-target", data: { name: "SEO Workspace" }, parentId: null, measured: { width: 220, height: 120 } },
|
||||
],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
expect(screen.getByText(/Drop into: SEO Workspace/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders the ghost slot div via data-testid", () => {
|
||||
// measured.height must be large enough that parentBR.y > slotTL.y=330 so
|
||||
// ghostVisible = (slotTL.y < parentBR.y) is true.
|
||||
// parentBR.y = abs.y + measured.height = 200 + h > 330 → h > 130
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
measured: { width: 220, height: 500 },
|
||||
});
|
||||
// Component calls flowToScreenPosition 5 times (confirmed via debug):
|
||||
// 1) badge {x:210, y:200} -> {x:420, y:400} (badge center)
|
||||
// 2) slotTL {x:116, y:330} -> {x:232, y:660} (slot origin)
|
||||
// 3) slotBR {x:356, y:460} -> {x:712, y:920} (ghost uses this)
|
||||
// 4) parentTL {x:100, y:200} -> {x:200, y:400} (parent origin)
|
||||
// 5) parentBR {x:320, y:320} -> {x:640, y:640} (parent corner)
|
||||
setFlowMock(({ x, y }: { x: number; y: number }) => {
|
||||
if (x === 210 && y === 200) return { x: 420, y: 400 };
|
||||
if (x === 116 && y === 330) return { x: 232, y: 660 };
|
||||
if (x === 356 && y === 460) return { x: 712, y: 920 };
|
||||
if (x === 100 && y === 200) return { x: 200, y: 400 };
|
||||
// 5th call: parentBR = abs + {w:220, h:500} = {320, 700}
|
||||
if (x === 320 && y === 700) return { x: 640, y: 1400 };
|
||||
return { x: x * 2, y: y * 2 };
|
||||
});
|
||||
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [
|
||||
{ id: "ws-target", data: { name: "Target" }, parentId: null, measured: { width: 220, height: 500 } },
|
||||
],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
expect(screen.getByTestId("ghost-slot")).toBeTruthy();
|
||||
// Ghost uses slotBR from 3rd call: slotBR - slotTL = (712-232, 920-660)
|
||||
expect(screen.getByTestId("ghost-slot").style.left).toBe("232px");
|
||||
expect(screen.getByTestId("ghost-slot").style.top).toBe("660px");
|
||||
expect(screen.getByTestId("ghost-slot").style.width).toBe("480px");
|
||||
expect(screen.getByTestId("ghost-slot").style.height).toBe("260px");
|
||||
});
|
||||
|
||||
it("ghost is hidden when slot falls entirely outside parent bounds", () => {
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
measured: { width: 220, height: 120 },
|
||||
});
|
||||
// Set slotBR (3rd call) to be inside parent to hide ghost.
|
||||
// slotBR.x ≤ parentTL.x makes slotBR.x - slotTL.x < 0 → ghostVisible = false.
|
||||
setFlowMock(({ x, y }: { x: number; y: number }) => {
|
||||
if (x === 210 && y === 200) return { x: 420, y: 400 }; // badge (1st call)
|
||||
if (x === 116 && y === 330) return { x: 232, y: 660 }; // slotTL (2nd call)
|
||||
if (x === 356 && y === 460) return { x: 150, y: 460 }; // slotBR (3rd): slotBR.x=150 < parentTL.x=200 → hidden
|
||||
if (x === 100 && y === 200) return { x: 200, y: 400 }; // parentTL (4th call)
|
||||
if (x === 320 && y === 320) return { x: 640, y: 640 }; // parentBR (5th call)
|
||||
return { x: x * 2, y: y * 2 };
|
||||
});
|
||||
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [
|
||||
{ id: "ws-target", data: { name: "Tiny" }, parentId: null, measured: { width: 220, height: 120 } },
|
||||
],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
// Badge should still render, ghost should not
|
||||
expect(screen.getByText(/Drop into: Tiny/)).toBeTruthy();
|
||||
expect(screen.queryByTestId("ghost-slot")).toBeNull();
|
||||
});
|
||||
|
||||
it("badge is absolutely positioned with left and top from flowToScreenPosition", () => {
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
measured: { width: 220, height: 120 },
|
||||
});
|
||||
setFlowMock(({ x, y }: { x: number; y: number }) => {
|
||||
if (x === 210 && y === 200) return { x: 420, y: 400 };
|
||||
if (x === 116 && y === 330) return { x: 232, y: 660 };
|
||||
if (x === 356 && y === 460) return { x: 712, y: 920 };
|
||||
if (x === 100 && y === 200) return { x: 200, y: 400 };
|
||||
if (x === 320 && y === 320) return { x: 640, y: 640 };
|
||||
return { x: x * 2, y: y * 2 };
|
||||
});
|
||||
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [
|
||||
{ id: "ws-target", data: { name: "Target" }, parentId: null, measured: { width: 220, height: 120 } },
|
||||
],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
expect(screen.getByTestId("drop-badge")).toBeTruthy();
|
||||
// Badge uses 1st call: {x:210,y:200} -> {x:420,y:400}, badge.y = 400-6 = 394
|
||||
expect(screen.getByTestId("drop-badge").style.left).toBe("420px");
|
||||
expect(screen.getByTestId("drop-badge").style.top).toBe("394px");
|
||||
expect(screen.getByText(/Drop into: Target/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -54,14 +54,9 @@ export function MobileChat({
|
||||
// user sees their prior thread on entry. The store is updated by the
|
||||
// socket → ChatTab flows the desktop runs; on mobile we read from the
|
||||
// same buffer to keep state coherent across viewports.
|
||||
// NOTE: do NOT use `?? []` in the selector — Zustand uses Object.is
|
||||
// for selector equality. A fallback `?? []` creates a new [] reference on
|
||||
// every store update when agentMessages[agentId] is undefined, causing an
|
||||
// infinite re-render loop (React error #185 / Maximum update depth
|
||||
// exceeded). The undefined case is handled by the initializer below.
|
||||
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
|
||||
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId] ?? []);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() =>
|
||||
(storedMessages ?? []).map((m) => ({
|
||||
storedMessages.map((m) => ({
|
||||
id: m.id,
|
||||
role: "agent",
|
||||
text: m.content,
|
||||
|
||||
@@ -402,7 +402,7 @@ function Row({ label, value, mono }: { label: string; value: string; mono?: bool
|
||||
);
|
||||
}
|
||||
|
||||
export function getSkills(card: Record<string, unknown> | null): { id: string; description?: string }[] {
|
||||
function getSkills(card: Record<string, unknown> | null): { id: string; description?: string }[] {
|
||||
if (!card) return [];
|
||||
const skills = card.skills;
|
||||
if (!Array.isArray(skills)) return [];
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* FilesTab: NotAvailablePanel + FilesToolbar coverage.
|
||||
*
|
||||
* NotAvailablePanel: pure presentational component — renders a "feature not
|
||||
* available" placeholder for external-runtime workspaces.
|
||||
* FilesToolbar: pure props-driven component — directory selector, file count,
|
||||
* action buttons (New, Upload, Export, Clear, Refresh) with correct aria-labels.
|
||||
*
|
||||
* No @testing-library/jest-dom import — use textContent / className /
|
||||
* getAttribute checks to avoid "expect is not defined" errors.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { FilesToolbar } from "../FilesToolbar";
|
||||
import { NotAvailablePanel } from "../NotAvailablePanel";
|
||||
|
||||
// ─── afterEach ─────────────────────────────────────────────────────────────────
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── NotAvailablePanel ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("NotAvailablePanel", () => {
|
||||
it("renders heading 'Files not available'", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
expect(container.textContent).toContain("Files not available");
|
||||
});
|
||||
|
||||
it("renders the runtime name in monospace", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
expect(container.textContent).toContain("external");
|
||||
const spans = container.querySelectorAll("span");
|
||||
const monoSpans = Array.from(spans).filter(
|
||||
(s) => s.className && s.className.includes("font-mono"),
|
||||
);
|
||||
expect(monoSpans.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("renders a Chat tab hint in description", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="remote-agent" />);
|
||||
expect(container.textContent).toContain("Chat tab");
|
||||
});
|
||||
|
||||
it("SVG icon has aria-hidden=true", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
const svg = container.querySelector("svg");
|
||||
expect(svg?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("renders without crashing for any runtime string", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="unknown-runtime" />);
|
||||
expect(container.textContent).toContain("unknown-runtime");
|
||||
});
|
||||
|
||||
it("applies the correct layout classes to root div", () => {
|
||||
const { container } = render(<NotAvailablePanel runtime="external" />);
|
||||
const root = container.firstElementChild as HTMLElement;
|
||||
expect(root.className).toContain("flex");
|
||||
expect(root.className).toContain("flex-col");
|
||||
expect(root.className).toContain("items-center");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── FilesToolbar ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("FilesToolbar", () => {
|
||||
const noop = vi.fn();
|
||||
|
||||
function renderToolbar(props: Partial<React.ComponentProps<typeof FilesToolbar>> = {}) {
|
||||
return render(
|
||||
<FilesToolbar
|
||||
root="/configs"
|
||||
setRoot={noop}
|
||||
fileCount={0}
|
||||
onNewFile={noop}
|
||||
onUpload={noop}
|
||||
onDownloadAll={noop}
|
||||
onClearAll={noop}
|
||||
onRefresh={noop}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
it("renders the directory selector with correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const select = container.querySelector("select");
|
||||
expect(select?.getAttribute("aria-label")).toBe("File root directory");
|
||||
});
|
||||
|
||||
it("directory selector has all four options", () => {
|
||||
const { container } = renderToolbar();
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
const options = Array.from(select?.options ?? []);
|
||||
const values = options.map((o) => o.value);
|
||||
expect(values).toContain("/configs");
|
||||
expect(values).toContain("/home");
|
||||
expect(values).toContain("/workspace");
|
||||
expect(values).toContain("/plugins");
|
||||
});
|
||||
|
||||
it("calls setRoot when directory changes", () => {
|
||||
const setRoot = vi.fn();
|
||||
const { container } = renderToolbar({ setRoot });
|
||||
const select = container.querySelector("select") as HTMLSelectElement;
|
||||
select.value = "/home";
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
expect(setRoot).toHaveBeenCalledWith("/home");
|
||||
});
|
||||
|
||||
it("displays the file count", () => {
|
||||
const { container } = renderToolbar({ fileCount: 42 });
|
||||
expect(container.textContent).toContain("42 files");
|
||||
});
|
||||
|
||||
it("shows New + Upload + Clear buttons for /configs", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).toContain("+ New");
|
||||
expect(texts).toContain("Upload");
|
||||
expect(texts).toContain("Clear");
|
||||
expect(texts).toContain("Export");
|
||||
expect(texts).toContain("↻");
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /workspace", () => {
|
||||
const { container } = renderToolbar({ root: "/workspace" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
expect(texts).toContain("Export");
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /home", () => {
|
||||
const { container } = renderToolbar({ root: "/home" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
});
|
||||
|
||||
it("hides New + Upload + Clear for /plugins", () => {
|
||||
const { container } = renderToolbar({ root: "/plugins" });
|
||||
const texts = Array.from(container.querySelectorAll("button")).map(
|
||||
(b) => b.textContent?.trim(),
|
||||
);
|
||||
expect(texts).not.toContain("+ New");
|
||||
expect(texts).not.toContain("Upload");
|
||||
expect(texts).not.toContain("Clear");
|
||||
});
|
||||
|
||||
it("New button has correct aria-label", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const newBtn = container.querySelector('button[aria-label="Create new file"]');
|
||||
expect(newBtn?.textContent?.trim()).toBe("+ New");
|
||||
});
|
||||
|
||||
it("Export button has correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const exportBtn = container.querySelector('button[aria-label="Download all files"]');
|
||||
expect(exportBtn?.textContent?.trim()).toBe("Export");
|
||||
});
|
||||
|
||||
it("Clear button has correct aria-label", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const clearBtn = container.querySelector('button[aria-label="Delete all files"]');
|
||||
expect(clearBtn?.textContent?.trim()).toBe("Clear");
|
||||
});
|
||||
|
||||
it("Refresh button has correct aria-label", () => {
|
||||
const { container } = renderToolbar();
|
||||
const refreshBtn = container.querySelector('button[aria-label="Refresh file list"]');
|
||||
expect(refreshBtn?.textContent?.trim()).toBe("↻");
|
||||
});
|
||||
|
||||
it("calls onNewFile when New button is clicked", () => {
|
||||
const onNewFile = vi.fn();
|
||||
const { container } = renderToolbar({ root: "/configs", onNewFile });
|
||||
container.querySelector('button[aria-label="Create new file"]')!.click();
|
||||
expect(onNewFile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onDownloadAll when Export button is clicked", () => {
|
||||
const onDownloadAll = vi.fn();
|
||||
const { container } = renderToolbar({ onDownloadAll });
|
||||
container.querySelector('button[aria-label="Download all files"]')!.click();
|
||||
expect(onDownloadAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onClearAll when Clear button is clicked", () => {
|
||||
const onClearAll = vi.fn();
|
||||
const { container } = renderToolbar({ root: "/configs", onClearAll });
|
||||
container.querySelector('button[aria-label="Delete all files"]')!.click();
|
||||
expect(onClearAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onRefresh when Refresh button is clicked", () => {
|
||||
const onRefresh = vi.fn();
|
||||
const { container } = renderToolbar({ onRefresh });
|
||||
container.querySelector('button[aria-label="Refresh file list"]')!.click();
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies focus-visible ring to all interactive buttons", () => {
|
||||
const { container } = renderToolbar({ root: "/configs" });
|
||||
const buttons = container.querySelectorAll("button");
|
||||
for (const btn of buttons) {
|
||||
expect(btn.className).toContain("focus-visible:ring-2");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -647,7 +647,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[] {
|
||||
function extractSkills(agentCard: Record<string, unknown> | null): SkillEntry[] {
|
||||
if (!agentCard) return [];
|
||||
const rawSkills = agentCard.skills;
|
||||
if (!Array.isArray(rawSkills)) return [];
|
||||
|
||||
@@ -1,535 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ActivityTab — activity ledger with live updates, filtering,
|
||||
* expand/collapse, and A2A error hint rendering.
|
||||
*
|
||||
* Covers:
|
||||
* - Loading state
|
||||
* - Error state (network failure)
|
||||
* - Empty state (no activities)
|
||||
* - Activity list rendering (single + multiple)
|
||||
* - Filter bar: 7 filters, active filter highlighted
|
||||
* - Each filter updates the rendered list
|
||||
* - Auto-refresh toggle (Live / Paused)
|
||||
* - Refresh button calls API
|
||||
* - Full Trace button opens ConversationTraceModal
|
||||
* - Duration display in activity rows
|
||||
* - Expand/collapse row details
|
||||
* - A2A rows show source → target name flow
|
||||
* - Error rows styled differently
|
||||
* - Error detail shown when expanded
|
||||
* - getSkills exported function (standalone unit)
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ActivityTab } from "../ActivityTab";
|
||||
import type { ActivityEntry } from "@/types/activity";
|
||||
|
||||
const mockApiGet = vi.fn();
|
||||
|
||||
const mockUseSocketEvent = vi.fn();
|
||||
const mockUseWorkspaceName = vi.fn<(id: string | null) => string>((_id: string | null) => "Test Workspace");
|
||||
const mockConversationTraceModal = vi.fn(() => null);
|
||||
const mockConversationTraceModalRender = vi.fn(
|
||||
({ open }: { open: boolean }) => (open ? <div data-testid="trace-modal">Trace</div> : null),
|
||||
);
|
||||
|
||||
vi.mock("@/hooks/useSocketEvent", () => ({
|
||||
useSocketEvent: (...args: unknown[]) => mockUseSocketEvent(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useWorkspaceName", () => ({
|
||||
useWorkspaceName: () => mockUseWorkspaceName,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ConversationTraceModal", () => ({
|
||||
ConversationTraceModal: (props: { open: boolean; onClose: () => void; workspaceId: string }) =>
|
||||
props.open ? <div data-testid="trace-modal">Trace</div> : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: { get: (...args: unknown[]) => mockApiGet(...args) },
|
||||
}));
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
function activity(overrides: Partial<ActivityEntry> = {}): ActivityEntry {
|
||||
return {
|
||||
id: "act-1",
|
||||
workspace_id: "ws-1",
|
||||
activity_type: "agent_log",
|
||||
source_id: null,
|
||||
target_id: null,
|
||||
method: null,
|
||||
summary: null,
|
||||
request_body: null,
|
||||
response_body: null,
|
||||
duration_ms: null,
|
||||
status: "ok",
|
||||
error_detail: null,
|
||||
created_at: new Date(Date.now() - 60_000).toISOString(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("ActivityTab — loading / error / empty", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows loading state initially", () => {
|
||||
mockApiGet.mockImplementation(() => new Promise(() => {}));
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
expect(screen.getByText("Loading activity...")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows error banner when API fails", async () => {
|
||||
mockApiGet.mockRejectedValue(new Error("network failure"));
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/network failure/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows empty state when no activities", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("No activity recorded yet")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — list rendering", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders a single activity row", async () => {
|
||||
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("LOG")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders multiple activity rows", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({ id: "a1", activity_type: "agent_log" }),
|
||||
activity({ id: "a2", activity_type: "task_update" }),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("LOG")).toBeTruthy();
|
||||
expect(screen.getByText("TASK")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows duration when duration_ms is present", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({ id: "a1", duration_ms: 1234, activity_type: "agent_log" }),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("1234ms")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows summary text when present", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({ id: "a1", summary: "Delegated task to SEO Agent", activity_type: "a2a_send" }),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/Delegated task to SEO Agent/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — filter bar", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders all 7 filter buttons", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByRole("button", { name: /all/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /a2a in/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /a2a out/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /tasks/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /skill promo/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /logs/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /errors/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("active filter has aria-pressed=true", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const allBtn = screen.getByRole("button", { name: /all/i });
|
||||
expect(allBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("clicking a filter updates aria-pressed and re-fetches", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const errorsBtn = screen.getByRole("button", { name: /errors/i });
|
||||
await act(async () => { errorsBtn.click(); });
|
||||
await flush();
|
||||
expect(errorsBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
// API was called with ?type=error
|
||||
expect(mockApiGet).toHaveBeenLastCalledWith("/workspaces/ws-1/activity?type=error");
|
||||
});
|
||||
|
||||
it("clicking All removes the type query param", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
// First click a specific filter
|
||||
const errorsBtn = screen.getByRole("button", { name: /errors/i });
|
||||
await act(async () => { errorsBtn.click(); });
|
||||
await flush();
|
||||
// Then click All
|
||||
const allBtn = screen.getByRole("button", { name: /all/i });
|
||||
await act(async () => { allBtn.click(); });
|
||||
await flush();
|
||||
expect(mockApiGet).toHaveBeenLastCalledWith("/workspaces/ws-1/activity");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — auto-refresh toggle", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders Live by default", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("⟳ Live")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking Live toggles to Paused", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const liveBtn = screen.getByText("⟳ Live");
|
||||
await act(async () => { liveBtn.click(); });
|
||||
await flush();
|
||||
expect(screen.getByText("⟳ Paused")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking Paused toggles back to Live", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const liveBtn = screen.getByText("⟳ Live");
|
||||
await act(async () => { liveBtn.click(); });
|
||||
await flush();
|
||||
const pausedBtn = screen.getByText("⟳ Paused");
|
||||
await act(async () => { pausedBtn.click(); });
|
||||
await flush();
|
||||
expect(screen.getByText("⟳ Live")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — refresh button", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Refresh calls the API", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const refreshBtn = screen.getByRole("button", { name: /refresh/i });
|
||||
await act(async () => { refreshBtn.click(); });
|
||||
await flush();
|
||||
// loadActivities called again (second call)
|
||||
expect(mockApiGet.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — Full Trace button", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Full Trace button opens the trace modal", async () => {
|
||||
mockApiGet.mockResolvedValue([]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const traceBtn = screen.getByRole("button", { name: /full trace/i });
|
||||
await act(async () => { traceBtn.click(); });
|
||||
await flush();
|
||||
expect(screen.getByTestId("trace-modal")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — row expand / collapse", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("row is collapsed by default (shows ▶)", async () => {
|
||||
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("▶")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking a row expands it (shows ▼)", async () => {
|
||||
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const rowBtn = screen.getByText("LOG").closest("button") as HTMLButtonElement;
|
||||
await act(async () => { rowBtn.click(); });
|
||||
await flush();
|
||||
expect(screen.getByText("▼")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking expanded row collapses it", async () => {
|
||||
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const rowBtn = screen.getByText("LOG").closest("button") as HTMLButtonElement;
|
||||
await act(async () => { rowBtn.click(); }); // expand
|
||||
await flush();
|
||||
await act(async () => { rowBtn.click(); }); // collapse
|
||||
await flush();
|
||||
expect(screen.getByText("▶")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — A2A rows with source/target", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
mockUseWorkspaceName.mockImplementation((id: string | null) => {
|
||||
if (id === "ws-agent-1") return "Alice Agent";
|
||||
if (id === "ws-agent-2") return "Bob Agent";
|
||||
return "Unknown";
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows source → target for a2a_receive rows", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({
|
||||
id: "a1",
|
||||
activity_type: "a2a_receive",
|
||||
source_id: "ws-agent-1",
|
||||
target_id: "ws-agent-2",
|
||||
method: "message/send",
|
||||
}),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("Alice Agent")).toBeTruthy();
|
||||
expect(screen.getByText("→")).toBeTruthy();
|
||||
expect(screen.getByText("Bob Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows A2A OUT badge for a2a_send rows", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({
|
||||
id: "a1",
|
||||
activity_type: "a2a_send",
|
||||
source_id: "ws-agent-1",
|
||||
target_id: "ws-agent-2",
|
||||
}),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("A2A OUT")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — error rows", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("error status row renders with ERROR badge", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({ id: "a1", activity_type: "error", status: "error" }),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("ERROR")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("error detail is shown when row is expanded", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({
|
||||
id: "a1",
|
||||
activity_type: "error",
|
||||
status: "error",
|
||||
error_detail: "Connection refused",
|
||||
duration_ms: null,
|
||||
}),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const rowBtn = screen.getByText("ERROR").closest("button") as HTMLButtonElement;
|
||||
await act(async () => { rowBtn.click(); });
|
||||
await flush();
|
||||
// Text appears twice: collapsed-row preview + expanded detail section
|
||||
expect(screen.getAllByText("Connection refused")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — type badge rendering", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders correct badge text for each type", async () => {
|
||||
const types: ActivityEntry["activity_type"][] = [
|
||||
"a2a_receive", "a2a_send", "task_update", "skill_promotion", "agent_log", "error",
|
||||
];
|
||||
const entries = types.map((t, i) =>
|
||||
activity({ id: `a${i}`, activity_type: t }),
|
||||
);
|
||||
mockApiGet.mockResolvedValue(entries);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText("A2A IN")).toBeTruthy();
|
||||
expect(screen.getByText("A2A OUT")).toBeTruthy();
|
||||
expect(screen.getByText("TASK")).toBeTruthy();
|
||||
expect(screen.getByText("PROMO")).toBeTruthy();
|
||||
expect(screen.getByText("LOG")).toBeTruthy();
|
||||
expect(screen.getByText("ERROR")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ActivityTab — count display", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockUseSocketEvent.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("shows count with 'activities' label when filter=all", async () => {
|
||||
mockApiGet.mockResolvedValue([
|
||||
activity({ id: "a1" }),
|
||||
activity({ id: "a2" }),
|
||||
]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
expect(screen.getByText(/2 activities/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows count with filter label when non-all filter selected", async () => {
|
||||
mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "error" })]);
|
||||
render(<ActivityTab workspaceId="ws-1" />);
|
||||
await flush();
|
||||
const errorsBtn = screen.getByRole("button", { name: /errors/i });
|
||||
await act(async () => { errorsBtn.click(); });
|
||||
await flush();
|
||||
expect(screen.getByText(/1 error entries/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSkills — unit", () => {
|
||||
it("returns empty array for null card", async () => {
|
||||
const { getSkills } = await import("../DetailsTab");
|
||||
expect(getSkills(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when skills is not an array", async () => {
|
||||
const { getSkills } = await import("../DetailsTab");
|
||||
expect(getSkills({ name: "test" } as Record<string, unknown>)).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts skill ids and descriptions", async () => {
|
||||
const { getSkills } = await import("../DetailsTab");
|
||||
const card = {
|
||||
skills: [
|
||||
{ id: "web-search", description: "Search the web" },
|
||||
{ name: "code-interpreter" },
|
||||
{ id: "analytics" },
|
||||
],
|
||||
};
|
||||
const result = getSkills(card as Record<string, unknown>);
|
||||
expect(result).toEqual([
|
||||
{ id: "web-search", description: "Search the web" },
|
||||
{ id: "code-interpreter" },
|
||||
{ id: "analytics" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out skills with no id or name", async () => {
|
||||
const { getSkills } = await import("../DetailsTab");
|
||||
const card = { skills: [{ description: "no id" }, { id: "valid" }] };
|
||||
expect(getSkills(card as Record<string, unknown>)).toEqual([{ id: "valid" }]);
|
||||
});
|
||||
});
|
||||
@@ -1,330 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
import { BudgetSection } from "../BudgetSection";
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// Queue-based mock for the api module. Each api call shifts from the queue.
|
||||
// Tests push with qGet/qPatch and the module-level mockImplementation
|
||||
// reads from the queue.
|
||||
type QueueEntry = { body?: unknown; err?: Error };
|
||||
const apiQueue: QueueEntry[] = [];
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: vi.fn(async (path: string) => {
|
||||
const next = apiQueue.shift();
|
||||
if (!next) throw new Error(`api.get queue exhausted at: ${path}`);
|
||||
if (next.err) throw next.err;
|
||||
return next.body;
|
||||
}),
|
||||
patch: vi.fn(async (path: string, _body?: unknown) => {
|
||||
const next = apiQueue.shift();
|
||||
if (!next) throw new Error(`api.patch queue exhausted at: ${path}`);
|
||||
if (next.err) throw next.err;
|
||||
return next.body;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeEach(() => {
|
||||
apiQueue.length = 0;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const WS_ID = "budget-test-ws";
|
||||
|
||||
function qGet(body: unknown) {
|
||||
apiQueue.push({ body });
|
||||
}
|
||||
|
||||
function qGetErr(status: number, msg: string) {
|
||||
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
|
||||
}
|
||||
|
||||
function qPatch(body: unknown) {
|
||||
apiQueue.push({ body });
|
||||
}
|
||||
|
||||
function qPatchErr(status: number, msg: string) {
|
||||
apiQueue.push({ err: new Error(`${msg}: ${status}`) });
|
||||
}
|
||||
|
||||
function makeBudget(overrides: Partial<{
|
||||
budget_limit: number | null;
|
||||
budget_used: number;
|
||||
budget_remaining: number | null;
|
||||
}> = {}) {
|
||||
return {
|
||||
budget_limit: 10_000,
|
||||
budget_used: 3_500,
|
||||
budget_remaining: 6_500,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BudgetSection", () => {
|
||||
describe("loading state", () => {
|
||||
it("shows loading indicator while fetching", async () => {
|
||||
let resolveGet: (v: unknown) => void;
|
||||
vi.mocked(api.get).mockImplementationOnce(
|
||||
async () => new Promise((r) => { resolveGet = r as (v: unknown) => void; }),
|
||||
);
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
expect(screen.getByTestId("budget-loading")).toBeTruthy();
|
||||
|
||||
// Resolve after render to verify state clears
|
||||
resolveGet!(makeBudget());
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-loading")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetch error state", () => {
|
||||
it("shows error message on non-402 fetch failure", async () => {
|
||||
qGetErr(500, "Internal Server Error");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByTestId("budget-fetch-error")!.textContent).toContain("500");
|
||||
});
|
||||
|
||||
it("shows 402 as exceeded banner, not fetch error", async () => {
|
||||
// 402 means the budget limit was hit — different UX from a network/API error.
|
||||
qGetErr(402, "Payment Required");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("budget-fetch-error")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("budget loaded — display", () => {
|
||||
it("renders used / limit stats row", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-used-value")!.textContent).toBe("3,500");
|
||||
});
|
||||
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("10,000");
|
||||
});
|
||||
|
||||
it("renders 'Unlimited' when budget_limit is null", async () => {
|
||||
qGet(makeBudget({ budget_limit: null, budget_used: 1_000, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-value")!.textContent).toBe("Unlimited");
|
||||
});
|
||||
});
|
||||
|
||||
it("renders remaining credits when present", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: 6_500 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("6,500");
|
||||
expect(screen.getByTestId("budget-remaining")!.textContent).toContain("credits remaining");
|
||||
});
|
||||
});
|
||||
|
||||
it("omits remaining credits when budget_remaining is null", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 3_500, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-remaining")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("caps progress bar at 100% when used > limit", async () => {
|
||||
// Over-limit: 12000 used of 10000 limit should show 100%, not 120%.
|
||||
qGet(makeBudget({ budget_limit: 10_000, budget_used: 12_000, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const fill = screen.getByTestId("budget-progress-fill");
|
||||
expect(fill.getAttribute("style")).toContain("100%");
|
||||
});
|
||||
});
|
||||
|
||||
it("omits progress bar when budget_limit is null (unlimited)", async () => {
|
||||
qGet(makeBudget({ budget_limit: null, budget_used: 5_000, budget_remaining: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-progress-fill")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("budget exceeded (402)", () => {
|
||||
it("shows exceeded banner when load returns 402", async () => {
|
||||
qGetErr(402, "Payment Required");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
expect(screen.getByTestId("budget-exceeded-banner")!.textContent).toContain("Budget exceeded");
|
||||
});
|
||||
});
|
||||
|
||||
it("clears exceeded banner after successful save", async () => {
|
||||
qGetErr(402, "Payment Required");
|
||||
qPatch(makeBudget({ budget_limit: 50_000, budget_used: 0, budget_remaining: 50_000 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("budget-limit-input");
|
||||
fireEvent.change(input, { target: { value: "50000" } });
|
||||
|
||||
const saveBtn = screen.getByTestId("budget-save-btn");
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("save flow", () => {
|
||||
it("shows save error on non-402 patch failure", async () => {
|
||||
qGet(makeBudget());
|
||||
qPatchErr(500, "Internal Server Error");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
const saveBtn = screen.getByTestId("budget-save-btn");
|
||||
fireEvent.click(saveBtn);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-save-error")).toBeTruthy();
|
||||
expect(screen.getByTestId("budget-save-error")!.textContent).toContain("500");
|
||||
});
|
||||
});
|
||||
|
||||
it("updates input to new limit value after successful save", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000 }));
|
||||
qPatch(makeBudget({ budget_limit: 20_000 }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
// Wait for the input to appear (loading → loaded)
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.queryByTestId("budget-loading")).toBeNull();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
|
||||
// Debug: check what values are rendered
|
||||
const limitValue = screen.getByTestId("budget-limit-value")?.textContent;
|
||||
expect(input.value).toBe("10000"); // initial value from API
|
||||
expect(limitValue).toBe("10,000");
|
||||
|
||||
fireEvent.change(input, { target: { value: "20000" } });
|
||||
expect(input.value).toBe("20000");
|
||||
|
||||
fireEvent.click(screen.getByTestId("budget-save-btn"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((screen.getByTestId("budget-limit-input") as HTMLInputElement).value).toBe("20000");
|
||||
});
|
||||
});
|
||||
|
||||
it("sends null when input is cleared (unlimited)", async () => {
|
||||
qGet(makeBudget({ budget_limit: 10_000 }));
|
||||
qPatch(makeBudget({ budget_limit: null }));
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
const input = screen.getByTestId("budget-limit-input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "" } });
|
||||
fireEvent.click(screen.getByTestId("budget-save-btn"));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
// After save with null limit, input should show empty (unlimited)
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows saving state on button while patch is in flight", async () => {
|
||||
qGet(makeBudget());
|
||||
let resolvePatch: (v: unknown) => void;
|
||||
vi.mocked(api.patch).mockImplementationOnce(
|
||||
async () => new Promise((r) => { resolvePatch = r as (v: unknown) => void; }),
|
||||
);
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-limit-input")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("budget-limit-input"), { target: { value: "50000" } });
|
||||
fireEvent.click(screen.getByTestId("budget-save-btn"));
|
||||
|
||||
const btn = screen.getByTestId("budget-save-btn");
|
||||
expect(btn.textContent).toContain("Saving");
|
||||
|
||||
resolvePatch!(makeBudget({ budget_limit: 50_000 }));
|
||||
await vi.waitFor(() => {
|
||||
expect(btn.textContent).toContain("Save");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isApiError402 — regression coverage", () => {
|
||||
it("classifies ': 402' with space as 402", async () => {
|
||||
qGetErr(402, "Payment Required");
|
||||
qPatch(makeBudget());
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies non-402 error messages as regular fetch errors", async () => {
|
||||
qGetErr(503, "Service Unavailable");
|
||||
|
||||
render(<BudgetSection workspaceId={WS_ID} />);
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(screen.getByTestId("budget-fetch-error")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,459 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for DetailsTab — workspace detail panel with editable fields,
|
||||
* delete/restart workflows, peers list, error display, and section
|
||||
* composition.
|
||||
*
|
||||
* Covers:
|
||||
* - View mode: all rows rendered (name, role, tier, status, URL, etc.)
|
||||
* - Edit mode: name/role/tier fields become editable
|
||||
* - Save workflow: calls PATCH and updates store
|
||||
* - Cancel: reverts fields to original data
|
||||
* - Delete: two-step confirm (confirm button shows alertdialog)
|
||||
* - Delete confirm: calls DELETE and removes node from store
|
||||
* - Restart button: calls POST /restart for failed/degraded/offline
|
||||
* - Error section: shown for failed/degraded with lastSampleError
|
||||
* - Skills section: rendered when agentCard has skills
|
||||
* - Peers section: loads and displays peer list
|
||||
* - Peers section: empty state when offline
|
||||
* - ConsoleModal: opens/closes via button click
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DetailsTab } from "../DetailsTab";
|
||||
import type { WorkspaceNodeData } from "@/store/canvas";
|
||||
|
||||
const mockApi = vi.hoisted(() => ({
|
||||
get: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
del: vi.fn(),
|
||||
post: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUpdateNodeData = vi.hoisted(() => vi.fn());
|
||||
const mockRemoveSubtree = vi.hoisted(() => vi.fn());
|
||||
const mockSelectNode = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockUseCanvasStore = vi.hoisted(() => {
|
||||
const fn = (selector: (s: {
|
||||
updateNodeData: typeof mockUpdateNodeData;
|
||||
removeSubtree: typeof mockRemoveSubtree;
|
||||
selectNode: typeof mockSelectNode;
|
||||
}) => unknown) =>
|
||||
selector({
|
||||
updateNodeData: mockUpdateNodeData,
|
||||
removeSubtree: mockRemoveSubtree,
|
||||
selectNode: mockSelectNode,
|
||||
});
|
||||
return fn;
|
||||
});
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: mockUseCanvasStore,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: mockApi,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/BudgetSection", () => ({
|
||||
BudgetSection: () => <div data-testid="budget-section">BudgetSection</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/WorkspaceUsage", () => ({
|
||||
WorkspaceUsage: () => <div data-testid="workspace-usage">WorkspaceUsage</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ConsoleModal", () => ({
|
||||
ConsoleModal: ({ open, onClose }: { open: boolean; onClose: () => void; workspaceId: string; workspaceName: string }) =>
|
||||
open ? (
|
||||
<div role="dialog" data-testid="console-modal">
|
||||
<button onClick={onClose}>Close Console</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
// ─── Fixtures ───────────────────────────────────────────────────────────────
|
||||
|
||||
const baseData: WorkspaceNodeData = {
|
||||
name: "Test Workspace",
|
||||
status: "online",
|
||||
tier: 2,
|
||||
url: "https://test.molecules.ai",
|
||||
parentId: null,
|
||||
activeTasks: 0,
|
||||
agentCard: null,
|
||||
} as WorkspaceNodeData;
|
||||
|
||||
function data(overrides: Partial<WorkspaceNodeData> = {}): WorkspaceNodeData {
|
||||
return { ...baseData, ...overrides } as WorkspaceNodeData;
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function flush() {
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("DetailsTab — view mode", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
mockUpdateNodeData.mockReset();
|
||||
mockRemoveSubtree.mockReset();
|
||||
mockSelectNode.mockReset();
|
||||
mockApi.get.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders name, role, tier, status, URL, parent rows", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ role: "SEO Specialist", url: "https://example.com" })} />);
|
||||
expect(screen.getByText("Test Workspace")).toBeTruthy();
|
||||
expect(screen.getByText("SEO Specialist")).toBeTruthy();
|
||||
expect(screen.getByText("T2")).toBeTruthy();
|
||||
expect(screen.getByText("online")).toBeTruthy();
|
||||
expect(screen.getByText("https://example.com")).toBeTruthy();
|
||||
expect(screen.getByText("root")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Edit button", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders BudgetSection and WorkspaceUsage", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
expect(screen.getByTestId("budget-section")).toBeTruthy();
|
||||
expect(screen.getByTestId("workspace-usage")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Restart button for failed status", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "failed" })} />);
|
||||
expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Restart button for offline status", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "offline" })} />);
|
||||
expect(screen.getByRole("button", { name: /restart/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Restart button for degraded status", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "degraded" })} />);
|
||||
expect(screen.getByRole("button", { name: /restart/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render Restart for online status", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
expect(screen.queryByRole("button", { name: /restart|retry/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders error section for failed status with lastSampleError", () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={data({ status: "failed", lastSampleError: "ModuleNotFoundError: No module named 'requests'" })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("details-error-log")).toBeTruthy();
|
||||
expect(screen.getByText(/ModuleNotFoundError/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders error rate for degraded status", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "degraded", lastErrorRate: 0.15 })} />);
|
||||
expect(screen.getByText(/15%/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders Delete Workspace button in Danger Zone", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
expect(screen.getByRole("button", { name: /delete workspace/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DetailsTab — edit mode", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.patch.mockReset();
|
||||
mockUpdateNodeData.mockReset();
|
||||
mockApi.get.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("clicking Edit shows form fields", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ role: "Agent" })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
expect(screen.getByLabelText(/name/i)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/role/i)).toBeTruthy();
|
||||
expect(screen.getByLabelText(/tier/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Edit form pre-fills current values", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ name: "My WS", role: "Coder" })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
expect((screen.getByLabelText(/name/i) as HTMLInputElement).value).toBe("My WS");
|
||||
expect((screen.getByLabelText(/role/i) as HTMLInputElement).value).toBe("Coder");
|
||||
});
|
||||
|
||||
it("Save calls PATCH and exits edit mode", async () => {
|
||||
mockApi.patch.mockResolvedValue({});
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ name: "WS" })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
const nameInput = screen.getByLabelText(/name/i) as HTMLInputElement;
|
||||
fireEvent.change(nameInput, { target: { value: "Renamed WS" } });
|
||||
await flush();
|
||||
// Use scoped search: BudgetSection also has a Save button
|
||||
const saveBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Save" && !b.getAttribute("data-testid"),
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(saveBtn);
|
||||
await flush();
|
||||
expect(mockApi.patch).toHaveBeenCalledWith(
|
||||
"/workspaces/ws-1",
|
||||
expect.objectContaining({ name: "Renamed WS" }),
|
||||
);
|
||||
expect(mockUpdateNodeData).toHaveBeenCalledWith("ws-1", expect.objectContaining({ name: "Renamed WS" }));
|
||||
// Edit fields should no longer be visible
|
||||
expect(screen.queryByLabelText(/name/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("Cancel reverts to view mode without saving", async () => {
|
||||
mockApi.patch.mockResolvedValue({});
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ name: "Original" })} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
const nameInput = screen.getByLabelText(/name/i) as HTMLInputElement;
|
||||
fireEvent.change(nameInput, { target: { value: "Changed" } });
|
||||
await flush();
|
||||
const cancelBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Cancel" && !b.getAttribute("data-testid"),
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(cancelBtn);
|
||||
await flush();
|
||||
expect(mockApi.patch).not.toHaveBeenCalled();
|
||||
expect(screen.getByText("Original")).toBeTruthy();
|
||||
expect(screen.queryByLabelText(/name/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("Save shows error banner on failure", async () => {
|
||||
mockApi.patch.mockRejectedValue(new Error("Server error"));
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: /edit/i }));
|
||||
await flush();
|
||||
const saveBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Save" && !b.getAttribute("data-testid"),
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(saveBtn);
|
||||
await flush();
|
||||
expect(screen.getByText(/server error/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DetailsTab — delete workflow", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.del.mockReset();
|
||||
mockRemoveSubtree.mockReset();
|
||||
mockSelectNode.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("clicking Delete shows confirm dialog", async () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
|
||||
await flush();
|
||||
expect(screen.getByRole("alertdialog")).toBeTruthy();
|
||||
expect(screen.getByText(/confirm deletion/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("confirming delete calls DELETE and removes node from store", async () => {
|
||||
mockApi.del.mockResolvedValue(undefined);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
|
||||
await flush();
|
||||
// Radix ConfirmDialog uses dispatchEvent with bubbling click
|
||||
const confirmBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Confirm Delete",
|
||||
) as HTMLButtonElement;
|
||||
fireEvent(confirmBtn, new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true");
|
||||
expect(mockRemoveSubtree).toHaveBeenCalledWith("ws-1");
|
||||
expect(mockSelectNode).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("cancelling delete returns to view mode", async () => {
|
||||
mockApi.del.mockResolvedValue(undefined);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
|
||||
await flush();
|
||||
const cancelBtn = Array.from(document.querySelectorAll("button")).find(
|
||||
(b) => b.textContent === "Cancel",
|
||||
) as HTMLButtonElement;
|
||||
fireEvent(cancelBtn, new MouseEvent("click", { bubbles: true }));
|
||||
await flush();
|
||||
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||
expect(screen.getByRole("button", { name: /delete workspace/i })).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DetailsTab — restart workflow", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.post.mockReset();
|
||||
mockUpdateNodeData.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("Restart button calls POST /restart and sets status to provisioning", async () => {
|
||||
mockApi.post.mockResolvedValue(undefined);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "failed" })} />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||
await flush();
|
||||
expect(mockApi.post).toHaveBeenCalledWith("/workspaces/ws-1/restart", {});
|
||||
expect(mockUpdateNodeData).toHaveBeenCalledWith("ws-1", { status: "provisioning" });
|
||||
});
|
||||
|
||||
it("Restart shows error on failure", async () => {
|
||||
mockApi.post.mockRejectedValue(new Error("Restart failed"));
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "offline" })} />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /restart/i }));
|
||||
await flush();
|
||||
expect(screen.getByText(/restart failed/i)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DetailsTab — peers section", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("loads peers from API", async () => {
|
||||
mockApi.get.mockResolvedValue([
|
||||
{ id: "p1", name: "Alice Agent", role: "seo", status: "online", tier: 2 },
|
||||
{ id: "p2", name: "Bob Agent", role: null, status: "offline", tier: 3 },
|
||||
]);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
await flush();
|
||||
expect(screen.getByText("Alice Agent")).toBeTruthy();
|
||||
expect(screen.getByText("Bob Agent")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows 'No reachable peers' when list is empty", async () => {
|
||||
mockApi.get.mockResolvedValue([]);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
await flush();
|
||||
expect(screen.getByText("No reachable peers")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows offline message when workspace is not online", async () => {
|
||||
mockApi.get.mockResolvedValue([]);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data({ status: "provisioning" })} />);
|
||||
await flush();
|
||||
expect(screen.getByText(/only discoverable while the workspace is online/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking peer name selects that node", async () => {
|
||||
mockApi.get.mockResolvedValue([{ id: "p1", name: "Alice Agent", role: null, status: "online", tier: 2 }]);
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByText("Alice Agent"));
|
||||
await flush();
|
||||
expect(mockSelectNode).toHaveBeenCalledWith("p1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DetailsTab — skills section", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("renders skills from agentCard", () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={data({ agentCard: { name: "Test Agent", skills: [
|
||||
{ id: "web-search", description: "Search the web" },
|
||||
{ id: "code-interpreter" },
|
||||
]} as unknown as WorkspaceNodeData["agentCard"] })}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("web-search")).toBeTruthy();
|
||||
expect(screen.getByText("Search the web")).toBeTruthy();
|
||||
expect(screen.getByText("code-interpreter")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render Skills section when agentCard is null", () => {
|
||||
render(<DetailsTab workspaceId="ws-1" data={data()} />);
|
||||
expect(screen.queryByText("Skills")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DetailsTab — ConsoleModal", () => {
|
||||
beforeEach(() => {
|
||||
mockApi.get.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("View console output button opens ConsoleModal", async () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={data({ status: "failed", lastSampleError: "Traceback..." })}
|
||||
/>,
|
||||
);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /view console output/i }));
|
||||
await flush();
|
||||
expect(screen.getByTestId("console-modal")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Close button closes ConsoleModal", async () => {
|
||||
render(
|
||||
<DetailsTab
|
||||
workspaceId="ws-1"
|
||||
data={data({ status: "failed", lastSampleError: "Traceback..." })}
|
||||
/>,
|
||||
);
|
||||
await flush();
|
||||
fireEvent.click(screen.getByRole("button", { name: /view console output/i }));
|
||||
await flush();
|
||||
expect(screen.getByTestId("console-modal")).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: /close console/i }));
|
||||
await flush();
|
||||
expect(screen.queryByTestId("console-modal")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,140 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Unit tests for extractSkills — pure helper from SkillsTab.
|
||||
*
|
||||
* Covers: null card, non-array skills, empty skills, full skill entries
|
||||
* (id, name, description, tags, examples), id-only fallback, name-only
|
||||
* fallback, string coercion, array coercion for tags/examples,
|
||||
* filtering entries with no id after coercion, empty string id (filtered).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { extractSkills } from "../SkillsTab";
|
||||
|
||||
describe("extractSkills", () => {
|
||||
it("returns [] for null card", () => {
|
||||
expect(extractSkills(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when card.skills is not an array", () => {
|
||||
expect(extractSkills({ skills: undefined })).toEqual([]);
|
||||
expect(extractSkills({ skills: "not-an-array" })).toEqual([]);
|
||||
expect(extractSkills({ skills: { id: "x" } })).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for empty skills array", () => {
|
||||
expect(extractSkills({ skills: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps a fully-populated skill entry", () => {
|
||||
const card = {
|
||||
skills: [
|
||||
{
|
||||
id: "code_search",
|
||||
name: "Code Search",
|
||||
description: "Semantic code search",
|
||||
tags: ["search", "code"],
|
||||
examples: ["Find unused exports", "Search by AST pattern"],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{
|
||||
id: "code_search",
|
||||
name: "Code Search",
|
||||
description: "Semantic code search",
|
||||
tags: ["search", "code"],
|
||||
examples: ["Find unused exports", "Search by AST pattern"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses name as id when id is absent", () => {
|
||||
const card = { skills: [{ name: "web_scraper" }] };
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "web_scraper", name: "web_scraper", description: "", tags: [], examples: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses id as name when name is absent", () => {
|
||||
const card = { skills: [{ id: "legacy_skill" }] };
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "legacy_skill", name: "legacy_skill", description: "", tags: [], examples: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("filters out entries with neither id nor name", () => {
|
||||
// id: String(undefined || undefined || "") → "" → filtered (id.length = 0)
|
||||
const card = { skills: [{ description: "orphan entry" }] };
|
||||
expect(extractSkills(card)).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out entries with no id after string coercion", () => {
|
||||
// id resolves to "" after String(undefined || null || {})
|
||||
const card = { skills: [{ id: null, name: null }] };
|
||||
expect(extractSkills(card)).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters out entries with empty-string id", () => {
|
||||
const card = { skills: [{ id: "", name: "" }] };
|
||||
expect(extractSkills(card)).toEqual([]);
|
||||
});
|
||||
|
||||
it("coerces numeric tags to strings", () => {
|
||||
const card = { skills: [{ id: "x", tags: [1, "two", 3] }] };
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "x", name: "x", description: "", tags: ["1", "two", "3"], examples: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("coerces non-array tags to empty array", () => {
|
||||
const card = { skills: [{ id: "x", tags: "not-an-array" }] };
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "x", name: "x", description: "", tags: [], examples: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("coerces non-array examples to empty array", () => {
|
||||
const card = { skills: [{ id: "x", examples: 42 }] };
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "x", name: "x", description: "", tags: [], examples: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
// NOTE: extractSkills uses `String(skill.description || "")` — falsy values
|
||||
// (0, null, false) fall through to "", NOT to their string form.
|
||||
it("returns '' for falsy description values (0, null, false)", () => {
|
||||
const card = { skills: [{ id: "x", description: 0 }] };
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "x", name: "x", description: "", tags: [], examples: [] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles mixed valid/invalid entries", () => {
|
||||
const card = {
|
||||
skills: [
|
||||
{ id: "valid_one", name: "One" },
|
||||
{ name: "named_only" },
|
||||
{ description: "orphan" }, // filtered — id becomes ""
|
||||
{ id: "valid_two", examples: ["a", "b"] },
|
||||
],
|
||||
};
|
||||
expect(extractSkills(card)).toEqual([
|
||||
{ id: "valid_one", name: "One", description: "", tags: [], examples: [] },
|
||||
{ id: "named_only", name: "named_only", description: "", tags: [], examples: [] },
|
||||
{ id: "valid_two", name: "valid_two", description: "", tags: [], examples: ["a", "b"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles a realistic agent card with multiple skills", () => {
|
||||
const card = {
|
||||
skills: [
|
||||
{ id: "web_search", name: "Web Search", description: "Search the web", tags: ["search"], examples: ["Latest news"] },
|
||||
{ id: "file_read", name: "Read Files", description: "Read from disk", tags: ["io"], examples: [] },
|
||||
],
|
||||
};
|
||||
const result = extractSkills(card);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe("web_search");
|
||||
expect(result[1].tags).toEqual(["io"]);
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Unit tests for getSkills — pure helper from DetailsTab.
|
||||
*
|
||||
* Covers: null card, non-array skills, empty skills, id-only entries,
|
||||
* name-only entries (id derives from name), entries with description,
|
||||
* entries with neither id nor name (filtered out), mixed entries.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { getSkills } from "../DetailsTab";
|
||||
|
||||
describe("getSkills", () => {
|
||||
it("returns [] for null card", () => {
|
||||
expect(getSkills(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when card.skills is not an array", () => {
|
||||
expect(getSkills({ skills: undefined })).toEqual([]);
|
||||
expect(getSkills({ skills: "not-an-array" })).toEqual([]);
|
||||
expect(getSkills({ skills: { id: "x" } })).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for empty skills array", () => {
|
||||
expect(getSkills({ skills: [] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("maps skill with id and description", () => {
|
||||
const card = { skills: [{ id: "code_search", description: "Find code patterns" }] };
|
||||
expect(getSkills(card)).toEqual([{ id: "code_search", description: "Find code patterns" }]);
|
||||
});
|
||||
|
||||
it("maps skill with id only (description absent)", () => {
|
||||
const card = { skills: [{ id: "code_search" }] };
|
||||
expect(getSkills(card)).toEqual([{ id: "code_search", description: undefined }]);
|
||||
});
|
||||
|
||||
it("derives id from name when id is absent", () => {
|
||||
const card = { skills: [{ name: "web_scraper" }] };
|
||||
expect(getSkills(card)).toEqual([{ id: "web_scraper" }]);
|
||||
});
|
||||
|
||||
it("maps description when present", () => {
|
||||
const card = { skills: [{ id: "file_write", description: "Writes files to disk" }] };
|
||||
expect(getSkills(card)).toEqual([{ id: "file_write", description: "Writes files to disk" }]);
|
||||
});
|
||||
|
||||
it("returns description as undefined when skill has no description", () => {
|
||||
const card = { skills: [{ id: "noop_skill" }] };
|
||||
const result = getSkills(card);
|
||||
// The map always includes description; it's undefined when absent
|
||||
expect(result).toEqual([{ id: "noop_skill", description: undefined }]);
|
||||
});
|
||||
|
||||
it("filters out skills with neither id nor name", () => {
|
||||
// id: String(undefined || undefined || "") → "" → filtered
|
||||
const card = { skills: [{ description: "loner" }] };
|
||||
expect(getSkills(card)).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles mixed valid/invalid entries", () => {
|
||||
const card = {
|
||||
skills: [
|
||||
{ id: "valid_one" },
|
||||
{ name: "named_skill" },
|
||||
{ description: "orphaned" }, // filtered
|
||||
{ id: "valid_two", description: "Has both" },
|
||||
],
|
||||
};
|
||||
expect(getSkills(card)).toEqual([
|
||||
{ id: "valid_one", description: undefined },
|
||||
{ id: "named_skill", description: undefined },
|
||||
{ id: "valid_two", description: "Has both" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles string coercion for numeric ids/names", () => {
|
||||
const card = { skills: [{ id: 42, name: "numeric_id" }] };
|
||||
expect(getSkills(card)).toEqual([{ id: "42" }]);
|
||||
});
|
||||
|
||||
it("uses id over name when both are present", () => {
|
||||
const card = { skills: [{ id: "priority_id", name: "fallback_name" }] };
|
||||
expect(getSkills(card)).toEqual([{ id: "priority_id", description: undefined }]);
|
||||
});
|
||||
|
||||
it("omits description when it is falsy (0 is falsy in JS)", () => {
|
||||
// The implementation uses `s.description ?` — 0 is falsy, so it's treated
|
||||
// as absent and undefined is returned. Non-zero numbers coerce fine.
|
||||
const cardZero = { skills: [{ id: "x", description: 0 }] };
|
||||
expect(getSkills(cardZero)).toEqual([{ id: "x", description: undefined }]);
|
||||
|
||||
const cardNum = { skills: [{ id: "x", description: 42 }] };
|
||||
expect(getSkills(cardNum)).toEqual([{ id: "x", description: "42" }]);
|
||||
});
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* AttachmentViews — pure presentational components for chat attachments.
|
||||
*
|
||||
* Covers:
|
||||
* - PendingAttachmentPill renders file name, formatted size, × button
|
||||
* - PendingAttachmentPill × button has correct aria-label
|
||||
* - PendingAttachmentPill calls onRemove when × clicked
|
||||
* - PendingAttachmentPill renders exactly one button
|
||||
* - AttachmentChip renders attachment name and download glyph
|
||||
* - AttachmentChip renders size when provided
|
||||
* - AttachmentChip omits size span when size is undefined
|
||||
* - AttachmentChip calls onDownload(attachment) on click
|
||||
* - AttachmentChip title attribute for hover tooltip
|
||||
* - AttachmentChip tone=user applies blue accent classes
|
||||
* - AttachmentChip tone=agent applies surface classes
|
||||
* - AttachmentChip renders exactly one button
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use textContent / className /
|
||||
* getAttribute checks to avoid "expect is not defined" errors in this vitest
|
||||
* configuration.
|
||||
*/
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import { AttachmentChip, PendingAttachmentPill } from "../AttachmentViews";
|
||||
import type { ChatAttachment } from "../types";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Create a File with actual content so size > 0 in jsdom. */
|
||||
function makeFile(name: string, content: string): File {
|
||||
return new File([content], name, { type: "application/octet-stream" });
|
||||
}
|
||||
|
||||
function makeAttachment(name: string, size?: number): ChatAttachment {
|
||||
return { name, uri: `workspace:/tmp/${name}`, size };
|
||||
}
|
||||
|
||||
// ─── PendingAttachmentPill ─────────────────────────────────────────────────────
|
||||
|
||||
describe("PendingAttachmentPill", () => {
|
||||
it("renders the file name", () => {
|
||||
const file = makeFile("report.pdf", "PDF content here");
|
||||
const { container } = render(
|
||||
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("report.pdf");
|
||||
});
|
||||
|
||||
it("renders the formatted file size (KB)", () => {
|
||||
// 50 KB = 50 * 1024 bytes
|
||||
const content = "x".repeat(50 * 1024);
|
||||
const file = makeFile("data.csv", content);
|
||||
const { container } = render(
|
||||
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("50 KB");
|
||||
});
|
||||
|
||||
it("renders 0 B for empty file", () => {
|
||||
const file = makeFile("empty.txt", "");
|
||||
const { container } = render(
|
||||
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("0 B");
|
||||
});
|
||||
|
||||
it("renders size in MB for files >= 1 MB", () => {
|
||||
// 2.5 MB = 2.5 * 1024 * 1024 bytes
|
||||
const content = "x".repeat(Math.round(2.5 * 1024 * 1024));
|
||||
const file = makeFile("video.mp4", content);
|
||||
const { container } = render(
|
||||
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("2.5 MB");
|
||||
});
|
||||
|
||||
it("× button has aria-label with file name", () => {
|
||||
const file = makeFile("notes.txt", "some content");
|
||||
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(btn.getAttribute("aria-label")).toBe("Remove notes.txt");
|
||||
});
|
||||
|
||||
it("calls onRemove when × button is clicked", () => {
|
||||
const file = makeFile("doc.pdf", "pdf data");
|
||||
const onRemove = vi.fn();
|
||||
render(<PendingAttachmentPill file={file} onRemove={onRemove} />);
|
||||
screen.getByRole("button").click();
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders exactly one button (the × remove button)", () => {
|
||||
const file = makeFile("img.png", "image bytes");
|
||||
const { container } = render(
|
||||
<PendingAttachmentPill file={file} onRemove={vi.fn()} />,
|
||||
);
|
||||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AttachmentChip ───────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentChip", () => {
|
||||
it("renders the attachment name", () => {
|
||||
const att = makeAttachment("chart.svg", 2048);
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
|
||||
);
|
||||
expect(container.textContent).toContain("chart.svg");
|
||||
});
|
||||
|
||||
it("renders size when provided", () => {
|
||||
const att = makeAttachment("dump.sql", 1024 * 150); // 150 KB
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
|
||||
);
|
||||
expect(container.textContent).toContain("150 KB");
|
||||
});
|
||||
|
||||
it("omits size span when attachment.size is undefined", () => {
|
||||
const att = makeAttachment("notes.md"); // no size
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
|
||||
);
|
||||
// The only <span> should be the truncated filename; no size <span>
|
||||
const spans = Array.from(container.querySelectorAll("span"));
|
||||
const sizeSpans = spans.filter(
|
||||
(s) => s.className && s.className.includes("tabular-nums"),
|
||||
);
|
||||
expect(sizeSpans).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("has title attribute with download hint", () => {
|
||||
const att = makeAttachment("readme.txt", 64);
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="agent" />,
|
||||
);
|
||||
const btn = container.querySelector("button");
|
||||
expect(btn?.getAttribute("title")).toBe("Download readme.txt");
|
||||
});
|
||||
|
||||
it("calls onDownload with the attachment on click", () => {
|
||||
const att = makeAttachment("export.csv", 8192);
|
||||
const onDownload = vi.fn();
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />,
|
||||
);
|
||||
container.querySelector("button")!.click();
|
||||
expect(onDownload).toHaveBeenCalledWith(att);
|
||||
});
|
||||
|
||||
it("tone=user applies blue accent class", () => {
|
||||
const att = makeAttachment("photo.jpg", 512);
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
|
||||
);
|
||||
const btn = container.querySelector("button")!;
|
||||
expect(btn.className).toContain("blue-400");
|
||||
});
|
||||
|
||||
it("tone=agent does not apply blue accent class", () => {
|
||||
const att = makeAttachment("photo.jpg", 512);
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="agent" />,
|
||||
);
|
||||
const btn = container.querySelector("button")!;
|
||||
expect(btn.className).not.toContain("blue-400");
|
||||
});
|
||||
|
||||
it("renders exactly one button", () => {
|
||||
const att = makeAttachment("icon.svg", 128);
|
||||
const { container } = render(
|
||||
<AttachmentChip attachment={att} onDownload={vi.fn()} tone="user" />,
|
||||
);
|
||||
expect(container.querySelectorAll("button")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* form-inputs — pure presentational form primitives for the Config tab.
|
||||
*
|
||||
* NOTE: No @testing-library/jest-dom import — use textContent / className /
|
||||
* getAttribute / checked / value checks to avoid "expect is not defined"
|
||||
* errors in this vitest configuration.
|
||||
*
|
||||
* Covers:
|
||||
* - TextInput renders label and input with correct value
|
||||
* - TextInput calls onChange with new value on keystroke
|
||||
* - TextInput renders placeholder text when provided
|
||||
* - TextInput applies mono class when mono=true
|
||||
* - TextInput input has accessible aria-label from label
|
||||
* - TextInput input is not mono by default
|
||||
* - NumberInput renders label and number input
|
||||
* - NumberInput calls onChange with parsed integer on keystroke
|
||||
* - NumberInput calls onChange with 0 for non-numeric input
|
||||
* - NumberInput respects min/max bounds
|
||||
* - NumberInput input has aria-label from label prop
|
||||
* - NumberInput input has font-mono class
|
||||
* - Toggle renders checkbox with label text
|
||||
* - Toggle renders checked/unchecked state correctly
|
||||
* - Toggle calls onChange with boolean on toggle
|
||||
* - TagList renders existing tags with remove buttons
|
||||
* - TagList × button has aria-label "Remove tag {value}"
|
||||
* - TagList calls onChange without removed tag on × click
|
||||
* - TagList renders the label text
|
||||
* - TagList renders placeholder text when provided
|
||||
* - TagList renders exactly one textbox
|
||||
* - TagList adds tag on Enter key
|
||||
* - TagList does not add empty/whitespace-only tags on Enter
|
||||
* - TagList clears input after adding tag
|
||||
* - Section renders the title
|
||||
* - Section renders children when open (defaultOpen=true)
|
||||
* - Section starts closed when defaultOpen=false
|
||||
* - Section opens/closes content on title click
|
||||
* - Section button has aria-expanded reflecting open state
|
||||
* - Section toggle indicator changes on open/close
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
import {
|
||||
TextInput,
|
||||
NumberInput,
|
||||
Toggle,
|
||||
TagList,
|
||||
Section,
|
||||
} from "../form-inputs";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// ─── TextInput ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TextInput", () => {
|
||||
it("renders the label text", () => {
|
||||
const { container } = render(
|
||||
<TextInput label="Agent Name" value="" onChange={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("Agent Name");
|
||||
});
|
||||
|
||||
it("renders the input with the given value", () => {
|
||||
render(<TextInput label="Model" value="claude-opus-4" onChange={vi.fn()} />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
expect(input.value).toBe("claude-opus-4");
|
||||
});
|
||||
|
||||
it("calls onChange with new value on keystroke", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<TextInput label="Name" value="hello" onChange={onChange} />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "hello world" } });
|
||||
expect(onChange).toHaveBeenCalledWith("hello world");
|
||||
});
|
||||
|
||||
it("renders placeholder text when provided", () => {
|
||||
render(
|
||||
<TextInput
|
||||
label="Token"
|
||||
value=""
|
||||
onChange={vi.fn()}
|
||||
placeholder="sk-..."
|
||||
/>,
|
||||
);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
expect(input.getAttribute("placeholder")).toBe("sk-...");
|
||||
});
|
||||
|
||||
it("applies mono class when mono=true", () => {
|
||||
const { container } = render(
|
||||
<TextInput label="Model" value="" onChange={vi.fn()} mono />,
|
||||
);
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input.className).toContain("font-mono");
|
||||
});
|
||||
|
||||
it("input has aria-label matching the label", () => {
|
||||
render(<TextInput label="API Key" value="" onChange={vi.fn()} />);
|
||||
const input = document.querySelector("input") as HTMLInputElement;
|
||||
expect(input.getAttribute("aria-label")).toBe("API Key");
|
||||
});
|
||||
|
||||
it("input is not mono by default", () => {
|
||||
const { container } = render(
|
||||
<TextInput label="Description" value="" onChange={vi.fn()} />,
|
||||
);
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input.className).not.toContain("font-mono");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── NumberInput ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe("NumberInput", () => {
|
||||
it("renders the label text", () => {
|
||||
const { container } = render(
|
||||
<NumberInput label="Timeout (s)" value={30} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("Timeout (s)");
|
||||
});
|
||||
|
||||
it("renders the input with the given numeric value", () => {
|
||||
render(<NumberInput label="Retries" value={3} onChange={vi.fn()} />);
|
||||
const input = document.querySelector("input[type=number]") as HTMLInputElement;
|
||||
expect(input.value).toBe("3");
|
||||
});
|
||||
|
||||
it("calls onChange with parsed integer on keystroke", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberInput label="Delay" value={1} onChange={onChange} />);
|
||||
const input = document.querySelector("input[type=number]") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "7" } });
|
||||
expect(onChange).toHaveBeenCalledWith(7);
|
||||
});
|
||||
|
||||
it("calls onChange with 0 for non-numeric input", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<NumberInput label="Count" value={5} onChange={onChange} />);
|
||||
const input = document.querySelector("input[type=number]") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "abc" } });
|
||||
expect(onChange).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it("respects min attribute", () => {
|
||||
render(
|
||||
<NumberInput
|
||||
label="Port"
|
||||
value={8000}
|
||||
onChange={vi.fn()}
|
||||
min={1024}
|
||||
/>,
|
||||
);
|
||||
const input = document.querySelector("input[type=number]") as HTMLInputElement;
|
||||
expect(input.getAttribute("min")).toBe("1024");
|
||||
});
|
||||
|
||||
it("respects max attribute", () => {
|
||||
render(
|
||||
<NumberInput
|
||||
label="Memory (MB)"
|
||||
value={256}
|
||||
onChange={vi.fn()}
|
||||
max={65535}
|
||||
/>,
|
||||
);
|
||||
const input = document.querySelector("input[type=number]") as HTMLInputElement;
|
||||
expect(input.getAttribute("max")).toBe("65535");
|
||||
});
|
||||
|
||||
it("input has aria-label from label prop", () => {
|
||||
render(<NumberInput label="Timeout" value={60} onChange={vi.fn()} />);
|
||||
const input = document.querySelector("input[type=number]") as HTMLInputElement;
|
||||
expect(input.getAttribute("aria-label")).toBe("Timeout");
|
||||
});
|
||||
|
||||
it("input has font-mono class", () => {
|
||||
const { container } = render(
|
||||
<NumberInput label="Budget" value={100} onChange={vi.fn()} />,
|
||||
);
|
||||
const input = container.querySelector("input") as HTMLInputElement;
|
||||
expect(input.className).toContain("font-mono");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Toggle ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Toggle", () => {
|
||||
it("renders the checkbox with label text", () => {
|
||||
const { container } = render(
|
||||
<Toggle label="Enable streaming" checked={false} onChange={vi.fn()} />,
|
||||
);
|
||||
const checkbox = container.querySelector(
|
||||
"input[type=checkbox]",
|
||||
) as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(false);
|
||||
expect(
|
||||
checkbox.closest("label")?.textContent,
|
||||
).toContain("Enable streaming");
|
||||
});
|
||||
|
||||
it("renders checked state correctly", () => {
|
||||
const { container } = render(
|
||||
<Toggle label="Push notifications" checked onChange={vi.fn()} />,
|
||||
);
|
||||
const checkbox = container.querySelector(
|
||||
"input[type=checkbox]",
|
||||
) as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onChange with true when toggled on", () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<Toggle label="Escalate" checked={false} onChange={onChange} />,
|
||||
);
|
||||
const checkbox = container.querySelector(
|
||||
"input[type=checkbox]",
|
||||
) as HTMLInputElement;
|
||||
checkbox.click();
|
||||
expect(onChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("calls onChange with false when toggled off", () => {
|
||||
const onChange = vi.fn();
|
||||
const { container } = render(
|
||||
<Toggle label="Escalate" checked onChange={onChange} />,
|
||||
);
|
||||
const checkbox = container.querySelector(
|
||||
"input[type=checkbox]",
|
||||
) as HTMLInputElement;
|
||||
checkbox.click();
|
||||
expect(onChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("checkbox is a native input element", () => {
|
||||
const { container } = render(
|
||||
<Toggle label="Feature flag" checked={false} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(container.querySelector("input[type=checkbox]")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── TagList ────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("TagList", () => {
|
||||
it("renders existing tags", () => {
|
||||
const { container } = render(
|
||||
<TagList label="Tools" values={["file_read", "bash"]} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("file_read");
|
||||
expect(container.textContent).toContain("bash");
|
||||
});
|
||||
|
||||
it("renders × remove button for each tag with aria-label", () => {
|
||||
render(
|
||||
<TagList
|
||||
label="Skills"
|
||||
values={["python", "golang"]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const buttons = document.querySelectorAll("button");
|
||||
// buttons[0] = first × (python), buttons[1] = second × (golang)
|
||||
expect(buttons[0].getAttribute("aria-label")).toBe(
|
||||
"Remove tag python",
|
||||
);
|
||||
expect(buttons[1].getAttribute("aria-label")).toBe(
|
||||
"Remove tag golang",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls onChange without removed tag when × is clicked", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<TagList
|
||||
label="Tags"
|
||||
values={["react", "vue", "angular"]}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
const buttons = document.querySelectorAll("button");
|
||||
// buttons[0] = react ×, buttons[1] = vue ×, buttons[2] = angular ×
|
||||
buttons[0].click(); // Remove react
|
||||
expect(onChange).toHaveBeenCalledWith(["vue", "angular"]);
|
||||
});
|
||||
|
||||
it("renders the label text", () => {
|
||||
const { container } = render(
|
||||
<TagList label="Required env vars" values={[]} onChange={vi.fn()} />,
|
||||
);
|
||||
expect(container.textContent).toContain("Required env vars");
|
||||
});
|
||||
|
||||
it("renders placeholder text when provided", () => {
|
||||
render(
|
||||
<TagList
|
||||
label="Tags"
|
||||
values={[]}
|
||||
onChange={vi.fn()}
|
||||
placeholder="Add a tag..."
|
||||
/>,
|
||||
);
|
||||
const input = document.querySelector("input[type=text]") as HTMLInputElement;
|
||||
expect(input.getAttribute("placeholder")).toBe("Add a tag...");
|
||||
});
|
||||
|
||||
it("renders exactly one textbox (the input)", () => {
|
||||
const { container } = render(
|
||||
<TagList
|
||||
label="Tools"
|
||||
values={["read", "write"]}
|
||||
onChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container.querySelectorAll("input[type=text]"),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("adds tag on Enter key", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<TagList label="Skills" values={["python"]} onChange={onChange} />,
|
||||
);
|
||||
const input = document.querySelector("input[type=text]") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "rust" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).toHaveBeenCalledWith(["python", "rust"]);
|
||||
});
|
||||
|
||||
it("does not add empty tag on Enter", () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<TagList label="Tools" values={[]} onChange={onChange} />,
|
||||
);
|
||||
const input = document.querySelector("input[type=text]") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: " " } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("clears input after adding tag", () => {
|
||||
render(
|
||||
<TagList label="Tags" values={[]} onChange={vi.fn()} />,
|
||||
);
|
||||
const input = document.querySelector("input[type=text]") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "golang" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(input.value).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Section ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe("Section", () => {
|
||||
it("renders the title", () => {
|
||||
const { container } = render(
|
||||
<Section title="Runtime config">Content here</Section>,
|
||||
);
|
||||
expect(container.textContent).toContain("Runtime config");
|
||||
});
|
||||
|
||||
it("renders children when open (defaultOpen=true)", () => {
|
||||
const { container } = render(
|
||||
<Section title="A section">Hidden content</Section>,
|
||||
);
|
||||
expect(container.textContent).toContain("Hidden content");
|
||||
});
|
||||
|
||||
it("starts closed when defaultOpen=false", () => {
|
||||
const { container } = render(
|
||||
<Section title="Collapsed" defaultOpen={false}>
|
||||
Should not be visible
|
||||
</Section>,
|
||||
);
|
||||
expect(container.textContent).not.toContain("Should not be visible");
|
||||
});
|
||||
|
||||
it("opens/closes content on title click", () => {
|
||||
const { container } = render(
|
||||
<Section title="Toggle me" defaultOpen={false}>
|
||||
Now you see me
|
||||
</Section>,
|
||||
);
|
||||
// Should be closed initially
|
||||
expect(container.textContent).not.toContain("Now you see me");
|
||||
// Click to open
|
||||
const btn = container.querySelector("button") as HTMLButtonElement;
|
||||
fireEvent.click(btn);
|
||||
expect(container.textContent).toContain("Now you see me");
|
||||
// Click to close
|
||||
fireEvent.click(btn);
|
||||
expect(container.textContent).not.toContain("Now you see me");
|
||||
});
|
||||
|
||||
it("title button has aria-expanded reflecting open state", () => {
|
||||
// Open section
|
||||
const { container: openContainer } = render(
|
||||
<Section title="A section" defaultOpen={true}>
|
||||
Open content
|
||||
</Section>,
|
||||
);
|
||||
const openBtn = openContainer.querySelector(
|
||||
"button",
|
||||
) as HTMLButtonElement;
|
||||
expect(openBtn.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
// Closed section
|
||||
const { container: closedContainer } = render(
|
||||
<Section title="B section" defaultOpen={false}>
|
||||
Closed content
|
||||
</Section>,
|
||||
);
|
||||
const closedBtn = closedContainer.querySelector(
|
||||
"button",
|
||||
) as HTMLButtonElement;
|
||||
expect(closedBtn.getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("toggle indicator changes between ▾ (open) and ▸ (closed)", () => {
|
||||
// Open: uses ▾
|
||||
const { container: openContainer } = render(
|
||||
<Section title="Indicator" defaultOpen={true}>
|
||||
Open
|
||||
</Section>,
|
||||
);
|
||||
// Button has two spans: title (first) and indicator (second, aria-hidden)
|
||||
const openSpans = openContainer
|
||||
.querySelectorAll("button span");
|
||||
const openIndicator = openSpans[1]?.textContent?.trim();
|
||||
expect(openIndicator).toBe("▾");
|
||||
|
||||
// Closed: uses ▸
|
||||
const { container: closedContainer } = render(
|
||||
<Section title="Indicator" defaultOpen={false}>
|
||||
Closed
|
||||
</Section>,
|
||||
);
|
||||
const closedSpans = closedContainer
|
||||
.querySelectorAll("button span");
|
||||
const closedIndicator = closedSpans[1]?.textContent?.trim();
|
||||
expect(closedIndicator).toBe("▸");
|
||||
});
|
||||
});
|
||||
@@ -127,13 +127,21 @@ export function TagList({ label, values, onChange, placeholder }: { label: strin
|
||||
|
||||
export function Section({ title, children, defaultOpen = true }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
// Stable id for aria-controls linkage
|
||||
const id = `section-content-${title.toLowerCase().replace(/\s+/g, "-")}`;
|
||||
return (
|
||||
<div className="border border-line rounded mb-2">
|
||||
<button type="button" onClick={() => setOpen(!open)} className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] text-ink-mid hover:text-ink bg-surface-sunken/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
aria-expanded={open}
|
||||
aria-controls={id}
|
||||
className="w-full flex items-center justify-between px-3 py-1.5 text-[10px] text-ink-mid hover:text-ink bg-surface-sunken/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
<span className="font-medium uppercase tracking-wider">{title}</span>
|
||||
<span>{open ? "▾" : "▸"}</span>
|
||||
<span aria-hidden="true">{open ? "▾" : "▸"}</span>
|
||||
</button>
|
||||
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||
{open && <div id={id} className="p-3 space-y-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for KeyValueField component.
|
||||
*
|
||||
* Covers: initial password type, onChange callback (including whitespace trim
|
||||
* on type), aria-label forwarding, disabled state, and auto-hide timer setup.
|
||||
*/
|
||||
import React from "react";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { KeyValueField } from "../KeyValueField";
|
||||
|
||||
describe("KeyValueField — rendering", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("renders input with type=password by default (secret hidden)", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} />);
|
||||
const input = screen.getByLabelText("Secret value");
|
||||
expect(input.getAttribute("type")).toBe("password");
|
||||
});
|
||||
|
||||
it("passes custom aria-label to the input element", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} aria-label="API secret key" />);
|
||||
expect(screen.getByLabelText("API secret key")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("disables the input when disabled=true", () => {
|
||||
render(<KeyValueField value="secret" onChange={vi.fn()} disabled />);
|
||||
expect(screen.getByLabelText("Secret value").disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders with the current value", () => {
|
||||
render(<KeyValueField value="sk-test-key-123" onChange={vi.fn()} />);
|
||||
expect(screen.getByLabelText("Secret value").value).toBe("sk-test-key-123");
|
||||
});
|
||||
|
||||
it("renders with the placeholder text", () => {
|
||||
render(<KeyValueField value="" onChange={vi.fn()} placeholder="Enter API key" />);
|
||||
expect(screen.getByLabelText("Secret value").getAttribute("placeholder")).toBe("Enter API key");
|
||||
});
|
||||
|
||||
it("renders the RevealToggle child button", () => {
|
||||
render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
// KeyValueField renders exactly one button (the RevealToggle)
|
||||
expect(screen.getByRole("button")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyValueField — onChange", () => {
|
||||
afterEach(cleanup);
|
||||
|
||||
it("calls onChange with the new value when user types", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "new-value" } });
|
||||
expect(onChange).toHaveBeenCalledWith("new-value");
|
||||
});
|
||||
|
||||
it("trims leading whitespace when user types with leading space", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " trimmed" } });
|
||||
expect(onChange).toHaveBeenCalledWith("trimmed");
|
||||
});
|
||||
|
||||
it("trims trailing whitespace when user types with trailing space", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "trimmed " } });
|
||||
expect(onChange).toHaveBeenCalledWith("trimmed");
|
||||
});
|
||||
|
||||
it("trims both sides when user types whitespace-surrounded value", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: " both sides " } });
|
||||
expect(onChange).toHaveBeenCalledWith("both sides");
|
||||
});
|
||||
|
||||
it("does not modify value with no whitespace", () => {
|
||||
const onChange = vi.fn();
|
||||
render(<KeyValueField value="" onChange={onChange} />);
|
||||
fireEvent.change(screen.getByLabelText("Secret value"), { target: { value: "clean-value" } });
|
||||
expect(onChange).toHaveBeenCalledWith("clean-value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyValueField — auto-hide timer setup", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("sets up a 30s setTimeout when the component mounts with a non-empty value", () => {
|
||||
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
||||
render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
// No timer should be set initially (revealed=false by default)
|
||||
const callsBeforeInteraction = setTimeoutSpy.mock.calls.length;
|
||||
|
||||
// Simulate reveal (click the only button)
|
||||
act(() => { fireEvent.click(screen.getByRole("button")); });
|
||||
|
||||
// After reveal, a 30s timer should be set
|
||||
const timerCalls = setTimeoutSpy.mock.calls.filter(
|
||||
([, delay]) => delay === 30_000,
|
||||
);
|
||||
expect(timerCalls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("clears existing timer when a new toggle happens before auto-hide fires", () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
||||
const timerObj = {}; // fake timer ID
|
||||
vi.spyOn(global, "setTimeout").mockImplementation((fn: () => void, delay: number) => {
|
||||
return timerObj;
|
||||
});
|
||||
render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
|
||||
// First toggle — reveal
|
||||
act(() => { fireEvent.click(screen.getByRole("button")); });
|
||||
|
||||
// Second toggle — hide (should clear the timer from first toggle)
|
||||
act(() => { fireEvent.click(screen.getByRole("button")); });
|
||||
|
||||
// clearTimeout was called with the timer object
|
||||
expect(clearTimeoutSpy).toHaveBeenCalledWith(timerObj);
|
||||
});
|
||||
|
||||
it("clears timer on unmount", () => {
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
||||
const { unmount } = render(<KeyValueField value="secret" onChange={vi.fn()} />);
|
||||
|
||||
// Toggle reveal to start the timer
|
||||
act(() => { fireEvent.click(screen.getByRole("button")); });
|
||||
|
||||
unmount();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for RevealToggle component.
|
||||
*
|
||||
* Covers: eye-icon (hidden) vs eye-off-icon (revealed), onToggle callback,
|
||||
* aria-label (default + custom), title attribute.
|
||||
*/
|
||||
import { afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { RevealToggle } from "../RevealToggle";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("RevealToggle", () => {
|
||||
it("renders as a button", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("uses default aria-label when not provided", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Toggle reveal secret");
|
||||
});
|
||||
|
||||
it("uses custom aria-label when provided", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} label="Show password" />);
|
||||
expect(screen.getByRole("button").getAttribute("aria-label")).toBe("Show password");
|
||||
});
|
||||
|
||||
it('title is "Hide value" when revealed', () => {
|
||||
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button").getAttribute("title")).toBe("Hide value");
|
||||
});
|
||||
|
||||
it('title is "Show value" when hidden', () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
expect(screen.getByRole("button").getAttribute("title")).toBe("Show value");
|
||||
});
|
||||
|
||||
it("calls onToggle when clicked (revealed=true → should hide)", () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<RevealToggle revealed={true} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("calls onToggle when clicked (revealed=false → should show)", () => {
|
||||
const onToggle = vi.fn();
|
||||
render(<RevealToggle revealed={false} onToggle={onToggle} />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders the eye-open SVG (hide icon) when revealed=false", () => {
|
||||
render(<RevealToggle revealed={false} onToggle={vi.fn()} />);
|
||||
const btn = screen.getByRole("button");
|
||||
// The eye SVG contains a circle element; eye-off has a strikethrough line
|
||||
expect(btn.querySelector("circle")).toBeTruthy();
|
||||
expect(btn.querySelectorAll("line")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("renders the eye-off SVG (show icon) when revealed=true", () => {
|
||||
render(<RevealToggle revealed={true} onToggle={vi.fn()} />);
|
||||
const btn = screen.getByRole("button");
|
||||
// EyeOffIcon has a line (strikethrough) through the eye
|
||||
expect(btn.querySelectorAll("line")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for ValidationHint component.
|
||||
*
|
||||
* Covers: null/neutral render, error state (red ⚠ + message), valid state
|
||||
* (green ✓ + "Valid format"), ARIA role="alert" on error.
|
||||
*/
|
||||
import { afterEach, describe, it, expect } from "vitest";
|
||||
import { render, screen, cleanup } from "@testing-library/react";
|
||||
import { ValidationHint } from "../ValidationHint";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("ValidationHint", () => {
|
||||
it("renders nothing when error is null and showValid is false", () => {
|
||||
const { container } = render(<ValidationHint error={null} showValid={false} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when error is null and showValid is undefined", () => {
|
||||
const { container } = render(<ValidationHint error={null} />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders error state with ⚠ icon and message", () => {
|
||||
render(<ValidationHint error="Key name must be UPPER_SNAKE_CASE" />);
|
||||
const el = screen.getByRole("alert");
|
||||
expect(el.textContent).toContain("⚠");
|
||||
expect(el.textContent).toContain("Key name must be UPPER_SNAKE_CASE");
|
||||
});
|
||||
|
||||
it("renders valid state with ✓ and 'Valid format'", () => {
|
||||
render(<ValidationHint error={null} showValid />);
|
||||
const el = screen.getByText("Valid format");
|
||||
expect(el.textContent).toContain("✓");
|
||||
});
|
||||
|
||||
it("prefers error over valid when both are set (error is not null)", () => {
|
||||
// ValidationHint checks error first; showValid is only rendered when error is falsy.
|
||||
render(<ValidationHint error="Some error" showValid />);
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
expect(screen.queryByText("Valid format")).toBeNull();
|
||||
});
|
||||
|
||||
it("error alert has role='alert' for screen readers", () => {
|
||||
render(<ValidationHint error="Invalid format" />);
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -4,11 +4,11 @@ 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-12 (infra-runtime-be-agent)
|
||||
> Last updated: 2026-05-11 (core-devops-agent)
|
||||
|
||||
---
|
||||
|
||||
## Quirk #1 — Large repo causes fetch timeout on Gitea Actions runner
|
||||
## Large repo causes fetch timeout on Gitea Actions runner
|
||||
|
||||
### Finding
|
||||
|
||||
@@ -68,7 +68,7 @@ confirming this is a repo-size constraint, not network isolation.
|
||||
|
||||
---
|
||||
|
||||
## Quirk #2 — `continue-on-error` only works at step level, not job level
|
||||
## `continue-on-error` only works at step level, not job level
|
||||
|
||||
### Finding
|
||||
|
||||
@@ -112,12 +112,12 @@ jobs:
|
||||
|
||||
### References
|
||||
|
||||
- Quirk #10 (this document): Gitea does NOT auto-populate `secrets.GITHUB_TOKEN`
|
||||
- Gitea Actions quirk #10 (from migration checklist)
|
||||
- PR #441: fix applied to `harness-replays.yml`
|
||||
|
||||
---
|
||||
|
||||
## Quirk #3 — `workflow_dispatch.inputs` not supported
|
||||
## `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
|
||||
@@ -127,21 +127,21 @@ YAML files ported from GitHub Actions. Manual triggers should use
|
||||
|
||||
---
|
||||
|
||||
## Quirk #4 — `merge_group` not supported
|
||||
## `merge_group` not supported
|
||||
|
||||
Gitea has no merge queue concept. Drop `merge_group:` triggers from all
|
||||
workflow YAML files.
|
||||
|
||||
---
|
||||
|
||||
## Quirk #5 — `environment:` blocks not supported
|
||||
## `environment:` blocks not supported
|
||||
|
||||
Gitea has no environments concept. Drop `environment:` from all workflow YAML
|
||||
files. Secrets and variables are repo-level.
|
||||
|
||||
---
|
||||
|
||||
## Quirk #6 — Gitea combined status reports `failure` when all contexts are `null`
|
||||
## Gitea combined status reports `failure` when all contexts are `null`
|
||||
|
||||
### Finding
|
||||
|
||||
@@ -189,215 +189,3 @@ primary consumer of combined status and is affected.
|
||||
|
||||
- Issue #481: first real-world case of this bug (2026-05-11)
|
||||
- `feedback_no_such_thing_as_flakes`: watchdog directive
|
||||
|
||||
---
|
||||
|
||||
## Quirk #7 — TBD
|
||||
|
||||
*[Placeholder — document here when a new Gitea Actions quirk is discovered.]*
|
||||
|
||||
### Finding
|
||||
|
||||
*[What Gitea Actions does differently from GitHub Actions.]*
|
||||
|
||||
### Impact
|
||||
|
||||
*[Which workflows or operations are affected.]*
|
||||
|
||||
### Workaround
|
||||
|
||||
*[How to work around this quirk.]*
|
||||
|
||||
### References
|
||||
|
||||
- internal#[N]: first observation
|
||||
|
||||
---
|
||||
|
||||
## Quirk #8 — TBD
|
||||
|
||||
*[Placeholder — document here when a new Gitea Actions quirk is discovered.]*
|
||||
|
||||
### Finding
|
||||
|
||||
*[What Gitea Actions does differently from GitHub Actions.]*
|
||||
|
||||
### Impact
|
||||
|
||||
*[Which workflows or operations are affected.]*
|
||||
|
||||
### Workaround
|
||||
|
||||
*[How to work around this quirk.]*
|
||||
|
||||
### References
|
||||
|
||||
- internal#[N]: first observation
|
||||
|
||||
---
|
||||
|
||||
## Quirk #9 — TBD
|
||||
|
||||
*[Placeholder — document here when a new Gitea Actions quirk is discovered.]*
|
||||
|
||||
### Finding
|
||||
|
||||
*[What Gitea Actions does differently from GitHub Actions.]*
|
||||
|
||||
### Impact
|
||||
|
||||
*[Which workflows or operations are affected.]*
|
||||
|
||||
### Workaround
|
||||
|
||||
*[How to work around this quirk.]*
|
||||
|
||||
### References
|
||||
|
||||
- internal#[N]: first observation
|
||||
|
||||
---
|
||||
|
||||
## Quirk #10 — Gitea does NOT auto-populate `secrets.GITHUB_TOKEN`
|
||||
|
||||
### Finding
|
||||
|
||||
Gitea Actions (1.22.6) does **not** auto-populate `secrets.GITHUB_TOKEN`
|
||||
the way GitHub Actions does. A workflow that references `secrets.GITHUB_TOKEN`
|
||||
without explicitly provisioning a named secret gets an empty string — not a
|
||||
read-only token scoped to the repo.
|
||||
|
||||
### Impact
|
||||
|
||||
Workflows that call the Gitea REST API using `secrets.GITHUB_TOKEN` as auth
|
||||
receive **HTTP 401** on every API call. Affected workflows in molecule-core:
|
||||
|
||||
| Workflow | Symptom | Workaround |
|
||||
|---|---|---|
|
||||
| `gate-check-v3.yml` | Reports BLOCKED on every PR | Provision `SOP_TIER_CHECK_TOKEN`; update workflow to use it |
|
||||
| `qa-review.yml` | Fails immediately on PR open | Same — needs named secret |
|
||||
| `security-review.yml` | Fails immediately on PR open | Same — needs named secret |
|
||||
|
||||
### How to diagnose
|
||||
|
||||
Add a debug step to the failing workflow:
|
||||
|
||||
```yaml
|
||||
- name: Diagnose token
|
||||
run: |
|
||||
echo "Token present: ${{ secrets.GITHUB_TOKEN != '' }}"
|
||||
curl -sS --fail -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
"$GITHUB_SERVER_URL/api/v1/user" | jq -r '.login'
|
||||
# Expected (GitHub): prints your username.
|
||||
# Actual (Gitea): HTTP 401 or empty string.
|
||||
```
|
||||
|
||||
### References
|
||||
|
||||
- internal#325: root-cause analysis and token provisioning
|
||||
- `feedback_gitea_no_auto_supplied_github_token`
|
||||
|
||||
---
|
||||
|
||||
## Quirk #11 — PR-create event dispatcher races — only 1 of N workflows fires on `pull_request opened`
|
||||
|
||||
### Finding
|
||||
|
||||
When a PR is created via the Gitea web UI or API, the Gitea Actions event
|
||||
dispatcher may fire **only 1 of N eligible workflows** on the initial
|
||||
`pull_request opened` event. All other eligible workflows are silently dropped.
|
||||
|
||||
This was observed on molecule-core PR #558 (created 2026-05-11T19:54:10Z):
|
||||
12+ workflows had no `paths:` filter and should have fired, but only
|
||||
`sop-tier-check.yml` dispatched.
|
||||
|
||||
Concurrent PRs created within the same minute received 12–30 dispatches each,
|
||||
confirming this is specific to the PR-create event dispatch, not a general
|
||||
runner capacity issue.
|
||||
|
||||
### Impact
|
||||
|
||||
- PRs may not run the full CI suite on first open.
|
||||
- `gate-check-v3`, `secret-scan`, `qa-review`, and `security-review` can be
|
||||
silently absent from the PR's status checks.
|
||||
- Branch protection may block merge even though CI is effectively green.
|
||||
|
||||
### How to diagnose
|
||||
|
||||
```bash
|
||||
# List workflow runs for the PR:
|
||||
gh run list --event pull_request --repo molecule-ai/molecule-core \
|
||||
| grep "$(gh pr view $PR --json number --jq '.number')"
|
||||
|
||||
# Expected: 12+ runs on PR open.
|
||||
# Actual (when race fires): only 1 run.
|
||||
```
|
||||
|
||||
### Workaround
|
||||
|
||||
Force a second dispatch by pushing a no-op synchronize commit:
|
||||
|
||||
```bash
|
||||
git commit --allow-empty -m "chore: trigger workflows [skip ci]"
|
||||
git push
|
||||
```
|
||||
|
||||
The synchronize event fires a second `pull_request` event, which reliably
|
||||
triggers all eligible workflows.
|
||||
|
||||
### References
|
||||
|
||||
- internal#329: first observation on PR #558
|
||||
- `feedback_gitea_pr_create_dispatcher_race`
|
||||
|
||||
---
|
||||
|
||||
## When you find a new quirk
|
||||
|
||||
Copy the template below, increment the quirk number, and fill in the finding,
|
||||
impact, workaround, and references. Place the new section in the **correct
|
||||
numerical position** (before the next higher-numbered quirk). Update this
|
||||
section's final paragraph to remove the next slot's number.
|
||||
|
||||
### Template
|
||||
|
||||
```markdown
|
||||
## Quirk #N — <short title>
|
||||
|
||||
### Finding
|
||||
|
||||
<What Gitea Actions does differently from GitHub Actions.>
|
||||
|
||||
### Impact
|
||||
|
||||
<Which workflows or operations are affected. Include an affected workflows
|
||||
table if more than one is affected.>
|
||||
|
||||
### How to diagnose
|
||||
|
||||
<Shell commands or API calls that confirm this is the quirk, not a real failure.>
|
||||
|
||||
### Workaround
|
||||
|
||||
<How to work around this quirk in workflow YAML or operations.>
|
||||
|
||||
### References
|
||||
|
||||
- internal#[N]: first observation
|
||||
- <Any Gitea issue, feedback label, or upstream bug tracker reference>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Open questions for Gitea 1.23
|
||||
|
||||
- [ ] **act_runner concurrent-job cap**: issue #305 — runner saturation under
|
||||
merge burst; needs `max_concurrent_jobs` cap configured on act_runner
|
||||
- [ ] **Infisical→Gitea secret-sync**: issue #307 — eliminate manual secret
|
||||
PUTs by wiring an Infisical cron to the Gitea API
|
||||
- [ ] **PR-create dispatcher race resolution**: internal #329 — is there a
|
||||
Gitea fix or config knob to disable the race? File upstream bug if not
|
||||
- [ ] **GITHUB_TOKEN auto-population**: internal #325 — is this on the
|
||||
Gitea 1.23 roadmap? If not, the workaround (named secret) is the permanent
|
||||
answer
|
||||
|
||||
|
||||
@@ -1,357 +0,0 @@
|
||||
"""Tests for `.gitea/scripts/lint_mask_pr_atomicity.py` — Tier 2d lint.
|
||||
|
||||
Structural enforcement of internal#350 Tier 2d: a PR that touches
|
||||
`.gitea/workflows/ci.yml` and modifies `continue-on-error` OR the
|
||||
`all-required` sentinel's `needs:` block must EITHER:
|
||||
|
||||
- Touch both atomically in the same PR (preferred), OR
|
||||
- Cross-link to the paired PR via `Paired: #NNN` in body OR a commit
|
||||
message.
|
||||
|
||||
The class this lint exists to prevent: PR#665 (interim
|
||||
continue-on-error: true on platform-build) + PR#668 (sentinel-exempt)
|
||||
were designed-as-a-pair but merged solo — #665 landed at 04:47Z, #668
|
||||
still open at 05:07Z when the watchdog fired. ~20 min of main red.
|
||||
|
||||
Test classes (per `feedback_branch_count_before_approving`, every
|
||||
prod branch enumerated):
|
||||
|
||||
- test_diff_touches_neither_passes — diff is in ci.yml
|
||||
but neither continue-on-error nor all-required.needs is touched.
|
||||
PR is exempt. Exit 0.
|
||||
- test_diff_touches_both_atomically_passes — both touched in
|
||||
the same PR. Atomic. Exit 0.
|
||||
- test_diff_touches_coe_only_no_pair_fails — continue-on-error
|
||||
flipped without sentinel-needs change AND no `Paired: #NNN`
|
||||
reference anywhere. Exit 1.
|
||||
- test_diff_touches_needs_only_no_pair_fails — sentinel `needs:`
|
||||
changed without `continue-on-error` change AND no pair reference.
|
||||
Exit 1.
|
||||
- test_diff_touches_coe_only_pair_in_body — coe changed, no
|
||||
needs change, body has `Paired: #668`. Exit 0.
|
||||
- test_diff_touches_needs_only_pair_in_commit — needs changed, no
|
||||
coe change, commit message includes `Paired: #665`. Exit 0.
|
||||
- test_paired_reference_must_be_numeric — `Paired: #abc` or
|
||||
`Paired: NNNN` (missing `#`) doesn't satisfy the rule. Exit 1.
|
||||
- test_ci_yml_unchanged_skips — no ci.yml in the
|
||||
diff at all (defensive — workflow paths-filter already prevents,
|
||||
but the lint should not crash). Exit 0.
|
||||
|
||||
The lint receives base SHA + head SHA via env (set by the workflow
|
||||
from the pull_request payload) and uses `git show` to read both
|
||||
sides without a separate clone. Tests stub `subprocess.run` to drive
|
||||
the diff content; the actual git is never invoked.
|
||||
|
||||
Run:
|
||||
python3 -m pytest tests/test_lint_mask_pr_atomicity.py -v
|
||||
|
||||
Dependencies: stdlib + PyYAML (the script reads ci.yml via PyYAML AST
|
||||
per `feedback_behavior_based_ast_gates`). No network. No live git.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SCRIPT_PATH = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ ".gitea"
|
||||
/ "scripts"
|
||||
/ "lint_mask_pr_atomicity.py"
|
||||
)
|
||||
|
||||
|
||||
# Minimal ci.yml fixture — only the bits the lint actually parses
|
||||
# (a job with continue-on-error + the all-required aggregator).
|
||||
CI_YML_BASE = """
|
||||
name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
jobs:
|
||||
platform-build:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- run: echo build
|
||||
canvas-build:
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: false
|
||||
steps:
|
||||
- run: echo build
|
||||
all-required:
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- platform-build
|
||||
- canvas-build
|
||||
if: always()
|
||||
steps:
|
||||
- run: echo agg
|
||||
"""
|
||||
|
||||
# Same as base but with continue-on-error flipped on platform-build.
|
||||
CI_YML_COE_FLIPPED = CI_YML_BASE.replace(
|
||||
" platform-build:\n runs-on: ubuntu-latest\n continue-on-error: false",
|
||||
" platform-build:\n runs-on: ubuntu-latest\n continue-on-error: true",
|
||||
)
|
||||
|
||||
# Same as base but with canvas-build dropped from all-required.needs.
|
||||
CI_YML_NEEDS_CHANGED = CI_YML_BASE.replace(
|
||||
" needs:\n - platform-build\n - canvas-build",
|
||||
" needs:\n - platform-build",
|
||||
)
|
||||
|
||||
# Both changed at once.
|
||||
CI_YML_BOTH = CI_YML_COE_FLIPPED.replace(
|
||||
" needs:\n - platform-build\n - canvas-build",
|
||||
" needs:\n - platform-build",
|
||||
)
|
||||
|
||||
|
||||
def _import_lint(monkeypatch):
|
||||
"""Import the lint module under a fresh name per test."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"lint_mask_pr_atomicity_{os.getpid()}_{id(monkeypatch)}",
|
||||
SCRIPT_PATH,
|
||||
)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
return m
|
||||
|
||||
|
||||
def _stub_git(base_yml: str | None, head_yml: str | None, commits: list[str]):
|
||||
"""Build a fake `subprocess.run` that emulates git show + log.
|
||||
|
||||
base_yml / head_yml: contents the lint sees at base/head SHA.
|
||||
Pass `None` to simulate "path didn't exist on that side" (git
|
||||
show returns exit code 128 — file-not-in-tree).
|
||||
commits: list of commit messages on the PR (head's ancestry up to
|
||||
the base merge-base). The lint runs
|
||||
`git log --format=%B base..head` to find Paired: refs.
|
||||
"""
|
||||
|
||||
def fake_run(cmd, *args, **kwargs):
|
||||
if not isinstance(cmd, list):
|
||||
raise AssertionError(f"unexpected non-list cmd: {cmd!r}")
|
||||
# `git show <sha>:<path>`
|
||||
if cmd[:2] == ["git", "show"] and len(cmd) >= 3 and ":" in cmd[2]:
|
||||
sha, path = cmd[2].split(":", 1)
|
||||
if "base" in sha or "BASE" in sha:
|
||||
content = base_yml
|
||||
else:
|
||||
content = head_yml
|
||||
if content is None:
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, returncode=128, stdout="", stderr="fatal: path not in tree"
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, returncode=0, stdout=content, stderr=""
|
||||
)
|
||||
# `git log --format=%B base..head -- .`
|
||||
if cmd[:2] == ["git", "log"]:
|
||||
body = "\n\n--commit-boundary--\n\n".join(commits)
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, returncode=0, stdout=body, stderr=""
|
||||
)
|
||||
# `git diff --name-only base..head`
|
||||
if cmd[:2] == ["git", "diff"]:
|
||||
# If either side had ci.yml, it's in the diff; else not.
|
||||
paths = []
|
||||
if (base_yml or "") != (head_yml or ""):
|
||||
paths.append(".gitea/workflows/ci.yml")
|
||||
return subprocess.CompletedProcess(
|
||||
cmd, returncode=0, stdout="\n".join(paths) + "\n", stderr=""
|
||||
)
|
||||
raise AssertionError(f"unexpected git invocation: {cmd!r}")
|
||||
|
||||
return fake_run
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def env(monkeypatch):
|
||||
monkeypatch.setenv("BASE_SHA", "base-sha-1")
|
||||
monkeypatch.setenv("HEAD_SHA", "head-sha-1")
|
||||
monkeypatch.setenv("PR_BODY", "")
|
||||
monkeypatch.setenv("CI_WORKFLOW_PATH", ".gitea/workflows/ci.yml")
|
||||
monkeypatch.setenv("SENTINEL_JOB_KEY", "all-required")
|
||||
return monkeypatch
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diff in ci.yml but neither rule predicate triggered → pass
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_diff_touches_neither_passes(env, monkeypatch, capsys):
|
||||
# Add a comment-only change (no coe flip, no needs change).
|
||||
base = CI_YML_BASE
|
||||
head = "# a harmless comment\n" + CI_YML_BASE
|
||||
monkeypatch.setattr(
|
||||
subprocess, "run", _stub_git(base, head, ["chore: comment"])
|
||||
)
|
||||
m = _import_lint(monkeypatch)
|
||||
rc = m.run()
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "no atomicity risk" in out.lower() or "ok" in out.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diff touches BOTH coe and sentinel.needs in the same PR → atomic, pass
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_diff_touches_both_atomically_passes(env, monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
_stub_git(CI_YML_BASE, CI_YML_BOTH, ["fix(ci): atomic flip"]),
|
||||
)
|
||||
m = _import_lint(monkeypatch)
|
||||
rc = m.run()
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "atomic" in out.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diff touches ONLY continue-on-error, no pair reference → fail
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_diff_touches_coe_only_no_pair_fails(env, monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
_stub_git(
|
||||
CI_YML_BASE,
|
||||
CI_YML_COE_FLIPPED,
|
||||
["fix(ci): flip coe on platform-build"],
|
||||
),
|
||||
)
|
||||
m = _import_lint(monkeypatch)
|
||||
rc = m.run()
|
||||
assert rc == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "paired" in out.lower() or "atomicity" in out.lower()
|
||||
# Actionable failure: must name what is missing.
|
||||
assert "continue-on-error" in out.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Diff touches ONLY sentinel.needs, no pair reference → fail
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_diff_touches_needs_only_no_pair_fails(env, monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
_stub_git(
|
||||
CI_YML_BASE,
|
||||
CI_YML_NEEDS_CHANGED,
|
||||
["fix(ci): drop canvas-build from sentinel"],
|
||||
),
|
||||
)
|
||||
m = _import_lint(monkeypatch)
|
||||
rc = m.run()
|
||||
assert rc == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "paired" in out.lower() or "atomicity" in out.lower()
|
||||
assert "needs" in out.lower() or "sentinel" in out.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# COE-only flip with `Paired: #668` in PR body → pass
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_diff_touches_coe_only_pair_in_body(env, monkeypatch, capsys):
|
||||
monkeypatch.setenv("PR_BODY", "Interim coe flip. Paired: #668")
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
_stub_git(
|
||||
CI_YML_BASE,
|
||||
CI_YML_COE_FLIPPED,
|
||||
["fix(ci): flip coe on platform-build"],
|
||||
),
|
||||
)
|
||||
m = _import_lint(monkeypatch)
|
||||
rc = m.run()
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "paired" in out.lower()
|
||||
assert "668" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Needs-only flip with `Paired: #665` in a commit message → pass
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_diff_touches_needs_only_pair_in_commit(env, monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
_stub_git(
|
||||
CI_YML_BASE,
|
||||
CI_YML_NEEDS_CHANGED,
|
||||
[
|
||||
"fix(ci): drop canvas-build from sentinel\n\nPaired: #665",
|
||||
],
|
||||
),
|
||||
)
|
||||
m = _import_lint(monkeypatch)
|
||||
rc = m.run()
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "paired" in out.lower()
|
||||
assert "665" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# `Paired: #abc` is not a valid issue/PR ref — fail
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_paired_reference_must_be_numeric(env, monkeypatch, capsys):
|
||||
monkeypatch.setenv("PR_BODY", "Paired: #abc")
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
_stub_git(
|
||||
CI_YML_BASE,
|
||||
CI_YML_COE_FLIPPED,
|
||||
["fix(ci): flip coe"],
|
||||
),
|
||||
)
|
||||
m = _import_lint(monkeypatch)
|
||||
rc = m.run()
|
||||
assert rc == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Defensive: ci.yml not in diff at all → skip cleanly
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_ci_yml_unchanged_skips(env, monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
subprocess, "run", _stub_git(CI_YML_BASE, CI_YML_BASE, ["chore: noop"])
|
||||
)
|
||||
m = _import_lint(monkeypatch)
|
||||
rc = m.run()
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "ci.yml" in out.lower() or "not in" in out.lower() or "skip" in out.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-cutting: file ADDED on head side (no base) — coe inferred as
|
||||
# "newly added with coe=true". Should NOT trigger the lint (it's a new
|
||||
# file, not a flip — Tier 2e covers tracking-issue for new coe=true).
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_ci_yml_newly_added_passes(env, monkeypatch, capsys):
|
||||
monkeypatch.setattr(
|
||||
subprocess,
|
||||
"run",
|
||||
_stub_git(None, CI_YML_COE_FLIPPED, ["feat(ci): add ci.yml"]),
|
||||
)
|
||||
m = _import_lint(monkeypatch)
|
||||
rc = m.run()
|
||||
assert rc == 0
|
||||
@@ -1,554 +0,0 @@
|
||||
"""Tests for `.gitea/scripts/lint-required-no-paths.py`.
|
||||
|
||||
Structural enforcement of `feedback_path_filtered_workflow_cant_be_required`:
|
||||
no workflow whose status-check context is in `branch_protections/main`
|
||||
`status_check_contexts` may use `paths:` or `paths-ignore:` filters in its
|
||||
`on:` block. A path-filtered workflow silently does not fire on a PR whose
|
||||
diff doesn't touch the filter — Gitea treats that as `pending` forever,
|
||||
not `skipped`-as-`success`, so the gate degrades to an indefinite block.
|
||||
Worse, a docs-only PR could never satisfy a required check whose filter
|
||||
excludes docs paths, and the protected branch becomes unreachable.
|
||||
|
||||
Five test classes:
|
||||
- test_no_required_workflows_succeeds — empty status_check_contexts → exit 0
|
||||
- test_required_workflow_no_paths_passes — required workflow with no
|
||||
paths filter → exit 0
|
||||
- test_required_workflow_with_paths_filter_fails — required workflow
|
||||
with `paths: ['**.go']` → exit 1, error names workflow
|
||||
- test_required_workflow_with_paths_ignore_fails — same shape for
|
||||
`paths-ignore`
|
||||
- test_unknown_required_context_warns_not_fails — context whose
|
||||
workflow file is missing → warn, do NOT fail (graceful — could be a
|
||||
cross-repo context name or a workflow renamed mid-PR; the lint is for
|
||||
paths-filter detection, not orphaned-context detection — that's
|
||||
ci-required-drift's job)
|
||||
|
||||
Also covers the workflow-name → file-path mapping (parses the
|
||||
`<workflow_name> / <job_name> (<event>)` context format) and the
|
||||
multi-event `on:` block edge cases (paths under `on.push` vs `on.pull_request`
|
||||
vs top-level `on.paths`).
|
||||
|
||||
Run:
|
||||
python3 -m pytest tests/test_lint_required_no_paths.py -v
|
||||
|
||||
Dependencies: stdlib + PyYAML (already required by the script itself).
|
||||
No network. No live Gitea calls — `api()` is stubbed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Module import fixture — mirror of tests/test_ci_required_drift.py shape
|
||||
# --------------------------------------------------------------------------
|
||||
SCRIPT_PATH = (
|
||||
Path(__file__).resolve().parent.parent
|
||||
/ ".gitea"
|
||||
/ "scripts"
|
||||
/ "lint-required-no-paths.py"
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def lint_module(tmp_path, monkeypatch):
|
||||
"""Import the script as a module with a clean env per test.
|
||||
|
||||
Tests need a per-test workflows directory under tmp_path; the module
|
||||
reads `WORKFLOWS_DIR` from env. Fresh import per test means tests
|
||||
cannot leak global state into each other.
|
||||
"""
|
||||
env = {
|
||||
"GITEA_TOKEN": "test-token",
|
||||
"GITEA_HOST": "git.example.test",
|
||||
"REPO": "owner/repo",
|
||||
"BRANCH": "main",
|
||||
"WORKFLOWS_DIR": str(tmp_path / ".gitea" / "workflows"),
|
||||
}
|
||||
(tmp_path / ".gitea" / "workflows").mkdir(parents=True)
|
||||
monkeypatch.setattr(os, "environ", {**os.environ, **env})
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
f"lint_required_no_paths_{id(tmp_path)}", SCRIPT_PATH
|
||||
)
|
||||
m = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(m)
|
||||
# Force-set the globals from env (they were captured at import time;
|
||||
# we mutate them so the per-test tmp_path is what the script reads).
|
||||
m.GITEA_TOKEN = env["GITEA_TOKEN"]
|
||||
m.GITEA_HOST = env["GITEA_HOST"]
|
||||
m.REPO = env["REPO"]
|
||||
m.BRANCH = env["BRANCH"]
|
||||
m.WORKFLOWS_DIR = env["WORKFLOWS_DIR"]
|
||||
m.OWNER, m.NAME = "owner", "repo"
|
||||
m.API = f"https://{env['GITEA_HOST']}/api/v1"
|
||||
return m
|
||||
|
||||
|
||||
def _write_workflow(workflows_dir: str, filename: str, content: str) -> Path:
|
||||
p = Path(workflows_dir) / filename
|
||||
p.write_text(content, encoding="utf-8")
|
||||
return p
|
||||
|
||||
|
||||
def _make_stub_api(responses: dict):
|
||||
"""Build a fake `api()` callable.
|
||||
|
||||
`responses` maps (method, path) tuples to either:
|
||||
- (status_int, body) → returned as-is
|
||||
- Exception instance → raised
|
||||
Calls are recorded in `.calls` for later assertion.
|
||||
"""
|
||||
class StubApi:
|
||||
def __init__(self):
|
||||
self.calls: list[tuple] = []
|
||||
|
||||
def __call__(self, method, path, *, body=None, query=None, expect_json=True):
|
||||
self.calls.append((method, path, body, query))
|
||||
key = (method, path)
|
||||
if key not in responses:
|
||||
raise AssertionError(
|
||||
f"unexpected api call: {method} {path} (no stub registered)"
|
||||
)
|
||||
r = responses[key]
|
||||
if isinstance(r, Exception):
|
||||
raise r
|
||||
return r
|
||||
|
||||
return StubApi()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# context → (workflow_name, job_name, event) parser
|
||||
# --------------------------------------------------------------------------
|
||||
def test_parse_context_standard_shape(lint_module):
|
||||
"""`<workflow_name> / <job_name> (<event>)` round-trips cleanly."""
|
||||
parsed = lint_module.parse_context(
|
||||
"Secret scan / Scan diff for credential-shaped strings (pull_request)"
|
||||
)
|
||||
assert parsed == (
|
||||
"Secret scan",
|
||||
"Scan diff for credential-shaped strings",
|
||||
"pull_request",
|
||||
)
|
||||
|
||||
|
||||
def test_parse_context_with_slash_in_job_name(lint_module):
|
||||
"""Job names CAN contain ' / ' literally in Gitea; the parser must
|
||||
split on the LAST ' / ' before the trailing ' (event)' suffix."""
|
||||
parsed = lint_module.parse_context(
|
||||
"ci / setup / install-deps (pull_request)"
|
||||
)
|
||||
# Workflow = first segment; job = everything between first ' / ' and
|
||||
# the trailing ' (event)'. Pragmatic split: the workflow name is
|
||||
# `name:` from the YAML, so multi-slash workflow names are unlikely;
|
||||
# treat the first ' / ' as the divider.
|
||||
assert parsed[0] == "ci"
|
||||
assert parsed[1] == "setup / install-deps"
|
||||
assert parsed[2] == "pull_request"
|
||||
|
||||
|
||||
def test_parse_context_unparseable_returns_none(lint_module):
|
||||
"""Malformed context string → None so the caller can warn-and-skip."""
|
||||
assert lint_module.parse_context("garbage no event marker") is None
|
||||
assert lint_module.parse_context("") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# workflow-name → file resolution
|
||||
# --------------------------------------------------------------------------
|
||||
def test_resolve_workflow_file_matches_name_attr(lint_module):
|
||||
"""Resolution scans workflows/*.yml for a `name:` matching the
|
||||
context's workflow_name. Filename is NOT the source of truth — the
|
||||
`name:` attribute is, because Gitea's context format uses
|
||||
`name:` (not the filename).
|
||||
"""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"some-file.yml",
|
||||
"name: Secret scan\non:\n pull_request:\n types: [opened]\njobs:\n scan:\n runs-on: ubuntu-latest\n",
|
||||
)
|
||||
p = lint_module.resolve_workflow_file("Secret scan")
|
||||
assert p is not None
|
||||
assert p.name == "some-file.yml"
|
||||
|
||||
|
||||
def test_resolve_workflow_file_returns_none_when_missing(lint_module):
|
||||
"""No matching `name:` found → None."""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"other.yml",
|
||||
"name: Other\non:\n pull_request: {}\njobs:\n x:\n runs-on: ubuntu-latest\n",
|
||||
)
|
||||
assert lint_module.resolve_workflow_file("Secret scan") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# paths-filter detection
|
||||
# --------------------------------------------------------------------------
|
||||
def test_workflow_has_no_paths_filter_clean(lint_module):
|
||||
"""No paths/paths-ignore → returns empty list (no findings)."""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"clean.yml",
|
||||
"name: Clean\n"
|
||||
"on:\n"
|
||||
" pull_request:\n"
|
||||
" types: [opened, synchronize]\n"
|
||||
"jobs:\n"
|
||||
" x:\n"
|
||||
" runs-on: ubuntu-latest\n",
|
||||
)
|
||||
findings = lint_module.detect_paths_filters(
|
||||
Path(lint_module.WORKFLOWS_DIR) / "clean.yml"
|
||||
)
|
||||
assert findings == []
|
||||
|
||||
|
||||
def test_workflow_with_pull_request_paths_filter_detected(lint_module):
|
||||
"""`on.pull_request.paths` → ONE finding naming pull_request + paths."""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"bad.yml",
|
||||
"name: Bad\n"
|
||||
"on:\n"
|
||||
" pull_request:\n"
|
||||
" paths: ['**.go', 'workspace/**']\n"
|
||||
"jobs:\n"
|
||||
" x:\n"
|
||||
" runs-on: ubuntu-latest\n",
|
||||
)
|
||||
findings = lint_module.detect_paths_filters(
|
||||
Path(lint_module.WORKFLOWS_DIR) / "bad.yml"
|
||||
)
|
||||
assert len(findings) == 1
|
||||
f = findings[0]
|
||||
assert "pull_request" in f
|
||||
assert "paths" in f
|
||||
assert "**.go" in f or "workspace/**" in f # filter content surfaced
|
||||
|
||||
|
||||
def test_workflow_with_paths_ignore_filter_detected(lint_module):
|
||||
"""`on.pull_request.paths-ignore` → finding naming paths-ignore.
|
||||
|
||||
paths-ignore is the SAME class of defect: a docs-only PR (that
|
||||
matches the ignore pattern) silently won't fire the workflow, and the
|
||||
required context stays pending.
|
||||
"""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"bad.yml",
|
||||
"name: Bad\n"
|
||||
"on:\n"
|
||||
" pull_request:\n"
|
||||
" paths-ignore: ['docs/**']\n"
|
||||
"jobs:\n"
|
||||
" x:\n"
|
||||
" runs-on: ubuntu-latest\n",
|
||||
)
|
||||
findings = lint_module.detect_paths_filters(
|
||||
Path(lint_module.WORKFLOWS_DIR) / "bad.yml"
|
||||
)
|
||||
assert len(findings) == 1
|
||||
assert "paths-ignore" in findings[0]
|
||||
|
||||
|
||||
def test_workflow_with_push_paths_filter_detected(lint_module):
|
||||
"""`on.push.paths` → also a finding. A required check on a PR is
|
||||
typically `(pull_request)`-event, but a workflow may ALSO have a
|
||||
push trigger; a paths filter on the push side affects the same
|
||||
workflow file, and a future PR might add `paths:` to the wrong
|
||||
event-branch and trip the gate. Surface all paths-filter sites.
|
||||
"""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"bad.yml",
|
||||
"name: Bad\n"
|
||||
"on:\n"
|
||||
" pull_request:\n"
|
||||
" types: [opened]\n"
|
||||
" push:\n"
|
||||
" branches: [main]\n"
|
||||
" paths: ['**.py']\n"
|
||||
"jobs:\n"
|
||||
" x:\n"
|
||||
" runs-on: ubuntu-latest\n",
|
||||
)
|
||||
findings = lint_module.detect_paths_filters(
|
||||
Path(lint_module.WORKFLOWS_DIR) / "bad.yml"
|
||||
)
|
||||
assert len(findings) == 1
|
||||
assert "push" in findings[0]
|
||||
assert "paths" in findings[0]
|
||||
|
||||
|
||||
def test_workflow_with_both_paths_and_paths_ignore_two_findings(lint_module):
|
||||
"""Both filters under one event → two findings (one per offending
|
||||
key). Test ensures the detector doesn't short-circuit after the
|
||||
first."""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"bad.yml",
|
||||
"name: Bad\n"
|
||||
"on:\n"
|
||||
" pull_request:\n"
|
||||
" paths: ['**.go']\n"
|
||||
" paths-ignore: ['docs/**']\n"
|
||||
"jobs:\n"
|
||||
" x:\n"
|
||||
" runs-on: ubuntu-latest\n",
|
||||
)
|
||||
findings = lint_module.detect_paths_filters(
|
||||
Path(lint_module.WORKFLOWS_DIR) / "bad.yml"
|
||||
)
|
||||
assert len(findings) == 2
|
||||
|
||||
|
||||
def test_workflow_with_on_shorthand_string_passes(lint_module):
|
||||
"""`on: pull_request` (string shorthand, no sub-keys) cannot have a
|
||||
paths filter — detector treats it as clean."""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"clean.yml",
|
||||
"name: Clean\non: pull_request\njobs:\n x:\n runs-on: ubuntu-latest\n",
|
||||
)
|
||||
findings = lint_module.detect_paths_filters(
|
||||
Path(lint_module.WORKFLOWS_DIR) / "clean.yml"
|
||||
)
|
||||
assert findings == []
|
||||
|
||||
|
||||
def test_workflow_with_on_list_shorthand_passes(lint_module):
|
||||
"""`on: [pull_request, push]` (list shorthand) cannot carry filters
|
||||
either — clean."""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"clean.yml",
|
||||
"name: Clean\non: [pull_request, push]\njobs:\n x:\n runs-on: ubuntu-latest\n",
|
||||
)
|
||||
findings = lint_module.detect_paths_filters(
|
||||
Path(lint_module.WORKFLOWS_DIR) / "clean.yml"
|
||||
)
|
||||
assert findings == []
|
||||
|
||||
|
||||
def test_workflow_on_event_with_null_value_passes(lint_module):
|
||||
"""`pull_request:` with no body (None / null) is event-shorthand —
|
||||
no filter possible."""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"clean.yml",
|
||||
"name: Clean\non:\n pull_request:\n push:\n branches: [main]\njobs:\n x:\n runs-on: ubuntu-latest\n",
|
||||
)
|
||||
findings = lint_module.detect_paths_filters(
|
||||
Path(lint_module.WORKFLOWS_DIR) / "clean.yml"
|
||||
)
|
||||
assert findings == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# End-to-end lint (main) — required-checks fan-out
|
||||
# --------------------------------------------------------------------------
|
||||
def test_no_required_workflows_succeeds(lint_module, monkeypatch, capsys):
|
||||
"""Empty status_check_contexts → exit 0, no findings reported."""
|
||||
stub = _make_stub_api({
|
||||
("GET", "/repos/owner/repo/branch_protections/main"): (
|
||||
200,
|
||||
{"status_check_contexts": []},
|
||||
),
|
||||
})
|
||||
monkeypatch.setattr(lint_module, "api", stub)
|
||||
rc = lint_module.run()
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "no required contexts" in out.lower() or "0 required" in out.lower()
|
||||
|
||||
|
||||
def test_required_workflow_no_paths_passes(lint_module, monkeypatch, capsys):
|
||||
"""A required workflow with no paths filter → exit 0."""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"secret-scan.yml",
|
||||
"name: Secret scan\non:\n pull_request:\n types: [opened]\njobs:\n scan:\n runs-on: ubuntu-latest\n",
|
||||
)
|
||||
stub = _make_stub_api({
|
||||
("GET", "/repos/owner/repo/branch_protections/main"): (
|
||||
200,
|
||||
{
|
||||
"status_check_contexts": [
|
||||
"Secret scan / scan (pull_request)",
|
||||
]
|
||||
},
|
||||
),
|
||||
})
|
||||
monkeypatch.setattr(lint_module, "api", stub)
|
||||
rc = lint_module.run()
|
||||
assert rc == 0
|
||||
|
||||
|
||||
def test_required_workflow_with_paths_filter_fails(
|
||||
lint_module, monkeypatch, capsys
|
||||
):
|
||||
"""A required workflow that has `paths:` filter → exit 1 + error
|
||||
names the offending workflow + the filter."""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"secret-scan.yml",
|
||||
"name: Secret scan\n"
|
||||
"on:\n"
|
||||
" pull_request:\n"
|
||||
" paths: ['**.go']\n"
|
||||
"jobs:\n"
|
||||
" scan:\n"
|
||||
" runs-on: ubuntu-latest\n",
|
||||
)
|
||||
stub = _make_stub_api({
|
||||
("GET", "/repos/owner/repo/branch_protections/main"): (
|
||||
200,
|
||||
{"status_check_contexts": ["Secret scan / scan (pull_request)"]},
|
||||
),
|
||||
})
|
||||
monkeypatch.setattr(lint_module, "api", stub)
|
||||
rc = lint_module.run()
|
||||
assert rc == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "secret-scan.yml" in out
|
||||
assert "Secret scan" in out
|
||||
assert "paths" in out
|
||||
assert "::error::" in out
|
||||
|
||||
|
||||
def test_required_workflow_with_paths_ignore_fails(
|
||||
lint_module, monkeypatch, capsys
|
||||
):
|
||||
"""Same defect class for `paths-ignore` — exit 1, named."""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"sop-tier-check.yml",
|
||||
"name: sop-tier-check\n"
|
||||
"on:\n"
|
||||
" pull_request_target:\n"
|
||||
" paths-ignore: ['docs/**']\n"
|
||||
"jobs:\n"
|
||||
" tier-check:\n"
|
||||
" runs-on: ubuntu-latest\n",
|
||||
)
|
||||
stub = _make_stub_api({
|
||||
("GET", "/repos/owner/repo/branch_protections/main"): (
|
||||
200,
|
||||
{
|
||||
"status_check_contexts": [
|
||||
"sop-tier-check / tier-check (pull_request_target)"
|
||||
]
|
||||
},
|
||||
),
|
||||
})
|
||||
monkeypatch.setattr(lint_module, "api", stub)
|
||||
rc = lint_module.run()
|
||||
assert rc == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "sop-tier-check.yml" in out
|
||||
assert "paths-ignore" in out
|
||||
|
||||
|
||||
def test_unknown_required_context_warns_not_fails(
|
||||
lint_module, monkeypatch, capsys
|
||||
):
|
||||
"""Required context with no matching workflow file → warn, don't
|
||||
fail. This is gracefully bounded — the lint's mandate is paths-filter
|
||||
detection, not orphaned-context detection (`ci-required-drift` is the
|
||||
canonical detector for that).
|
||||
"""
|
||||
# No workflows written → all required contexts will be unresolved.
|
||||
stub = _make_stub_api({
|
||||
("GET", "/repos/owner/repo/branch_protections/main"): (
|
||||
200,
|
||||
{
|
||||
"status_check_contexts": [
|
||||
"Mystery / job (pull_request)",
|
||||
]
|
||||
},
|
||||
),
|
||||
})
|
||||
monkeypatch.setattr(lint_module, "api", stub)
|
||||
rc = lint_module.run()
|
||||
assert rc == 0 # warn-not-fail
|
||||
out = capsys.readouterr().out
|
||||
assert "::warning::" in out
|
||||
assert "Mystery" in out
|
||||
|
||||
|
||||
def test_multi_required_one_bad_one_good_fails(
|
||||
lint_module, monkeypatch, capsys
|
||||
):
|
||||
"""Two required contexts; one workflow is bad. Lint still fails
|
||||
(one defect is enough) and the error names ONLY the bad workflow."""
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"good.yml",
|
||||
"name: Good\non:\n pull_request:\n types: [opened]\njobs:\n x:\n runs-on: ubuntu-latest\n",
|
||||
)
|
||||
_write_workflow(
|
||||
lint_module.WORKFLOWS_DIR,
|
||||
"bad.yml",
|
||||
"name: Bad\n"
|
||||
"on:\n"
|
||||
" pull_request:\n"
|
||||
" paths: ['src/**']\n"
|
||||
"jobs:\n x:\n runs-on: ubuntu-latest\n",
|
||||
)
|
||||
stub = _make_stub_api({
|
||||
("GET", "/repos/owner/repo/branch_protections/main"): (
|
||||
200,
|
||||
{
|
||||
"status_check_contexts": [
|
||||
"Good / x (pull_request)",
|
||||
"Bad / x (pull_request)",
|
||||
]
|
||||
},
|
||||
),
|
||||
})
|
||||
monkeypatch.setattr(lint_module, "api", stub)
|
||||
rc = lint_module.run()
|
||||
assert rc == 1
|
||||
out = capsys.readouterr().out
|
||||
assert "bad.yml" in out
|
||||
# `good.yml` should NOT show up in the error block — only the bad one.
|
||||
# (It may appear as a "checked" notice; assert it's not flagged as bad.)
|
||||
assert "::error::" in out
|
||||
error_lines = [ln for ln in out.split("\n") if ln.startswith("::error::") or "paths" in ln.lower() and "good" in ln.lower()]
|
||||
# The good workflow must not appear under an ::error:: line referencing paths.
|
||||
for ln in error_lines:
|
||||
if ln.startswith("::error::"):
|
||||
# The error line itself shouldn't name good.yml as offending.
|
||||
assert "good.yml" not in ln
|
||||
|
||||
|
||||
def test_protection_403_treated_as_skip(lint_module, monkeypatch, capsys):
|
||||
"""If the token can't read branch_protections (HTTP 403), exit 0
|
||||
with a clear ::error::-but-non-fatal note. Same scope-fallback shape
|
||||
as ci-required-drift.py per the precedent.
|
||||
|
||||
Rationale: if the lint workflow itself can't read protection, the PR
|
||||
can't make THIS state worse (a paths-filter PR was already addable
|
||||
without the lint). Better to surface a token-scope problem loudly
|
||||
than to red-X every PR until the token is fixed.
|
||||
"""
|
||||
stub = _make_stub_api({
|
||||
("GET", "/repos/owner/repo/branch_protections/main"): (
|
||||
lint_module.ApiError(
|
||||
"GET /repos/owner/repo/branch_protections/main → HTTP 403: forbidden"
|
||||
)
|
||||
),
|
||||
})
|
||||
monkeypatch.setattr(lint_module, "api", stub)
|
||||
rc = lint_module.run()
|
||||
assert rc == 0
|
||||
err = capsys.readouterr().err
|
||||
assert "::error::" in err
|
||||
assert "403" in err
|
||||
@@ -1,413 +0,0 @@
|
||||
"""Tests for `.gitea/scripts/lint-workflow-yaml.py` — Gitea-1.22.6-hostile shape lint.
|
||||
|
||||
Hard-gate (Tier-2) lint that catches workflow YAML shapes Gitea 1.22.6
|
||||
silently rejects, so they never reach `main`. The six anti-patterns are
|
||||
documented in saved memory; this test suite is the structural enforcement.
|
||||
|
||||
Per-rule positive (anti-pattern present -> exit 1) + negative (clean -> exit 0)
|
||||
cases, plus a multi-file collision case and an aggregation case.
|
||||
|
||||
Run:
|
||||
python3 -m pytest tests/test_lint_workflow_yaml.py -v
|
||||
|
||||
Dependencies: stdlib + PyYAML. No network.
|
||||
|
||||
Cross-links:
|
||||
- feedback_gitea_workflow_dispatch_inputs_unsupported (rule 1)
|
||||
- internal task #81 (rule 2 — workflow_run unsupported)
|
||||
- feedback_workflow_name_with_slash_breaks_parsing (rule 3, if filed)
|
||||
- feedback_gitea_cross_repo_uses_blocked (rule 5)
|
||||
- feedback_act_runner_github_server_url (rule 6)
|
||||
- feedback_smoke_test_vendor_truth_not_shape_match (test-shape rule)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
|
||||
import pytest # noqa: F401 (declares the dep)
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = REPO_ROOT / ".gitea" / "scripts" / "lint-workflow-yaml.py"
|
||||
|
||||
|
||||
def _run_lint(workflow_dir: Path) -> subprocess.CompletedProcess:
|
||||
"""Invoke the lint as a subprocess against an isolated workflow dir."""
|
||||
return subprocess.run(
|
||||
[sys.executable, str(SCRIPT), "--workflow-dir", str(workflow_dir)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _write(workflow_dir: Path, name: str, content: str) -> Path:
|
||||
"""Write a workflow YAML fixture and return its path."""
|
||||
workflow_dir.mkdir(parents=True, exist_ok=True)
|
||||
p = workflow_dir / name
|
||||
p.write_text(textwrap.dedent(content).lstrip())
|
||||
return p
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 1 — workflow_dispatch.inputs (Gitea 1.22.6 parser rejects)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WD_INPUTS_BAD = """
|
||||
name: bad-wd-inputs
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "version"
|
||||
required: true
|
||||
type: string
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
"""
|
||||
|
||||
WD_INPUTS_OK = """
|
||||
name: ok-wd-no-inputs
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
"""
|
||||
|
||||
|
||||
def test_rule1_workflow_dispatch_inputs_detects_violation(tmp_path):
|
||||
_write(tmp_path, "bad.yml", WD_INPUTS_BAD)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert "workflow_dispatch.inputs" in r.stdout
|
||||
assert "bad.yml" in r.stdout
|
||||
|
||||
|
||||
def test_rule1_workflow_dispatch_inputs_passes_when_absent(tmp_path):
|
||||
_write(tmp_path, "ok.yml", WD_INPUTS_OK)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 2 — workflow_run event (not supported on Gitea 1.22.6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
WF_RUN_BAD = """
|
||||
name: bad-workflow-run
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["upstream"]
|
||||
types: [completed]
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
"""
|
||||
|
||||
WF_RUN_OK = """
|
||||
name: ok-no-workflow-run
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
"""
|
||||
|
||||
|
||||
def test_rule2_workflow_run_event_detects_violation(tmp_path):
|
||||
_write(tmp_path, "bad.yml", WF_RUN_BAD)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert "workflow_run" in r.stdout
|
||||
assert "bad.yml" in r.stdout
|
||||
|
||||
|
||||
def test_rule2_workflow_run_event_passes_when_absent(tmp_path):
|
||||
_write(tmp_path, "ok.yml", WF_RUN_OK)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 3 — name: contains "/" (breaks "<workflow> / <job> (<event>)" parsing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
NAME_SLASH_BAD = """
|
||||
name: ci / build
|
||||
on: [push]
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
"""
|
||||
|
||||
NAME_SLASH_OK = """
|
||||
name: ci-build
|
||||
on: [push]
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
"""
|
||||
|
||||
|
||||
def test_rule3_name_with_slash_detects_violation(tmp_path):
|
||||
_write(tmp_path, "bad.yml", NAME_SLASH_BAD)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert "name" in r.stdout.lower()
|
||||
assert "/" in r.stdout
|
||||
assert "bad.yml" in r.stdout
|
||||
|
||||
|
||||
def test_rule3_name_with_slash_passes_when_absent(tmp_path):
|
||||
_write(tmp_path, "ok.yml", NAME_SLASH_OK)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 4 — name collision across files (cross-file)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
COLLISION_A = """
|
||||
name: shared-name
|
||||
on: [push]
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo a
|
||||
"""
|
||||
|
||||
COLLISION_B = """
|
||||
name: shared-name
|
||||
on: [push]
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo b
|
||||
"""
|
||||
|
||||
DISTINCT_A = """
|
||||
name: name-a
|
||||
on: [push]
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo a
|
||||
"""
|
||||
|
||||
DISTINCT_B = """
|
||||
name: name-b
|
||||
on: [push]
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo b
|
||||
"""
|
||||
|
||||
|
||||
def test_rule4_name_collision_across_two_files_detects_violation(tmp_path):
|
||||
_write(tmp_path, "a.yml", COLLISION_A)
|
||||
_write(tmp_path, "b.yml", COLLISION_B)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert ("collision" in r.stdout.lower()) or ("duplicate" in r.stdout.lower())
|
||||
assert "shared-name" in r.stdout
|
||||
|
||||
|
||||
def test_rule4_name_collision_passes_when_names_distinct(tmp_path):
|
||||
_write(tmp_path, "a.yml", DISTINCT_A)
|
||||
_write(tmp_path, "b.yml", DISTINCT_B)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 5 — cross-repo `uses: org/repo/...@ref` (blocked on 1.22.6)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CROSS_REPO_BAD = """
|
||||
name: bad-cross-repo
|
||||
on: [push]
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: molecule-ai/molecule-ci/.gitea/actions/audit-force-merge@main
|
||||
"""
|
||||
|
||||
# actions/checkout — bare `org/repo@ref` form — allowed. Rule 5 targets
|
||||
# `org/repo/SUBPATH@ref` cross-repo composite/reusable references because
|
||||
# only those resolve through `[actions].DEFAULT_ACTIONS_URL`+org-suspended-host.
|
||||
CROSS_REPO_OK = """
|
||||
name: ok-no-cross-repo
|
||||
on: [push]
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
- run: echo hi
|
||||
"""
|
||||
|
||||
|
||||
def test_rule5_cross_repo_uses_detects_violation(tmp_path):
|
||||
_write(tmp_path, "bad.yml", CROSS_REPO_BAD)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 1
|
||||
assert ("cross-repo" in r.stdout.lower()) or ("uses" in r.stdout.lower())
|
||||
assert "bad.yml" in r.stdout
|
||||
|
||||
|
||||
def test_rule5_cross_repo_uses_passes_when_only_actions_org(tmp_path):
|
||||
_write(tmp_path, "ok.yml", CROSS_REPO_OK)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 0, f"stdout={r.stdout}\nstderr={r.stderr}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rule 6 — GITHUB_SERVER_URL heuristic (warn-not-fail per halt-condition 3)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
GH_API_REF_NO_SERVER = """
|
||||
name: warn-server-url
|
||||
on: [push]
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: curl https://api.github.com/repos/foo/bar
|
||||
"""
|
||||
|
||||
GH_API_REF_WITH_SERVER = """
|
||||
name: ok-server-url-set
|
||||
on: [push]
|
||||
env:
|
||||
GITHUB_SERVER_URL: https://git.moleculesai.app
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: curl https://api.github.com/repos/foo/bar
|
||||
"""
|
||||
|
||||
|
||||
def test_rule6_github_server_url_missing_is_warning_not_fatal(tmp_path):
|
||||
"""Heuristic rule — emits warning but does NOT exit 1.
|
||||
|
||||
Per halt-condition 3: heuristic may false-positive (current main has 3:
|
||||
OCI label + jq-release URL refs). Downgrade to warn-not-fail.
|
||||
"""
|
||||
_write(tmp_path, "warn.yml", GH_API_REF_NO_SERVER)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 0
|
||||
combined = (r.stdout + r.stderr).lower()
|
||||
assert ("github_server_url" in combined) or ("::warning" in combined)
|
||||
|
||||
|
||||
def test_rule6_github_server_url_present_no_warning(tmp_path):
|
||||
_write(tmp_path, "ok.yml", GH_API_REF_WITH_SERVER)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 0
|
||||
# No warning emitted (server URL is set)
|
||||
assert "::warning" not in r.stdout
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aggregation — single file with multiple anti-patterns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
MULTI_VIOLATIONS = """
|
||||
name: ci / multi
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
v:
|
||||
type: string
|
||||
workflow_run:
|
||||
workflows: [up]
|
||||
types: [completed]
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: molecule-ai/molecule-ci/.gitea/actions/x@main
|
||||
"""
|
||||
|
||||
|
||||
def test_all_violations_aggregated_single_file(tmp_path):
|
||||
_write(tmp_path, "multi.yml", MULTI_VIOLATIONS)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 1
|
||||
out = r.stdout
|
||||
# All four FATAL rules should be reported (1, 2, 3, 5)
|
||||
assert "workflow_dispatch.inputs" in out
|
||||
assert "workflow_run" in out
|
||||
assert "/" in out # rule 3 surfaces the slash
|
||||
assert ("cross-repo" in out.lower()) or ("uses" in out.lower())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Empty-dir / no-workflows edge case
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_no_workflows_exits_zero(tmp_path):
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Vendor-truth: rule 1 catches the exact 2026-05-11 publish-runtime.yml shape
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The exact YAML shape from feedback_gitea_workflow_dispatch_inputs_unsupported
|
||||
# that caused publish-runtime-v1.0.0 to silently freeze PyPI at 0.1.129 for ~24h.
|
||||
PUBLISH_RUNTIME_VENDOR_TRUTH = """
|
||||
name: publish-runtime
|
||||
on:
|
||||
push:
|
||||
tags: ['runtime-v*']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version to publish (e.g. 0.1.6). Required for manual dispatch."
|
||||
required: true
|
||||
type: string
|
||||
jobs:
|
||||
x:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo hi
|
||||
"""
|
||||
|
||||
|
||||
def test_rule1_catches_2026_05_11_publish_runtime_regression(tmp_path):
|
||||
"""Vendor-truth fixture: the exact YAML shape that froze PyPI for 24h."""
|
||||
_write(tmp_path, "publish-runtime.yml", PUBLISH_RUNTIME_VENDOR_TRUTH)
|
||||
r = _run_lint(tmp_path)
|
||||
assert r.returncode == 1, (
|
||||
"Lint must catch the 2026-05-11 publish-runtime regression "
|
||||
f"(memory: feedback_gitea_workflow_dispatch_inputs_unsupported)."
|
||||
f"\nstdout={r.stdout}"
|
||||
)
|
||||
@@ -189,78 +189,6 @@ def test_is_red_no_statuses(wd_module):
|
||||
assert failed == []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Per-entry vendor-truth key (rev4) — see status-reaper rev4 sibling
|
||||
#
|
||||
# Gitea 1.22.6 returns per-entry items in combined.statuses[] with key
|
||||
# `status`, not `state`. Pre-rev4 code only read `state` → failed[]
|
||||
# was always empty → render_body always emitted the fallback "no
|
||||
# per-context entries were in a red state". These tests use the
|
||||
# canonical Gitea shape to lock the fix in.
|
||||
# --------------------------------------------------------------------------
|
||||
def test_is_red_vendor_truth_status_key_under_pending(wd_module):
|
||||
"""Real Gitea 1.22.6 shape: per-entry uses `status`. A single failed
|
||||
context counts as red even when combined is `pending`. Pre-rev4
|
||||
this returned `(False, [])` because `s.get("state")` was None."""
|
||||
red, failed = wd_module.is_red({
|
||||
"state": "pending",
|
||||
"statuses": [
|
||||
{"context": "ci/lint", "status": "success"},
|
||||
{"context": "ci/test", "status": "failure"},
|
||||
{"context": "ci/build", "status": "pending"},
|
||||
],
|
||||
})
|
||||
assert red is True
|
||||
assert [s["context"] for s in failed] == ["ci/test"]
|
||||
|
||||
|
||||
def test_is_red_status_takes_precedence_over_state(wd_module):
|
||||
"""If both keys present (defensive), `status` (vendor truth) wins."""
|
||||
red, failed = wd_module.is_red({
|
||||
"state": "pending",
|
||||
"statuses": [
|
||||
# `status=failure` is truth even though `state=success` is
|
||||
# stale. Locking in the precedence prevents a hypothetical
|
||||
# future Gitea release that emits both from re-introducing
|
||||
# the bug under a different shape.
|
||||
{"context": "ci/test", "status": "failure", "state": "success"},
|
||||
],
|
||||
})
|
||||
assert red is True
|
||||
assert len(failed) == 1
|
||||
|
||||
|
||||
def test_is_red_state_only_fallback_still_works(wd_module):
|
||||
"""Backward-compat: a legacy fixture or future Gitea variant that
|
||||
only emits `state` still trips the red detection via the fallback
|
||||
chain. Keeps pre-rev4 fixtures green during the rev4 rollout."""
|
||||
red, failed = wd_module.is_red({
|
||||
"state": "pending",
|
||||
"statuses": [
|
||||
{"context": "ci/test", "state": "failure"}, # legacy shape
|
||||
],
|
||||
})
|
||||
assert red is True
|
||||
assert len(failed) == 1
|
||||
|
||||
|
||||
def test_render_body_uses_status_key_for_per_entry_state(wd_module):
|
||||
"""render_body must surface the per-entry `status` value in the
|
||||
issue body. Pre-rev4 it read `state` (always None on real Gitea) →
|
||||
every issue body said `(no state)`, defeating the diagnostic."""
|
||||
failed = [
|
||||
{"context": "ci/test", "status": "failure",
|
||||
"target_url": "https://example.test/run/1",
|
||||
"description": "broke"},
|
||||
]
|
||||
body = wd_module.render_body("deadbeefcafe1234", failed, {})
|
||||
assert "`failure`" in body, (
|
||||
"render_body did not surface per-entry status — likely still "
|
||||
"reading `state` key only (rev1-3 bug)."
|
||||
)
|
||||
assert "(no state)" not in body
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Happy path — main is green, no issue created
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -35,12 +35,6 @@ 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"
|
||||
|
||||
# Timeout in seconds for all HTTP calls. Defence-in-depth: ensures a missing or
|
||||
# invalid SOP_TIER_CHECK_TOKEN causes a fast (~15 s) failure rather than an
|
||||
# indefinite hang. The real fix is provisioning the token; this caps worst-case
|
||||
# wall-clock on a broken/unreachable Gitea host.
|
||||
DEFAULT_TIMEOUT = 15
|
||||
|
||||
|
||||
def api_get(path: str) -> dict | list:
|
||||
url = f"{API_BASE}{path}"
|
||||
@@ -52,7 +46,7 @@ def api_get(path: str) -> dict | list:
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=DEFAULT_TIMEOUT) as r:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.loads(r.read())
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors="replace")
|
||||
@@ -527,12 +521,12 @@ def run(repo: str, pr_number: int, post_comment: bool = False) -> dict:
|
||||
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, timeout=DEFAULT_TIMEOUT) as r:
|
||||
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, timeout=DEFAULT_TIMEOUT) as r:
|
||||
with urllib.request.urlopen(req) as r:
|
||||
r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code == 403:
|
||||
|
||||
@@ -983,16 +983,7 @@ func expectExecuteDelegationBase(mock sqlmock.Sqlmock) {
|
||||
WithArgs("dispatched", "", testSourceID, testDelegationID).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
// CanCommunicate: source != target → fires two getWorkspaceRef lookups.
|
||||
// Both test fixtures have parent_id = NULL (root-level siblings) → allowed.
|
||||
// Order matches call order: source first, then target.
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id").
|
||||
WithArgs(testSourceID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testSourceID, nil))
|
||||
mock.ExpectQuery("SELECT id, parent_id FROM workspaces WHERE id").
|
||||
WithArgs(testTargetID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "parent_id"}).AddRow(testTargetID, nil))
|
||||
|
||||
// CanCommunicate (source=target self-call is always allowed — no DB lookup needed)
|
||||
// resolveAgentURL: reads ws:{id}:url from Redis, falls back to DB for target
|
||||
mock.ExpectQuery("SELECT url, status FROM workspaces WHERE id = ").
|
||||
WithArgs(testTargetID).
|
||||
|
||||
@@ -763,7 +763,6 @@ def test_sanitize_agent_error_stderr_and_exc():
|
||||
out = sanitize_agent_error(exc=err, stderr="rate limit exceeded")
|
||||
assert "ValueError" in out # exc class IS the tag when stderr is provided
|
||||
assert "rate limit exceeded" in out
|
||||
assert "workspace logs" not in out # stderr form, not the generic form
|
||||
|
||||
|
||||
def test_sanitize_agent_error_stderr_empty_string():
|
||||
|
||||
Reference in New Issue
Block a user