From a2fcd9bafc4e7a7283855c743a0d966e75c92126 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 09:33:26 +0700 Subject: [PATCH] feat(cli): add ccam lanes ci status --- bin/ccam.js | 40 +++++++++++++++ server/__tests__/lanes-cli.test.js | 78 +++++++++++++++++++++++++++++- server/lib/gh.js | 6 ++- 3 files changed, 121 insertions(+), 3 deletions(-) diff --git a/bin/ccam.js b/bin/ccam.js index 189647c1..247f9098 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 @@ -2552,6 +2584,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]", @@ -3389,6 +3426,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/server/__tests__/lanes-cli.test.js b/server/__tests__/lanes-cli.test.js index f3fa85b1..bb592a81 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 = ""; @@ -542,3 +542,77 @@ describe("ccam lanes — inferred stage detection", () => { } }); }); + +describe("ccam lanes ci status", () => { + let restorePath; + + before(() => { + const 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(() => restorePath && restorePath()); + + 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/gh.js b/server/lib/gh.js index e662a969..a50cf890 100644 --- a/server/lib/gh.js +++ b/server/lib/gh.js @@ -23,7 +23,11 @@ const execFileAsync = promisify(execFile); */ async function gh(cwd, args) { try { - const result = await execFileAsync("gh", args, { cwd, maxBuffer: 8 * 1024 * 1024 }); + const result = await execFileAsync("gh", args, { + cwd, + env: process.env, + maxBuffer: 8 * 1024 * 1024, + }); return { stdout: result.stdout, stderr: result.stderr }; } catch (err) { const error = new Error(`gh ${args[0]} failed`);