pr-shepherd 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +14 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/marketplace.json +8 -0
- package/package.json +62 -0
- package/skills/check/SKILL.md +70 -0
- package/skills/monitor/SKILL.md +108 -0
- package/skills/resolve/SKILL.md +85 -0
- package/src/cache/file-cache.mts +101 -0
- package/src/cache/file-cache.test.mts +91 -0
- package/src/cache/fix-attempts.mts +86 -0
- package/src/checks/classify.mts +80 -0
- package/src/checks/classify.test.mts +164 -0
- package/src/checks/triage.mock.test.mts +202 -0
- package/src/checks/triage.mts +88 -0
- package/src/cli.mts +423 -0
- package/src/commands/check.mts +188 -0
- package/src/commands/iterate.mock.test.mts +1111 -0
- package/src/commands/iterate.mts +371 -0
- package/src/commands/ready-delay.mts +117 -0
- package/src/commands/ready-delay.test.mts +116 -0
- package/src/commands/resolve.mts +92 -0
- package/src/commands/status.mts +173 -0
- package/src/comments/outdated.mts +18 -0
- package/src/comments/resolve.mts +179 -0
- package/src/config/load.mts +240 -0
- package/src/config.json +52 -0
- package/src/github/batch.mts +351 -0
- package/src/github/client.mts +207 -0
- package/src/github/client.test.mts +19 -0
- package/src/github/gql/batch-pr.gql +130 -0
- package/src/github/gql/dismiss-review.gql +7 -0
- package/src/github/gql/minimize-comment.gql +7 -0
- package/src/github/gql/multi-pr-status-paged.gql +31 -0
- package/src/github/gql/multi-pr-status.gql +32 -0
- package/src/github/gql/resolve-thread.gql +7 -0
- package/src/github/pagination.mts +86 -0
- package/src/github/pagination.test.mts +140 -0
- package/src/github/queries.mts +30 -0
- package/src/index.mts +17 -0
- package/src/merge-status/derive.mts +74 -0
- package/src/merge-status/derive.test.mts +130 -0
- package/src/reporters/json.mts +12 -0
- package/src/reporters/text.mts +140 -0
- package/src/types.mts +309 -0
- package/src/util/path-segment.mts +2 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { cacheGet, cacheSet, type CacheKey } from "./file-cache.mts";
|
|
5
|
+
|
|
6
|
+
// Use a unique test prefix so runs never collide.
|
|
7
|
+
function testKey(shape = "test"): CacheKey {
|
|
8
|
+
return {
|
|
9
|
+
owner: "test-owner",
|
|
10
|
+
repo: "test-repo",
|
|
11
|
+
pr: Math.floor(Math.random() * 900000) + 100000,
|
|
12
|
+
shape,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let testCacheDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
// Point cache at a temp subdir isolated per test run.
|
|
20
|
+
testCacheDir = `${process.env["TMPDIR"] ?? "/tmp"}/shepherd-test-${randomBytes(4).toString("hex")}`;
|
|
21
|
+
process.env["PR_SHEPHERD_CACHE_DIR"] = testCacheDir;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
delete process.env["PR_SHEPHERD_CACHE_DIR"];
|
|
26
|
+
await rm(testCacheDir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("cacheGet / cacheSet", () => {
|
|
30
|
+
it("returns null on a cache miss", async () => {
|
|
31
|
+
const result = await cacheGet<string>(testKey());
|
|
32
|
+
expect(result).toBeNull();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns the stored value on a cache hit", async () => {
|
|
36
|
+
const key = testKey();
|
|
37
|
+
const value = { foo: "bar", n: 42 };
|
|
38
|
+
await cacheSet(key, value);
|
|
39
|
+
const result = await cacheGet<typeof value>(key);
|
|
40
|
+
expect(result).toEqual(value);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns null when the cache entry is expired", async () => {
|
|
44
|
+
const key = testKey();
|
|
45
|
+
await cacheSet(key, { data: "stale" });
|
|
46
|
+
// ttlSeconds=0 means the entry is immediately expired.
|
|
47
|
+
const result = await cacheGet(key, { ttlSeconds: 0 });
|
|
48
|
+
expect(result).toBeNull();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("returns the value within TTL", async () => {
|
|
52
|
+
const key = testKey();
|
|
53
|
+
await cacheSet(key, "fresh");
|
|
54
|
+
const result = await cacheGet<string>(key, { ttlSeconds: 60 });
|
|
55
|
+
expect(result).toBe("fresh");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns null when disabled", async () => {
|
|
59
|
+
const key = testKey();
|
|
60
|
+
await cacheSet(key, "should-not-be-returned", { disabled: false });
|
|
61
|
+
const result = await cacheGet(key, { disabled: true });
|
|
62
|
+
expect(result).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("does not write when disabled", async () => {
|
|
66
|
+
const key = testKey();
|
|
67
|
+
await cacheSet(key, "ignored", { disabled: true });
|
|
68
|
+
const result = await cacheGet(key);
|
|
69
|
+
expect(result).toBeNull();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("overwrites an existing cache entry", async () => {
|
|
73
|
+
const key = testKey();
|
|
74
|
+
await cacheSet(key, "first");
|
|
75
|
+
await cacheSet(key, "second");
|
|
76
|
+
const result = await cacheGet<string>(key);
|
|
77
|
+
expect(result).toBe("second");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("handles different shapes as separate entries", async () => {
|
|
81
|
+
const pr = Math.floor(Math.random() * 900000) + 100000;
|
|
82
|
+
const keyA: CacheKey = { owner: "o", repo: "r", pr, shape: "shape-a" };
|
|
83
|
+
const keyB: CacheKey = { owner: "o", repo: "r", pr, shape: "shape-b" };
|
|
84
|
+
|
|
85
|
+
await cacheSet(keyA, "valueA");
|
|
86
|
+
await cacheSet(keyB, "valueB");
|
|
87
|
+
|
|
88
|
+
expect(await cacheGet<string>(keyA)).toBe("valueA");
|
|
89
|
+
expect(await cacheGet<string>(keyB)).toBe("valueB");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent attempt counter for the iterate escalation guard.
|
|
3
|
+
*
|
|
4
|
+
* Tracks how many times each review thread has been dispatched to the fix_code
|
|
5
|
+
* handler without being resolved. Counts are reset automatically when the HEAD
|
|
6
|
+
* commit SHA changes (i.e. a new push landed).
|
|
7
|
+
*
|
|
8
|
+
* State lives in `$TMPDIR/pr-shepherd-cache/<owner>-<repo>/<pr>/fix-attempts.json`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFile, writeFile, rename, unlink, mkdir } from "node:fs/promises";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { join, dirname } from "node:path";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { SAFE_SEGMENT } from "../util/path-segment.mts";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Types
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
export interface FixAttemptsState {
|
|
22
|
+
/** HEAD SHA at the time the counts were last written. Reset key. */
|
|
23
|
+
headSha: string;
|
|
24
|
+
/** Map from thread ID → number of fix_code dispatches that included this thread. */
|
|
25
|
+
threadAttempts: Record<string, number>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface CacheKey {
|
|
29
|
+
owner: string;
|
|
30
|
+
repo: string;
|
|
31
|
+
pr: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Public API
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/** Read the current attempt state. Returns null on miss. */
|
|
39
|
+
export async function readFixAttempts(key: CacheKey): Promise<FixAttemptsState | null> {
|
|
40
|
+
try {
|
|
41
|
+
const raw = await readFile(resolvePath(key), "utf8");
|
|
42
|
+
return JSON.parse(raw) as FixAttemptsState;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Write attempt state (fire-and-forget — never throws). */
|
|
49
|
+
export async function writeFixAttempts(key: CacheKey, state: FixAttemptsState): Promise<void> {
|
|
50
|
+
let tmp: string | undefined;
|
|
51
|
+
try {
|
|
52
|
+
const path = resolvePath(key);
|
|
53
|
+
tmp = `${path}.${randomUUID()}.tmp`;
|
|
54
|
+
await mkdir(dirname(path), { recursive: true });
|
|
55
|
+
await writeFile(tmp, JSON.stringify(state), "utf8");
|
|
56
|
+
await rename(tmp, path);
|
|
57
|
+
tmp = undefined;
|
|
58
|
+
} catch {
|
|
59
|
+
// Best-effort.
|
|
60
|
+
} finally {
|
|
61
|
+
if (tmp !== undefined) {
|
|
62
|
+
try {
|
|
63
|
+
await unlink(tmp);
|
|
64
|
+
} catch {
|
|
65
|
+
// Best-effort cleanup.
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Helpers
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function resolvePath(key: CacheKey): string {
|
|
76
|
+
for (const [field, value] of [
|
|
77
|
+
["owner", key.owner],
|
|
78
|
+
["repo", key.repo],
|
|
79
|
+
] as const) {
|
|
80
|
+
if (!SAFE_SEGMENT.test(value)) {
|
|
81
|
+
throw new Error(`Invalid cache key segment "${field}": ${value}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
const base = process.env["PR_SHEPHERD_CACHE_DIR"] ?? join(tmpdir(), "pr-shepherd-cache");
|
|
85
|
+
return join(base, `${key.owner}-${key.repo}`, String(key.pr), "fix-attempts.json");
|
|
86
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classifies check runs into shepherd categories and filters out irrelevant ones.
|
|
3
|
+
*
|
|
4
|
+
* Rules:
|
|
5
|
+
* 1. Skip checks whose workflow event is NOT `pull_request` or `pull_request_target`.
|
|
6
|
+
* Push-triggered, merge-queue, schedule, and workflow-dispatch runs are irrelevant
|
|
7
|
+
* to PR readiness.
|
|
8
|
+
* 2. Drop checks with `conclusion == SKIPPED` or `conclusion == NEUTRAL` from the
|
|
9
|
+
* pass/fail tally. Report them as "skipped" for transparency but don't block on them.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { CheckRun, ClassifiedCheck } from "../types.mts";
|
|
13
|
+
import { loadConfig } from "../config/load.mts";
|
|
14
|
+
|
|
15
|
+
const RELEVANT_EVENTS = new Set(loadConfig().checks.ciTriggerEvents);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Classify a list of raw check runs into shepherd categories.
|
|
19
|
+
*
|
|
20
|
+
* @param checks Raw check runs from the batch query.
|
|
21
|
+
* @returns Classified checks. "filtered" items were excluded from the tally.
|
|
22
|
+
*/
|
|
23
|
+
export function classifyChecks(checks: CheckRun[]): ClassifiedCheck[] {
|
|
24
|
+
return checks.map((c) => classify(c));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function classify(check: CheckRun): ClassifiedCheck {
|
|
28
|
+
// Filter: runs from non-PR events don't count toward PR readiness.
|
|
29
|
+
if (check.event !== null && !RELEVANT_EVENTS.has(check.event)) {
|
|
30
|
+
return { ...check, category: "filtered" };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const { status, conclusion } = check;
|
|
34
|
+
|
|
35
|
+
// Not yet finished.
|
|
36
|
+
if (status !== "COMPLETED") {
|
|
37
|
+
return { ...check, category: "in_progress" };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Skipped / neutral — report but don't block.
|
|
41
|
+
if (conclusion === "SKIPPED" || conclusion === "NEUTRAL") {
|
|
42
|
+
return { ...check, category: "skipped" };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Success.
|
|
46
|
+
if (conclusion === "SUCCESS") {
|
|
47
|
+
return { ...check, category: "passed" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Everything else (FAILURE, TIMED_OUT, CANCELLED, ACTION_REQUIRED, STARTUP_FAILURE, STALE).
|
|
51
|
+
return { ...check, category: "failing" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// Aggregate verdict helpers
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export interface CiVerdict {
|
|
59
|
+
/** True when all relevant (non-filtered, non-skipped) checks passed. */
|
|
60
|
+
allPassed: boolean;
|
|
61
|
+
/** True when at least one check is still running/queued. */
|
|
62
|
+
anyInProgress: boolean;
|
|
63
|
+
/** True when at least one check failed. */
|
|
64
|
+
anyFailing: boolean;
|
|
65
|
+
/** Names of checks that were filtered out (triggered by non-PR events). */
|
|
66
|
+
filteredNames: string[];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Compute a high-level CI verdict from a list of classified checks. */
|
|
70
|
+
export function getCiVerdict(classified: ClassifiedCheck[]): CiVerdict {
|
|
71
|
+
const relevant = classified.filter((c) => c.category !== "filtered" && c.category !== "skipped");
|
|
72
|
+
const anyInProgress = relevant.some((c) => c.category === "in_progress");
|
|
73
|
+
const anyFailing = relevant.some((c) => c.category === "failing");
|
|
74
|
+
// When there are no relevant checks (e.g. docs-only PR where all checks are filtered/skipped),
|
|
75
|
+
// treat as allPassed rather than blocking — there's nothing to fail.
|
|
76
|
+
const allPassed = !anyInProgress && !anyFailing;
|
|
77
|
+
const filteredNames = classified.filter((c) => c.category === "filtered").map((c) => c.name);
|
|
78
|
+
|
|
79
|
+
return { allPassed, anyInProgress, anyFailing, filteredNames };
|
|
80
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { classifyChecks, getCiVerdict } from "./classify.mts";
|
|
3
|
+
import type { CheckRun } from "../types.mts";
|
|
4
|
+
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
// Fixtures
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
|
|
9
|
+
function makeCheck(overrides: Partial<CheckRun>): CheckRun {
|
|
10
|
+
return {
|
|
11
|
+
name: "tests",
|
|
12
|
+
status: "COMPLETED",
|
|
13
|
+
conclusion: "SUCCESS",
|
|
14
|
+
detailsUrl: "https://github.com/owner/repo/actions/runs/123/jobs/456",
|
|
15
|
+
event: "pull_request",
|
|
16
|
+
runId: "123",
|
|
17
|
+
...overrides,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// classifyChecks
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
describe("classifyChecks — event filtering", () => {
|
|
26
|
+
it("keeps pull_request checks", () => {
|
|
27
|
+
const [c] = classifyChecks([makeCheck({ event: "pull_request" })]);
|
|
28
|
+
expect(c!.category).not.toBe("filtered");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("keeps pull_request_target checks", () => {
|
|
32
|
+
const [c] = classifyChecks([makeCheck({ event: "pull_request_target" })]);
|
|
33
|
+
expect(c!.category).not.toBe("filtered");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("filters push-triggered checks", () => {
|
|
37
|
+
const [c] = classifyChecks([makeCheck({ event: "push" })]);
|
|
38
|
+
expect(c!.category).toBe("filtered");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("filters merge_group-triggered checks", () => {
|
|
42
|
+
const [c] = classifyChecks([makeCheck({ event: "merge_group" })]);
|
|
43
|
+
expect(c!.category).toBe("filtered");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("filters schedule-triggered checks", () => {
|
|
47
|
+
const [c] = classifyChecks([makeCheck({ event: "schedule" })]);
|
|
48
|
+
expect(c!.category).toBe("filtered");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("filters workflow_dispatch checks", () => {
|
|
52
|
+
const [c] = classifyChecks([makeCheck({ event: "workflow_dispatch" })]);
|
|
53
|
+
expect(c!.category).toBe("filtered");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps checks with null event (StatusContext nodes, no event available)", () => {
|
|
57
|
+
const [c] = classifyChecks([makeCheck({ event: null })]);
|
|
58
|
+
expect(c!.category).not.toBe("filtered");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("classifyChecks — conclusion mapping", () => {
|
|
63
|
+
it("classifies SUCCESS as passed", () => {
|
|
64
|
+
const [c] = classifyChecks([makeCheck({ conclusion: "SUCCESS" })]);
|
|
65
|
+
expect(c!.category).toBe("passed");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("classifies SKIPPED as skipped", () => {
|
|
69
|
+
const [c] = classifyChecks([makeCheck({ conclusion: "SKIPPED" })]);
|
|
70
|
+
expect(c!.category).toBe("skipped");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("classifies NEUTRAL as skipped", () => {
|
|
74
|
+
const [c] = classifyChecks([makeCheck({ conclusion: "NEUTRAL" })]);
|
|
75
|
+
expect(c!.category).toBe("skipped");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("classifies FAILURE as failing", () => {
|
|
79
|
+
const [c] = classifyChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
80
|
+
expect(c!.category).toBe("failing");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("classifies TIMED_OUT as failing", () => {
|
|
84
|
+
const [c] = classifyChecks([makeCheck({ conclusion: "TIMED_OUT" })]);
|
|
85
|
+
expect(c!.category).toBe("failing");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("classifies CANCELLED as failing", () => {
|
|
89
|
+
const [c] = classifyChecks([makeCheck({ conclusion: "CANCELLED" })]);
|
|
90
|
+
expect(c!.category).toBe("failing");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("classifies ACTION_REQUIRED as failing", () => {
|
|
94
|
+
const [c] = classifyChecks([makeCheck({ conclusion: "ACTION_REQUIRED" })]);
|
|
95
|
+
expect(c!.category).toBe("failing");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("classifies in-progress check (QUEUED) as in_progress", () => {
|
|
99
|
+
const [c] = classifyChecks([makeCheck({ status: "QUEUED", conclusion: null })]);
|
|
100
|
+
expect(c!.category).toBe("in_progress");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("classifies in-progress check (IN_PROGRESS) as in_progress", () => {
|
|
104
|
+
const [c] = classifyChecks([makeCheck({ status: "IN_PROGRESS", conclusion: null })]);
|
|
105
|
+
expect(c!.category).toBe("in_progress");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// getCiVerdict
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
describe("getCiVerdict", () => {
|
|
114
|
+
it("returns allPassed true when all relevant checks passed", () => {
|
|
115
|
+
const classified = classifyChecks([
|
|
116
|
+
makeCheck({ conclusion: "SUCCESS" }),
|
|
117
|
+
makeCheck({ name: "lint", conclusion: "SUCCESS" }),
|
|
118
|
+
]);
|
|
119
|
+
const verdict = getCiVerdict(classified);
|
|
120
|
+
expect(verdict.allPassed).toBe(true);
|
|
121
|
+
expect(verdict.anyFailing).toBe(false);
|
|
122
|
+
expect(verdict.anyInProgress).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns anyFailing when a check failed", () => {
|
|
126
|
+
const classified = classifyChecks([
|
|
127
|
+
makeCheck({ conclusion: "SUCCESS" }),
|
|
128
|
+
makeCheck({ name: "lint", conclusion: "FAILURE" }),
|
|
129
|
+
]);
|
|
130
|
+
const verdict = getCiVerdict(classified);
|
|
131
|
+
expect(verdict.anyFailing).toBe(true);
|
|
132
|
+
expect(verdict.allPassed).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("returns anyInProgress when a check is still running", () => {
|
|
136
|
+
const classified = classifyChecks([
|
|
137
|
+
makeCheck({ conclusion: "SUCCESS" }),
|
|
138
|
+
makeCheck({ name: "tests", status: "IN_PROGRESS", conclusion: null }),
|
|
139
|
+
]);
|
|
140
|
+
const verdict = getCiVerdict(classified);
|
|
141
|
+
expect(verdict.anyInProgress).toBe(true);
|
|
142
|
+
expect(verdict.allPassed).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("ignores filtered and skipped checks in the verdict", () => {
|
|
146
|
+
const classified = classifyChecks([
|
|
147
|
+
makeCheck({ event: "push", conclusion: "FAILURE" }), // filtered
|
|
148
|
+
makeCheck({ conclusion: "SKIPPED" }), // skipped
|
|
149
|
+
makeCheck({ name: "tests", conclusion: "SUCCESS" }), // passes
|
|
150
|
+
]);
|
|
151
|
+
const verdict = getCiVerdict(classified);
|
|
152
|
+
expect(verdict.allPassed).toBe(true);
|
|
153
|
+
expect(verdict.anyFailing).toBe(false);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns allPassed true when no relevant checks exist (e.g. docs-only PR)", () => {
|
|
157
|
+
// Only filtered checks — no relevant checks → allPassed true (nothing to fail).
|
|
158
|
+
const classified = classifyChecks([makeCheck({ event: "push", conclusion: "SUCCESS" })]);
|
|
159
|
+
const verdict = getCiVerdict(classified);
|
|
160
|
+
expect(verdict.allPassed).toBe(true);
|
|
161
|
+
expect(verdict.anyFailing).toBe(false);
|
|
162
|
+
expect(verdict.anyInProgress).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Hoist mock BEFORE any imports so node:child_process is replaced before
|
|
5
|
+
// triage.mts captures a reference to execFile via promisify().
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
const { mockExecFile } = vi.hoisted(() => ({ mockExecFile: vi.fn() }));
|
|
9
|
+
|
|
10
|
+
vi.mock("node:child_process", () => ({
|
|
11
|
+
execFile: (
|
|
12
|
+
_cmd: string,
|
|
13
|
+
_args: string[],
|
|
14
|
+
_opts: Record<string, unknown>,
|
|
15
|
+
cb: (err: Error | null, result: { stdout: string }) => void,
|
|
16
|
+
) => {
|
|
17
|
+
// Simulate promisify-compatible callback shape.
|
|
18
|
+
mockExecFile(_cmd, _args, _opts)
|
|
19
|
+
.then((result: { stdout: string }) => cb(null, result))
|
|
20
|
+
.catch((err: Error) => cb(err, { stdout: "" }));
|
|
21
|
+
},
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
import { triageFailingChecks } from "./triage.mts";
|
|
25
|
+
import type { ClassifiedCheck } from "../types.mts";
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Helpers
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
function makeCheck(overrides: Partial<ClassifiedCheck> = {}): ClassifiedCheck {
|
|
32
|
+
return {
|
|
33
|
+
name: "tests",
|
|
34
|
+
status: "COMPLETED",
|
|
35
|
+
conclusion: "FAILURE",
|
|
36
|
+
detailsUrl: "https://github.com/owner/repo/actions/runs/99/jobs/1",
|
|
37
|
+
event: "pull_request",
|
|
38
|
+
runId: "run-99",
|
|
39
|
+
category: "failing",
|
|
40
|
+
...overrides,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Tests
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
mockExecFile.mockReset();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("triageFailingChecks — no runId", () => {
|
|
53
|
+
it("skips log fetch and returns actionable when runId is null", async () => {
|
|
54
|
+
const check = makeCheck({ runId: null });
|
|
55
|
+
const [result] = await triageFailingChecks([check]);
|
|
56
|
+
expect(result!.failureKind).toBe("actionable");
|
|
57
|
+
expect(mockExecFile).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("triageFailingChecks — TIMED_OUT", () => {
|
|
62
|
+
it("returns timeout for TIMED_OUT conclusion regardless of logs", async () => {
|
|
63
|
+
mockExecFile.mockResolvedValue({ stdout: "test output: all good\n" });
|
|
64
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "TIMED_OUT" })]);
|
|
65
|
+
expect(result!.failureKind).toBe("timeout");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('returns timeout when logs contain "exceeded the maximum execution time"', async () => {
|
|
69
|
+
mockExecFile.mockResolvedValue({ stdout: "Run exceeded the maximum execution time\n" });
|
|
70
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
71
|
+
expect(result!.failureKind).toBe("timeout");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('returns timeout when logs contain "cancel timeout"', async () => {
|
|
75
|
+
mockExecFile.mockResolvedValue({ stdout: "cancel timeout after 6 hours\n" });
|
|
76
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
77
|
+
expect(result!.failureKind).toBe("timeout");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('returns timeout when logs contain "job was cancelled"', async () => {
|
|
81
|
+
mockExecFile.mockResolvedValue({ stdout: "Job was cancelled due to timeout\n" });
|
|
82
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "CANCELLED" })]);
|
|
83
|
+
expect(result!.failureKind).toBe("timeout");
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("triageFailingChecks — infrastructure", () => {
|
|
88
|
+
it("returns infrastructure for CANCELLED + runner error logs", async () => {
|
|
89
|
+
mockExecFile.mockResolvedValue({ stdout: "Runner error: the runner crashed\n" });
|
|
90
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "CANCELLED" })]);
|
|
91
|
+
expect(result!.failureKind).toBe("infrastructure");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns infrastructure for CANCELLED + ECONNRESET logs", async () => {
|
|
95
|
+
mockExecFile.mockResolvedValue({ stdout: "Error: ECONNRESET connection reset\n" });
|
|
96
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "CANCELLED" })]);
|
|
97
|
+
expect(result!.failureKind).toBe("infrastructure");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('returns infrastructure for CANCELLED + "lost communication with the server"', async () => {
|
|
101
|
+
mockExecFile.mockResolvedValue({
|
|
102
|
+
stdout: "The runner has lost communication with the server\n",
|
|
103
|
+
});
|
|
104
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "CANCELLED" })]);
|
|
105
|
+
expect(result!.failureKind).toBe("infrastructure");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns infrastructure when gh run view fails (empty logs)", async () => {
|
|
109
|
+
mockExecFile.mockRejectedValue(new Error("exit 1"));
|
|
110
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
111
|
+
expect(result!.failureKind).toBe("infrastructure");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("returns infrastructure for blank logs even with FAILURE conclusion", async () => {
|
|
115
|
+
mockExecFile.mockResolvedValue({ stdout: " \n \n " });
|
|
116
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
117
|
+
expect(result!.failureKind).toBe("infrastructure");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("triageFailingChecks — flaky", () => {
|
|
122
|
+
it('returns flaky when logs contain "flaky"', async () => {
|
|
123
|
+
mockExecFile.mockResolvedValue({ stdout: "Test is flaky: TestFooBar failed 1/3 runs\n" });
|
|
124
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
125
|
+
expect(result!.failureKind).toBe("flaky");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('returns flaky when logs contain "race condition"', async () => {
|
|
129
|
+
mockExecFile.mockResolvedValue({ stdout: "Detected race condition in TestBar\n" });
|
|
130
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
131
|
+
expect(result!.failureKind).toBe("flaky");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('returns flaky when logs contain "retry"', async () => {
|
|
135
|
+
mockExecFile.mockResolvedValue({ stdout: "Attempt 3/3 failed, no retry left\n" });
|
|
136
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
137
|
+
expect(result!.failureKind).toBe("flaky");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("triageFailingChecks — actionable", () => {
|
|
142
|
+
it("returns actionable for compile errors", async () => {
|
|
143
|
+
mockExecFile.mockResolvedValue({
|
|
144
|
+
stdout: "error TS2345: Argument of type 'string' is not assignable\n",
|
|
145
|
+
});
|
|
146
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
147
|
+
expect(result!.failureKind).toBe("actionable");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns actionable for test assertion failures", async () => {
|
|
151
|
+
mockExecFile.mockResolvedValue({ stdout: "AssertionError: expected 42 to equal 43\n" });
|
|
152
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
153
|
+
expect(result!.failureKind).toBe("actionable");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("returns actionable for lint violations", async () => {
|
|
157
|
+
mockExecFile.mockResolvedValue({
|
|
158
|
+
stdout: "no-unused-vars: variable `foo` is defined but never used\n",
|
|
159
|
+
});
|
|
160
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
161
|
+
expect(result!.failureKind).toBe("actionable");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("triageFailingChecks — logExcerpt", () => {
|
|
166
|
+
it("attaches logExcerpt for actionable failures", async () => {
|
|
167
|
+
mockExecFile.mockResolvedValue({ stdout: "Error: test failed\n" });
|
|
168
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
169
|
+
expect(result!.logExcerpt).toBeDefined();
|
|
170
|
+
expect(result!.logExcerpt).toContain("Error: test failed");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("truncates logExcerpt to 3000 chars", async () => {
|
|
174
|
+
mockExecFile.mockResolvedValue({ stdout: "x".repeat(10_000) });
|
|
175
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
176
|
+
expect((result!.logExcerpt?.length ?? 0) <= 3000).toBe(true);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("strips ANSI escape codes from logs", async () => {
|
|
180
|
+
mockExecFile.mockResolvedValue({ stdout: "\u001B[31mERROR\u001B[0m: something failed\n" });
|
|
181
|
+
const [result] = await triageFailingChecks([makeCheck({ conclusion: "FAILURE" })]);
|
|
182
|
+
expect(result!.logExcerpt).not.toContain("\u001B");
|
|
183
|
+
expect(result!.logExcerpt).toContain("ERROR: something failed");
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("triageFailingChecks — batch", () => {
|
|
188
|
+
it("triages multiple checks in parallel", async () => {
|
|
189
|
+
mockExecFile
|
|
190
|
+
.mockResolvedValueOnce({ stdout: "AssertionError: expected 1 to equal 2\n" })
|
|
191
|
+
.mockResolvedValueOnce({ stdout: "Runner error: crashed\n" });
|
|
192
|
+
|
|
193
|
+
const checks: ClassifiedCheck[] = [
|
|
194
|
+
makeCheck({ name: "tests", runId: "run-1", conclusion: "FAILURE" }),
|
|
195
|
+
makeCheck({ name: "build", runId: "run-2", conclusion: "CANCELLED" }),
|
|
196
|
+
];
|
|
197
|
+
const results = await triageFailingChecks(checks);
|
|
198
|
+
expect(results).toHaveLength(2);
|
|
199
|
+
expect(results[0]!.failureKind).toBe("actionable");
|
|
200
|
+
expect(results[1]!.failureKind).toBe("infrastructure");
|
|
201
|
+
});
|
|
202
|
+
});
|