pr-shepherd 0.51.0 → 0.51.1
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 +1 -1
- package/bin/checks/log-excerpt.d.mts +1 -0
- package/bin/checks/log-excerpt.mjs +192 -0
- package/bin/checks/triage.mjs +2 -121
- package/package.json +4 -4
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/.codex.mcp.json +1 -1
- package/plugins/pr-shepherd/.mcp.json +1 -1
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function buildLogExcerpt(raw: string): string | undefined;
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { loadConfig } from "../config/load.mjs";
|
|
2
|
+
const LOG_EXCERPT_CONTEXT_LINES = 16;
|
|
3
|
+
const LOG_EXCERPT_TAIL_LINES = 28;
|
|
4
|
+
const LOG_EXCERPT_MAX_CHARS = 4_000;
|
|
5
|
+
const TRUNCATED_MARK = "[truncated]";
|
|
6
|
+
const ANSI_SGR_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
7
|
+
const STEP_GROUP_RE = /^##\[group\](?:Run |Post )/;
|
|
8
|
+
const GROUP_RE = /^##\[group\]/;
|
|
9
|
+
const ENDGROUP_RE = /^##\[endgroup\]$/;
|
|
10
|
+
const POST_JOB_RE = /^(?:Post job cleanup\.?|Cleaning up orphan processes)$/;
|
|
11
|
+
export function buildLogExcerpt(raw) {
|
|
12
|
+
const ignorePatterns = compileIgnoreLogLinePatterns();
|
|
13
|
+
const prepared = raw
|
|
14
|
+
.split(/\r?\n/)
|
|
15
|
+
.map(cleanLogLine)
|
|
16
|
+
.filter((line) => line.trim() !== "");
|
|
17
|
+
if (prepared.length === 0)
|
|
18
|
+
return undefined;
|
|
19
|
+
const isolated = isolateFailedStep(prepared);
|
|
20
|
+
const lines = isolated.lines
|
|
21
|
+
.map(stripGroupMarkers)
|
|
22
|
+
.filter((line) => line.trim() !== "" && !isNoiseLine(line, ignorePatterns));
|
|
23
|
+
if (lines.length === 0)
|
|
24
|
+
return undefined;
|
|
25
|
+
const aggregateExcerpt = buildAggregateJobResultsExcerpt(lines);
|
|
26
|
+
if (aggregateExcerpt !== undefined)
|
|
27
|
+
return aggregateExcerpt;
|
|
28
|
+
if (isolated.isolated) {
|
|
29
|
+
const errorIndex = findLogExcerptAnchor(lines);
|
|
30
|
+
return truncateTail(lines.join("\n"), errorIndex === -1 ? undefined : lines[errorIndex]);
|
|
31
|
+
}
|
|
32
|
+
return boundFallbackExcerpt(lines);
|
|
33
|
+
}
|
|
34
|
+
function isolateFailedStep(lines) {
|
|
35
|
+
const postJob = lines.findIndex((line) => POST_JOB_RE.test(line));
|
|
36
|
+
const capped = postJob === -1 ? lines : lines.slice(0, postJob);
|
|
37
|
+
const anchor = findLogExcerptAnchor(capped);
|
|
38
|
+
const groupStart = findPrecedingStepGroup(capped, anchor === -1 ? capped.length - 1 : anchor);
|
|
39
|
+
if (groupStart === -1)
|
|
40
|
+
return { lines: capped, isolated: false };
|
|
41
|
+
const endgroup = findMatchingEndgroup(capped, groupStart);
|
|
42
|
+
const after = endgroup === -1 ? groupStart + 1 : endgroup + 1;
|
|
43
|
+
const next = findNextStepGroup(capped, after);
|
|
44
|
+
return { lines: capped.slice(after, next === -1 ? capped.length : next), isolated: true };
|
|
45
|
+
}
|
|
46
|
+
function findPrecedingStepGroup(lines, from) {
|
|
47
|
+
for (let i = Math.min(from, lines.length - 1); i >= 0; i--) {
|
|
48
|
+
if (STEP_GROUP_RE.test(lines[i] ?? ""))
|
|
49
|
+
return i;
|
|
50
|
+
}
|
|
51
|
+
return -1;
|
|
52
|
+
}
|
|
53
|
+
function findMatchingEndgroup(lines, groupStart) {
|
|
54
|
+
let depth = 0;
|
|
55
|
+
for (let i = groupStart; i < lines.length; i++) {
|
|
56
|
+
const line = lines[i] ?? "";
|
|
57
|
+
if (GROUP_RE.test(line))
|
|
58
|
+
depth++;
|
|
59
|
+
else if (ENDGROUP_RE.test(line) && --depth === 0)
|
|
60
|
+
return i;
|
|
61
|
+
}
|
|
62
|
+
return -1;
|
|
63
|
+
}
|
|
64
|
+
function findNextStepGroup(lines, from) {
|
|
65
|
+
let depth = 0;
|
|
66
|
+
for (let i = from; i < lines.length; i++) {
|
|
67
|
+
const line = lines[i] ?? "";
|
|
68
|
+
if (GROUP_RE.test(line)) {
|
|
69
|
+
if (depth === 0 && STEP_GROUP_RE.test(line))
|
|
70
|
+
return i;
|
|
71
|
+
depth++;
|
|
72
|
+
}
|
|
73
|
+
else if (ENDGROUP_RE.test(line) && depth > 0)
|
|
74
|
+
depth--;
|
|
75
|
+
}
|
|
76
|
+
return -1;
|
|
77
|
+
}
|
|
78
|
+
function boundFallbackExcerpt(lines) {
|
|
79
|
+
const errorIndex = findLogExcerptAnchor(lines);
|
|
80
|
+
if (errorIndex === -1) {
|
|
81
|
+
return truncateLogExcerpt(lines.slice(-LOG_EXCERPT_TAIL_LINES).join("\n"));
|
|
82
|
+
}
|
|
83
|
+
const start = Math.max(0, errorIndex - LOG_EXCERPT_CONTEXT_LINES);
|
|
84
|
+
const excerpt = lines.slice(start, Math.min(lines.length, errorIndex + LOG_EXCERPT_CONTEXT_LINES + 1));
|
|
85
|
+
return truncateAnchoredExcerpt(excerpt, errorIndex - start);
|
|
86
|
+
}
|
|
87
|
+
function findLogExcerptAnchor(lines) {
|
|
88
|
+
const explicitError = lines.findIndex((line) => line.includes("##[error]"));
|
|
89
|
+
if (explicitError !== -1)
|
|
90
|
+
return explicitError;
|
|
91
|
+
return lines.findIndex((line) => /\b(error|failed|cancelled)\b/i.test(line));
|
|
92
|
+
}
|
|
93
|
+
function buildAggregateJobResultsExcerpt(lines) {
|
|
94
|
+
const jobResults = extractJobResults(lines);
|
|
95
|
+
if (jobResults === undefined)
|
|
96
|
+
return undefined;
|
|
97
|
+
const failed = Object.entries(jobResults)
|
|
98
|
+
.map(([name, value]) => ({ name, result: extractJobResult(value) }))
|
|
99
|
+
.filter((entry) => entry.result !== undefined && !["success", "skipped"].includes(entry.result));
|
|
100
|
+
if (failed.length === 0)
|
|
101
|
+
return undefined;
|
|
102
|
+
return truncateLogExcerpt([
|
|
103
|
+
...lines.filter((line) => /required jobs failed|exit code \d+/i.test(line)),
|
|
104
|
+
"Job results (non-success):",
|
|
105
|
+
...failed.map((entry) => `${entry.name}: ${entry.result}`),
|
|
106
|
+
].join("\n"));
|
|
107
|
+
}
|
|
108
|
+
function extractJobResults(lines) {
|
|
109
|
+
const startIndex = lines.findIndex((line) => line.includes("Job results:"));
|
|
110
|
+
if (startIndex === -1)
|
|
111
|
+
return undefined;
|
|
112
|
+
const block = collectJsonBlock(lines, startIndex);
|
|
113
|
+
if (block === undefined)
|
|
114
|
+
return undefined;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = JSON.parse(block);
|
|
117
|
+
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
118
|
+
? parsed
|
|
119
|
+
: undefined;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function collectJsonBlock(lines, startIndex) {
|
|
126
|
+
const startLine = lines[startIndex] ?? "";
|
|
127
|
+
const objectStart = startLine.indexOf("{");
|
|
128
|
+
if (objectStart === -1)
|
|
129
|
+
return undefined;
|
|
130
|
+
const collected = [startLine.slice(objectStart)];
|
|
131
|
+
let depth = braceDepth(collected[0]);
|
|
132
|
+
for (let i = startIndex + 1; i < lines.length && depth > 0; i++) {
|
|
133
|
+
const line = lines[i] ?? "";
|
|
134
|
+
collected.push(line);
|
|
135
|
+
depth += braceDepth(line);
|
|
136
|
+
}
|
|
137
|
+
return depth === 0 ? collected.join("\n") : undefined;
|
|
138
|
+
}
|
|
139
|
+
function braceDepth(line) {
|
|
140
|
+
return [...line].reduce((depth, ch) => {
|
|
141
|
+
if (ch === "{")
|
|
142
|
+
return depth + 1;
|
|
143
|
+
if (ch === "}")
|
|
144
|
+
return depth - 1;
|
|
145
|
+
return depth;
|
|
146
|
+
}, 0);
|
|
147
|
+
}
|
|
148
|
+
function extractJobResult(value) {
|
|
149
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
150
|
+
return undefined;
|
|
151
|
+
const result = value.result;
|
|
152
|
+
return typeof result === "string" ? result : undefined;
|
|
153
|
+
}
|
|
154
|
+
function truncateLogExcerpt(text) {
|
|
155
|
+
if (text.length <= LOG_EXCERPT_MAX_CHARS)
|
|
156
|
+
return text;
|
|
157
|
+
return `${text.slice(0, LOG_EXCERPT_MAX_CHARS - `\n${TRUNCATED_MARK}`.length).trimEnd()}\n${TRUNCATED_MARK}`;
|
|
158
|
+
}
|
|
159
|
+
function truncateAnchoredExcerpt(lines, anchorIndex) {
|
|
160
|
+
const text = lines.join("\n");
|
|
161
|
+
if (text.length <= LOG_EXCERPT_MAX_CHARS)
|
|
162
|
+
return text;
|
|
163
|
+
return truncateLogExcerpt(`${TRUNCATED_MARK}\n${lines.slice(anchorIndex).join("\n")}`);
|
|
164
|
+
}
|
|
165
|
+
function truncateTail(text, keep) {
|
|
166
|
+
if (text.length <= LOG_EXCERPT_MAX_CHARS)
|
|
167
|
+
return text;
|
|
168
|
+
const head = `${TRUNCATED_MARK}\n`;
|
|
169
|
+
const slice = text.slice(-(LOG_EXCERPT_MAX_CHARS - head.length));
|
|
170
|
+
const nl = slice.indexOf("\n");
|
|
171
|
+
const tail = `${head}${nl === -1 ? slice : slice.slice(nl + 1)}`;
|
|
172
|
+
if (!keep || tail.includes(keep))
|
|
173
|
+
return tail;
|
|
174
|
+
const from = text.lastIndexOf(keep);
|
|
175
|
+
return from === -1 ? tail : truncateLogExcerpt(`${head}${text.slice(from)}`);
|
|
176
|
+
}
|
|
177
|
+
function compileIgnoreLogLinePatterns() {
|
|
178
|
+
return loadConfig().checks.ignoreLogLines.map((pattern) => new RegExp(pattern));
|
|
179
|
+
}
|
|
180
|
+
function isNoiseLine(line, patterns) {
|
|
181
|
+
return patterns.some((re) => re.test(line));
|
|
182
|
+
}
|
|
183
|
+
function cleanLogLine(line) {
|
|
184
|
+
return line
|
|
185
|
+
.replace(/^\uFEFF/, "")
|
|
186
|
+
.replace(ANSI_SGR_RE, "")
|
|
187
|
+
.replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s*/, "")
|
|
188
|
+
.trimEnd();
|
|
189
|
+
}
|
|
190
|
+
function stripGroupMarkers(line) {
|
|
191
|
+
return line.replace(/##\[(?:group|endgroup)\]/g, "");
|
|
192
|
+
}
|
package/bin/checks/triage.mjs
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
/* eslint-disable max-lines */
|
|
2
2
|
import { restWithRateLimit, restText } from "../github/http.mjs";
|
|
3
3
|
import { loadDerived, storeDerived } from "../state/rest-cache.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { buildLogExcerpt } from "./log-excerpt.mjs";
|
|
5
5
|
const STARTUP_FAILURE_STATUS = "startup_failure";
|
|
6
|
-
const LOG_EXCERPT_CONTEXT_LINES = 16;
|
|
7
|
-
const LOG_EXCERPT_TAIL_LINES = 28;
|
|
8
|
-
const LOG_EXCERPT_MAX_CHARS = 4_000;
|
|
9
|
-
const TRUNCATED_SUFFIX = "\n[truncated]";
|
|
10
|
-
const ANSI_SGR_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
|
|
11
6
|
export function triageFailingChecks(failingChecks, repo, stateKey) {
|
|
12
7
|
const jobsCache = new Map();
|
|
13
8
|
return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache, stateKey)));
|
|
@@ -163,7 +158,7 @@ function pickJobInfo(jobs, checkName) {
|
|
|
163
158
|
* never gets frozen into the cache.
|
|
164
159
|
*/
|
|
165
160
|
async function fetchJobLogExcerpt(jobId, repo, stateKey, cacheable = false) {
|
|
166
|
-
const cacheName = `joblog-${jobId}`;
|
|
161
|
+
const cacheName = `joblog-v2-${jobId}`;
|
|
167
162
|
if (stateKey && cacheable) {
|
|
168
163
|
const cached = await loadDerived(stateKey, cacheName);
|
|
169
164
|
if (cached)
|
|
@@ -181,117 +176,3 @@ async function fetchJobLogExcerpt(jobId, repo, stateKey, cacheable = false) {
|
|
|
181
176
|
return undefined;
|
|
182
177
|
}
|
|
183
178
|
}
|
|
184
|
-
function buildLogExcerpt(raw) {
|
|
185
|
-
const ignorePatterns = compileIgnoreLogLinePatterns();
|
|
186
|
-
const lines = raw
|
|
187
|
-
.split(/\r?\n/)
|
|
188
|
-
.map(cleanLogLine)
|
|
189
|
-
.filter((line) => line.trim() !== "" && !isNoiseLine(line, ignorePatterns));
|
|
190
|
-
if (lines.length === 0)
|
|
191
|
-
return undefined;
|
|
192
|
-
const aggregateExcerpt = buildAggregateJobResultsExcerpt(lines);
|
|
193
|
-
if (aggregateExcerpt !== undefined)
|
|
194
|
-
return aggregateExcerpt;
|
|
195
|
-
const errorIndex = findLogExcerptAnchor(lines);
|
|
196
|
-
if (errorIndex === -1)
|
|
197
|
-
return truncateLogExcerpt(lines.slice(-LOG_EXCERPT_TAIL_LINES).join("\n"));
|
|
198
|
-
const start = Math.max(0, errorIndex - LOG_EXCERPT_CONTEXT_LINES);
|
|
199
|
-
const excerpt = lines.slice(start, Math.min(lines.length, errorIndex + LOG_EXCERPT_CONTEXT_LINES + 1));
|
|
200
|
-
return truncateAnchoredExcerpt(excerpt, errorIndex - start);
|
|
201
|
-
}
|
|
202
|
-
function findLogExcerptAnchor(lines) {
|
|
203
|
-
const explicitError = lines.findIndex((line) => line.includes("##[error]"));
|
|
204
|
-
if (explicitError !== -1)
|
|
205
|
-
return explicitError;
|
|
206
|
-
return lines.findIndex((line) => /\b(error|failed|cancelled)\b/i.test(line));
|
|
207
|
-
}
|
|
208
|
-
function buildAggregateJobResultsExcerpt(lines) {
|
|
209
|
-
const jobResults = extractJobResults(lines);
|
|
210
|
-
if (jobResults === undefined)
|
|
211
|
-
return undefined;
|
|
212
|
-
const failed = Object.entries(jobResults)
|
|
213
|
-
.map(([name, value]) => ({ name, result: extractJobResult(value) }))
|
|
214
|
-
.filter((entry) => entry.result !== undefined && !["success", "skipped"].includes(entry.result));
|
|
215
|
-
if (failed.length === 0)
|
|
216
|
-
return undefined;
|
|
217
|
-
const output = [
|
|
218
|
-
...lines.filter((line) => /required jobs failed|exit code \d+/i.test(line)),
|
|
219
|
-
"Job results (non-success):",
|
|
220
|
-
...failed.map((entry) => `${entry.name}: ${entry.result}`),
|
|
221
|
-
];
|
|
222
|
-
return truncateLogExcerpt(output.join("\n"));
|
|
223
|
-
}
|
|
224
|
-
function extractJobResults(lines) {
|
|
225
|
-
const startIndex = lines.findIndex((line) => line.includes("Job results:"));
|
|
226
|
-
if (startIndex === -1)
|
|
227
|
-
return undefined;
|
|
228
|
-
const block = collectJsonBlock(lines, startIndex);
|
|
229
|
-
if (block === undefined)
|
|
230
|
-
return undefined;
|
|
231
|
-
try {
|
|
232
|
-
const parsed = JSON.parse(block);
|
|
233
|
-
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
234
|
-
? parsed
|
|
235
|
-
: undefined;
|
|
236
|
-
}
|
|
237
|
-
catch {
|
|
238
|
-
return undefined;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
function collectJsonBlock(lines, startIndex) {
|
|
242
|
-
const startLine = lines[startIndex] ?? "";
|
|
243
|
-
const objectStart = startLine.indexOf("{");
|
|
244
|
-
if (objectStart === -1)
|
|
245
|
-
return undefined;
|
|
246
|
-
const collected = [startLine.slice(objectStart)];
|
|
247
|
-
let depth = braceDepth(collected[0]);
|
|
248
|
-
for (let i = startIndex + 1; i < lines.length && depth > 0; i++) {
|
|
249
|
-
const line = lines[i] ?? "";
|
|
250
|
-
collected.push(line);
|
|
251
|
-
depth += braceDepth(line);
|
|
252
|
-
}
|
|
253
|
-
return depth === 0 ? collected.join("\n") : undefined;
|
|
254
|
-
}
|
|
255
|
-
function braceDepth(line) {
|
|
256
|
-
return [...line].reduce((depth, ch) => {
|
|
257
|
-
if (ch === "{")
|
|
258
|
-
return depth + 1;
|
|
259
|
-
if (ch === "}")
|
|
260
|
-
return depth - 1;
|
|
261
|
-
return depth;
|
|
262
|
-
}, 0);
|
|
263
|
-
}
|
|
264
|
-
function extractJobResult(value) {
|
|
265
|
-
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
266
|
-
return undefined;
|
|
267
|
-
const result = value.result;
|
|
268
|
-
return typeof result === "string" ? result : undefined;
|
|
269
|
-
}
|
|
270
|
-
function truncateLogExcerpt(text) {
|
|
271
|
-
if (text.length <= LOG_EXCERPT_MAX_CHARS)
|
|
272
|
-
return text;
|
|
273
|
-
return `${text.slice(0, LOG_EXCERPT_MAX_CHARS - TRUNCATED_SUFFIX.length).trimEnd()}${TRUNCATED_SUFFIX}`;
|
|
274
|
-
}
|
|
275
|
-
function truncateAnchoredExcerpt(lines, anchorIndex) {
|
|
276
|
-
const text = lines.join("\n");
|
|
277
|
-
if (text.length <= LOG_EXCERPT_MAX_CHARS)
|
|
278
|
-
return text;
|
|
279
|
-
return truncateLogExcerpt(`${TRUNCATED_SUFFIX.trim()}\n${lines.slice(anchorIndex).join("\n")}`);
|
|
280
|
-
}
|
|
281
|
-
// User-configured via `checks.ignoreLogLines` (regex source strings) — empty by
|
|
282
|
-
// default. What counts as noise varies by CI toolchain, so Shepherd ships no
|
|
283
|
-
// built-in patterns; a project opts in via `.pr-shepherdrc.yml`.
|
|
284
|
-
function compileIgnoreLogLinePatterns() {
|
|
285
|
-
return loadConfig().checks.ignoreLogLines.map((pattern) => new RegExp(pattern));
|
|
286
|
-
}
|
|
287
|
-
function isNoiseLine(line, patterns) {
|
|
288
|
-
return patterns.some((re) => re.test(line));
|
|
289
|
-
}
|
|
290
|
-
function cleanLogLine(line) {
|
|
291
|
-
return line
|
|
292
|
-
.replace(/^\uFEFF/, "")
|
|
293
|
-
.replace(ANSI_SGR_RE, "")
|
|
294
|
-
.replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s*/, "")
|
|
295
|
-
.replace(/##\[(?:group|endgroup)\]/g, "")
|
|
296
|
-
.trimEnd();
|
|
297
|
-
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.1",
|
|
4
4
|
"description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"automation",
|
|
@@ -85,14 +85,14 @@
|
|
|
85
85
|
"devDependencies": {
|
|
86
86
|
"@types/node": "^26.0.0",
|
|
87
87
|
"@types/picomatch": "^4.0.3",
|
|
88
|
-
"@vitest/coverage-v8": "^
|
|
88
|
+
"@vitest/coverage-v8": "^5.0.0",
|
|
89
89
|
"husky": "^9.1.7",
|
|
90
90
|
"knip": "^6.14.1",
|
|
91
91
|
"marked": "^18.0.11",
|
|
92
|
-
"oxfmt": "^0.
|
|
92
|
+
"oxfmt": "^0.66.0",
|
|
93
93
|
"oxlint": "^1.60.0",
|
|
94
94
|
"typescript": "^7.0.2",
|
|
95
|
-
"vitest": "^
|
|
95
|
+
"vitest": "^5.0.0"
|
|
96
96
|
},
|
|
97
97
|
"engines": {
|
|
98
98
|
"node": ">=22.18.0"
|