From 8c76ce708e48a9eacc3f17be4e6cc99496390c44 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Tue, 25 Aug 2026 09:21:25 +0700 Subject: [PATCH] feat(server): derive CI state from a single gh run snapshot --- server/__tests__/ci-status.test.js | 125 +++++++++++++++++++++++++++++ server/lib/ci-status.js | 61 ++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 server/__tests__/ci-status.test.js create mode 100644 server/lib/ci-status.js 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/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 };