diff --git a/AGENTS.md b/AGENTS.md index b47ed8c..92df235 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,3 +8,5 @@ A draft plus local tests is not done. Pull-request done is signed commits on a branch in this repository, grok quality and logic then Codex quality and logic on this head with zero findings, CI green on this head, then ready. The authoring session does not sit those PR reviews. A human merges. +The local-CI comment schema for private product repositories is +`docs/local-ci-v1.md`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ea4c55d..c23f5ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,7 +21,7 @@ Done is all of: 3. Codex runs only if both grok dimensions are approved. If a vendor cannot run, abort loudly; do not record `approved`; do not substitute another vendor. 4. Zero findings only after an explicit complete pass. Empty, partial, timeout, or unavailable output is not zero findings. Iterate until all four lane verdicts on this head are approved. 5. Inner implement/review rounds (`review-loop`) are not the PR reviews (`pr-review`). -6. CI green on **this** head. `skipped` and `cancelled` are not green unless the workflow documents that skip. +6. CI green on **this** head. This public repository uses GitHub Actions. `skipped` and `cancelled` are not green unless the workflow documents that skip. The local-CI comment schema for **private** product repositories is defined in [docs/local-ci-v1.md](docs/local-ci-v1.md) and verified by `agent local-ci verify`. 7. Stay draft until the reviews and CI above hold on this head. Then one comment whose review-pass count is those four `approved` verdicts on this head, then mark the GitHub pull request ready. When spine and pr-review are attached, `agent allow --action pr-ready` only checks task state (`pushing` or `pr-review`); it is not the leave-draft verdict. Do not mark ready if it denies. 8. A human merges. When spine is attached, `agent allow --action task-done` still needs the workflow checklist and both summary sentences. diff --git a/DESIGN.md b/DESIGN.md index 661c739..9f9c608 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -45,6 +45,7 @@ The AI session talks **only** to the local database. Scripts perform every actio | AI vs scripts | The AI inserts local intent. Scripts perform every side effect that leaves the machine. Model text is never a state transition. | | Checks and gates | A **check** records a fact (`agent check record`). A **gate** is a policy verdict over evidence (`agent gate record`). A model claim is neither. Confidence is not proof. | | Pull request done | A draft plus local tests is not done. CONTRIBUTING.md is the contract for this repository. When spine and pr-review are attached, grok then Codex on this head are the gates; `agent allow --action pr-ready` only checks task state. A human merges. | +| Local CI report | Frozen comment schema `dfx-local-ci/v1` in [docs/local-ci-v1.md](docs/local-ci-v1.md). `agent local-ci verify` parses it and computes pass/fail. Private product repositories attach the block to the ready comment. This public client still uses GitHub Actions for its own PRs. | | Merge | The client never merges. A human merges. | | Repos | Public MIT: `DFXswiss/agent` (client), `DFXswiss/agent-core` (hub). | | Website host | `agent.dfx.swiss` (development: `dev.agent.dfx.swiss`). Singular product name. | @@ -400,6 +401,7 @@ agent checklist set … # spine skill agent round start --task UUID # spine skill agent agent start|finish … # review-loop (implementer|reviewer) or pr-review (pr-reviewer-*) agent check record … # spine skill +agent local-ci verify|parse|render [--file PATH] [--require-ids id,id] [--expect-head SHA] [--expect-private] [--json] agent gate record … # pr-review skill agent work add|set|list … # spine skill (open_work) agent allow|next|close-step|run … # spine skill; run: [--dry-run] [--head SHA] [--cwd PATH] [--spec-file PATH] [--no-tmux] @@ -542,7 +544,7 @@ A draft plus local tests is not done. A check records the local suite. When spin Quality and logic of one vendor stage run together. Vendors are `grok`, then `codex`. Codex runs only after both grok dimensions are `approved`. The session that authored the diff does not sit those PR reviews. If a vendor cannot run, abort loudly; do not record `approved`; do not substitute another vendor. Empty, partial, timeout, or unavailable review output is not zero findings. -CI on this head is a script-measured fact. `skipped` and `cancelled` are not green unless the workflow documents that skip. Stay draft until that holds. One comment whose review-pass count is those four `approved` verdicts on this head, then ready. A retry reuses the existing draft. A human merges. +CI on this head is a script-measured fact. On a **private** GitHub repository that fact is a full local `ci:full` equivalent, recorded as `dfx-local-ci/v1` and checked with `agent local-ci verify` (`--expect-head`, `--expect-private`, status `pass`). GitHub Actions is not the ready gate there. On a **public** repository, GitHub Actions on this head remains the gate; `skipped` and `cancelled` are not green unless the workflow documents that skip. Stay draft until that holds. One comment whose review-pass count is those four `approved` verdicts on this head, then ready. A retry reuses the existing draft. A human merges. ## 20. Refused: hub as a coding control plane diff --git a/README.md b/README.md index dbcf15e..67de66a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Local session-store client. Record sessions, activities and (when a skill is attached) tasks on this machine, then pair the device to the [agent-core](https://github.com/DFXswiss/agent-core) hub with GitHub. -Product decisions (visibility, pairing, sync, restore, what we will not build) are in [DESIGN.md](DESIGN.md). That file also locks the deterministic core: scripts execute, checks measure, gates decide, model text is never a transition, and the hub is not a coding control plane. A draft plus local tests is not a finished pull request; see [CONTRIBUTING.md](CONTRIBUTING.md). Production-error → draft pull request is the opt-in **error-fix** skill on this device, not the hub. +Product decisions (visibility, pairing, sync, restore, what we will not build) are in [DESIGN.md](DESIGN.md). That file also locks the deterministic core: scripts execute, checks measure, gates decide, model text is never a transition, and the hub is not a coding control plane. A draft plus local tests is not a finished pull request; see [CONTRIBUTING.md](CONTRIBUTING.md). The frozen local-CI comment schema for private product repositories is [docs/local-ci-v1.md](docs/local-ci-v1.md) (`agent local-ci verify`). Production-error → draft pull request is the opt-in **error-fix** skill on this device, not the hub. This device is the write owner of its own rows. The local store is PostgreSQL on `127.0.0.1`. `device.json` next to it is the device identity: wiping only the database must not mint a new device. The hub holds a full copy. `agent sync` pushes own events and pulls own catch-up, session-mail inbox snapshots, and person-ping snapshots. `agent restore` rebuilds a wiped database from the hub. diff --git a/docs/local-ci-v1.md b/docs/local-ci-v1.md new file mode 100644 index 0000000..68f3824 --- /dev/null +++ b/docs/local-ci-v1.md @@ -0,0 +1,70 @@ +# Local CI report `dfx-local-ci/v1` + +This is the frozen comment payload that records a **full local CI run** +(`ci:full` equivalent) for a pull request. `agent local-ci verify` parses it +and decides pass or fail. Do not invent a second format. + +Private GitHub repositories must attach this block to the ready comment. +Public repositories keep GitHub Actions as the CI gate and omit the block. + +## Markers + +A pull-request comment contains **exactly one** pair of HTML comments: +`` and ``. Between them +sits one fenced JSON object whose language tag is `json`. Nothing else may +sit between the markers. + +## Payload + +Every key is required. Unknown keys are rejected. + +| Key | Rule | +|---|---| +| `schema` | Exactly `dfx-local-ci/v1` | +| `repo` | `owner/name` | +| `head` | 40-character lowercase hex SHA of the pull-request head | +| `private` | JSON boolean. `true` for the private-repo local-CI gate | +| `recorded_at` | UTC `YYYY-MM-DDTHH:MM:SSZ` | +| `required` | Unique kebab-case ids. This is the full `ci:full` job set. Empty only when the repository has no pull-request CI jobs | +| `runs` | One object per id that ran. Empty only when `required` is empty | + +Each run object: + +| Key | Rule | +|---|---| +| `id` | kebab-case, unique, must match an entry in `required` for that job | +| `name` | Human job name | +| `command` | Exact local command that was executed | +| `result` | `pass` \| `fail` \| `error` \| `timeout` | +| `exit_code` | Integer | +| `duration_s` | Number ≥ 0 | +| `timeout_s` | Number > 0. The job timeout | + +There is no `verdict` field. The script computes it. + +## Verdict + +`agent local-ci verify` exits `0` only when: + +1. The comment parses. +2. `private` is `false` (`not_applicable`), **or** +3. `private` is `true` and every `required` id has a run with `result=pass`, + `exit_code=0`, and `duration_s <= timeout_s`. An empty `required` list + (no pull-request CI jobs in the repository) is a pass. + +`--require-ids a,b,c` additionally demands that `required` is exactly that set. +`--expect-head SHA` demands the payload head matches. `--expect-private` demands +`private` is true and rejects `not_applicable`. + +Parse errors exit with `agent: …`. A computed fail exits `1` after printing +`local-ci fail …`. + +## Commands + +``` +agent local-ci verify [--file PATH] [--require-ids id,id] [--expect-head SHA] [--expect-private] [--json] +agent local-ci parse [--file PATH] [--json] +agent local-ci render [--file PATH] +``` + +Without `--file`, the comment or JSON is read from stdin. diff --git a/src/agent_cli/local_ci.py b/src/agent_cli/local_ci.py new file mode 100644 index 0000000..2784c92 --- /dev/null +++ b/src/agent_cli/local_ci.py @@ -0,0 +1,306 @@ +"""Parse and verify the DFX local-CI report block in a pull-request comment. + +The on-the-wire format is frozen as ``dfx-local-ci/v1``. A comment may contain +exactly one pair of markers. Between them sits one fenced JSON object. The +script never trusts a ``verdict`` field in the payload; it computes pass/fail. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Any, Mapping + +SCHEMA_ID = "dfx-local-ci/v1" +BEGIN_MARK = "" +END_MARK = "" +FENCE_RE = re.compile(r"```json\s*\n(.*?)\n```", re.DOTALL) +REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +HEAD_RE = re.compile(r"^[0-9a-f]{40}$") +ID_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,62}$") +RECORDED_RE = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$") +RESULTS = frozenset({"pass", "fail", "error", "timeout"}) +PAYLOAD_KEYS = frozenset({"schema", "repo", "head", "private", "recorded_at", "required", "runs"}) +RUN_KEYS = frozenset({"id", "name", "command", "result", "exit_code", "duration_s", "timeout_s"}) + + +class LocalCiError(ValueError): + """The comment is not a valid v1 local-CI report.""" + + +@dataclass(frozen=True) +class LocalCiRun: + id: str + name: str + command: str + result: str + exit_code: int + duration_s: float + timeout_s: float + + +@dataclass(frozen=True) +class LocalCiReport: + schema: str + repo: str + head: str + private: bool + recorded_at: str + required: tuple[str, ...] + runs: tuple[LocalCiRun, ...] + + +@dataclass(frozen=True) +class LocalCiVerdict: + ok: bool + status: str + reasons: tuple[str, ...] = field(default_factory=tuple) + report: LocalCiReport | None = None + + def to_json(self) -> dict[str, Any]: + out: dict[str, Any] = { + "ok": self.ok, + "status": self.status, + "reasons": list(self.reasons), + } + if self.report is not None: + out["repo"] = self.report.repo + out["head"] = self.report.head + out["private"] = self.report.private + out["required"] = list(self.report.required) + return out + + +def extract_json_text(comment: str) -> str: + begins = list(re.finditer(re.escape(BEGIN_MARK), comment)) + ends = list(re.finditer(re.escape(END_MARK), comment)) + if len(begins) != 1 or len(ends) != 1: + raise LocalCiError("comment must contain exactly one DFX-LOCAL-CI:v1 marker pair") + start = begins[0].end() + stop = ends[0].start() + if stop < start: + raise LocalCiError("DFX-LOCAL-CI:v1 end marker precedes begin marker") + inner = comment[start:stop] + match = FENCE_RE.search(inner) + if match is None: + raise LocalCiError("DFX-LOCAL-CI:v1 block must contain one fenced json object") + if FENCE_RE.search(inner, match.end()) is not None: + raise LocalCiError("DFX-LOCAL-CI:v1 block must contain exactly one fenced json object") + leftover = (inner[: match.start()] + inner[match.end() :]).strip() + if leftover: + raise LocalCiError("DFX-LOCAL-CI:v1 block must contain only the fenced json object") + return match.group(1) + + +def _require_keys(obj: Mapping[str, Any], allowed: frozenset[str], label: str) -> None: + keys = set(obj) + extra = keys - allowed + missing = allowed - keys + if extra: + raise LocalCiError(f"{label} has unknown keys: {', '.join(sorted(extra))}") + if missing: + raise LocalCiError(f"{label} missing keys: {', '.join(sorted(missing))}") + + +def _as_str(value: Any, label: str) -> str: + if not isinstance(value, str) or value.strip() == "": + raise LocalCiError(f"{label} must be a non-empty string") + return value + + +def _as_bool(value: Any, label: str) -> bool: + if not isinstance(value, bool): + raise LocalCiError(f"{label} must be a boolean") + return value + + +def _as_int(value: Any, label: str) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise LocalCiError(f"{label} must be an integer") + return value + + +def _as_number(value: Any, label: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise LocalCiError(f"{label} must be a number") + return float(value) + + +def parse_payload(raw: Mapping[str, Any]) -> LocalCiReport: + _require_keys(raw, PAYLOAD_KEYS, "payload") + schema = _as_str(raw["schema"], "schema") + if schema != SCHEMA_ID: + raise LocalCiError(f"schema must be {SCHEMA_ID}") + repo = _as_str(raw["repo"], "repo") + if REPO_RE.match(repo) is None: + raise LocalCiError("repo must be owner/name") + head = _as_str(raw["head"], "head").lower() + if HEAD_RE.match(head) is None: + raise LocalCiError("head must be a 40-character lowercase hex SHA") + private = _as_bool(raw["private"], "private") + recorded_at = _as_str(raw["recorded_at"], "recorded_at") + if RECORDED_RE.match(recorded_at) is None: + raise LocalCiError("recorded_at must be UTC ISO-8601 YYYY-MM-DDTHH:MM:SSZ") + required_raw = raw["required"] + if not isinstance(required_raw, list): + raise LocalCiError("required must be an array of ids") + required: list[str] = [] + seen: set[str] = set() + for item in required_raw: + ident = _as_str(item, "required id") + if ID_RE.match(ident) is None: + raise LocalCiError(f"required id {ident!r} is not kebab-case") + if ident in seen: + raise LocalCiError(f"required id {ident!r} is duplicated") + seen.add(ident) + required.append(ident) + runs_raw = raw["runs"] + if not isinstance(runs_raw, list): + raise LocalCiError("runs must be an array") + if bool(required_raw) != bool(runs_raw): + raise LocalCiError("required and runs must both be empty or both be non-empty") + runs: list[LocalCiRun] = [] + run_ids: set[str] = set() + for index, item in enumerate(runs_raw): + if not isinstance(item, dict): + raise LocalCiError(f"runs[{index}] must be an object") + _require_keys(item, RUN_KEYS, f"runs[{index}]") + ident = _as_str(item["id"], f"runs[{index}].id") + if ID_RE.match(ident) is None: + raise LocalCiError(f"runs[{index}].id {ident!r} is not kebab-case") + if ident not in seen: + raise LocalCiError(f"runs[{index}].id {ident!r} is not in required") + if ident in run_ids: + raise LocalCiError(f"runs id {ident!r} is duplicated") + run_ids.add(ident) + result = _as_str(item["result"], f"runs[{index}].result") + if result not in RESULTS: + raise LocalCiError(f"runs[{index}].result must be pass|fail|error|timeout") + duration_s = _as_number(item["duration_s"], f"runs[{index}].duration_s") + timeout_s = _as_number(item["timeout_s"], f"runs[{index}].timeout_s") + if duration_s < 0: + raise LocalCiError(f"runs[{index}].duration_s must be >= 0") + if timeout_s <= 0: + raise LocalCiError(f"runs[{index}].timeout_s must be > 0") + runs.append( + LocalCiRun( + id=ident, + name=_as_str(item["name"], f"runs[{index}].name"), + command=_as_str(item["command"], f"runs[{index}].command"), + result=result, + exit_code=_as_int(item["exit_code"], f"runs[{index}].exit_code"), + duration_s=duration_s, + timeout_s=timeout_s, + ) + ) + missing_runs = sorted(seen - run_ids) + if missing_runs: + raise LocalCiError("runs missing required ids: " + ",".join(missing_runs)) + return LocalCiReport( + schema=schema, + repo=repo, + head=head, + private=private, + recorded_at=recorded_at, + required=tuple(required), + runs=tuple(runs), + ) + + +def parse_comment(comment: str) -> LocalCiReport: + text = extract_json_text(comment) + try: + payload = json.loads(text) + except json.JSONDecodeError as exc: + raise LocalCiError(f"JSON is invalid: {exc.msg}") from exc + if not isinstance(payload, dict): + raise LocalCiError("payload must be a JSON object") + return parse_payload(payload) + + +def evaluate( + report: LocalCiReport, + *, + require_ids: frozenset[str] | None = None, + expect_head: str | None = None, + expect_private: bool | None = None, +) -> LocalCiVerdict: + reasons: list[str] = [] + if expect_private is True and report.private is not True: + reasons.append("private must be true") + if expect_head is not None: + wanted = expect_head.lower() + if HEAD_RE.match(wanted) is None: + return LocalCiVerdict(ok=False, status="fail", reasons=("expect_head is not a 40-character hex SHA",), report=report) + if report.head != wanted: + reasons.append("head does not match expect_head") + if not report.private and expect_private is not True: + if reasons: + return LocalCiVerdict(ok=False, status="fail", reasons=tuple(reasons), report=report) + return LocalCiVerdict(ok=True, status="not_applicable", reasons=("private is false",), report=report) + required = set(report.required) + if require_ids is not None: + if required != set(require_ids): + missing = sorted(set(require_ids) - required) + extra = sorted(required - set(require_ids)) + if missing: + reasons.append("required missing ids: " + ",".join(missing)) + if extra: + reasons.append("required extra ids: " + ",".join(extra)) + by_id = {run.id: run for run in report.runs} + for ident in report.required: + run = by_id.get(ident) + if run is None: + reasons.append(f"{ident}: missing run") + continue + if run.result != "pass": + reasons.append(f"{ident}: result is {run.result}") + if run.exit_code != 0: + reasons.append(f"{ident}: exit_code is {run.exit_code}") + if run.duration_s > run.timeout_s: + reasons.append(f"{ident}: duration_s {run.duration_s} exceeds timeout_s {run.timeout_s}") + if reasons: + return LocalCiVerdict(ok=False, status="fail", reasons=tuple(reasons), report=report) + return LocalCiVerdict(ok=True, status="pass", report=report) + + +def verify_comment( + comment: str, + *, + require_ids: frozenset[str] | None = None, + expect_head: str | None = None, + expect_private: bool | None = None, +) -> LocalCiVerdict: + report = parse_comment(comment) + return evaluate( + report, + require_ids=require_ids, + expect_head=expect_head, + expect_private=expect_private, + ) + + +def render_block(report: LocalCiReport) -> str: + payload = { + "schema": report.schema, + "repo": report.repo, + "head": report.head, + "private": report.private, + "recorded_at": report.recorded_at, + "required": list(report.required), + "runs": [ + { + "id": run.id, + "name": run.name, + "command": run.command, + "result": run.result, + "exit_code": run.exit_code, + "duration_s": run.duration_s, + "timeout_s": run.timeout_s, + } + for run in report.runs + ], + } + body = json.dumps(payload, indent=2, sort_keys=True) + return f"{BEGIN_MARK}\n```json\n{body}\n```\n{END_MARK}\n" diff --git a/src/agent_cli/main.py b/src/agent_cli/main.py index f85449b..dd76d93 100644 --- a/src/agent_cli/main.py +++ b/src/agent_cli/main.py @@ -34,6 +34,7 @@ from .knock import drain as knock_drain from .knock import listen_once as knock_listen from .lane import LANE_ROLES, LANE_VENDORS, LaneResult, launch +from .local_ci import LocalCiError, parse_comment, parse_payload, render_block, verify_comment from .pg import PgError, cluster_exists, cluster_running, ensure_cluster, require_loopback_dsn, stop_cluster from .runtime import ( Runtime, @@ -1050,6 +1051,84 @@ def cmd_check(args: list[str]) -> None: store.close() +def _read_text_arg(args: list[str]) -> str: + path = flag(args, "--file") + if path is not None: + return Path(path).read_text(encoding="utf-8") + return sys.stdin.read() + + +def cmd_local_ci(args: list[str]) -> None: + if not args or args[0] not in ("verify", "parse", "render"): + die( + "Usage: agent local-ci verify|parse|render [--file PATH] " + "[--require-ids id,id] [--expect-head SHA] [--expect-private] [--json]" + ) + action = args[0] + rest = args[1:] + as_json = "--json" in rest + expect_private = "--expect-private" in rest + expect_head = flag(rest, "--expect-head") + require_raw = flag(rest, "--require-ids") + require_ids = None + if require_raw: + require_ids = frozenset(part.strip() for part in require_raw.split(",") if part.strip()) + if not require_ids: + die("--require-ids must list at least one id") + raw = _read_text_arg(rest) + if action == "render": + try: + payload = json.loads(raw) + except json.JSONDecodeError as exc: + die(f"JSON is invalid: {exc.msg}") + if not isinstance(payload, dict): + die("render input must be a JSON object") + try: + report = parse_payload(payload) + except LocalCiError as exc: + die(str(exc)) + sys.stdout.write(render_block(report)) + return + try: + if action == "parse": + report = parse_comment(raw) + if as_json: + print( + json.dumps( + { + "schema": report.schema, + "repo": report.repo, + "head": report.head, + "private": report.private, + "recorded_at": report.recorded_at, + "required": list(report.required), + "runs": [run.id for run in report.runs], + }, + sort_keys=True, + ) + ) + else: + print(f"local-ci repo={report.repo} head={report.head} required={len(report.required)}") + return + verdict = verify_comment( + raw, + require_ids=require_ids, + expect_head=expect_head, + expect_private=True if expect_private else None, + ) + except LocalCiError as exc: + die(str(exc)) + if as_json: + print(json.dumps(verdict.to_json(), sort_keys=True)) + else: + extra = "" + if verdict.reasons: + extra = " " + "; ".join(verdict.reasons) + print(f"local-ci {verdict.status}{extra}") + if not verdict.ok: + raise SystemExit(1) + + def _task_pull_request(task: dict) -> tuple[str, int] | None: """The task's pull request as (repo, number), or None when it has none.""" repo = _repo_ok(task.get("repo")) @@ -3294,6 +3373,7 @@ def start(session_id: str, cwd: Path) -> None: "round": cmd_round, "agent": cmd_agent, "check": cmd_check, + "local-ci": cmd_local_ci, "gate": cmd_gate, "work": cmd_work, "allow": cmd_allow, @@ -3323,7 +3403,7 @@ def main(argv: list[str] | None = None) -> None: args = list(sys.argv[1:] if argv is None else argv) if not args or args[0] in ("-h", "--help"): die( - "Usage: agent …" ) diff --git a/src/agent_cli/skills/pr-review/SKILL.md b/src/agent_cli/skills/pr-review/SKILL.md index 94c0f89..4abd06c 100644 --- a/src/agent_cli/skills/pr-review/SKILL.md +++ b/src/agent_cli/skills/pr-review/SKILL.md @@ -95,9 +95,13 @@ The agent does not merge. Open pull requests as drafts; a human merges. A draft plus local tests is not done. Quality and logic of one vendor stage run in parallel on **this** head. The session that authored the diff does not sit those reviews. Inner `review-loop` rounds are not these gates. Stay draft -until four lane verdicts on this head are approved (grok quality and grok logic, then Codex quality and Codex logic) and CI on this head is green -(`skipped` and `cancelled` are not green unless the workflow documents -that skip). `agent allow --action pr-ready` only checks task state; do +until four lane verdicts on this head are approved (grok quality and grok logic, then Codex quality and Codex logic) and CI on this head is green. +On a **private** GitHub repository that CI is a full local `ci:full` +equivalent, recorded in the ready comment as `dfx-local-ci/v1` and checked +with `agent local-ci verify` (see `docs/local-ci-v1.md` in this repository). +GitHub Actions is not the ready gate there. On a **public** repository, +GitHub Actions on this head remains the gate (`skipped` and `cancelled` are +not green unless the workflow documents that skip). `agent allow --action pr-ready` only checks task state; do not mark ready if it denies. Then one comment whose review-pass count is those four `approved` verdicts on this head, then mark the GitHub pull request ready. diff --git a/tests/test_local_ci.py b/tests/test_local_ci.py new file mode 100644 index 0000000..021cf50 --- /dev/null +++ b/tests/test_local_ci.py @@ -0,0 +1,168 @@ +from __future__ import annotations + +import json +import unittest + +try: + import pytest + + pytestmark = pytest.mark.no_pg +except ImportError: + pass + +from agent_cli.local_ci import ( + BEGIN_MARK, + END_MARK, + LocalCiError, + evaluate, + parse_comment, + render_block, + verify_comment, +) + +HEAD = "ee9040d9013c38acee07fd15628a3a9c4404a147" + + +def _run(**overrides: object) -> dict: + base: dict = { + "id": "format", + "name": "Format", + "command": "npm run format:check", + "result": "pass", + "exit_code": 0, + "duration_s": 12.4, + "timeout_s": 900, + } + base.update(overrides) + return base + + +def _payload(**overrides: object) -> dict: + base: dict = { + "schema": "dfx-local-ci/v1", + "repo": "example/private-app", + "head": HEAD, + "private": True, + "recorded_at": "2026-09-02T15:00:00Z", + "required": ["format", "test"], + "runs": [ + _run(), + _run(id="test", name="Test", command="npm test", duration_s=172.3, timeout_s=1800), + ], + } + base.update(overrides) + return base + + +def _comment(payload: dict) -> str: + return ( + "EN:\nReady after 1 review pass.\nTruncates sample display names to 35 characters.\n\n" + "DE:\nBereit nach 1 Review-Durchlauf.\nKürzt Beispiel-Anzeigenamen auf 35 Zeichen.\n\n" + "
\nDetails\n\n" + f"{BEGIN_MARK}\n```json\n{json.dumps(payload)}\n```\n{END_MARK}\n\n" + "
\n" + ) + + +class LocalCiTests(unittest.TestCase): + def test_round_trip_pass(self) -> None: + comment = _comment(_payload()) + report = parse_comment(comment) + self.assertEqual(report.head, HEAD) + self.assertEqual(report.required, ("format", "test")) + verdict = evaluate(report) + self.assertTrue(verdict.ok) + self.assertEqual(verdict.status, "pass") + again = parse_comment("preamble\n" + render_block(report) + "\ntail") + self.assertEqual(again, report) + + def test_missing_marker_pair(self) -> None: + with self.assertRaisesRegex(LocalCiError, "exactly one"): + parse_comment("no markers here") + + def test_two_marker_pairs(self) -> None: + body = _comment(_payload()) + with self.assertRaisesRegex(LocalCiError, "exactly one"): + parse_comment(body + body) + + def test_duration_over_timeout_fails(self) -> None: + payload = _payload( + runs=[ + _run(), + _run(id="test", name="Test", command="npm test", duration_s=1801, timeout_s=1800), + ] + ) + verdict = verify_comment(_comment(payload)) + self.assertFalse(verdict.ok) + self.assertEqual(verdict.status, "fail") + self.assertTrue(any("duration_s" in r for r in verdict.reasons)) + + def test_failed_run_fails_verdict(self) -> None: + payload = _payload( + runs=[ + _run(), + _run(id="test", name="Test", command="npm test", result="fail", exit_code=1), + ] + ) + verdict = verify_comment(_comment(payload)) + self.assertFalse(verdict.ok) + self.assertTrue(any("result is fail" in r for r in verdict.reasons)) + + def test_missing_required_run(self) -> None: + payload = _payload(runs=[_run()]) + with self.assertRaisesRegex(LocalCiError, "missing required ids"): + parse_comment(_comment(payload)) + + def test_require_ids_mismatch(self) -> None: + verdict = verify_comment(_comment(_payload()), require_ids=frozenset({"format", "build"})) + self.assertFalse(verdict.ok) + self.assertTrue(any("missing ids" in r for r in verdict.reasons)) + + def test_public_repo_not_applicable(self) -> None: + payload = _payload(private=False) + verdict = verify_comment(_comment(payload)) + self.assertTrue(verdict.ok) + self.assertEqual(verdict.status, "not_applicable") + + def test_expect_private_rejects_public_payload(self) -> None: + payload = _payload(private=False) + verdict = verify_comment(_comment(payload), expect_private=True) + self.assertFalse(verdict.ok) + self.assertTrue(any("private must be true" in r for r in verdict.reasons)) + + def test_expect_head_mismatch(self) -> None: + verdict = verify_comment(_comment(_payload()), expect_head="0" * 40) + self.assertFalse(verdict.ok) + self.assertTrue(any("expect_head" in r for r in verdict.reasons)) + + def test_orphan_run_rejected(self) -> None: + payload = _payload(runs=[_run(), _run(id="extra", name="Extra", command="true")]) + with self.assertRaisesRegex(LocalCiError, "not in required"): + parse_comment(_comment(payload)) + + def test_unknown_payload_key(self) -> None: + payload = _payload() + payload["verdict"] = "pass" + with self.assertRaisesRegex(LocalCiError, "unknown keys"): + parse_comment(_comment(payload)) + + def test_empty_required_and_runs_pass(self) -> None: + payload = _payload(required=[], runs=[]) + verdict = verify_comment(_comment(payload)) + self.assertTrue(verdict.ok) + self.assertEqual(verdict.status, "pass") + + def test_empty_required_with_runs_rejected(self) -> None: + with self.assertRaisesRegex(LocalCiError, "both be empty"): + parse_comment(_comment(_payload(required=[]))) + + def test_pass_with_nonzero_exit_fails(self) -> None: + payload = _payload( + runs=[_run(exit_code=1), _run(id="test", name="Test", command="npm test")] + ) + verdict = verify_comment(_comment(payload)) + self.assertFalse(verdict.ok) + self.assertTrue(any("exit_code is 1" in r for r in verdict.reasons)) + +if __name__ == "__main__": + unittest.main()