Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/gittensory-miner/bin/gittensory-miner.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { createRequire } from "node:module";
import { printHelp, printVersion, runCli } from "../lib/cli.js";
import { runCiWait } from "../lib/ci-wait.js";
import {
awaitOpportunisticUpdateCheck,
resolveUpgradeCommand,
Expand Down Expand Up @@ -41,6 +42,15 @@ if (
process.exit(0);
}

if (cliArgs[0] === "ci" && cliArgs[1] === "wait") {
const exitCode = await runCiWait(cliArgs.slice(2), {
packageName,
env: process.env,
});
await awaitOpportunisticUpdateCheck(updateCheck);
process.exit(exitCode);
}

const exitCode = runCli(cliArgs, { packageName });
await awaitOpportunisticUpdateCheck(updateCheck);
process.exit(exitCode);
17 changes: 17 additions & 0 deletions packages/gittensory-miner/lib/ci-wait.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export type ParsedCiWaitArgs =
| {
repoFullName: string;
prNumber: number;
json: boolean;
maxAttempts: number | undefined;
minIntervalMs: number | undefined;
maxIntervalMs: number | undefined;
}
| { error: string };

export function parseCiWaitArgs(args: string[]): ParsedCiWaitArgs;

export function runCiWait(
args: string[],
input: { env?: Record<string, string | undefined> },
): Promise<number>;
112 changes: 112 additions & 0 deletions packages/gittensory-miner/lib/ci-wait.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { pollCheckRuns } from "./ci-poller.js";

const CI_WAIT_USAGE =
"Usage: gittensory-miner ci wait <owner/repo> <pr-number> [--json] [--max-attempts N] [--min-interval-ms N] [--max-interval-ms N]";

function parsePositiveInt(flag, value) {
if (value === undefined) {
return { error: `Missing value for ${flag}.` };
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
return { error: `Invalid value for ${flag}: must be a positive integer.` };
}
return { value: parsed };
}

export function parseCiWaitArgs(args) {
const positional = [];
const options = {
json: false,
maxAttempts: undefined,
minIntervalMs: undefined,
maxIntervalMs: undefined,
};

for (let index = 0; index < args.length; index += 1) {
const token = args[index];
if (token === "--json") {
options.json = true;
continue;
}
if (token === "--max-attempts") {
const parsed = parsePositiveInt("--max-attempts", args[++index]);
if ("error" in parsed) return { error: parsed.error };
options.maxAttempts = parsed.value;
continue;
}
if (token === "--min-interval-ms") {
const parsed = parsePositiveInt("--min-interval-ms", args[++index]);
if ("error" in parsed) return { error: parsed.error };
options.minIntervalMs = parsed.value;
continue;
}
if (token === "--max-interval-ms") {
const parsed = parsePositiveInt("--max-interval-ms", args[++index]);
if ("error" in parsed) return { error: parsed.error };
options.maxIntervalMs = parsed.value;
continue;
}
if (token.startsWith("-")) {
return { error: `Unknown option: ${token}` };
}
positional.push(token);
}

if (positional.length !== 2) {
return { error: CI_WAIT_USAGE };
}

const prNumber = Number(positional[1]);
if (!Number.isInteger(prNumber) || prNumber <= 0) {
return { error: "PR number must be a positive integer." };
}

return {
repoFullName: positional[0],
prNumber,
...options,
};
}

export async function runCiWait(args, input) {
const parsed = parseCiWaitArgs(args);
if ("error" in parsed) {
console.error(parsed.error);
return 2;
}

const token = String(
input.env?.GITTENSOR_MINER_GITHUB_TOKEN ?? input.env?.GITHUB_TOKEN ?? "",
).trim();
if (!token) {
console.error("Missing GitHub token: set GITHUB_TOKEN or GITTENSOR_MINER_GITHUB_TOKEN.");
return 2;
}

try {
const result = await pollCheckRuns(parsed.repoFullName, parsed.prNumber, {
githubToken: token,
...(parsed.maxAttempts !== undefined ? { maxAttempts: parsed.maxAttempts } : {}),
...(parsed.minIntervalMs !== undefined ? { minIntervalMs: parsed.minIntervalMs } : {}),
...(parsed.maxIntervalMs !== undefined ? { maxIntervalMs: parsed.maxIntervalMs } : {}),
});

if (parsed.json) {
console.log(JSON.stringify(result));
} else {
const shortSha = result.headSha ? result.headSha.slice(0, 7) : "unknown";
console.error(
`CI ${result.conclusion} for ${parsed.repoFullName}#${parsed.prNumber} @ ${shortSha} (${result.attempts} attempt(s))`,
);
for (const check of result.checks) {
console.error(` ${check.name}: ${check.conclusion}`);
}
}

return result.conclusion === "success" ? 0 : 1;
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
return 1;
}
}
1 change: 1 addition & 0 deletions packages/gittensory-miner/lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export function printHelp(input) {
" gittensory-miner --version",
" gittensory-miner help",
" gittensory-miner version",
" gittensory-miner ci wait <owner/repo> <pr-number> [--json] [--max-attempts N] [--min-interval-ms N] [--max-interval-ms N]",
"",
"Options:",
" --no-update-check Skip the npm registry version nudge (also GITTENSORY_MINER_NO_UPDATE_CHECK=1)",
Expand Down
2 changes: 1 addition & 1 deletion packages/gittensory-miner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"lib"
],
"scripts": {
"build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js"
"build": "node --check bin/gittensory-miner.js && node --check lib/cli.js && node --check lib/ci-wait.js && node --check lib/update-check.js && node --check lib/opportunity-fanout.js && node --check lib/ci-poller.js && node --check lib/run-state.js"
},
"dependencies": {
"@jsonbored/gittensory-engine": "0.1.0"
Expand Down
110 changes: 110 additions & 0 deletions test/unit/miner-cli-ci-wait.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { describe, expect, it, vi } from "vitest";
import { parseCiWaitArgs, runCiWait } from "../../packages/gittensory-miner/lib/ci-wait.js";
import * as ciPoller from "../../packages/gittensory-miner/lib/ci-poller.js";

describe("gittensory-miner ci wait command", () => {
it("parseCiWaitArgs requires owner/repo and pr number", () => {
expect(parseCiWaitArgs([])).toEqual({
error: expect.stringContaining("Usage: gittensory-miner ci wait"),
});
expect(parseCiWaitArgs(["acme/widgets"])).toEqual({
error: expect.stringContaining("Usage: gittensory-miner ci wait"),
});
expect(parseCiWaitArgs(["acme/widgets", "0"])).toEqual({
error: "PR number must be a positive integer.",
});
expect(parseCiWaitArgs(["acme/widgets", "42", "--json"])).toEqual({
repoFullName: "acme/widgets",
prNumber: 42,
json: true,
maxAttempts: undefined,
minIntervalMs: undefined,
maxIntervalMs: undefined,
});
});

it("parseCiWaitArgs validates polling option flags", () => {
expect(parseCiWaitArgs(["acme/widgets", "42", "--max-attempts"])).toEqual({
error: "Missing value for --max-attempts.",
});
expect(parseCiWaitArgs(["acme/widgets", "42", "--max-attempts", "0"])).toEqual({
error: "Invalid value for --max-attempts: must be a positive integer.",
});
expect(parseCiWaitArgs(["acme/widgets", "42", "--min-interval-ms", "2500"])).toEqual({
repoFullName: "acme/widgets",
prNumber: 42,
json: false,
maxAttempts: undefined,
minIntervalMs: 2500,
maxIntervalMs: undefined,
});
});

it("parseCiWaitArgs rejects unknown flags", () => {
expect(parseCiWaitArgs(["acme/widgets", "42", "--wat"])).toEqual({
error: "Unknown option: --wat",
});
});

it("runCiWait returns exit code 2 when no GitHub token is configured", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
await expect(
runCiWait(["acme/widgets", "42"], { env: {} }),
).resolves.toBe(2);
expect(error).toHaveBeenCalledWith(
"Missing GitHub token: set GITHUB_TOKEN or GITTENSOR_MINER_GITHUB_TOKEN.",
);
});

it("runCiWait prints JSON and exits 0 when CI succeeds", async () => {
vi.spyOn(ciPoller, "pollCheckRuns").mockResolvedValue({
conclusion: "success",
checks: [{ name: "validate", status: "completed", conclusion: "success", detailsUrl: null, startedAt: null, completedAt: null }],
headSha: "abc1234567",
attempts: 1,
});
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
await expect(
runCiWait(["acme/widgets", "42", "--json", "--max-attempts", "3"], {
env: { GITHUB_TOKEN: "github-token" },
}),
).resolves.toBe(0);
expect(log).toHaveBeenCalledWith(
expect.stringContaining('"conclusion":"success"'),
);
expect(ciPoller.pollCheckRuns).toHaveBeenCalledWith("acme/widgets", 42, {
githubToken: "github-token",
maxAttempts: 3,
});
});

it("runCiWait returns exit code 2 for invalid polling flags", async () => {
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
await expect(
runCiWait(["acme/widgets", "42", "--max-attempts", "nope"], {
env: { GITHUB_TOKEN: "github-token" },
}),
).resolves.toBe(2);
expect(error).toHaveBeenCalledWith(
"Invalid value for --max-attempts: must be a positive integer.",
);
});

it("runCiWait exits 1 when CI fails", async () => {
vi.spyOn(ciPoller, "pollCheckRuns").mockResolvedValue({
conclusion: "failure",
checks: [],
headSha: "deadbeef",
attempts: 2,
});
const error = vi.spyOn(console, "error").mockImplementation(() => undefined);
await expect(
runCiWait(["acme/widgets", "7"], {
env: { GITTENSOR_MINER_GITHUB_TOKEN: "github-token" },
}),
).resolves.toBe(1);
expect(error).toHaveBeenCalledWith(
expect.stringContaining("CI failure for acme/widgets#7"),
);
});
});
1 change: 1 addition & 0 deletions test/unit/miner-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ describe("gittensory-miner CLI helpers", () => {
const text = log.mock.calls[0]?.[0];
expect(text).toContain("gittensory-miner --help");
expect(text).toContain("gittensory-miner version");
expect(text).toContain("gittensory-miner ci wait");
expect(text).toContain("--no-update-check");
});

Expand Down
Loading