fix(tools): corrected run collection outcome classification on job failure

- Added early exit in getRunCollectionOutcome when any job fails, preventing incorrect success classification.
This commit is contained in:
can1357
2026-04-02 06:54:26 +02:00
parent 279ae220a4
commit ff86b21e8a
2 changed files with 276 additions and 0 deletions
+4
View File
@@ -1085,6 +1085,10 @@ function getRunCollectionOutcome(runs: GhRunSnapshot[]): "success" | "failure" |
let pending = false;
for (const run of runs) {
if (run.jobs.some(isFailedJob)) {
return "failure";
}
const outcome = getRunSnapshotOutcome(run);
if (outcome === "failure") {
return "failure";
+272
View File
@@ -124,6 +124,29 @@ async function createPrFixture(): Promise<{
};
}
async function createGitRepoFixture(): Promise<{
baseDir: string;
repoRoot: string;
headSha: string;
}> {
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "gh-run-watch-repo-"));
const repoRoot = path.join(baseDir, "repo");
await fs.mkdir(repoRoot, { recursive: true });
runGit(baseDir, ["init", "-b", "main", repoRoot]);
runGit(repoRoot, ["config", "user.name", "Test User"]);
runGit(repoRoot, ["config", "user.email", "test@example.com"]);
await fs.writeFile(path.join(repoRoot, "README.md"), "watch me\n");
runGit(repoRoot, ["add", "README.md"]);
runGit(repoRoot, ["commit", "-m", "initial commit"]);
return {
baseDir,
repoRoot,
headSha: runGit(repoRoot, ["rev-parse", "HEAD"]),
};
}
describe("GitHub CLI tools", () => {
afterEach(() => {
vi.useRealTimers();
@@ -491,4 +514,253 @@ describe("GitHub CLI tools", () => {
await fs.rm(artifactsDir, { recursive: true, force: true });
}
});
it("fast-fails commit watch after the grace window once any job fails", async () => {
vi.useFakeTimers();
const fixture = await createGitRepoFixture();
const repo = "owner/repo";
const runId = 88;
const updates: string[] = [];
const runListResponses = [
{
total_count: 1,
workflow_runs: [
{
id: runId,
name: "CI",
display_title: "main checks",
status: "in_progress",
conclusion: null,
head_branch: "main",
head_sha: fixture.headSha,
created_at: "2026-04-01T08:00:00Z",
updated_at: "2026-04-01T08:01:00Z",
html_url: `https://github.com/${repo}/actions/runs/${runId}`,
},
],
},
{
total_count: 1,
workflow_runs: [
{
id: runId,
name: "CI",
display_title: "main checks",
status: "in_progress",
conclusion: null,
head_branch: "main",
head_sha: fixture.headSha,
created_at: "2026-04-01T08:00:00Z",
updated_at: "2026-04-01T08:02:00Z",
html_url: `https://github.com/${repo}/actions/runs/${runId}`,
},
],
},
{
total_count: 1,
workflow_runs: [
{
id: runId,
name: "CI",
display_title: "main checks",
status: "in_progress",
conclusion: null,
head_branch: "main",
head_sha: fixture.headSha,
created_at: "2026-04-01T08:00:00Z",
updated_at: "2026-04-01T08:03:00Z",
html_url: `https://github.com/${repo}/actions/runs/${runId}`,
},
],
},
];
const jobResponses = [
{
total_count: 3,
jobs: [
{
id: 301,
name: "build",
status: "completed",
conclusion: "success",
started_at: "2026-04-01T08:00:00Z",
completed_at: "2026-04-01T08:01:00Z",
html_url: `https://github.com/${repo}/actions/runs/${runId}/job/301`,
},
{
id: 302,
name: "test",
status: "in_progress",
conclusion: null,
started_at: "2026-04-01T08:00:00Z",
completed_at: null,
html_url: `https://github.com/${repo}/actions/runs/${runId}/job/302`,
},
{
id: 303,
name: "lint",
status: "queued",
conclusion: null,
started_at: null,
completed_at: null,
html_url: `https://github.com/${repo}/actions/runs/${runId}/job/303`,
},
],
},
{
total_count: 3,
jobs: [
{
id: 301,
name: "build",
status: "completed",
conclusion: "success",
started_at: "2026-04-01T08:00:00Z",
completed_at: "2026-04-01T08:01:00Z",
html_url: `https://github.com/${repo}/actions/runs/${runId}/job/301`,
},
{
id: 302,
name: "test",
status: "completed",
conclusion: "failure",
started_at: "2026-04-01T08:00:00Z",
completed_at: "2026-04-01T08:02:00Z",
html_url: `https://github.com/${repo}/actions/runs/${runId}/job/302`,
},
{
id: 303,
name: "lint",
status: "in_progress",
conclusion: null,
started_at: "2026-04-01T08:01:30Z",
completed_at: null,
html_url: `https://github.com/${repo}/actions/runs/${runId}/job/303`,
},
],
},
{
total_count: 3,
jobs: [
{
id: 301,
name: "build",
status: "completed",
conclusion: "success",
started_at: "2026-04-01T08:00:00Z",
completed_at: "2026-04-01T08:01:00Z",
html_url: `https://github.com/${repo}/actions/runs/${runId}/job/301`,
},
{
id: 302,
name: "test",
status: "completed",
conclusion: "failure",
started_at: "2026-04-01T08:00:00Z",
completed_at: "2026-04-01T08:02:00Z",
html_url: `https://github.com/${repo}/actions/runs/${runId}/job/302`,
},
{
id: 303,
name: "lint",
status: "completed",
conclusion: "failure",
started_at: "2026-04-01T08:01:30Z",
completed_at: "2026-04-01T08:03:00Z",
html_url: `https://github.com/${repo}/actions/runs/${runId}/job/303`,
},
],
},
];
vi.spyOn(ghCli, "runGhText").mockImplementation(async (_cwd, args) => {
if (args.join(" ") === "repo view --json nameWithOwner -q .nameWithOwner") {
return repo;
}
throw new Error(`Unexpected runGhText args: ${args.join(" ")}`);
});
const runGhJsonSpy = vi.spyOn(ghCli, "runGhJson").mockImplementation(async (_cwd, args) => {
const endpoint = args[3];
if (args[0] === "api" && endpoint === `/repos/${repo}/actions/runs`) {
const response = runListResponses.shift();
if (!response) {
throw new Error("No more run list responses available");
}
return response as never;
}
if (args[0] === "api" && endpoint === `/repos/${repo}/actions/runs/${runId}/jobs`) {
const response = jobResponses.shift();
if (!response) {
throw new Error("No more job responses available");
}
return response as never;
}
throw new Error(`Unexpected runGhJson args: ${args.join(" ")}`);
});
vi.spyOn(ghCli, "runGhCommand").mockImplementation(async (_cwd, args) => {
if (args[1] === `/repos/${repo}/actions/jobs/302/logs`) {
return {
exitCode: 0,
stdout: "setup\ncompile\nassertion failed\ntrace line",
stderr: "",
};
}
if (args[1] === `/repos/${repo}/actions/jobs/303/logs`) {
return {
exitCode: 0,
stdout: "prepare\nlint\nstyle failure\nsecond failure",
stderr: "",
};
}
throw new Error(`Unexpected runGhCommand args: ${args.join(" ")}`);
});
try {
const tool = new GhRunWatchTool(createSession(fixture.repoRoot));
const resultPromise = tool.execute("run-watch", { tail: 2 }, undefined, update => {
const text = update.content[0];
if (text?.type === "text") {
updates.push(text.text);
}
});
await vi.advanceTimersByTimeAsync(8000);
const settled = await Promise.race([
resultPromise.then(result => ({ done: true as const, result })),
Promise.resolve({ done: false as const }),
]);
expect(settled.done).toBe(true);
if (!settled.done) {
return;
}
const text = settled.result.content[0]?.type === "text" ? settled.result.content[0].text : "";
expect(text).toContain(`# GitHub Actions for ${fixture.headSha.slice(0, 12)}`);
expect(text).toContain(`Commit: ${fixture.headSha}`);
expect(text).toContain("Status: in_progress");
expect(text).toContain("### test [failure]");
expect(text).toContain("assertion failed");
expect(text).toContain("trace line");
expect(text).toContain("### lint [failure]");
expect(text).toContain("style failure");
expect(text).toContain("second failure");
expect(text).toContain("Workflow runs for this commit failed.");
expect(
updates.some(text =>
text.includes("Failure detected. Waiting 5s to capture concurrent failures before fetching logs."),
),
).toBe(true);
expect(runGhJsonSpy.mock.calls.filter(([, args]) => args[3] === `/repos/${repo}/actions/runs`)).toHaveLength(
3,
);
} finally {
await fs.rm(fixture.baseDir, { recursive: true, force: true });
}
});
});