diff --git a/CHANGELOG.md b/CHANGELOG.md index 8273924..1b70304 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,13 @@ Notable changes to the Swarm plugin. Format follows agent) as settled, instead of refusing cleanup until its tab is focused. ### Added +- Opt-in `publish-pr ` harvest verb (`g`, then slot in the pane) pushes + the audited commit and creates/reuses its exact GitHub draft PR. Existing + `publish` remains push-only. Optional SHA-bound validation JSON or Browser + QA results contribute only typed check names/statuses to a new draft. +- `pr-status ` (`c`, then slot) reads PR/CI state with explicit no-checks, + unknown, failure, and local/remote head-drift reporting. No automatic merge, + force push, or existing PR-body rewrite is performed. - `npm run doctor` checks Node, Git, and the selected Herdr binary without creating plugin state or contacting a running session; incompatible versions include an explicit `HERDR_BIN_PATH` remedy. diff --git a/CONCEPTS.md b/CONCEPTS.md index a5c5ab4..be14d10 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -61,6 +61,19 @@ forge. Publish deliberately introduces no new terminal state — once the forge merge lands and base updates, the ordinary preview detection (ancestry or squash containment) settles the Slot. +### Draft PR handoff +An opt-in extension of Publish that creates a draft GitHub pull request for +the exact repository, slot branch, and recorded base branch, or reuses that +same PR. Supplied validation is bound to the published commit, not inferred +from an agent's completion state. Reuse preserves the existing PR body; it +does not replace earlier evidence or turn a review-ready PR back into a draft. + +### CI snapshot +A read of GitHub's checks for the PR's current head, reported alongside the +local slot head so callers can identify drift. Empty checks mean not run. +Passing checks are observations, not approval to merge or proof that all +required checks exist. + ### Locus Where a merge physically executes. Two cases, and the distinction is load-bearing: when the base branch is not checked out anywhere, the merge runs diff --git a/README.md b/README.md index 2c660c8..7beb00e 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,8 @@ add your own, see below): base updates, the next re-preview auto-detects it (ancestry for merge commits, tree containment for squashes) and the slot proceeds to archive. Scriptable as `harvest-step.sh publish `. + For the optional GitHub draft handoff, use `g` then a slot digit or + `publish-pr `; `c` then a digit reads its current CI status. - *Archive* — after merge/skip, the worktree is removed (branch kept). Recursive ignored-file inventory is byte-safe and requires the exact digest-bound, one-use approval before ignored data can be removed. The @@ -265,6 +267,33 @@ command = "structupath.swarm.fanout" description = "swarm fan-out" ``` +## GitHub draft PR handoff + +Swarm 0.4 adds two opt-in harvest verbs and pane shortcuts: + +- `bash scripts/harvest-step.sh publish-pr 1` (pane: `g`, then slot) performs + the existing audited-commit push, then creates a **draft** GitHub PR or reuses + the exact matching PR. `publish` and the `p` shortcut still only push. +- `bash scripts/harvest-step.sh pr-status 1` (pane: `c`, then slot) reads the + PR state and current CI summary; it never merges, pushes, or edits GitHub. + +Install and authenticate `gh` first. The selected +`HERDR_SWARM_PUBLISH_REMOTE` (default `origin`) must have exactly one fetch and +push URL pointing to the same GitHub.com repository. Fork handoffs and GitHub +Enterprise hosts are outside this first release. The PR base is the run's +recorded base branch; the head is the slot's existing `swarm//` branch. + +Supply `HERDR_SWARM_VALIDATION_FILE=/absolute/path/result.json` to include a +SHA-bound validation summary. It accepts either the explicit checks format or a +Browser QA `result.json`. Without it, the draft says validation was **not run**. +Swarm does not execute validation commands or authenticate supplied evidence. +PR bodies contain only run/slot identifiers, commit SHAs, and whitelisted check +names/statuses; task text, logs, screenshots, browser URLs, and local paths are +not copied. + +See [GitHub handoff contract and recovery](docs/github-handoff.md) for the file +schema, Console integration output, and failure handling. + ## Presets Each slot runs the argv of a named preset. Config file: diff --git a/bin/renderer-harvest.mjs b/bin/renderer-harvest.mjs index 808be33..c11d089 100644 --- a/bin/renderer-harvest.mjs +++ b/bin/renderer-harvest.mjs @@ -180,6 +180,13 @@ export function renderHarvest(model, cols = 80) { ); lines.push(`${ESC}[2m [1-9]slot [Esc]cancel${ESC}[0m`); break; + case "github-pick": + lines.push(ph.operation === "publish-pr" + ? " GITHUB DRAFT: push a slot and create/reuse its exact matching PR?" + : " GITHUB CI: inspect which slot's PR? (read-only)"); + lines.push(" Validation comes from HERDR_SWARM_VALIDATION_FILE; absent means not run."); + lines.push(`${ESC}[2m [1-9]slot [Esc]cancel${ESC}[0m`); + break; default: { // Any row still carrying a journal wedges every merge (sequencer_scan // / the merge verb's own refusal), so the escape hatch has to be @@ -190,6 +197,7 @@ export function renderHarvest(model, cols = 80) { j ? ` a:abort stale merge (slot ${j.slot})` : "" } q:quit${ESC}[0m`, ); + lines.push(`${ESC}[2m g:draft GitHub PR c:read GitHub CI${ESC}[0m`); } } return lines.join("\n"); @@ -663,6 +671,32 @@ export class HarvestRenderer { this.paint(); } break; + case "github-pick": + if (ch >= "1" && ch <= "9") { + const slot = Number(ch); + this.phase = { name: "list" }; + if (!this.rows.some((row) => row.slot === slot)) { + this.banner = `no slot ${slot} in this run`; + this.paint(); + break; + } + const result = await this.step(ph.operation, [slot]); + if (result.code !== 0) this.banner = this.lastErrLine(result); + else { + try { + const key = ph.operation === "publish-pr" ? "pull_request" : "ci_status"; + const value = JSON.parse(result.out[key]?.[0]?.[0]); + this.banner = ph.operation === "publish-pr" + ? `${value.reused ? "reused" : "draft created"}: ${value.url}${value.reused ? " (existing body preserved)" : ""}` + : `CI ${value.status}${value.matches_local_head === false ? " (remote head differs from local)" : ""}${value.url ? `: ${value.url}` : ""}`; + } catch { this.banner = "GitHub response was malformed; inspect the PR before retrying."; } + } + this.paint(); + } else if (ch === "b" || ch === "\x1b") { + this.phase = { name: "list" }; + this.paint(); + } + break; case "ignored": if (ch === "y" || ch === "Y") { const slot = ph.slot; @@ -686,6 +720,9 @@ export class HarvestRenderer { else if (ch === "p") { this.phase = { name: "publish-pick" }; this.paint(); + } else if (ch === "g" || ch === "c") { + this.phase = { name: "github-pick", operation: ch === "g" ? "publish-pr" : "pr-status" }; + this.paint(); } else if (ch === "r") { this.banner = ""; await this.reload(); diff --git a/docs/github-handoff.md b/docs/github-handoff.md new file mode 100644 index 0000000..8c9dd16 --- /dev/null +++ b/docs/github-handoff.md @@ -0,0 +1,125 @@ +# GitHub draft handoff contract + +Swarm 0.4 adds optional GitHub handoff to the existing harvest commands. The +five manifest actions remain unchanged; these are new `harvest-step.sh` verbs. + +## Commands and authorization + +From a configured Swarm run: + +```sh +# Push committed slot work, then create/reuse its exact matching draft PR. +HERDR_SWARM_VALIDATION_FILE=/absolute/path/result.json \ + bash scripts/harvest-step.sh publish-pr 1 + +# Read the PR's current head and CI state. +bash scripts/harvest-step.sh pr-status 1 +``` + +The harvest pane exposes `g`, then a slot digit, for draft handoff and `c`, then +a slot digit, for CI status. Selecting the slot in the draft prompt authorizes +the push/PR creation. `p` and `publish` retain their existing push-only behavior. +Scripts use the same workspace/repository context as other harvest verbs. + +Requirements: Node >=20, Git, authenticated `gh` with access to the destination, +and an active Swarm run with owned slot resources. `HERDR_SWARM_PUBLISH_REMOTE` +defaults to `origin`. It must name a Git remote with exactly one fetch and push +URL identifying the same GitHub.com repository (ordinary HTTPS or SSH, without +embedded credentials). Fork destinations, multiple push URLs, and Enterprise +hosts are not supported yet. No remote/auth/CI configuration is changed. + +The PR base is the manifest's recorded base branch. The head is the slot branch. +The existing ownership, fork ancestry, non-empty-work, and non-force-push guards +remain active. Uncommitted work is excluded with the existing warning. The +prepared SHA must still match before publishing; the push uses that audited SHA +and destination URL, then verifies the PR head. No automatic merge occurs. + +## Explicit validation evidence + +`HERDR_SWARM_VALIDATION_FILE` is optional. Omitting it reports `not_run`, never +success. This file is read only for `publish-pr`; `pr-status` does not read it. +Swarm executes no validation commands. It accepts a regular JSON file of at most +1 MiB in either format below. Symlinks and special files are refused; the byte +limit applies to the actual read, including a file growing while read. + +```json +{ + "schema_version": 1, + "head_sha": "0123456789012345678901234567890123456789", + "checks": [ + { "name": "unit-tests", "status": "not_run" }, + { "name": "typecheck", "status": "pending" } + ] +} +``` + +Replace the example SHA with the actual tested slot commit. `head_sha` must +equal the prepared published SHA. Supply 1–30 checks with unique names matching +`[a-z][a-z0-9_-]{0,47}`. Statuses are `passed`, `failed`, `pending`, or `not_run`. +Check names are explicitly public labels; keep them free of secrets. Additional +fields such as raw command output are never copied to the PR. + +Browser QA's `result.json` is accepted directly when it has `schemaVersion: 1`, +`kind: "herdr-browser-qa"`, a matching `git.commit`, and explicitly false +`git.dirty` and `git.changedDuringRun`. Its summary fields must be nonnegative +integers, and `scenario.policy.failOnConsoleError`, `failOnPageError`, and +`failOnFailedRequest` must all be explicitly true. The summary requires +1–4 viewports and matching pass/fail totals. Typed per-run steps and telemetry +must agree with the summary counts. A passing `browser-qa` check additionally +requires every viewport run and step to pass, at least one successful assertion, +passed cleanup, no recorded errors or incomplete telemetry, and zero console +errors, page errors, failed requests, or unresolved requests. +Other valid reports become `failed`. Raw runs, URLs, paths, and screenshots are +not published. Browser QA observations do not prove the served application was +built from the recorded commit. + +Both formats are caller-supplied observations, not authenticated attestations. +Failed or pending checks can be attached to a draft; Swarm does not mislabel them +or treat them as approval. + +## Retry and existing PRs + +Discovery requires exact repository/head/base identity and excludes fork PRs. +One matching open PR is reused whether draft or already marked ready for review. +Existing title/body/state is preserved. Consequently, `validation_attached` is +false on reuse: the existing body may describe an earlier SHA and must not be +treated as fresh evidence. The caller can review/update it manually on GitHub. +Closed/merged matches or ambiguous/truncated discovery refuse handoff. + +Evidence, CLI access, and remote-format failures occur before the push. A push +failure never creates a PR. A PR API failure can occur **after** publication; +the existing manifest `published` receipt records the pushed SHA, and the branch +remains on the remote. Retry the same verb. If creation succeeded but its response +was lost, Swarm searches again and reuses the exact PR instead of duplicating it. +If GitHub's head differs from the audited SHA, handoff refuses success and leaves +the published branch/PR for inspection; it never force-pushes a correction. + +## Machine-readable output + +All commands retain the `keyvalue` stdout protocol and human errors on stderr. +Preparation drift uses exit 30; handoff/precondition failures use exit 36. +Existing ownership/manifest refusal codes continue to apply. A failed create may +still emit the earlier successful `published` record; check the process exit code. + +Successful `publish-pr` emits the existing `published` record followed by: + +```text +pull_request{"schema_version":1,"repository":"owner/repo","number":7,"url":"https://github.com/owner/repo/pull/7","state":"OPEN","draft":true,"head_sha":"...","base":"main","branch":"swarm/run/slot","reused":false,"validation_attached":true,"validation":{"source":"supplied","head_sha":"...","checks":[{"name":"unit-tests","status":"passed"}]}} +``` + +Validation sources are `none`, `supplied`, or `browser_qa`. + +`pr-status` emits `ci_status` with schema version, repository, number, URL, PR +state/draft status, `head_sha`, `local_head_sha`, `matches_local_head`, `status`, +and `check_count`. No matching PR emits only schema version, repository, +`local_head_sha`, and `status: "no_pr"`. + +CI status values are `passed`, `failed`, `pending`, `not_run`, and `unknown`. +Empty checks and otherwise successful sets containing skipped/neutral checks +are `not_run`; unrecognized responses are `unknown`. +A remote/local mismatch leaves the remote CI status intact and sets +`matches_local_head: false`; callers must display that mismatch. Passing checks +do not establish branch-protection completeness or authorize a merge. + +CI inspection makes only GitHub reads, with no fetch/push/PR edits. As with other +harvest verbs, it takes the local run lock while resolving and verifying context. diff --git a/docs/readiness.md b/docs/readiness.md index c4a7d95..8cb7c25 100644 --- a/docs/readiness.md +++ b/docs/readiness.md @@ -1,6 +1,6 @@ # Swarm readiness -The plugin remains version 0.3.0; fixes on this branch are recorded under +The plugin targets version 0.4.0; changes on this branch are recorded under Unreleased in the changelog. The manifest still requires Herdr >=0.7.4. ## Repeatable validation @@ -49,5 +49,6 @@ push, default user session, or production repository was used by the smoke run. setup-hook failure currently warns and still starts the agent. 3. Keep conflict resolution review-first. The resolver-agent document in `docs/plans/` describes future work, not a shipped feature. -4. Treat `publish` as a branch push for forge review, not automatic PR creation - or merging. The orchestrator remains responsible for the final review. +4. Use `publish-pr` for an explicit draft GitHub handoff with SHA-bound supplied + evidence, and `pr-status` for a current CI snapshot. Ordinary `publish` still + only pushes. The orchestrator remains responsible for the final review. diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 08ec574..0dfbe3f 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -1,6 +1,6 @@ id = "structupath.swarm" name = "Swarm" -version = "0.3.0" +version = "0.4.0" min_herdr_version = "0.7.4" description = "Worktree-per-agent fan-out, per-slot change visibility, and review-first harvest for parallel coding agents" platforms = ["macos", "linux"] diff --git a/package.json b/package.json index fb9274f..906dbfe 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "herdr-swarm", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "engines": { "node": ">=20" }, diff --git a/scripts/doctor.sh b/scripts/doctor.sh index 14d8b20..8a4560a 100755 --- a/scripts/doctor.sh +++ b/scripts/doctor.sh @@ -36,5 +36,10 @@ else fi echo 'Agent prerequisites: check your selected preset binaries and authentication before fan-out.' +if command -v gh >/dev/null 2>&1; then + echo 'Optional GitHub handoff: gh is installed; publish-pr/pr-status also require repository access.' +else + echo 'Optional GitHub handoff: install gh for publish-pr/pr-status (ordinary publish does not need it).' +fi echo 'This checks local prerequisites only; it does not exercise a Herdr session or start agents.' exit "$failed" diff --git a/scripts/harvest-step.sh b/scripts/harvest-step.sh index 4515fad..20a2167 100755 --- a/scripts/harvest-step.sh +++ b/scripts/harvest-step.sh @@ -11,7 +11,7 @@ # Verbs: preview | commit-wip | snapshot | # discard | skip | merge | # resume [complete ] | archive | abort-merge | -# publish +# publish | publish-pr | pr-status # # Output protocol: machine-readable "keyvalue…" lines on stdout, human # messages on stderr, typed exit codes (HS_EC_*) so the renderer branches on @@ -68,6 +68,8 @@ VERB="${1-}" } shift +case "$VERB" in publish-pr | pr-status) clear_git_routing_env ;; esac + # --- Run + slot context ------------------------------------------------------ # Internal field separator is ASCII unit separator (\x1f), NOT tab: tab is @@ -694,6 +696,7 @@ do_resume() { do_publish() { read_slot "$1" || return $? local remote="${HERDR_SWARM_PUBLISH_REMOTE:-origin}" tip out patch + local expected="${2-}" destination="${3:-${HERDR_SWARM_PUBLISH_REMOTE:-origin}}" if ! git -C "$REPO_ROOT" remote get-url "$remote" >/dev/null 2>&1; then echo "herdr-swarm: remote '$remote' is not configured in this repository — add it, or point HERDR_SWARM_PUBLISH_REMOTE at the remote to publish to." >&2 return "$HS_EC_REFUSED" @@ -706,6 +709,10 @@ do_publish() { echo "herdr-swarm: slot $1 has no commits past the fork point — nothing to publish." >&2 return "$HS_EC_REFUSED" fi + if [ -n "$expected" ] && [ "$tip" != "$expected" ]; then + echo "herdr-swarm: slot head moved after PR/evidence preparation — re-run publish-pr." >&2 + return "$HS_EC_DRIFT" + fi # The branch must still contain the recorded fork point: a rewritten slot # branch (reset onto foreign history) would otherwise publish commits this # run never audited. Same authority prune uses — ancestry, not bookkeeping. @@ -727,7 +734,7 @@ do_publish() { if [ -n "${HERDR_SWARM_TEST_PAUSE_BEFORE_PUBLISH:-}" ]; then sleep "$HERDR_SWARM_TEST_PAUSE_BEFORE_PUBLISH" fi - if ! out="$(git -C "$REPO_ROOT" push "$remote" "$tip:refs/heads/$SLOT_BRANCH" 2>&1)"; then + if ! out="$(git -C "$REPO_ROOT" push "$destination" "$tip:refs/heads/$SLOT_BRANCH" 2>&1)"; then printf '%s\n' "$out" >&2 echo "herdr-swarm: publish of slot $1 to '$remote' was rejected — nothing was force-pushed; resolve the refusal above and retry." >&2 return "$HS_EC_REFUSED" @@ -740,6 +747,21 @@ do_publish() { printf 'published\t%s\t%s\t%s\n' "$1" "$remote" "$tip" } +do_publish_pr() { + read_slot "$1" || return $? + local plan expected destination + plan="$(node "$PLUGIN_ROOT/scripts/pr-handoff.mjs" prepare "$REPO_ROOT" "$RUN_ID" "$1" "$SLOT_BRANCH" "$BASE_BRANCH" "$FORK_SHA")" || return "$HS_EC_REFUSED" + expected="$(printf '%s' "$plan" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.parse(d).sha));')" || return 1 + destination="$(printf '%s' "$plan" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.parse(d).push_url));')" || return 1 + do_publish "$1" "$expected" "$destination" || return $? + printf '%s' "$plan" | node "$PLUGIN_ROOT/scripts/pr-handoff.mjs" handoff +} + +do_pr_status() { + read_slot "$1" || return $? + node "$PLUGIN_ROOT/scripts/pr-handoff.mjs" status "$REPO_ROOT" "$RUN_ID" "$1" "$SLOT_BRANCH" "$BASE_BRANCH" "$FORK_SHA" +} + do_archive() { read_slot "$1" || return $? case "$SLOT_STATUS" in @@ -975,6 +997,14 @@ publish) require_slot_arg "${1-}" || exit 1 do_publish "$1" ;; +publish-pr) + require_slot_arg "${1-}" || exit 1 + do_publish_pr "$1" + ;; +pr-status) + require_slot_arg "${1-}" || exit 1 + do_pr_status "$1" + ;; *) echo "herdr-swarm: unknown harvest verb '$VERB'" >&2 exit 1 diff --git a/scripts/lib.sh b/scripts/lib.sh index abadc85..513c63e 100644 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -129,6 +129,20 @@ herdr_binary_path() { command -v "$HERDR" || printf '%s\n' "$HERDR" } +# New forge handoffs must resolve the named worktree, not inherited Git +# routing/config injection. Keep identity, credential, and user config settings. +clear_git_routing_env() { + local key + for key in GIT_DIR GIT_WORK_TREE GIT_COMMON_DIR GIT_INDEX_FILE GIT_OBJECT_DIRECTORY \ + GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_NAMESPACE GIT_CEILING_DIRECTORIES \ + GIT_DISCOVERY_ACROSS_FILESYSTEM GIT_PREFIX GIT_CONFIG_COUNT GIT_CONFIG_PARAMETERS; do + unset "$key" + done + while IFS= read -r key; do + case "$key" in GIT_CONFIG_KEY_* | GIT_CONFIG_VALUE_*) unset "$key" ;; esac + done < <(compgen -e) +} + # Portable timeout (macOS lacks GNU timeout): poll the child and SIGKILL it # after SECONDS (exit 137). Done in-shell (no background watchdog) so a dying # script can never orphan a sleep that holds the caller's stdout pipe open. diff --git a/scripts/pr-handoff.mjs b/scripts/pr-handoff.mjs new file mode 100644 index 0000000..b2c5b3e --- /dev/null +++ b/scripts/pr-handoff.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +const SHA = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/; +const FIELDS = "number,url,state,isDraft,headRefName,headRefOid,baseRefName,headRepository,isCrossRepository"; +const refuse = (message) => { throw new Error(message); }; +const ROUTING = new Set(["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_INDEX_FILE", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_NAMESPACE", "GIT_CEILING_DIRECTORIES", "GIT_DISCOVERY_ACROSS_FILESYSTEM", "GIT_PREFIX", "GIT_CONFIG_COUNT", "GIT_CONFIG_PARAMETERS"]); + +function command(binary, args, cwd) { + const env = { ...process.env, GH_HOST: "github.com", GH_PROMPT_DISABLED: "1", GH_PAGER: "cat" }; + if (binary === "git") { + for (const key of Object.keys(env)) { + if (ROUTING.has(key) || /^GIT_CONFIG_(KEY|VALUE)_/.test(key)) delete env[key]; + } + } + const result = spawnSync(binary, args, { + cwd, encoding: "utf8", timeout: 30_000, maxBuffer: 2 * 1024 * 1024, + env, + }); + if (result.status !== 0) { + refuse(`${path.basename(binary)} ${args[0]} failed${result.error?.code === "ENOENT" ? " (command not installed)" : ""}; verify access and retry. No automatic merge or force push was attempted.`); + } + return result.stdout.trim(); +} + +function readEvidenceFile(filename) { + const limit = 1024 * 1024; + const fd = fs.openSync(filename, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile() || stat.size > limit) refuse("Validation evidence must be a regular JSON file no larger than 1 MiB."); + const bytes = Buffer.alloc(limit + 1); + let length = 0; + while (length < bytes.length) { + const read = fs.readSync(fd, bytes, length, bytes.length - length, null); + if (read === 0) break; + length += read; + } + if (length > limit) refuse("Validation evidence exceeded 1 MiB while reading."); + return bytes.subarray(0, length).toString("utf8"); + } finally { fs.closeSync(fd); } +} + +function githubRepository(url) { + const match = url.match(/^(?:https:\/\/github\.com\/|git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/); + if (!match || [".", ".."].includes(match[1]) || [".", ".."].includes(match[2])) { + refuse("PR handoff requires an ordinary GitHub.com SSH/HTTPS remote without embedded credentials."); + } + return `${match[1]}/${match[2]}`; +} + +export function validationEvidence(filename, sha) { + if (!filename) return { source: "none", head_sha: sha, checks: [{ name: "validation", status: "not_run" }] }; + const value = JSON.parse(readEvidenceFile(filename)); + if (value?.kind === "herdr-browser-qa") { + const summary = value.summary; + if (value.schemaVersion !== 1 || value.git?.commit !== sha || value.git?.dirty !== false || value.git?.changedDuringRun !== false) { + refuse("Browser QA evidence must match the published commit and record a clean, unchanged checkout."); + } + if (!["failOnConsoleError", "failOnPageError", "failOnFailedRequest"].every((key) => value.scenario?.policy?.[key] === true)) refuse("Browser QA handoff requires all three strict error policies to be enabled."); + if (!["passed", "failed"].includes(value.status) || !["passed", "failed"].includes(value.cleanup?.status) || + !["viewports", "passed", "failed", "assertions", "consoleErrors", "pageErrors", "failedRequests"].every((key) => Number.isSafeInteger(summary?.[key]) && summary[key] >= 0) || + summary.viewports < 1 || summary.passed + summary.failed !== summary.viewports) refuse("Browser QA summary is malformed."); + const runs = value.runs; + const stepTypes = new Set(["navigate", "click", "fill", "waitFor", "screenshot", "assertVisible", "assertText", "assertUrl", "assertTitle"]); + if (!Array.isArray(runs) || runs.length > summary.viewports || summary.viewports > 4 || runs.some(run => + !["passed", "failed"].includes(run?.status) || !Array.isArray(run.steps) || run.steps.length > 40 || + run.steps.some((step, index) => !stepTypes.has(step?.type) || step.index !== index || !["passed", "failed"].includes(step.status)) || + !["consoleErrors", "pageErrors", "failedRequests"].every(key => Array.isArray(run[key])) || + !Number.isSafeInteger(run.unresolvedRequests) || run.unresolvedRequests < 0)) refuse("Browser QA runs are malformed."); + const assertions = runs.flatMap(run => run.steps).filter(step => step.type.startsWith("assert")); + if (summary.passed !== runs.filter(run => run.status === "passed").length || summary.assertions !== assertions.length || + !["consoleErrors", "pageErrors", "failedRequests"].every(key => summary[key] === runs.reduce((sum, run) => sum + run[key].length, 0))) refuse("Browser QA summary contradicts its runs."); + const passed = value.status === "passed" && value.cleanup.status === "passed" && !value.error && + runs.length === summary.viewports && summary.failed === 0 && assertions.length > 0 && assertions.every(step => step.status === "passed") && + runs.every(run => run.status === "passed" && run.steps.length > 0 && run.steps.every(step => step.status === "passed" && !step.error) && + !["error", "evidenceError", "telemetryError", "cleanupError"].some(key => run[key]) && + run.consoleErrors.length === 0 && run.pageErrors.length === 0 && run.failedRequests.length === 0 && run.unresolvedRequests === 0); + return { source: "browser_qa", head_sha: sha, checks: [{ name: "browser-qa", status: passed ? "passed" : "failed" }] }; + } + if (value?.schema_version !== 1 || value.head_sha !== sha || !Array.isArray(value.checks) || value.checks.length < 1 || value.checks.length > 30) { + refuse("Validation evidence must have schema_version 1, the published head_sha, and 1–30 checks."); + } + const names = new Set(); + const checks = value.checks.map((check) => { + if (!/^[a-z][a-z0-9_-]{0,47}$/.test(check?.name) || names.has(check.name) || !["passed", "failed", "pending", "not_run"].includes(check.status)) refuse("Validation check names/statuses are invalid or duplicated."); + names.add(check.name); + return { name: check.name, status: check.status }; + }); + return { source: "supplied", head_sha: sha, checks }; +} + +function context(args) { + const [root, run, slot, branch, base, fork] = args; + if (!root || !/^[A-Za-z0-9_-]+$/.test(run) || !/^[0-9]+$/.test(slot) || !branch?.startsWith(`swarm/${run}/`) || !base || !SHA.test(fork)) refuse("Invalid handoff context."); + const git = (...values) => command("git", ["-C", root, ...values], root); + const remote = process.env.HERDR_SWARM_PUBLISH_REMOTE || "origin"; + if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(remote)) refuse("PR handoff requires a named Git remote."); + const push = git("remote", "get-url", "--push", "--all", remote).split("\n"); + const fetch = git("remote", "get-url", "--all", remote).split("\n"); + if (push.length !== 1 || fetch.length !== 1) refuse("PR handoff requires exactly one fetch and push URL."); + const repository = githubRepository(push[0]); + if (githubRepository(fetch[0]).toLowerCase() !== repository.toLowerCase()) refuse("Fetch and push remotes must identify the same GitHub repository."); + const sha = git("rev-parse", "--verify", `refs/heads/${branch}`); + if (!SHA.test(sha)) refuse("Cannot resolve slot commit."); + return { root, run, slot: Number(slot), branch, base, fork, sha, remote, repository, push_url: push[0] }; +} + +function gh(plan, args) { + return command("gh", args, plan.root); +} + +function assertPrIdentity(plan, pr) { + if (!pr || !Number.isSafeInteger(pr.number) || pr.number < 1 || + typeof pr.url !== "string" || pr.url.toLowerCase() !== `https://github.com/${plan.repository}/pull/${pr.number}`.toLowerCase() || + !SHA.test(pr.headRefOid) || typeof pr.isDraft !== "boolean" || !["OPEN", "CLOSED", "MERGED"].includes(pr.state) || + pr.headRefName !== plan.branch || pr.baseRefName !== plan.base || pr.isCrossRepository !== false || + pr.headRepository?.nameWithOwner?.toLowerCase() !== plan.repository.toLowerCase()) { + refuse("GitHub returned an invalid PR identity."); + } +} + +function matchingPr(plan) { + const prs = JSON.parse(gh(plan, ["pr", "list", "--repo", `github.com/${plan.repository}`, "--state", "all", "--head", plan.branch, "--base", plan.base, "--limit", "100", "--json", FIELDS])); + if (!Array.isArray(prs) || prs.length >= 100) refuse("PR discovery is ambiguous or truncated; inspect GitHub manually."); + const matching = prs.filter((pr) => pr.headRefName === plan.branch && pr.baseRefName === plan.base && pr.isCrossRepository === false && pr.headRepository?.nameWithOwner?.toLowerCase() === plan.repository.toLowerCase()); + if (matching.length > 1) refuse("Multiple exact matching PRs found; inspect GitHub manually."); + if (matching.length === 1) { + const pr = matching[0]; + assertPrIdentity(plan, pr); + return pr; + } + return null; +} + +function body(plan) { + return [ + `Swarm slot ${plan.slot} from run \`${plan.run}\`.`, "", + `Published commit: \`${plan.sha}\``, + `Fork commit: \`${plan.fork}\``, "", + "Validation summary (caller-supplied evidence, not an authenticated attestation):", "", + ...plan.validation.checks.map((check) => `- ${check.name}: **${check.status}**`), "", + "No validation commands were run by this handoff. Browser QA observations do not prove the served application was built from this commit.", + "Review this draft and CI before deciding whether to merge. No automatic merge is configured.", "", + ].join("\n"); +} + +function prResult(plan, pr, reused) { + return { schema_version: 1, repository: plan.repository, number: pr.number, url: pr.url, + state: pr.state, draft: pr.isDraft, head_sha: pr.headRefOid, base: plan.base, branch: plan.branch, + reused, validation_attached: !reused, validation: plan.validation ?? null }; +} + +export function ciSummary(checks) { + if (!Array.isArray(checks)) return "unknown"; + if (checks.length === 0) return "not_run"; + const states = checks.map((check) => { + if (check.__typename === "CheckRun") { + if (check.status !== "COMPLETED") return ["QUEUED", "IN_PROGRESS", "WAITING", "PENDING", "REQUESTED"].includes(check.status) ? "pending" : "unknown"; + if (check.conclusion === "SUCCESS") return "passed"; + if (["NEUTRAL", "SKIPPED"].includes(check.conclusion)) return "not_run"; + return ["FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"].includes(check.conclusion) ? "failed" : "unknown"; + } + if (check.__typename === "StatusContext") return ({ SUCCESS: "passed", FAILURE: "failed", ERROR: "failed", PENDING: "pending", EXPECTED: "pending" })[check.state] ?? "unknown"; + return "unknown"; + }); + for (const state of ["failed", "unknown", "pending"]) if (states.includes(state)) return state; + return states.every(state => state === "passed") ? "passed" : "not_run"; +} + +function main(mode, args) { + if (mode === "prepare") { + const plan = context(args); + plan.validation = validationEvidence(process.env.HERDR_SWARM_VALIDATION_FILE, plan.sha); + const pr = matchingPr(plan); + if (pr && pr.state !== "OPEN") refuse("The exact matching PR is closed or merged; inspect it manually before retrying."); + process.stdout.write(JSON.stringify(plan)); + } else if (mode === "handoff") { + const plan = JSON.parse(fs.readFileSync(0, "utf8")); + let pr = matchingPr(plan); + let reused = true; + if (pr && pr.state !== "OPEN") refuse("The matching PR was closed while publishing; branch remains published."); + if (!pr) { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), "swarm-pr-")); + try { + const filename = path.join(directory, "body.md"); + fs.writeFileSync(filename, body(plan), { mode: 0o600 }); + try { + gh(plan, ["pr", "create", "--repo", `github.com/${plan.repository}`, "--draft", "--head", plan.branch, "--base", plan.base, "--title", `Swarm ${plan.run}: slot ${plan.slot}`, "--body-file", filename]); + reused = false; + } catch (error) { + // The request may have succeeded before its response was lost. + pr = matchingPr(plan); + if (!pr) throw error; + } + } finally { fs.rmSync(directory, { recursive: true, force: true }); } + pr ??= matchingPr(plan); + } + if (!pr || pr.state !== "OPEN" || pr.headRefOid !== plan.sha) refuse("Published PR head changed or could not be verified; inspect GitHub and retry. The branch remains published."); + if (!reused && !pr.isDraft) refuse("GitHub did not confirm a draft PR; inspect the created PR manually."); + process.stdout.write(`pull_request\t${JSON.stringify(prResult(plan, pr, reused))}\n`); + } else if (mode === "status") { + const plan = context(args); + const pr = matchingPr(plan); + if (!pr) { + process.stdout.write(`ci_status\t${JSON.stringify({ schema_version: 1, repository: plan.repository, status: "no_pr", local_head_sha: plan.sha })}\n`); + return; + } + const current = JSON.parse(gh(plan, ["pr", "view", String(pr.number), "--repo", `github.com/${plan.repository}`, "--json", `${FIELDS},statusCheckRollup`])); + assertPrIdentity(plan, current); + if (current.number !== pr.number) refuse("PR identity changed during status inspection."); + process.stdout.write(`ci_status\t${JSON.stringify({ schema_version: 1, repository: plan.repository, number: current.number, url: pr.url, state: current.state, draft: current.isDraft, head_sha: current.headRefOid, local_head_sha: plan.sha, matches_local_head: current.headRefOid === plan.sha, status: ciSummary(current.statusCheckRollup), check_count: Array.isArray(current.statusCheckRollup) ? current.statusCheckRollup.length : 0 })}\n`); + } else refuse("Unknown PR handoff operation."); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { main(process.argv[2], process.argv.slice(3)); } + catch (error) { + process.stderr.write(`herdr-swarm: ${error instanceof SyntaxError ? "Malformed JSON in evidence or GitHub response." : error.message}\n`); + process.exitCode = 36; + } +} diff --git a/tests/harness.mjs b/tests/harness.mjs index 73286b8..eb823f5 100644 --- a/tests/harness.mjs +++ b/tests/harness.mjs @@ -234,6 +234,48 @@ export function createHarness() { fs.chmodSync(p, 0o755); } + // GitHub CLI fixture uses fields captured from gh pr list/view. Git writes + // still run against real local repositories; this only replaces the forge. + function writeGithubStub() { + const script = path.join(stubDir, "github-stub.mjs"); + fs.writeFileSync(script, ` +import fs from "node:fs"; +import { spawnSync } from "node:child_process"; +const args = process.argv.slice(2); +fs.appendFileSync(process.env.STUB_LOG, JSON.stringify(["gh", ...args]) + "\\n"); +const file = process.env.STUB_GITHUB_STATE; +const data = JSON.parse(fs.readFileSync(file, "utf8")); +if (data.expectedHost && process.env.GH_HOST !== data.expectedHost) process.exit(1); +const option = (name) => args[args.indexOf(name) + 1]; +if (args[0] !== "pr") process.exit(1); +if (args[1] === "list") { + if (data.listError) process.exit(1); + if (data.advance) { + const result = spawnSync("git", ["-C", data.advance.repo, "update-ref", data.advance.ref, data.advance.sha], { encoding: "utf8" }); + if (result.status !== 0) process.exit(1); + delete data.advance; + fs.writeFileSync(file, JSON.stringify(data)); + } + process.stdout.write(data.malformed ? "{" : JSON.stringify(data.prs)); +} else if (args[1] === "create") { + if (data.createError && !data.createThenError) process.exit(1); + data.body = fs.readFileSync(option("--body-file"), "utf8"); + data.prs.push({ number: 7, url: "https://github.com/" + data.repository + "/pull/7", + state: "OPEN", isDraft: args.includes("--draft"), headRefName: data.branch, + headRefOid: data.sha, baseRefName: data.base, + headRepository: { nameWithOwner: data.repository }, isCrossRepository: false, + body: data.body, statusCheckRollup: data.checks ?? [] }); + fs.writeFileSync(file, JSON.stringify(data)); + if (data.createThenError) process.exit(1); + console.log(data.prs.at(-1).url); +} else if (args[1] === "view") { + if (data.viewError) process.exit(1); + console.log(JSON.stringify(data.view ?? data.prs.find(pr => String(pr.number) === args[2]))); +} else process.exit(1); +`); + writeStub("gh", `exec node '${script}' "$@"`); + } + function freshEnv(overrides = {}) { fs.writeFileSync(logFile, ""); return { @@ -340,6 +382,7 @@ exit 0`, stateDir, logFile, writeStub, + writeGithubStub, freshEnv, runScript, runLib, diff --git a/tests/pr-handoff.test.mjs b/tests/pr-handoff.test.mjs new file mode 100644 index 0000000..6ae06e5 --- /dev/null +++ b/tests/pr-handoff.test.mjs @@ -0,0 +1,247 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { createHarness, makeFannedOutRun, commitIn, mkdtemp } from "./harness.mjs"; +import { ciSummary, validationEvidence } from "../scripts/pr-handoff.mjs"; +import { HarvestRenderer, renderHarvest } from "../bin/renderer-harvest.mjs"; + +const h = createHarness(); +h.writeHerdrStub(); +h.writeGithubStub(); + +function fixture() { + const run = makeFannedOutRun(h, { prefix: "pr" }); + const sha = commitIn(run.wt(1), "change.txt"); + const bare = path.join(mkdtemp("hs-pr-remote-"), "remote.git"); + h.git(run.repo, "init", "-q", "--bare", bare); + h.git(run.repo, "remote", "add", "origin", "https://github.com/example/project.git"); + // Only route the push transport to a local bare repository. Every Git + // operation, including ancestry, refs, and worktree ownership, remains real. + h.writeStub("git", ` +args=("$@") +if [ "\${args[2]-}" = push ]; then + printf 'git-push %s\\n' "\${args[*]}" >> "$STUB_LOG" + if [ "\${STUB_PUSH_FAIL:-}" = yes ]; then exit 1; fi + args[3]="$STUB_BARE_REMOTE" +fi +exec /usr/bin/git "\${args[@]}" +`); + const state = path.join(run.sdir, "github.json"); + fs.writeFileSync(state, JSON.stringify({ repository: "example/project", branch: run.branch(1), base: "main", sha, prs: [] })); + Object.assign(run.env, { STUB_GITHUB_STATE: state, STUB_BARE_REMOTE: bare }); + return { ...run, sha, bare, state, + data: () => JSON.parse(fs.readFileSync(state, "utf8")), + patch: (patch) => fs.writeFileSync(state, JSON.stringify({ ...JSON.parse(fs.readFileSync(state, "utf8")), ...patch })), + step: (verb, env = {}) => h.runScript("harvest-step.sh", [verb, "1"], { ...run.env, ...env }), + }; +} + +function output(result, key) { + assert.equal(result.status, 0, result.stderr); + const line = result.stdout.split("\n").find(value => value.startsWith(`${key}\t`)); + assert.ok(line, result.stdout); + return JSON.parse(line.slice(key.length + 1)); +} + +test("draft handoff publishes audited commit, reports not-run evidence, and reuses exact PR without editing", () => { + const run = fixture(); + const first = output(run.step("publish-pr"), "pull_request"); + assert.equal(first.reused, false); + assert.equal(first.draft, true); + assert.equal(first.validation.checks[0].status, "not_run"); + assert.equal(h.git(run.repo, "--git-dir", run.bare, "rev-parse", run.branch(1)).stdout.trim(), run.sha); + const body = run.data().body; + assert.match(body, /not_run/); + assert.doesNotMatch(body, /\.swarm-task|\/Users\/|\/tmp\//); + const next = output(run.step("publish-pr"), "pull_request"); + assert.equal(next.reused, true); + assert.equal(next.validation_attached, false); + assert.equal(run.data().body, body); + const log = fs.readFileSync(h.logFile, "utf8"); + assert.equal(log.split('\n').filter(line => line.includes('"create"')).length, 1); + assert.doesNotMatch(log, /--force|"merge"|"edit"/); +}); + +test("typed validation publishes only whitelisted names/statuses and rejects stale evidence before push", () => { + const run = fixture(); + const file = path.join(run.sdir, "validation.json"); + fs.writeFileSync(file, JSON.stringify({ schema_version: 1, head_sha: run.fork, checks: [{ name: "tests", status: "passed" }] })); + let result = run.step("publish-pr", { HERDR_SWARM_VALIDATION_FILE: file }); + assert.equal(result.status, 36); + assert.doesNotMatch(h.log(), /git-push/); + fs.writeFileSync(file, JSON.stringify({ schema_version: 1, head_sha: run.sha, logs: "SECRET_TOKEN=/private/path", checks: [{ name: "tests", status: "failed", output: "SECRET_TOKEN" }] })); + result = run.step("publish-pr", { HERDR_SWARM_VALIDATION_FILE: file }); + assert.equal(output(result, "pull_request").validation.checks[0].status, "failed"); + assert.match(run.data().body, /tests: \*\*failed\*\*/); + assert.doesNotMatch(run.data().body, /SECRET_TOKEN|private\/path/); +}); + +test("Browser QA evidence binds clean unchanged HEAD and never copies browser URLs or logs", () => { + const run = fixture(); + const file = path.join(run.sdir, "qa.json"); + const report = { schemaVersion: 1, kind: "herdr-browser-qa", status: "passed", git: { commit: run.sha, dirty: false, changedDuringRun: false }, scenario: { policy: { failOnConsoleError: true, failOnPageError: true, failOnFailedRequest: true } }, cleanup: { status: "passed" }, summary: { viewports: 2, passed: 2, failed: 0, assertions: 8, consoleErrors: 0, pageErrors: 0, failedRequests: 0 }, runs: [0, 1].map(() => ({ status: "passed", steps: Array.from({ length: 4 }, (_, index) => ({ index, type: "assertVisible", status: "passed" })), consoleErrors: [], pageErrors: [], failedRequests: [], unresolvedRequests: 0, url: "https://private.invalid/secret" })) }; + for (const mutate of [ + value => { value.runs[0].status = "failed"; }, + value => { value.runs = []; }, + value => { value.runs.pop(); }, + value => { value.summary.assertions++; }, + ]) { + const invalid = structuredClone(report); mutate(invalid); + fs.writeFileSync(file, JSON.stringify(invalid)); + assert.throws(() => validationEvidence(file, run.sha), /runs/); + } + for (const mutate of [ + value => { value.runs[0].steps[0].status = "failed"; }, + value => { value.runs[0].unresolvedRequests = 1; }, + value => { value.runs[0].telemetryError = "incomplete"; }, + value => { value.cleanup.status = "failed"; }, + value => { value.error = "interrupted"; }, + ]) { + const failed = structuredClone(report); mutate(failed); + fs.writeFileSync(file, JSON.stringify(failed)); + assert.equal(validationEvidence(file, run.sha).checks[0].status, "failed"); + } + fs.writeFileSync(file, JSON.stringify(report)); + const result = output(run.step("publish-pr", { HERDR_SWARM_VALIDATION_FILE: file }), "pull_request"); + assert.equal(result.validation.source, "browser_qa"); + assert.deepEqual(result.validation.checks, [{ name: "browser-qa", status: "passed" }]); + assert.doesNotMatch(run.data().body, /private.invalid|secret/); + for (const git of [{ ...report.git, dirty: true }, { ...report.git, changedDuringRun: true }, { ...report.git, commit: run.fork }]) { + fs.writeFileSync(file, JSON.stringify({ ...report, git })); + assert.throws(() => validationEvidence(file, run.sha), /clean, unchanged/); + } + fs.writeFileSync(file, JSON.stringify({ ...report, scenario: { policy: { ...report.scenario.policy, failOnPageError: false } } })); + assert.throws(() => validationEvidence(file, run.sha), /strict error policies/); +}); + +test("gh authentication, malformed evidence and non-GitHub remotes fail before publication", () => { + const run = fixture(); + run.patch({ listError: true }); + assert.equal(run.step("publish-pr").status, 36); + assert.doesNotMatch(h.log(), /git-push/); + run.patch({ listError: false, malformed: true }); + assert.equal(run.step("publish-pr").status, 36); + assert.doesNotMatch(h.log(), /git-push/); + h.git(run.repo, "remote", "set-url", "origin", run.bare); + assert.equal(run.step("publish-pr").status, 36); + assert.doesNotMatch(h.log(), /git-push/); +}); + +test("failed push never creates a PR; create failure leaves published branch retryable without duplicate", () => { + const run = fixture(); + assert.equal(run.step("publish-pr", { STUB_PUSH_FAIL: "yes" }).status, 36); + assert.equal(run.data().prs.length, 0); + run.patch({ createError: true }); + assert.equal(run.step("publish-pr").status, 36); + assert.equal(run.slotRow(1).published.sha, run.sha); + assert.equal(run.data().prs.length, 0); + run.patch({ createError: false, createThenError: true }); + const result = output(run.step("publish-pr"), "pull_request"); + assert.equal(result.reused, true); + assert.equal(run.data().prs.length, 1); +}); + +test("read-only CI status distinguishes no PR, no checks, pending, failure and remote head drift", () => { + const run = fixture(); + assert.equal(output(run.step("pr-status"), "ci_status").status, "no_pr"); + output(run.step("publish-pr"), "pull_request"); + const before = fs.readFileSync(path.join(run.sdir, "run-w9.json"), "utf8"); + fs.writeFileSync(h.logFile, ""); + assert.equal(output(run.step("pr-status"), "ci_status").status, "not_run"); + const pr = run.data().prs[0]; + run.patch({ view: { ...pr, headRefOid: run.fork, statusCheckRollup: [{ __typename: "CheckRun", status: "IN_PROGRESS" }] } }); + const status = output(run.step("pr-status"), "ci_status"); + assert.equal(status.status, "pending"); + assert.equal(status.matches_local_head, false); + assert.equal(fs.readFileSync(path.join(run.sdir, "run-w9.json"), "utf8"), before); + assert.doesNotMatch(h.log(), /git-push|"create"|"edit"|"merge"/); + assert.equal(ciSummary([{ __typename: "CheckRun", status: "COMPLETED", conclusion: "FAILURE" }]), "failed"); + assert.equal(ciSummary([{ __typename: "StatusContext", state: "SUCCESS" }]), "passed"); + assert.equal(ciSummary([{ __typename: "CheckRun", status: "COMPLETED", conclusion: "SKIPPED" }]), "not_run"); + assert.equal(ciSummary([{ unrecognized: true }]), "unknown"); +}); + +test("existing closed, ambiguous or foreign identity never becomes a reused handoff", () => { + const run = fixture(); + output(run.step("publish-pr"), "pull_request"); + const pr = run.data().prs[0]; + run.patch({ prs: [{ ...pr, state: "CLOSED" }] }); + assert.equal(run.step("publish-pr").status, 36); + run.patch({ prs: [pr, { ...pr, number: 8 }] }); + assert.equal(run.step("publish-pr").status, 36); + run.patch({ prs: [{ ...pr, headRefOid: "invalid" }] }); + assert.equal(run.step("pr-status").status, 36); +}); + +test("prepared-head drift and ownership tampering refuse before push", () => { + const run = fixture(); + const advanced = commitIn(run.wt(1), "later.txt"); + h.git(run.repo, "update-ref", `refs/heads/${run.branch(1)}`, run.sha, advanced); + run.patch({ advance: { repo: run.repo, ref: `refs/heads/${run.branch(1)}`, sha: advanced } }); + assert.equal(run.step("publish-pr").status, 30); + assert.doesNotMatch(h.log(), /git-push/); + const manifest = run.manifest(); + manifest.slots[0].path = run.repo; + fs.writeFileSync(path.join(run.sdir, "run-w9.json"), JSON.stringify(manifest)); + fs.writeFileSync(h.logFile, ""); + assert.notEqual(run.step("publish-pr").status, 0); + assert.doesNotMatch(h.log(), /git-push|"gh"/); +}); + +test("PR head mismatch after create is reported as partial handoff, never as successful evidence", () => { + const run = fixture(); + run.patch({ sha: run.fork }); + const result = run.step("publish-pr"); + assert.equal(result.status, 36); + assert.match(result.stderr, /head changed/); + assert.doesNotMatch(result.stdout, /pull_request\t/); + assert.equal(run.slotRow(1).published.sha, run.sha); +}); + +test("handoff pins GitHub host and ignores inherited Git repository/config routing", () => { + const run = fixture(); + const foreign = h.makeRepo(); + run.patch({ expectedHost: "github.com" }); + const result = run.step("publish-pr", { GH_HOST: "enterprise.invalid", GIT_DIR: path.join(foreign, ".git"), GIT_WORK_TREE: foreign, GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "remote.origin.url", GIT_CONFIG_VALUE_0: "https://github.com/foreign/repo.git" }); + assert.equal(output(result, "pull_request").repository, "example/project"); + assert.match(h.log(), /github.com\/example\/project/); + assert.equal(h.git(foreign, "status", "--porcelain").stdout, ""); +}); + +test("validation files reject symlinks, FIFOs and oversized content without waiting for a writer", () => { + const directory = mkdtemp("hs-evidence-bounds-"); + const file = path.join(directory, "evidence.json"); + fs.writeFileSync(file, "{}"); + const link = path.join(directory, "link.json"); + fs.symlinkSync(file, link); + assert.throws(() => validationEvidence(link, "a".repeat(40))); + const fifo = path.join(directory, "pipe"); + const made = spawnSync("mkfifo", [fifo], { encoding: "utf8" }); + assert.equal(made.status, 0, made.stderr); + assert.throws(() => validationEvidence(fifo, "a".repeat(40)), /regular JSON file/); + fs.writeFileSync(file, "x".repeat(1024 * 1024 + 1)); + assert.throws(() => validationEvidence(file, "a".repeat(40)), /1 MiB/); + assert.equal(ciSummary([{ __typename: "CheckRun", status: "COMPLETED", conclusion: "SUCCESS" }, { __typename: "CheckRun", status: "COMPLETED", conclusion: "SKIPPED" }]), "not_run"); +}); + +test("harvest pane exposes separate opt-in draft and CI actions without changing push", async () => { + const renderer = new HarvestRenderer(h.freshEnv()); + renderer.write = () => {}; + renderer.rows = [{ slot: 1 }]; + const verbs = []; + renderer.step = async (verb) => { + verbs.push(verb); + return { code: 0, out: { pull_request: [[JSON.stringify({ url: "https://github.com/example/project/pull/7", reused: false })]], ci_status: [[JSON.stringify({ status: "not_run" })]] } }; + }; + await renderer.onKey("g"); + assert.match(renderHarvest({ phase: renderer.phase, rows: [] }, 100), /GITHUB DRAFT/); + await renderer.onKey("1"); + await renderer.onKey("c"); + await renderer.onKey("1"); + assert.deepEqual(verbs, ["publish-pr", "pr-status"]); + assert.match(renderer.banner, /not_run/); + await renderer.onKey("p"); + assert.equal(renderer.phase.name, "publish-pick"); +});