feat(server): derive CI state from a single gh run snapshot
This commit is contained in:
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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ĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
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 };
|
||||
Reference in New Issue
Block a user