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.
Files changed (62) hide show
  1. package/.claude-plugin/plugin.json +8 -2
  2. package/README.md +126 -83
  3. package/dist/cache/file-cache.mjs +78 -0
  4. package/dist/cache/fix-attempts.mjs +67 -0
  5. package/dist/checks/classify.mjs +53 -0
  6. package/dist/checks/triage.mjs +77 -0
  7. package/dist/cli/args.mjs +153 -0
  8. package/dist/cli.mjs +203 -0
  9. package/dist/commands/check.mjs +140 -0
  10. package/dist/commands/iterate.mjs +295 -0
  11. package/dist/commands/ready-delay.mjs +87 -0
  12. package/dist/commands/resolve.mjs +64 -0
  13. package/dist/commands/status.mjs +107 -0
  14. package/{src/comments/outdated.mts → dist/comments/outdated.mjs} +2 -5
  15. package/dist/comments/resolve.mjs +113 -0
  16. package/dist/config/load.mjs +154 -0
  17. package/dist/github/batch.mjs +208 -0
  18. package/dist/github/client.mjs +153 -0
  19. package/{src/github/pagination.mts → dist/github/pagination.mjs} +26 -52
  20. package/{src/github/queries.mts → dist/github/queries.mjs} +1 -10
  21. package/{src/index.mts → dist/index.mjs} +3 -5
  22. package/dist/merge-status/derive.mjs +72 -0
  23. package/dist/reporters/agent.mjs +41 -0
  24. package/{src/reporters/json.mts → dist/reporters/json.mjs} +2 -5
  25. package/dist/reporters/text.mjs +111 -0
  26. package/dist/types.mjs +2 -0
  27. package/package.json +6 -6
  28. package/skills/check/SKILL.md +1 -1
  29. package/skills/monitor/SKILL.md +9 -5
  30. package/src/cache/file-cache.mts +0 -101
  31. package/src/cache/file-cache.test.mts +0 -91
  32. package/src/cache/fix-attempts.mts +0 -86
  33. package/src/checks/classify.mts +0 -80
  34. package/src/checks/classify.test.mts +0 -164
  35. package/src/checks/triage.mock.test.mts +0 -202
  36. package/src/checks/triage.mts +0 -88
  37. package/src/cli.mts +0 -423
  38. package/src/commands/check.mts +0 -188
  39. package/src/commands/iterate.mock.test.mts +0 -1111
  40. package/src/commands/iterate.mts +0 -371
  41. package/src/commands/ready-delay.mts +0 -117
  42. package/src/commands/ready-delay.test.mts +0 -116
  43. package/src/commands/resolve.mts +0 -92
  44. package/src/commands/status.mts +0 -173
  45. package/src/comments/resolve.mts +0 -179
  46. package/src/config/load.mts +0 -240
  47. package/src/github/batch.mts +0 -351
  48. package/src/github/client.mts +0 -207
  49. package/src/github/client.test.mts +0 -19
  50. package/src/github/pagination.test.mts +0 -140
  51. package/src/merge-status/derive.mts +0 -74
  52. package/src/merge-status/derive.test.mts +0 -130
  53. package/src/reporters/text.mts +0 -140
  54. package/src/types.mts +0 -309
  55. /package/{src → dist}/config.json +0 -0
  56. /package/{src → dist}/github/gql/batch-pr.gql +0 -0
  57. /package/{src → dist}/github/gql/dismiss-review.gql +0 -0
  58. /package/{src → dist}/github/gql/minimize-comment.gql +0 -0
  59. /package/{src → dist}/github/gql/multi-pr-status-paged.gql +0 -0
  60. /package/{src → dist}/github/gql/multi-pr-status.gql +0 -0
  61. /package/{src → dist}/github/gql/resolve-thread.gql +0 -0
  62. /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
- });
@@ -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
- }