pr-shepherd 0.29.0 → 0.30.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.
@@ -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.29.0",
4
+ "version": "0.30.0",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",
7
7
  "email": "jonathanrichardong@gmail.com"
package/README.md CHANGED
@@ -181,6 +181,7 @@ checks:
181
181
  - pull_request_target
182
182
  - merge_group
183
183
  actions:
184
+ autoMinimizeSuppressed: true
184
185
  autoMarkReady: false
185
186
  ```
186
187
 
@@ -208,7 +209,7 @@ const rule: ClassifyRule = (item) => {
208
209
  export default rule;
209
210
  ```
210
211
 
211
- `suppress: true` hides the item from agent output. `autoResolve: true` queues it for the minimize/resolve mutation. Both can apply together.
212
+ `suppress: true` hides the item from agent output. `autoResolve: true` queues it for the minimize/resolve mutation. When both apply together, Shepherd performs that mutation silently during `iterate` by default (`actions.autoMinimizeSuppressed: true`) so repetitive bot noise does not create a `fix_code` handoff.
212
213
 
213
214
  TypeScript rules are loaded by the runtime's native TypeScript support; keep them to erasable syntax such as type annotations and `import type`. Runtime TypeScript features that need transpilation, such as enums, namespaces, parameter properties, and decorators, are not supported. Use `.mts` for portable ESM rules across Node, Bun, and Deno.
214
215
 
@@ -1,5 +1,11 @@
1
- import { rest } from "../github/http.mjs";
1
+ /* eslint-disable max-lines */
2
+ import { rest, restText } from "../github/http.mjs";
2
3
  const STARTUP_FAILURE_STATUS = "startup_failure";
4
+ const LOG_EXCERPT_CONTEXT_LINES = 16;
5
+ const LOG_EXCERPT_TAIL_LINES = 28;
6
+ const LOG_EXCERPT_MAX_CHARS = 4_000;
7
+ const TRUNCATED_SUFFIX = "\n[truncated]";
8
+ const ANSI_SGR_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
3
9
  export function triageFailingChecks(failingChecks, repo) {
4
10
  const jobsCache = new Map();
5
11
  return Promise.all(failingChecks.map((c) => triageCheck(c, repo, jobsCache)));
@@ -12,11 +18,13 @@ async function triageCheck(check, repo, jobsCache) {
12
18
  }
13
19
  const jobs = await fetchJobs(check.runId, repo, jobsCache);
14
20
  const jobInfo = jobs ? pickJobInfo(jobs, check.name) : undefined;
21
+ const logExcerpt = jobInfo?.jobId ? await fetchJobLogExcerpt(jobInfo.jobId, repo) : undefined;
15
22
  return {
16
23
  ...check,
17
24
  ...(jobInfo?.workflowName !== undefined && { workflowName: jobInfo.workflowName }),
18
25
  ...(jobInfo?.jobName !== undefined && { jobName: jobInfo.jobName }),
19
26
  ...(jobInfo?.failedStep !== undefined && { failedStep: jobInfo.failedStep }),
27
+ ...(logExcerpt !== undefined && { logExcerpt }),
20
28
  };
21
29
  }
22
30
  export async function fetchStartupFailureChecks(repo, headSha, prNumber) {
@@ -107,8 +115,122 @@ function pickJobInfo(jobs, checkName) {
107
115
  s.conclusion !== "skipped" &&
108
116
  s.conclusion !== "neutral")?.name;
109
117
  return {
118
+ ...(job.id !== undefined && { jobId: job.id }),
110
119
  workflowName: job.workflow_name,
111
120
  jobName: job.name,
112
121
  failedStep,
113
122
  };
114
123
  }
124
+ async function fetchJobLogExcerpt(jobId, repo) {
125
+ const { owner, name } = repo;
126
+ try {
127
+ return buildLogExcerpt(await restText(`/repos/${owner}/${name}/actions/jobs/${jobId}/logs`));
128
+ }
129
+ catch {
130
+ return undefined;
131
+ }
132
+ }
133
+ function buildLogExcerpt(raw) {
134
+ const lines = raw
135
+ .split(/\r?\n/)
136
+ .map(cleanLogLine)
137
+ .filter((line) => line.trim() !== "");
138
+ if (lines.length === 0)
139
+ return undefined;
140
+ const aggregateExcerpt = buildAggregateJobResultsExcerpt(lines);
141
+ if (aggregateExcerpt !== undefined)
142
+ return aggregateExcerpt;
143
+ const errorIndex = findLogExcerptAnchor(lines);
144
+ if (errorIndex === -1)
145
+ return truncateLogExcerpt(lines.slice(-LOG_EXCERPT_TAIL_LINES).join("\n"));
146
+ const start = Math.max(0, errorIndex - LOG_EXCERPT_CONTEXT_LINES);
147
+ const excerpt = lines.slice(start, Math.min(lines.length, errorIndex + LOG_EXCERPT_CONTEXT_LINES + 1));
148
+ return truncateAnchoredExcerpt(excerpt, errorIndex - start);
149
+ }
150
+ function findLogExcerptAnchor(lines) {
151
+ const explicitError = lines.findIndex((line) => line.includes("##[error]"));
152
+ if (explicitError !== -1)
153
+ return explicitError;
154
+ return lines.findIndex((line) => /\b(error|failed|cancelled)\b/i.test(line));
155
+ }
156
+ function buildAggregateJobResultsExcerpt(lines) {
157
+ const jobResults = extractJobResults(lines);
158
+ if (jobResults === undefined)
159
+ return undefined;
160
+ const failed = Object.entries(jobResults)
161
+ .map(([name, value]) => ({ name, result: extractJobResult(value) }))
162
+ .filter((entry) => entry.result !== undefined && !["success", "skipped"].includes(entry.result));
163
+ if (failed.length === 0)
164
+ return undefined;
165
+ const output = [
166
+ ...lines.filter((line) => /required jobs failed|exit code \d+/i.test(line)),
167
+ "Job results (non-success):",
168
+ ...failed.map((entry) => `${entry.name}: ${entry.result}`),
169
+ ];
170
+ return truncateLogExcerpt(output.join("\n"));
171
+ }
172
+ function extractJobResults(lines) {
173
+ const startIndex = lines.findIndex((line) => line.includes("Job results:"));
174
+ if (startIndex === -1)
175
+ return undefined;
176
+ const block = collectJsonBlock(lines, startIndex);
177
+ if (block === undefined)
178
+ return undefined;
179
+ try {
180
+ const parsed = JSON.parse(block);
181
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
182
+ ? parsed
183
+ : undefined;
184
+ }
185
+ catch {
186
+ return undefined;
187
+ }
188
+ }
189
+ function collectJsonBlock(lines, startIndex) {
190
+ const startLine = lines[startIndex] ?? "";
191
+ const objectStart = startLine.indexOf("{");
192
+ if (objectStart === -1)
193
+ return undefined;
194
+ const collected = [startLine.slice(objectStart)];
195
+ let depth = braceDepth(collected[0]);
196
+ for (let i = startIndex + 1; i < lines.length && depth > 0; i++) {
197
+ const line = lines[i] ?? "";
198
+ collected.push(line);
199
+ depth += braceDepth(line);
200
+ }
201
+ return depth === 0 ? collected.join("\n") : undefined;
202
+ }
203
+ function braceDepth(line) {
204
+ return [...line].reduce((depth, ch) => {
205
+ if (ch === "{")
206
+ return depth + 1;
207
+ if (ch === "}")
208
+ return depth - 1;
209
+ return depth;
210
+ }, 0);
211
+ }
212
+ function extractJobResult(value) {
213
+ if (value === null || typeof value !== "object" || Array.isArray(value))
214
+ return undefined;
215
+ const result = value.result;
216
+ return typeof result === "string" ? result : undefined;
217
+ }
218
+ function truncateLogExcerpt(text) {
219
+ if (text.length <= LOG_EXCERPT_MAX_CHARS)
220
+ return text;
221
+ return `${text.slice(0, LOG_EXCERPT_MAX_CHARS - TRUNCATED_SUFFIX.length).trimEnd()}${TRUNCATED_SUFFIX}`;
222
+ }
223
+ function truncateAnchoredExcerpt(lines, anchorIndex) {
224
+ const text = lines.join("\n");
225
+ if (text.length <= LOG_EXCERPT_MAX_CHARS)
226
+ return text;
227
+ return truncateLogExcerpt(`${TRUNCATED_SUFFIX.trim()}\n${lines.slice(anchorIndex).join("\n")}`);
228
+ }
229
+ function cleanLogLine(line) {
230
+ return line
231
+ .replace(/^\uFEFF/, "")
232
+ .replace(ANSI_SGR_RE, "")
233
+ .replace(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z\s*/, "")
234
+ .replace(/##\[(?:group|endgroup)\]/g, "")
235
+ .trimEnd();
236
+ }
@@ -53,6 +53,8 @@ export function formatFixCodeResult(header, result) {
53
53
  lines.push(` > ${ch.failedStep}`);
54
54
  if (ch.summary)
55
55
  lines.push(` > ${ch.summary}`);
56
+ if (ch.logExcerpt)
57
+ lines.push(indentBlockquote(ch.logExcerpt, " "));
56
58
  }
57
59
  return lines.join("\n");
58
60
  });
@@ -141,6 +143,12 @@ function renderCheckAnnotation(a) {
141
143
  lines.push(blockquote(a.rawDetails));
142
144
  return lines.join("\n");
143
145
  }
146
+ function indentBlockquote(body, indent) {
147
+ return blockquote(body)
148
+ .split("\n")
149
+ .map((line) => `${indent}${line}`)
150
+ .join("\n");
151
+ }
144
152
  function renderAnnotationRange(a) {
145
153
  if (a.startLine === null && a.endLine === null)
146
154
  return "?";
@@ -14,6 +14,7 @@ import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs"
14
14
  import { threadTranscriptBody } from "../threads/transcript.mjs";
15
15
  import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
16
16
  import { classifyReviewsForDisplay, classifyChangesRequestedReviewsForDisplay, } from "../comments/review-visibility.mjs";
17
+ import { autoMinimizeComments, autoResolveThreads } from "../comments/resolve.mjs";
17
18
  import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers.mjs";
18
19
  import { normalizeBotUsernames } from "../comments/authors.mjs";
19
20
  import { discoverRuleFiles, loadRules } from "../classify/loader.mjs";
@@ -98,6 +99,7 @@ export async function runCheck(opts) {
98
99
  .map((r) => markSeen(stateKey, r.id, r.body)),
99
100
  ]);
100
101
  await markReviewInlineThreadMarkers(stateKey, batchData.reviewThreads);
102
+ const { threadIds: ruleAutoResolveThreadIds, commentIds: ruleAutoResolveCommentIds, reviewSummaryIds: ruleAutoResolveReviewSummaryIds, } = await remainingRuleAutoResolveIds(partition, opts.autoMinimizeSuppressed);
101
103
  const changesRequestedReviews = changesRequestedReviewVisibility.visible;
102
104
  const changesRequestedReviewCount = batchData.changesRequestedReviews.filter((r) => !partition.suppressedChangesRequestedIds.has(r.id)).length;
103
105
  const approvedReviews = approvedReviewVisibility.visible;
@@ -132,16 +134,13 @@ export async function runCheck(opts) {
132
134
  autoResolved: [],
133
135
  autoResolveErrors: [],
134
136
  firstLook: threadVisibility.firstLookThreads,
135
- ...(partition.ruleAutoResolveThreadIds.length > 0
136
- ? { ruleAutoResolveIds: partition.ruleAutoResolveThreadIds }
137
+ ...(ruleAutoResolveThreadIds.length > 0
138
+ ? { ruleAutoResolveIds: ruleAutoResolveThreadIds }
137
139
  : undefined),
138
140
  },
139
141
  comments: {
140
142
  actionable: visibleCommentClassification.actionable,
141
- minimizeIds: [
142
- ...visibleCommentClassification.minimizeIds,
143
- ...partition.ruleAutoResolveCommentIds,
144
- ],
143
+ minimizeIds: [...visibleCommentClassification.minimizeIds, ...ruleAutoResolveCommentIds],
145
144
  firstLook: firstLookComments,
146
145
  },
147
146
  changesRequestedReviews,
@@ -149,10 +148,39 @@ export async function runCheck(opts) {
149
148
  firstLookSummaries,
150
149
  editedSummaries,
151
150
  approvedReviews,
152
- ...(partition.ruleAutoResolveReviewSummaryIds.length > 0
153
- ? { ruleAutoResolveReviewSummaryIds: partition.ruleAutoResolveReviewSummaryIds }
151
+ ...(ruleAutoResolveReviewSummaryIds.length > 0
152
+ ? { ruleAutoResolveReviewSummaryIds }
154
153
  : undefined),
155
154
  branchProtection: batchData.branchProtection,
156
155
  activity: batchData.activity,
157
156
  };
158
157
  }
158
+ async function remainingRuleAutoResolveIds(partition, autoMinimizeSuppressed = false) {
159
+ const consumedIds = autoMinimizeSuppressed
160
+ ? await selfApplySuppressedRuleAutoResolve(partition)
161
+ : { minimized: new Set(), resolvedThreads: new Set() };
162
+ return {
163
+ threadIds: partition.ruleAutoResolveThreadIds.filter((id) => !consumedIds.resolvedThreads.has(id)),
164
+ commentIds: partition.ruleAutoResolveCommentIds.filter((id) => !consumedIds.minimized.has(id)),
165
+ reviewSummaryIds: partition.ruleAutoResolveReviewSummaryIds.filter((id) => !consumedIds.minimized.has(id)),
166
+ };
167
+ }
168
+ async function selfApplySuppressedRuleAutoResolve(partition) {
169
+ const minimizeIds = [
170
+ ...partition.ruleAutoResolveCommentIds.filter((id) => partition.suppressedCommentIds.has(id)),
171
+ ...partition.ruleAutoResolveReviewSummaryIds.filter((id) => partition.suppressedReviewSummaryIds.has(id)),
172
+ ];
173
+ const threadIds = partition.ruleAutoResolveThreadIds.filter((id) => partition.suppressedThreadIds.has(id));
174
+ const [minimized, resolved] = await Promise.all([
175
+ minimizeIds.length > 0
176
+ ? autoMinimizeComments(minimizeIds)
177
+ : Promise.resolve({ minimized: [], errors: [] }),
178
+ threadIds.length > 0
179
+ ? autoResolveThreads(threadIds)
180
+ : Promise.resolve({ resolved: [], errors: [] }),
181
+ ]);
182
+ return {
183
+ minimized: new Set(minimized.minimized),
184
+ resolvedThreads: new Set(resolved.resolved),
185
+ };
186
+ }
@@ -18,7 +18,7 @@ export function buildFailingCheckInstructions(checks) {
18
18
  const hasBare = checks.some((c) => !c.runId && !c.detailsUrl);
19
19
  const parts = [];
20
20
  if (hasRunId) {
21
- parts.push("fetch the log with `gh run view <runId> --log-failed` and decide: rerun with `gh run rerun <runId> --failed` for transient infrastructure failures (network timeout, OOM kill, runner crash), or apply a code fix for real test/build failures");
21
+ parts.push("read any included log excerpt first; fetch the full log with `gh run view <runId> --log-failed` when the excerpt is insufficient; decide whether to rerun with `gh run rerun <runId> --failed` for transient infrastructure failures (network timeout, OOM kill, runner crash), or apply a code fix for real test/build failures; if GitHub omits workflow-evaluation details from API/log output, open the run URL in the GitHub UI");
22
22
  }
23
23
  if (hasCancelled) {
24
24
  parts.push("for `[conclusion: CANCELLED]` entries: rerun with `gh run rerun <runId>` if the cancellation looks unintended (not superseded by a newer push or concurrency-group eviction); otherwise treat as resolved — do NOT confuse with IDs under `## Cancelled runs`");
@@ -53,6 +53,7 @@ export function buildRelevantChecks(report) {
53
53
  ...(c.jobName !== undefined && { jobName: c.jobName }),
54
54
  ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
55
55
  ...(c.summary !== undefined && { summary: c.summary }),
56
+ ...(c.logExcerpt !== undefined && { logExcerpt: c.logExcerpt }),
56
57
  ...(c.annotations !== undefined && { annotations: c.annotations }),
57
58
  },
58
59
  ];
@@ -23,6 +23,7 @@ export async function runIterate(opts) {
23
23
  const report = await runCheck({
24
24
  ...optsWithPr,
25
25
  autoResolve: config.actions.autoResolveOutdated,
26
+ autoMinimizeSuppressed: config.actions.autoMinimizeSuppressed,
26
27
  });
27
28
  const [repoOwner, repoName] = report.repo.split("/");
28
29
  if (!repoOwner || !repoName) {
@@ -57,6 +57,11 @@ export async function applyResolveOptions(pr, repo, opts) {
57
57
  return result;
58
58
  }
59
59
  export async function autoResolveOutdated(threadIds) {
60
+ return autoResolveThreads(threadIds);
61
+ }
62
+ export async function autoResolveThreads(threadIds) {
63
+ if (threadIds.length === 0)
64
+ return { resolved: [], errors: [] };
60
65
  const result = {
61
66
  repliedThreads: [],
62
67
  resolvedThreads: [],
@@ -67,6 +72,19 @@ export async function autoResolveOutdated(threadIds) {
67
72
  await bulkApply([], threadIds, [], [], "", result);
68
73
  return { resolved: result.resolvedThreads, errors: result.errors };
69
74
  }
75
+ export async function autoMinimizeComments(minimizeIds) {
76
+ if (minimizeIds.length === 0)
77
+ return { minimized: [], errors: [] };
78
+ const result = {
79
+ repliedThreads: [],
80
+ resolvedThreads: [],
81
+ minimizedComments: [],
82
+ dismissedReviews: [],
83
+ errors: [],
84
+ };
85
+ await bulkApply([], [], minimizeIds, [], "", result);
86
+ return { minimized: result.minimizedComments, errors: result.errors };
87
+ }
70
88
  // Keep mutation batches small so rate-limit stops leave a precise pending list.
71
89
  const BULK_CHUNK_SIZE = 10;
72
90
  function buildBulkMutation(replyIds, resolveIds, minimizeIds, dismissIds, dismissMessage) {
package/bin/config.json CHANGED
@@ -37,6 +37,7 @@
37
37
  },
38
38
  "actions": {
39
39
  "autoResolveOutdated": true,
40
+ "autoMinimizeSuppressed": true,
40
41
  "autoMarkReady": true,
41
42
  "commitSuggestions": true
42
43
  }
@@ -3,6 +3,8 @@ import { graphql } from "./client.mjs";
3
3
  import { CHECK_RUN_ANNOTATIONS_QUERY } from "./queries.mjs";
4
4
  const ANNOTATIONS_PER_PAGE = 100;
5
5
  const MAX_ANNOTATION_PAGES = 10;
6
+ const ANNOTATION_TEXT_MAX_CHARS = 4_000;
7
+ const TRUNCATED_SUFFIX = "\n[truncated]";
6
8
  export async function fetchCheckRunAnnotations(checkRunId) {
7
9
  let cursor = null;
8
10
  const nodes = [];
@@ -49,11 +51,16 @@ function toCheckAnnotation(checkRunId, raw) {
49
51
  }),
50
52
  level: raw.annotationLevel,
51
53
  ...(title !== undefined && { title }),
52
- message: raw.message,
53
- ...(rawDetails !== undefined && { rawDetails }),
54
+ message: truncateAnnotationText(raw.message),
55
+ ...(rawDetails !== undefined && { rawDetails: truncateAnnotationText(rawDetails) }),
54
56
  ...(blobUrl !== undefined && { blobUrl }),
55
57
  };
56
58
  }
59
+ function truncateAnnotationText(text) {
60
+ if (text.length <= ANNOTATION_TEXT_MAX_CHARS)
61
+ return text;
62
+ return `${text.slice(0, ANNOTATION_TEXT_MAX_CHARS - TRUNCATED_SUFFIX.length).trimEnd()}${TRUNCATED_SUFFIX}`;
63
+ }
57
64
  function fallbackId(checkRunId, raw) {
58
65
  const start = raw.location?.start;
59
66
  const end = raw.location?.end;
@@ -59,6 +59,7 @@ export function toAgentCheck(c) {
59
59
  ...(c.jobName !== undefined && { jobName: c.jobName }),
60
60
  ...(c.failedStep !== undefined && { failedStep: c.failedStep }),
61
61
  ...(c.summary !== undefined && { summary: c.summary }),
62
+ ...(c.logExcerpt !== undefined && { logExcerpt: c.logExcerpt }),
62
63
  ...(c.annotations !== undefined && { annotations: c.annotations }),
63
64
  };
64
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for agentic coding tools",
5
5
  "license": "MIT",
6
6
  "author": "Jonathan Ong",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-shepherd",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "Autonomous PR CI monitor and review-comment resolver for Codex.",
5
5
  "author": {
6
6
  "name": "Jonathan Ong",