pr-shepherd 0.2.0 → 0.3.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 +8 -2
- package/README.md +126 -83
- package/dist/cache/file-cache.mjs +78 -0
- package/dist/cache/fix-attempts.mjs +67 -0
- package/dist/checks/classify.mjs +53 -0
- package/dist/checks/triage.mjs +77 -0
- package/dist/cli/args.mjs +153 -0
- package/dist/cli.mjs +203 -0
- package/dist/commands/check.mjs +140 -0
- package/dist/commands/iterate.mjs +295 -0
- package/dist/commands/ready-delay.mjs +87 -0
- package/dist/commands/resolve.mjs +64 -0
- package/dist/commands/status.mjs +107 -0
- package/{src/comments/outdated.mts → dist/comments/outdated.mjs} +2 -5
- package/dist/comments/resolve.mjs +113 -0
- package/dist/config/load.mjs +154 -0
- package/dist/github/batch.mjs +208 -0
- package/dist/github/client.mjs +153 -0
- package/{src/github/pagination.mts → dist/github/pagination.mjs} +26 -52
- package/{src/github/queries.mts → dist/github/queries.mjs} +1 -10
- package/{src/index.mts → dist/index.mjs} +3 -5
- package/dist/merge-status/derive.mjs +72 -0
- package/dist/reporters/agent.mjs +41 -0
- package/{src/reporters/json.mts → dist/reporters/json.mjs} +2 -5
- package/dist/reporters/text.mjs +111 -0
- package/dist/types.mjs +2 -0
- package/package.json +6 -6
- package/skills/check/SKILL.md +1 -1
- package/skills/monitor/SKILL.md +9 -5
- package/src/cache/file-cache.mts +0 -101
- package/src/cache/file-cache.test.mts +0 -91
- package/src/cache/fix-attempts.mts +0 -86
- package/src/checks/classify.mts +0 -80
- package/src/checks/classify.test.mts +0 -164
- package/src/checks/triage.mock.test.mts +0 -202
- package/src/checks/triage.mts +0 -88
- package/src/cli.mts +0 -423
- package/src/commands/check.mts +0 -188
- package/src/commands/iterate.mock.test.mts +0 -1111
- package/src/commands/iterate.mts +0 -371
- package/src/commands/ready-delay.mts +0 -117
- package/src/commands/ready-delay.test.mts +0 -116
- package/src/commands/resolve.mts +0 -92
- package/src/commands/status.mts +0 -173
- package/src/comments/resolve.mts +0 -179
- package/src/config/load.mts +0 -240
- package/src/github/batch.mts +0 -351
- package/src/github/client.mts +0 -207
- package/src/github/client.test.mts +0 -19
- package/src/github/pagination.test.mts +0 -140
- package/src/merge-status/derive.mts +0 -74
- package/src/merge-status/derive.test.mts +0 -130
- package/src/reporters/text.mts +0 -140
- package/src/types.mts +0 -309
- /package/{src → dist}/config.json +0 -0
- /package/{src → dist}/github/gql/batch-pr.gql +0 -0
- /package/{src → dist}/github/gql/dismiss-review.gql +0 -0
- /package/{src → dist}/github/gql/minimize-comment.gql +0 -0
- /package/{src → dist}/github/gql/multi-pr-status-paged.gql +0 -0
- /package/{src → dist}/github/gql/multi-pr-status.gql +0 -0
- /package/{src → dist}/github/gql/resolve-thread.gql +0 -0
- /package/{src/util/path-segment.mts → dist/util/path-segment.mjs} +0 -0
|
@@ -1,164 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,202 +0,0 @@
|
|
|
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
|
-
});
|
package/src/checks/triage.mts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Triage failing check runs into four categories:
|
|
3
|
-
* - timeout: conclusion is TIMED_OUT or logs contain timeout markers.
|
|
4
|
-
* - infrastructure: conclusion is CANCELLED + infra-error log patterns.
|
|
5
|
-
* - actionable: compile error, test failure, lint violation from the PR's changes.
|
|
6
|
-
* - flaky: pre-existing or timing-dependent failures in untouched files.
|
|
7
|
-
*
|
|
8
|
-
* Shepherd computes and returns triage results, including `failureKind`, for
|
|
9
|
-
* downstream callers or slash-command logic to consume.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
import { execFile as execFileCb } from "node:child_process";
|
|
13
|
-
import { promisify } from "node:util";
|
|
14
|
-
import type { ClassifiedCheck, TriagedCheck, FailureKind } from "../types.mts";
|
|
15
|
-
import { loadConfig } from "../config/load.mts";
|
|
16
|
-
|
|
17
|
-
const execFile = promisify(execFileCb);
|
|
18
|
-
|
|
19
|
-
const config = loadConfig();
|
|
20
|
-
const TIMEOUT_PATTERNS = config.checks.timeoutPatterns.map((p) => new RegExp(p, "i"));
|
|
21
|
-
const INFRA_PATTERNS = config.checks.infraPatterns.map((p) => new RegExp(p, "i"));
|
|
22
|
-
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// Public API
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Fetch logs and triage each failing check.
|
|
29
|
-
*
|
|
30
|
-
* Fetching logs is skipped for checks that have no `runId` (e.g. StatusContext nodes).
|
|
31
|
-
*/
|
|
32
|
-
export function triageFailingChecks(failingChecks: ClassifiedCheck[]): Promise<TriagedCheck[]> {
|
|
33
|
-
return Promise.all(failingChecks.map((c) => triageCheck(c)));
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
// Internal
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
|
|
40
|
-
async function triageCheck(check: ClassifiedCheck): Promise<TriagedCheck> {
|
|
41
|
-
if (check.runId === null) {
|
|
42
|
-
return { ...check, failureKind: "actionable" };
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const logExcerpt = await fetchFailedLogs(check.runId);
|
|
46
|
-
const failureKind = classifyLogs(check, logExcerpt);
|
|
47
|
-
|
|
48
|
-
return {
|
|
49
|
-
...check,
|
|
50
|
-
failureKind,
|
|
51
|
-
logExcerpt: logExcerpt.slice(-config.checks.logMaxChars) || undefined,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
async function fetchFailedLogs(runId: string): Promise<string> {
|
|
56
|
-
try {
|
|
57
|
-
const { stdout } = await execFile("gh", ["run", "view", runId, "--log-failed"], {
|
|
58
|
-
maxBuffer: config.execution.triageLogBufferMb * 1024 * 1024,
|
|
59
|
-
});
|
|
60
|
-
// Strip ANSI escape codes.
|
|
61
|
-
// eslint-disable-next-line no-control-regex
|
|
62
|
-
const ansiEscapes = /\u001B\[[0-9;]*m/g;
|
|
63
|
-
return stdout.replace(ansiEscapes, "").split("\n").slice(-config.checks.logMaxLines).join("\n");
|
|
64
|
-
} catch {
|
|
65
|
-
return "";
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function classifyLogs(check: ClassifiedCheck, logs: string): FailureKind {
|
|
70
|
-
// Timed out — check conclusion first, then logs.
|
|
71
|
-
if (check.conclusion === "TIMED_OUT") return "timeout";
|
|
72
|
-
if (TIMEOUT_PATTERNS.some((re) => re.test(logs))) return "timeout";
|
|
73
|
-
|
|
74
|
-
// Infrastructure error — typically CANCELLED with infra markers in logs.
|
|
75
|
-
if (check.conclusion === "CANCELLED" && INFRA_PATTERNS.some((re) => re.test(logs))) {
|
|
76
|
-
return "infrastructure";
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// No logs at all — treat as infrastructure.
|
|
80
|
-
if (!logs.trim()) return "infrastructure";
|
|
81
|
-
|
|
82
|
-
// Heuristic: if the failure is in a file the PR likely didn't touch
|
|
83
|
-
// and the message contains "flaky" or timing language, call it flaky.
|
|
84
|
-
if (/flaky|timing|race condition|retry/i.test(logs)) return "flaky";
|
|
85
|
-
|
|
86
|
-
// Default: assume actionable.
|
|
87
|
-
return "actionable";
|
|
88
|
-
}
|