diff --git a/CLAUDE.md b/CLAUDE.md index b362f5c4..9b0d35b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,3 +75,4 @@ See `docs/LANES.md` for full guide: stage reporting, custom templates, lane acti - Use project skills from `skills/` for repeatable workflows (they ship with the `ccam` plugin: `/ccam:`, or the bare `/` alias). - Use `agents/` subagents for focused review or investigation passes (they ship with the plugin: `ccam:backend-reviewer`, etc.). - **Declare lane stage even in plain chat, not just inside skills.** `ccam stage` (see `docs/LANES.md` § Reporting a stage) is a reporting command, not a skill-only ritual — any Claude session working inside an adopted lane's cwd should call it on real stage transitions (starting to plan, starting to implement, running tests, opening the PR, etc.), whether or not a skill is driving. Tool-event detection (`server/lib/stage-detect.js`) only ever paints the amber "detected" badge, never the blue `current` ring — a lane worked entirely through plain chat with no `ccam stage` calls will show a stale `current` stage no matter how much real work happens. Skip it only when `ccam stage` reports no lane owns the cwd (not adopted). +- **Prefer `ccam:ship-feature`(`-lane`) over invoking `superpowers:brainstorming`/`writing-plans`/`executing-plans`/`subagent-driven-development`/`test-driven-development` directly for implementation work inside a lane's cwd.** Those Superpowers skills know nothing about CCAM lanes and never call `ccam stage` themselves — going straight to them (skipping the CCAM wrapper) is exactly how a lane's `current` stage goes stale mid-implementation while `ccam stage` is never called for real transitions. `ccam:ship-feature`/`ship-feature-lane` drive the same underlying Superpowers skills phase-by-phase and declare `ccam stage` at every boundary — pick one of them first for any multi-phase feature/bugfix inside a lane; reach for a bare Superpowers skill directly only for work with no lane cwd to report against (e.g. a scratch repo, or explicitly told to skip the CCAM wrapper). diff --git a/bin/ccam.js b/bin/ccam.js index f96e687e..eb1b3387 100755 --- a/bin/ccam.js +++ b/bin/ccam.js @@ -2263,6 +2263,38 @@ async function cmdLanesIntegration(args) { process.exitCode = enabled ? 0 : 1; } +/** `ccam lanes ci status []` — one-shot GitHub + * Actions status snapshot for a ref, via GET /:id/ci/status. Never polls; + * the caller (ship-feature-lane Stage 13) owns the wait loop. Exit code + * mirrors the derived state: 0 success, 1 failure or any error, 3 pending. */ +async function cmdLanesCi(args) { + if (args[0] !== "status") { + console.error("usage: ccam lanes ci status []"); + process.exitCode = 1; + return; + } + const ref = args[1]; + if (!ref) { + console.error("usage: ccam lanes ci status []"); + process.exitCode = 1; + return; + } + const resolved = await resolveLaneArg(args.slice(2)); + if (!resolved) return; + const result = await get( + `/api/lanes/${resolved.laneId}/ci/status?ref=${encodeURIComponent(ref)}`, + undefined, + { allowError: true } + ); + if (result.status) { + console.error(`✖ ci status → ${result.data?.error?.message || result.status}`); + process.exitCode = 1; + return; + } + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result.state === "success" ? 0 : result.state === "pending" ? 3 : 1; +} + /** * `ccam lanes pipeline [] []` — show or switch which pipeline * template a lane renders against. `ccam lanes add --pipeline` could only set @@ -2645,6 +2677,11 @@ const COMMAND_GROUPS = [ " []", "Check whether a named integration (tracker, dev_qc, ci_wait) is on for this lane — exit 0/1", ], + [ + "lanes ci status", + " []", + "One-shot GitHub Actions status for a ref — exit 0 success / 1 failure / 3 pending", + ], [ "lanes gc", "[--dry-run]", @@ -3482,6 +3519,9 @@ async function runCommand(argv) { if (rest[0] === "integration") { return cmdLanesIntegration(rest.slice(1)); } + if (rest[0] === "ci") { + return cmdLanesCi(rest.slice(1)); + } if (rest[0] === "pipeline") { return cmdLanesPipeline(rest.slice(1)); } diff --git a/docs/LANES.md b/docs/LANES.md index 38dfc5e6..031f795d 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -1127,7 +1127,7 @@ Copies `skills/ship-feature-lane/` into `~/.claude/skills/ship-feature-lane/` (o The skill frontloads all clarifying questions once, then runs unattended. The only interactive touchpoints are **Stage 0 (Q&A intake)** and **merging the PR on GitHub**; everything else runs to completion or escalates with a `blocked` status visible on the dashboard. Progress is declared via `ccam stage` commands at every step. -### Current status: stages 0–8, 10–12, 14 work; stages 9, 13 deferred; stages 6–7 agent-gated +### Current status: stages 0–8, 10–12, 14 work; stage 9 deferred, stage 13 partially wired (CI-wait works, dev-QC doesn't); stages 6–7 agent-gated **Stages implemented now:** - **0-8**: Intake & frontloaded Q&A → plan → implementation → independent unit tests (1b) → gates+preflight → e2e on feature branch → code review → QC plan → QC → senior gate → publish PR @@ -1136,7 +1136,9 @@ The skill frontloads all clarifying questions once, then runs unattended. The on **Stages currently hardcoded skipped** (pending F's integrations): - **9 (Ticket)**: Ticket-filing integration not yet built — this stage is skipped entirely. -- **13 (Post-merge verification)**: Dev-CI-wait and dev-QC integrations are off — the stage transitions immediately to `done` with a note explaining the gap. + +**Partially wired:** +- **13 (Post-merge verification)**: CI-wait half now runs for real via `ccam lanes ci status`. The dev-QC half (QCing the deployed site) is still unavailable — Stage 13 always ends noting that gap even when CI-wait passes clean. **Stages 6 and 7 are now unblocked** — run `ccam lanes agents install` once per lane (see "Installing the QC/gate agents" below) before a lane's first run through Stage 6. A lane that reaches Stage 6/7 without having installed the agents will fail to find the `qc-local`/`senior-gate-reviewer` subagent type; install and re-run. @@ -1195,7 +1197,7 @@ Writes both agent definitions (from the repo's own `agents/`, the same files the **Credentials are not embedded.** Unlike Shipyard's original per-lane agent generation, these templates carry no QA account credentials — this repo has no seeded-QA-account system yet. `qc-local` degrades gracefully: if it hits a login page with no credentials block present, it reports `LOCAL-QC: FAIL — login required, no seed-account mechanism configured for this profile` rather than guessing. -**`ticketer`, `dev-qc`, and `pr-reviewer`** are not ported yet — the first two are invoked only by Stages 9/13, which are hardcoded-skipped pending F's integrations; the third isn't referenced anywhere in this skill's text. +**`ticketer`, `dev-qc`, and `pr-reviewer`** are not ported yet — `ticketer` is invoked by Stage 9 (skipped), and `dev-qc` by Stage 13's dev-QC half (unavailable); `pr-reviewer` isn't referenced anywhere in this skill's text. ### Syncing MCP servers: mcp sync @@ -1221,7 +1223,17 @@ A profile can declare `.ccam/profile/integrations.env` with `TRACKER_ENABLED`, ` ccam lanes integration tracker # exit 0 = on, 1 = off ``` -This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agents that would act on an enabled toggle (`ticketer`, `dev-qc`) aren't built yet; `ci_wait`'s consumer (`ccam ci`) isn't either. `ship-feature-lane`'s Stage 9/13/10 stay skipped/fallback regardless of what the toggle reads, until those land. +This only reports what the PROFILE wants — it doesn't file a ticket, run dev-QC, or wait on CI. The agent that would act on an enabled `dev_qc` toggle isn't built yet, and neither is a `tracker` ticketer — `ship-feature-lane`'s Stage 9 stays skipped and Stage 13's dev-QC half stays unavailable regardless of what those two toggles read. `ci_wait`'s consumer is `ccam lanes ci status` (added — see the command reference below); Stage 13's CI-wait half now honors it for real. + +### Post-merge CI status: ccam lanes ci status + +Check GitHub Actions status for a commit SHA or branch ref — used by Stage 13's CI-wait loop: + +```bash +ccam lanes ci status [] +``` + +Exit codes: `0` = all checks passed, `1` = at least one check failed, `3` = checks pending or in progress. The `` parameter is optional and filters results to a specific lane if provided; if omitted, it's not needed. ### Pipeline template: ship-feature (16 node stages) diff --git a/server/__tests__/ci-status.test.js b/server/__tests__/ci-status.test.js new file mode 100644 index 00000000..9f447cc2 --- /dev/null +++ b/server/__tests__/ci-status.test.js @@ -0,0 +1,125 @@ +/** + * @file Tests for server/lib/ci-status.js against a fake `gh` binary that + * returns canned `run list`/`run view` JSON for each scenario this module + * has to classify: no run yet, an in-progress run, a green run, and a red + * run — plus branch-vs-sha ref detection. + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-")); +const { ciStatus } = require("../lib/ci-status"); + +/** + * Fake `gh` that branches on the subcommand: `run list` returns `listJson`, + * `run view` returns `viewJson`. Also records every invocation's args (one + * line per call, space-joined) to `$CALLS_LOG` so a test can assert which + * flag (--branch vs --commit) was used. + */ +function stubGh(listJson, viewJson) { + const bin = path.join(ROOT, `bin-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(bin, { recursive: true }); + const callsLog = path.join(bin, "calls.log"); + fs.writeFileSync(callsLog, ""); + const script = `#!/usr/bin/env bash +echo "$@" >> "${callsLog}" +if [ "$1" = "run" ] && [ "$2" = "list" ]; then + cat <<'EOF' +${JSON.stringify(listJson)} +EOF + exit 0 +fi +if [ "$1" = "run" ] && [ "$2" = "view" ]; then + cat <<'EOF' +${JSON.stringify(viewJson)} +EOF + exit 0 +fi +echo "unexpected gh invocation: $@" >&2 +exit 1 +`; + fs.writeFileSync(path.join(bin, "gh"), script); + fs.chmodSync(path.join(bin, "gh"), 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + return { + calls: () => fs.readFileSync(callsLog, "utf8").trim().split("\n").filter(Boolean), + restore: () => { + process.env.PATH = originalPath; + }, + }; +} + +after(() => fs.rmSync(ROOT, { recursive: true, force: true })); + +describe("ciStatus()", () => { + it("reports pending when no run exists yet for the ref", async () => { + const stub = stubGh([], {}); + try { + const result = await ciStatus(ROOT, "development"); + assert.deepEqual(result, { state: "pending", ref: "development", checks: [] }); + } finally { + stub.restore(); + } + }); + + it("reports pending when the latest run is still in progress", async () => { + const stub = stubGh([{ databaseId: 1, status: "in_progress", conclusion: null }], { + jobs: [{ name: "server-tests", status: "in_progress", conclusion: null, url: null }], + }); + try { + const result = await ciStatus(ROOT, "development"); + assert.equal(result.state, "pending"); + } finally { + stub.restore(); + } + }); + + it("reports success when the latest run completed clean", async () => { + const stub = stubGh([{ databaseId: 2, status: "completed", conclusion: "success" }], { + jobs: [ + { name: "server-tests", status: "completed", conclusion: "success", url: "https://x/1" }, + { name: "client-tests", status: "completed", conclusion: "skipped", url: "https://x/2" }, + ], + }); + try { + const result = await ciStatus(ROOT, "development"); + assert.equal(result.state, "success"); + assert.equal(result.checks.length, 2); + } finally { + stub.restore(); + } + }); + + it("reports failure when any job failed", async () => { + const stub = stubGh([{ databaseId: 3, status: "completed", conclusion: "failure" }], { + jobs: [ + { name: "server-tests", status: "completed", conclusion: "failure", url: "https://x/3" }, + ], + }); + try { + const result = await ciStatus(ROOT, "development"); + assert.equal(result.state, "failure"); + assert.equal(result.checks[0].conclusion, "failure"); + } finally { + stub.restore(); + } + }); + + it("uses --commit for a sha-shaped ref and --branch otherwise", async () => { + const stub = stubGh([], {}); + try { + await ciStatus(ROOT, "a1b2c3d"); + await ciStatus(ROOT, "feat/some-slug"); + const calls = stub.calls().filter((line) => line.startsWith("run list")); + assert.match(calls[0], /--commit a1b2c3d/); + assert.match(calls[1], /--branch feat\/some-slug/); + } finally { + stub.restore(); + } + }); +}); diff --git a/server/__tests__/gh.test.js b/server/__tests__/gh.test.js new file mode 100644 index 00000000..8e5691dd --- /dev/null +++ b/server/__tests__/gh.test.js @@ -0,0 +1,65 @@ +/** + * @file Tests for server/lib/gh.js's execFile wrapper against real fake `gh` + * scripts on PATH — the same fixture style server/__tests__/lane-lifecycle.test.js + * uses for a fake `claude` binary, since this codebase's server tests use real + * subprocesses instead of mocking child_process. + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, after } = require("node:test"); +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); + +const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-gh-")); +const { gh } = require("../lib/gh"); + +/** Puts a fake `gh` script on PATH for the duration of one test. */ +function stubGh(scriptBody) { + const bin = path.join(ROOT, `bin-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(bin, { recursive: true }); + const bin_gh = path.join(bin, "gh"); + fs.writeFileSync(bin_gh, `#!/usr/bin/env bash\n${scriptBody}\n`); + fs.chmodSync(bin_gh, 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + return () => { + process.env.PATH = originalPath; + }; +} + +after(() => fs.rmSync(ROOT, { recursive: true, force: true })); + +describe("gh()", () => { + it("returns stdout on success", async () => { + const restore = stubGh('echo "$@" >&2\necho \'{"ok":true}\''); + try { + const result = await gh(ROOT, ["run", "list"]); + assert.match(result.stdout, /"ok":true/); + } finally { + restore(); + } + }); + + it("throws with .gh details on a non-zero exit", async () => { + const restore = stubGh('echo "boom" >&2\nexit 7'); + try { + await assert.rejects( + () => gh(ROOT, ["run", "list"]), + (err) => err.gh.code === 7 && /boom/.test(err.gh.stderr) + ); + } finally { + restore(); + } + }); + + it("throws ENOGH when gh is not on PATH", async () => { + const originalPath = process.env.PATH; + process.env.PATH = ROOT; // a directory with no gh binary in it + try { + await assert.rejects(() => gh(ROOT, ["run", "list"]), { code: "ENOGH" }); + } finally { + process.env.PATH = originalPath; + } + }); +}); diff --git a/server/__tests__/lane-agents.test.js b/server/__tests__/lane-agents.test.js index 67f90caa..06cb7dbc 100644 --- a/server/__tests__/lane-agents.test.js +++ b/server/__tests__/lane-agents.test.js @@ -22,13 +22,29 @@ const { installAgents } = require("../lib/lane-agents"); after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true })); +// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this +// very suite) sets in its own environment — those leak to every child +// process and override an explicit `cwd`, so without this `git init` here +// operates on the outer repo's .git instead of this fixture's throwaway one. +// Same scrub `lanes-cli.test.js` and `worktree.js`'s own git() helper use. +const GIT_ENV = { ...process.env }; +delete GIT_ENV.GIT_DIR; +delete GIT_ENV.GIT_WORK_TREE; +delete GIT_ENV.GIT_INDEX_FILE; +delete GIT_ENV.GIT_COMMON_DIR; +delete GIT_ENV.GIT_OBJECT_DIRECTORY; +delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES; +delete GIT_ENV.GIT_PREFIX; +delete GIT_ENV.GIT_NAMESPACE; +delete GIT_ENV.GIT_CONFIG_PARAMETERS; + let laneSeq = 0; /** Create a real git repo dir (used as a lane's cwd). */ function makeRepoDir() { const cwd = path.join(SUITE_ROOT, `repo-${++laneSeq}`); fs.mkdirSync(cwd, { recursive: true }); - execFileSync("git", ["init", "--initial-branch=main"], { cwd, stdio: "pipe" }); + execFileSync("git", ["init", "--initial-branch=main"], { cwd, stdio: "pipe", env: GIT_ENV }); return cwd; } diff --git a/server/__tests__/lanes-api.test.js b/server/__tests__/lanes-api.test.js index db346405..740c1301 100644 --- a/server/__tests__/lanes-api.test.js +++ b/server/__tests__/lanes-api.test.js @@ -9,6 +9,7 @@ const assert = require("node:assert/strict"); const path = require("path"); const os = require("os"); const http = require("http"); +const fs = require("fs"); const { WebSocket } = require("ws"); const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-api-${Date.now()}-${process.pid}.db`); @@ -928,3 +929,62 @@ describe("detection attributes work to the lane the work touched", () => { await request("DELETE", `/api/lanes/${session.body.lane.id}`); }); }); + +describe("GET /:id/ci/status", () => { + let laneId; + let laneCwd; + let restorePath; + let bin; + + before(async () => { + laneCwd = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-route-")); + const r = await request("POST", "/api/lanes", { title: "CI status lane", cwd: laneCwd }); + laneId = r.body.lane.id; + + bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-bin-")); + fs.writeFileSync( + path.join(bin, "gh"), + `#!/usr/bin/env bash +if [ "$1" = "run" ] && [ "$2" = "list" ]; then + echo '[{"databaseId":1,"status":"completed","conclusion":"success"}]' + exit 0 +fi +if [ "$1" = "run" ] && [ "$2" = "view" ]; then + echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"success","url":"https://x/1"}]}' + exit 0 +fi +exit 1 +` + ); + fs.chmodSync(path.join(bin, "gh"), 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + restorePath = () => { + process.env.PATH = originalPath; + }; + }); + + after(() => { + if (restorePath) restorePath(); + fs.rmSync(laneCwd, { recursive: true, force: true }); + fs.rmSync(bin, { recursive: true, force: true }); + }); + + it("404s for an unknown lane", async () => { + const r = await request("GET", "/api/lanes/999999/ci/status?ref=development"); + assert.equal(r.status, 404); + }); + + it("400s when ref is missing", async () => { + const r = await request("GET", `/api/lanes/${laneId}/ci/status`); + assert.equal(r.status, 400); + assert.equal(r.body.error.code, "EBADREF"); + }); + + it("returns the derived state for a real ref", async () => { + const r = await request("GET", `/api/lanes/${laneId}/ci/status?ref=development`); + assert.equal(r.status, 200); + assert.equal(r.body.state, "success"); + assert.equal(r.body.checks[0].name, "tests"); + }); +}); diff --git a/server/__tests__/lanes-cli.test.js b/server/__tests__/lanes-cli.test.js index 44d1f026..5d2d5905 100644 --- a/server/__tests__/lanes-cli.test.js +++ b/server/__tests__/lanes-cli.test.js @@ -126,11 +126,11 @@ function get(urlPath) { // spawnSync would stall the event loop and the CLI child's request to // 127.0.0.1 would never be served — a deadlock that looks like a network // sandbox blocking loopback. -function cli(args, cwd) { +function cli(args, cwd, extraEnv = {}) { return new Promise((resolve, reject) => { const child = spawn(process.execPath, [CLI, ...args], { cwd, - env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(server.address().port) }, + env: { ...process.env, CLAUDE_DASHBOARD_PORT: String(server.address().port), ...extraEnv }, }); let stdout = ""; let stderr = ""; @@ -573,3 +573,81 @@ describe("ccam ship-full / ccam ship-quick", () => { assert.match(q.stderr, /usage: ccam ship-quick/); }); }); + +describe("ccam lanes ci status", () => { + let restorePath; + let bin; + + before(() => { + bin = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-ci-status-cli-bin-")); + fs.writeFileSync( + path.join(bin, "gh"), + `#!/usr/bin/env bash +if [ "$1" = "run" ] && [ "$2" = "list" ]; then + if [ "$CI_STATUS_FIXTURE" = "failure" ]; then + echo '[{"databaseId":1,"status":"completed","conclusion":"failure"}]' + elif [ "$CI_STATUS_FIXTURE" = "pending" ]; then + echo '[]' + else + echo '[{"databaseId":1,"status":"completed","conclusion":"success"}]' + fi + exit 0 +fi +if [ "$1" = "run" ] && [ "$2" = "view" ]; then + if [ "$CI_STATUS_FIXTURE" = "failure" ]; then + echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"failure","url":"https://x/1"}]}' + else + echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"success","url":"https://x/1"}]}' + fi + exit 0 +fi +exit 1 +` + ); + fs.chmodSync(path.join(bin, "gh"), 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + restorePath = () => { + process.env.PATH = originalPath; + }; + }); + + after(() => { + if (restorePath) restorePath(); + fs.rmSync(bin, { recursive: true, force: true }); + }); + + it("exits 0 and prints success state", async () => { + const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR); + assert.equal(r.status, 0); + assert.match(r.stdout, /"state": "success"/); + }); + + it("exits 1 on a failed run", async () => { + const orig = process.env.CI_STATUS_FIXTURE; + try { + process.env.CI_STATUS_FIXTURE = "failure"; + const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, { + CI_STATUS_FIXTURE: "failure", + }); + assert.equal(r.status, 1); + assert.match(r.stdout, /"state": "failure"/); + } finally { + process.env.CI_STATUS_FIXTURE = orig; + } + }); + + it("exits 3 when no run exists yet", async () => { + const orig = process.env.CI_STATUS_FIXTURE; + try { + process.env.CI_STATUS_FIXTURE = "pending"; + const r = await cli(["lanes", "ci", "status", "development"], LANE_DIR, { + CI_STATUS_FIXTURE: "pending", + }); + assert.equal(r.status, 3); + assert.match(r.stdout, /"state": "pending"/); + } finally { + process.env.CI_STATUS_FIXTURE = orig; + } + }); +}); diff --git a/server/lib/ci-status.js b/server/lib/ci-status.js new file mode 100644 index 00000000..f2c8250d --- /dev/null +++ b/server/lib/ci-status.js @@ -0,0 +1,61 @@ +/** + * @file Turns a GitHub Actions run for one ref into a single derived fact — + * {state, checks} — for ship-feature-lane's Stage 13 CI-wait. Deliberately a + * snapshot, not a poll loop: the driving skill owns the wait/retry decision + * (docs/LANES.md "Orchestration: what CCAM does NOT do"), this module only + * reports what gh already knows. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { gh } = require("./gh"); + +/** A 7-40 char hex string reads as a commit sha; anything else is a branch name. */ +const SHA_RE = /^[0-9a-f]{7,40}$/i; + +/** + * @param {string} cwd - The lane's repo directory (gh resolves the GitHub + * repo from this directory's git remote). + * @param {string} ref - A branch name or commit sha. + * @returns {Promise<{state: "success"|"failure"|"pending", ref: string, checks: Array}>} + */ +async function ciStatus(cwd, ref) { + const refFlag = SHA_RE.test(ref) ? "--commit" : "--branch"; + const { stdout: listOut } = await gh(cwd, [ + "run", + "list", + refFlag, + ref, + "--json", + "databaseId,status,conclusion", + "--limit", + "1", + ]); + const runs = JSON.parse(listOut); + if (runs.length === 0) { + return { state: "pending", ref, checks: [] }; + } + const run = runs[0]; + if (run.status !== "completed") { + return { state: "pending", ref, checks: [] }; + } + + const { stdout: viewOut } = await gh(cwd, [ + "run", + "view", + String(run.databaseId), + "--json", + "jobs", + ]); + const { jobs } = JSON.parse(viewOut); + const checks = jobs.map((job) => ({ + name: job.name, + conclusion: job.conclusion || job.status, + url: job.url || null, + })); + + const FAILING = new Set(["failure", "cancelled", "timed_out", "action_required"]); + const state = checks.some((c) => FAILING.has(c.conclusion)) ? "failure" : "success"; + return { state, ref, checks }; +} + +module.exports = { ciStatus }; diff --git a/server/lib/gh.js b/server/lib/gh.js new file mode 100644 index 00000000..e662a969 --- /dev/null +++ b/server/lib/gh.js @@ -0,0 +1,36 @@ +/** + * @file Thin execFile wrapper around the GitHub CLI (`gh`), used by + * server/lib/ci-status.js to read Actions run state. Mirrors worktree.js's + * git() helper's shape (cwd-scoped, throws with structured .gh details on + * failure) but has no env-scrubbing to do — gh doesn't read GIT_* variables. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { execFile } = require("node:child_process"); +const { promisify } = require("node:util"); + +const execFileAsync = promisify(execFile); + +/** + * Run `gh ` in `cwd`. Resolves `{ stdout, stderr }` on a zero exit. + * On failure, throws an Error with `.gh = { args, code, stderr }` — `.code` + * is `"ENOGH"` when the binary itself is missing, otherwise the process's + * exit code. + * + * @param {string} cwd + * @param {string[]} args + * @returns {Promise<{stdout: string, stderr: string}>} + */ +async function gh(cwd, args) { + try { + const result = await execFileAsync("gh", args, { cwd, maxBuffer: 8 * 1024 * 1024 }); + return { stdout: result.stdout, stderr: result.stderr }; + } catch (err) { + const error = new Error(`gh ${args[0]} failed`); + error.code = err.code === "ENOENT" ? "ENOGH" : err.code; + error.gh = { args, code: err.code, stderr: err.stderr || "" }; + throw error; + } +} + +module.exports = { gh }; diff --git a/server/routes/lanes.js b/server/routes/lanes.js index 0ef1a888..7f32ba17 100644 --- a/server/routes/lanes.js +++ b/server/routes/lanes.js @@ -50,6 +50,7 @@ const { } = require("../lib/lane-runtime"); const { reapOrphanMcp, capOversizedLogs } = require("../lib/lane-gc"); const { detectNode, scaffoldProfile } = require("../lib/lane-detect"); +const { ciStatus } = require("../lib/ci-status"); const router = Router(); const MAX_WORKTREE_DIRECTORY_ATTEMPTS = 50; @@ -520,6 +521,27 @@ router.get("/:id/git", async (req, res) => { } }); +/** + * A single GitHub Actions status snapshot for one ref (branch or commit sha) + * in the lane's repo — NOT a poll loop. ship-feature-lane's Stage 13 owns + * the wait/retry decision (docs/LANES.md "Orchestration: what CCAM does NOT + * do"); this route only reports what `gh` currently knows. Read-only, no + * same-origin guard, same reasoning as GET /:id/git. + */ +router.get("/:id/ci/status", async (req, res) => { + const lane = laneOr404(req, res); + if (!lane) return; + const ref = typeof req.query.ref === "string" ? req.query.ref.trim() : ""; + if (!ref) { + return res.status(400).json({ error: { code: "EBADREF", message: "ref is required" } }); + } + try { + res.json(await ciStatus(lane.cwd, ref)); + } catch (err) { + sendRuntimeError(res, err); + } +}); + /** * Create a dashboard-managed worktree lane. Git work happens after the 202 * response because provisioning a large repository can take seconds. diff --git a/skills/ship-feature-lane/SKILL.md b/skills/ship-feature-lane/SKILL.md index ad9c06d4..2c027673 100644 --- a/skills/ship-feature-lane/SKILL.md +++ b/skills/ship-feature-lane/SKILL.md @@ -1,12 +1,20 @@ --- name: ship-feature-lane -description: "Autonomous end-to-end feature pipeline for ONE CCAM lane. Invoke inside a lane's working directory with a requirement: `/ship-feature-lane `. Frontloads ALL clarifying questions once, then runs unattended: implement (TDD) → independent unit-test agent → pre-push CI gates + dev preflight → e2e on the feature branch (agent-run) → code review (fresh-context agent) → local QC → senior GO/NO-GO gate → push branch + open PR (base `development`) → CI watch → report, then watches the PR (new comments → gated fix-loop; base conflicts → sync development into the branch) until a HUMAN merges it. The PR is only published after all local gates pass and the senior gate says GO — it's finalized when reviewers see it. Declares lane stage at every step via `ccam stage` for the dashboard. Use when the user wants to build/ship/implement a feature in a CCAM lane. NOTE: ticket-filing and post-merge dev-site QC are currently OFF (F's integrations aren't built yet) — this pipeline stops at Stage 14 once dev CI/dev-QC support lands." +description: "Autonomous end-to-end feature pipeline for ONE CCAM lane. Invoke inside a lane's working directory with a requirement: `/ship-feature-lane `. Frontloads ALL clarifying questions once, then runs unattended: implement (TDD) → independent unit-test agent → pre-push CI gates + dev preflight → e2e on the feature branch (agent-run) → code review (fresh-context agent) → local QC → senior GO/NO-GO gate → push branch + open PR (base `development`) → CI watch → report, then watches the PR (new comments → gated fix-loop; base conflicts → sync development into the branch) until a HUMAN merges it. The PR is only published after all local gates pass and the senior gate says GO — it's finalized when reviewers see it. Declares lane stage at every step via `ccam stage` for the dashboard. Use when the user wants to build/ship/implement a feature in a CCAM lane. NOTE: ticket-filing (Stage 9) and post-merge dev-site QC (Stage 13's second half) are currently OFF (F's integrations aren't built yet) — Stage 13's CI-wait half now runs via ccam lanes ci status." --- # Ship Feature Lane (CCAM lane pipeline) You are running the autonomous feature pipeline for **one CCAM lane**. The human's only interactive touchpoints are **Stage 0 (frontloaded Q&A)** and **merging the PR on GitHub**; everything else runs to completion or to a `blocked` escalation, reporting progress through `ccam stage` (which the dashboard renders). +## Current status + +**Stages currently hardcoded skipped** (pending F's integrations): +- **9 (Ticket)**: Ticket-filing integration not yet built — this stage is skipped entirely. + +**Partially wired:** +- **13 (Post-merge verification)**: CI-wait half now runs for real via `ccam lanes ci status`. The dev-QC half (QCing the deployed site) is still unavailable — Stage 13 always ends noting that gap even when CI-wait passes clean. + ## Setup — do this first, every run ```bash @@ -192,13 +200,18 @@ The e2e hook doesn't run migrations itself — it tests the already-running stac - **Sign every reply** with a distinct attribution — end each posted body, on its own line, with: `— 🤖 ship-feature-lane pipeline`. - Nothing new → background another `sleep 300` (`run_in_background: true`) and end the turn; you'll be re-invoked for the next poll. A PR can sit for days — that's fine. -### 13 — Post-merge verification *(currently SKIPPED — CI-wait and dev-QC integrations are off)* -- `ccam stage merged --status running --note "PR merged — post-merge verification unavailable (F not built)"` -- Both dev-CI-wait and dev-QC are hardcoded off (see Setup). Go straight to Stage 14. When F ships these integrations, this stage gets its real implementation (mirroring Shipyard's: dev CI watch, then a `dev-qc` agent QCing the deployed site, looping to a Stage 15 follow-up-fix pattern on any issue found). +### 13 — Post-merge verification *(CI-wait works; dev-QC still unavailable)* +- `ccam stage merged --status running --note "watching development CI post-merge"` +- Get the merge commit: `gh pr view --json mergeCommit -q .mergeCommit.oid`. +- Loop every ~5 minutes, paced by a **backgrounded** wait (same shape as Stage 12): `sleep 300` with `run_in_background: true`. Each iteration, bump the heartbeat and check: + - `ccam lanes ci status ` — exit `3` (pending) → keep waiting, background another `sleep 300`, end the turn. + - Exit `1` (failure) → triage exactly like Stage 10 (read the failing job via `gh run view ` — the run id is embedded in each failing check's `url` in the command's JSON output; distinguish real failure from shared-runner flake the same way). A real failure on `development` post-merge is a regression the fix-loop doesn't reach (the PR is already merged) — `ccam stage merged --status blocked --note " red on development after merge — human decision"` and STOP; this is not something the pipeline auto-fixes on a shared branch. + - Exit `0` (success) → CI-wait passed. Dev-QC (QCing the deployed site) is a separate, not-yet-built integration — `ccam stage merged --status passed --note "CI green on development; dev-QC unavailable (separate task)"` and proceed to Stage 14. +- If `ccam lanes integration ci_wait` reads off for this profile, skip the loop entirely and go straight to Stage 14 with `--note "ci_wait integration off for this profile"` — the toggle means the profile doesn't want this check, not that the command is unavailable. ### 14 — Done -- Post the final report: PR merged, that post-merge verification is unavailable pending F. -- `ccam stage done --status passed --note "merged; post-merge verification unavailable (F not built)"` → STOP (leave the lane for the human to clear from the dashboard whenever). +- Post the final report: PR merged, that dev-QC (deployed-site QC) is unavailable pending F; CI-wait already ran in Stage 13. +- `ccam stage done --status passed --note "merged; dev-QC unavailable (F not built)"` → STOP (leave the lane for the human to clear from the dashboard whenever). ## Escalation Whenever you STOP early (an ambiguous merge conflict, an unexpected/unrecoverable failure, a missing agent this skill depends on), set `ccam stage --status blocked --note ""` — the dashboard surfaces it. Then summarize for the human and wait.