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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
3
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
4
- "version": "0.51.0",
4
+ "version": "0.51.1",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
@@ -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
+ }
@@ -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 { loadConfig } from "../config/load.mjs";
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.0",
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": "^4.1.4",
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.65.0",
92
+ "oxfmt": "^0.66.0",
93
93
  "oxlint": "^1.60.0",
94
94
  "typescript": "^7.0.2",
95
- "vitest": "^4.1.4"
95
+ "vitest": "^5.0.0"
96
96
  },
97
97
  "engines": {
98
98
  "node": ">=22.18.0"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.51.0",
3
+ "version": "0.51.1",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "pr-shepherd": {
4
4
  "command": "npx",
5
- "args": ["--yes", "--package", "pr-shepherd@0.51.0", "pr-shepherd-mcp"]
5
+ "args": ["--yes", "--package", "pr-shepherd@0.51.1", "pr-shepherd-mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "pr-shepherd": {
3
3
  "command": "npx",
4
- "args": ["--yes", "--package", "pr-shepherd@0.51.0", "pr-shepherd-mcp"]
4
+ "args": ["--yes", "--package", "pr-shepherd@0.51.1", "pr-shepherd-mcp"]
5
5
  }
6
6
  }