pr-shepherd 0.50.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/README.md +11 -4
- 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/bin/cli/poll-summary-emitter.mjs +15 -0
- package/bin/cli/poll-summary-formatter.mjs +10 -24
- package/bin/commands/poll-quota.d.mts +2 -1
- package/bin/commands/poll-quota.mjs +12 -0
- package/bin/commands/poll-summary-explicit-instructions.d.mts +2 -0
- package/bin/commands/poll-summary-explicit-instructions.mjs +22 -0
- package/bin/commands/poll-summary-instructions.d.mts +3 -0
- package/bin/commands/poll-summary-instructions.mjs +139 -0
- package/bin/commands/poll-summary-signature.d.mts +2 -0
- package/bin/commands/poll-summary-signature.mjs +16 -0
- package/bin/commands/poll-summary.mjs +25 -39
- package/bin/github/gql/poll-summary-fragment.gql +1 -0
- package/bin/github/poll-summary-projector.mjs +2 -1
- package/bin/github/poll-summary-raw.d.mts +1 -0
- package/bin/github/poll-summary-route.mjs +4 -1
- package/bin/github/poll-summary.d.mts +2 -1
- package/bin/github/poll-summary.mjs +19 -0
- package/bin/types/poll-summary.d.mts +14 -0
- 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
- package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +3 -3
- package/plugins/pr-shepherd/skills/reduce-pr-noise/SKILL.md +17 -0
- package/plugins/pr-shepherd/skills/reduce-pr-noise/references/classifiers.md +24 -0
- package/plugins/pr-shepherd/skills/reduce-pr-noise/references/settings.md +27 -0
package/README.md
CHANGED
|
@@ -144,10 +144,15 @@ needs agent work, all rows are terminal, the bounded timeout expires, or `--unti
|
|
|
144
144
|
a configured GraphQL quota-warning band. Check counts use the same ignored, protected-run,
|
|
145
145
|
superseded-run, and event rules as singular iteration and include active merge-queue commit checks.
|
|
146
146
|
Bounded review/check overflow remains visible without permanently forcing work, and clean rows use
|
|
147
|
-
the configured ready-delay before becoming terminal.
|
|
148
|
-
|
|
149
|
-
Stack rows are ordered bottom-to-top
|
|
150
|
-
|
|
147
|
+
the configured ready-delay before becoming terminal. Explicit PR sets give each actionable row an
|
|
148
|
+
exact single-PR `pollCommand`, so independent rows can proceed before the next aggregate poll.
|
|
149
|
+
Stack rows are ordered bottom-to-top and follow the one ordered stack instruction block instead.
|
|
150
|
+
The summary also checks that every open child was based on
|
|
151
|
+
its direct parent's current head. A stale child/parent OID pair is actionable even when GitHub
|
|
152
|
+
reports both PRs clean: without `--merge`, rebase the upstack branches from their parent and push
|
|
153
|
+
them with the emitted `gh stack` commands; with `--merge`, finish the contiguous ready lower
|
|
154
|
+
layers with the emitted `gh stack merge --squash` command, recheck, then repair the child. API and
|
|
155
|
+
MCP aggregate calls perform one summary tick and leave recurrence to the caller.
|
|
151
156
|
|
|
152
157
|
Polling defaults can be set under `poll` in `.pr-shepherdrc.yml`: `intervalSeconds`, `timeoutSeconds`, `debounceSeconds`, and `quietStatus`. Explicit flags override configuration, including `--no-quiet-status` when a shared config enables quiet output. Quiet status remains off by default.
|
|
153
158
|
|
|
@@ -286,6 +291,8 @@ Environment variables:
|
|
|
286
291
|
|
|
287
292
|
See [docs/configuration.md](docs/configuration.md) for the full reference.
|
|
288
293
|
|
|
294
|
+
For guided noise reduction, use the plugin's `reduce-pr-noise` skill. It loads focused guidance for bot-comment classifiers or settings only when relevant; see [docs/skills.md](docs/skills.md).
|
|
295
|
+
|
|
289
296
|
### Classification rules
|
|
290
297
|
|
|
291
298
|
Drop `.ts` / `.mts` / `.mjs` / `.js` files under `.pr-shepherd/classification/` to suppress and/or auto-resolve specific bot comments — useful for silencing repetitive noise like rate-limit notices from `gemini-code-assist` or "Reviews paused" from `coderabbitai`.
|
|
@@ -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
|
-
}
|
|
@@ -5,6 +5,21 @@ export function emitPollSummaryResult(result, opts) {
|
|
|
5
5
|
process.exitCode = pollSummaryExitCode(result);
|
|
6
6
|
}
|
|
7
7
|
function pollSummaryExitCode(result) {
|
|
8
|
+
if (result.nextAction === "cancel" &&
|
|
9
|
+
result.prs.some((item) => item.reasons.includes("closed"))) {
|
|
10
|
+
return EXIT.CLOSED;
|
|
11
|
+
}
|
|
12
|
+
if (result.nextAction) {
|
|
13
|
+
const stackExitCode = {
|
|
14
|
+
escalate: EXIT.ESCALATE,
|
|
15
|
+
fix_code: EXIT.FIX_CODE,
|
|
16
|
+
merge: EXIT.MERGE,
|
|
17
|
+
mark_ready: EXIT.MARK_READY,
|
|
18
|
+
wait: EXIT.WAIT,
|
|
19
|
+
cancel: EXIT.OK,
|
|
20
|
+
};
|
|
21
|
+
return stackExitCode[result.nextAction] ?? EXIT.OK;
|
|
22
|
+
}
|
|
8
23
|
const actions = new Set(result.prs.map((item) => item.action));
|
|
9
24
|
if (actions.has("escalate"))
|
|
10
25
|
return EXIT.ESCALATE;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { formatApiUsage, formatQuotaWarning } from "./api-usage-formatter.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { withPollSummaryInstructions } from "../commands/poll-summary-instructions.mjs";
|
|
3
3
|
export function formatPollSummaryResult(result) {
|
|
4
4
|
const selection = result.selection.kind === "stack"
|
|
5
5
|
? `stack #${result.selection.stackNumber} anchored at PR #${result.selection.anchor} (${result.selection.stackSize} PRs)`
|
|
@@ -7,42 +7,28 @@ export function formatPollSummaryResult(result) {
|
|
|
7
7
|
const lines = [
|
|
8
8
|
`# Poll summary [${result.reason.toUpperCase()}]`,
|
|
9
9
|
"",
|
|
10
|
-
`**repo** \`${result.repo}\` · **selection** ${selection} · **mode** \`${result.mode}
|
|
10
|
+
`**repo** \`${result.repo}\` · **selection** ${selection} · **mode** \`${result.mode}\`${result.nextAction ? ` · **next action** \`${result.nextAction}\`` : ""}`,
|
|
11
11
|
"",
|
|
12
12
|
"## Pull requests",
|
|
13
13
|
"",
|
|
14
14
|
...result.prs.map(formatItem),
|
|
15
15
|
];
|
|
16
|
+
if (result.stackAncestry?.length) {
|
|
17
|
+
lines.push("", "## Stack ancestry", "");
|
|
18
|
+
for (const pair of result.stackAncestry) {
|
|
19
|
+
lines.push(`- PR #${pair.childPr} base \`${pair.childBaseRefName}\` at \`${pair.childBaseRefOid}\` differs from parent PR #${pair.parentPr} head \`${pair.parentHeadRefName}\` at \`${pair.parentHeadRefOid}\`.`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
16
22
|
const apiUsage = result.apiUsage ? formatApiUsage(result.apiUsage) : null;
|
|
17
23
|
const quotaWarning = formatQuotaWarning(result.quotaWarning);
|
|
18
24
|
if (quotaWarning)
|
|
19
25
|
lines.push("", quotaWarning);
|
|
20
26
|
if (apiUsage)
|
|
21
27
|
lines.push("", apiUsage);
|
|
22
|
-
|
|
28
|
+
const instructions = result.instructions ?? withPollSummaryInstructions(result, false).instructions ?? [];
|
|
29
|
+
lines.push("", "## Instructions", "", ...instructions);
|
|
23
30
|
return lines.join("\n");
|
|
24
31
|
}
|
|
25
|
-
function formatInstructions(result) {
|
|
26
|
-
if (result.reason === "all_terminal")
|
|
27
|
-
return ["1. Stop — every selected PR is terminal."];
|
|
28
|
-
if (result.quotaWarning && result.reason !== "actionable") {
|
|
29
|
-
return [
|
|
30
|
-
buildQuotaAwareContinuation(result.quotaWarning, "1. This aggregate selection is non-terminal. Before continuing,"),
|
|
31
|
-
];
|
|
32
|
-
}
|
|
33
|
-
if (result.reason === "waiting" || result.reason === "timeout") {
|
|
34
|
-
return ["1. Run this aggregate selector again when the caller is ready to recheck."];
|
|
35
|
-
}
|
|
36
|
-
const instructions = [
|
|
37
|
-
"1. Choose each non-WAIT, non-CANCEL row that can proceed independently and run or delegate its exact `pollCommand`.",
|
|
38
|
-
"2. Follow each selected one-PR poll's `## Instructions` until it returns `CANCEL` or `ESCALATE`.",
|
|
39
|
-
"3. Run this aggregate poll again after selected work completes; one row's `ESCALATE` does not stop work on other rows.",
|
|
40
|
-
];
|
|
41
|
-
if (result.quotaWarning) {
|
|
42
|
-
instructions[2] = buildQuotaAwareContinuation(result.quotaWarning, "3. After selected work completes,");
|
|
43
|
-
}
|
|
44
|
-
return instructions;
|
|
45
|
-
}
|
|
46
32
|
function formatItem(item) {
|
|
47
33
|
const flags = [item.isDraft ? "draft" : null, item.isInMergeQueue ? "queued" : null]
|
|
48
34
|
.filter((value) => value !== null)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { GraphqlQuotaWarningBand } from "../config/load.mts";
|
|
2
|
-
import type { GraphqlApiUsage } from "../types.mts";
|
|
2
|
+
import type { GraphqlApiUsage, PollSummaryResult } from "../types.mts";
|
|
3
3
|
/** Sleep at least `--interval`, and at least the active crossed quota band. */
|
|
4
4
|
export declare function graphqlQuotaPollIntervalMs(bands: GraphqlQuotaWarningBand[], usage: Pick<GraphqlApiUsage, "remaining" | "limit"> | undefined, fallbackMs: number, maxMs: number): number;
|
|
5
5
|
/**
|
|
@@ -7,3 +7,4 @@ export declare function graphqlQuotaPollIntervalMs(bands: GraphqlQuotaWarningBan
|
|
|
7
7
|
* limit. `null` means the error is not a retryable rate limit.
|
|
8
8
|
*/
|
|
9
9
|
export declare function pollGraphQlRetryAfterMs(err: unknown): number | null;
|
|
10
|
+
export declare function aggregateQuotaWarning(result: PollSummaryResult, bands: GraphqlQuotaWarningBand[], intervalSeconds: number): Promise<PollSummaryResult["quotaWarning"]>;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { evaluateWorktreeGraphqlQuotaWarning } from "../state/graphql-quota-warnings.mjs";
|
|
2
|
+
import { summarizeApiTelemetry } from "../github/api-telemetry.mjs";
|
|
1
3
|
import { GitHubRequestError } from "../github/errors.mjs";
|
|
2
4
|
import { isRateLimitMessage } from "../comments/rate-limit.mjs";
|
|
3
5
|
const GRAPHQL_RETRY_AFTER_DEFAULT_MS = 60_000;
|
|
@@ -34,3 +36,13 @@ export function pollGraphQlRetryAfterMs(err) {
|
|
|
34
36
|
}
|
|
35
37
|
return GRAPHQL_RETRY_AFTER_DEFAULT_MS;
|
|
36
38
|
}
|
|
39
|
+
export async function aggregateQuotaWarning(result, bands, intervalSeconds) {
|
|
40
|
+
const usage = summarizeApiTelemetry()?.graphql;
|
|
41
|
+
const [owner, repo] = result.repo.split("/");
|
|
42
|
+
if (!usage || !owner || !repo)
|
|
43
|
+
return undefined;
|
|
44
|
+
return evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands.map((band) => ({
|
|
45
|
+
...band,
|
|
46
|
+
pollIntervalMinutes: Math.max(band.pollIntervalMinutes, intervalSeconds / 60),
|
|
47
|
+
})), usage, true);
|
|
48
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
|
|
2
|
+
export function explicitInstructions(result) {
|
|
3
|
+
if (result.reason === "all_terminal")
|
|
4
|
+
return ["1. Stop — every selected PR is terminal."];
|
|
5
|
+
if (result.quotaWarning && result.reason !== "actionable") {
|
|
6
|
+
return [
|
|
7
|
+
buildQuotaAwareContinuation(result.quotaWarning, "1. This aggregate selection is non-terminal. Before continuing,"),
|
|
8
|
+
];
|
|
9
|
+
}
|
|
10
|
+
if (result.reason === "waiting" || result.reason === "timeout") {
|
|
11
|
+
return ["1. Run this aggregate selector again when the caller is ready to recheck."];
|
|
12
|
+
}
|
|
13
|
+
const instructions = [
|
|
14
|
+
"1. Choose each non-WAIT, non-CANCEL row that can proceed independently and run or delegate its exact `pollCommand`.",
|
|
15
|
+
"2. Follow each selected one-PR poll's `## Instructions` until it returns `CANCEL` or `ESCALATE`.",
|
|
16
|
+
"3. Run this aggregate poll again after selected work completes; one row's `ESCALATE` does not stop work on other rows.",
|
|
17
|
+
];
|
|
18
|
+
if (result.quotaWarning) {
|
|
19
|
+
instructions[2] = buildQuotaAwareContinuation(result.quotaWarning, "3. After selected work completes,");
|
|
20
|
+
}
|
|
21
|
+
return instructions;
|
|
22
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { buildQuotaAwareContinuation } from "../quota-warning.mjs";
|
|
2
|
+
import { explicitInstructions } from "./poll-summary-explicit-instructions.mjs";
|
|
3
|
+
/** Keep aggregate JSON, Markdown, and MCP instructions on one projection. */
|
|
4
|
+
export function withPollSummaryInstructions(result, mergeRequested) {
|
|
5
|
+
if (result.selection.kind !== "stack") {
|
|
6
|
+
return { ...result, instructions: explicitInstructions(result) };
|
|
7
|
+
}
|
|
8
|
+
const planned = planStack(result, mergeRequested);
|
|
9
|
+
const reason = planned.action === "cancel"
|
|
10
|
+
? "all_terminal"
|
|
11
|
+
: planned.action === "wait"
|
|
12
|
+
? result.reason === "timeout"
|
|
13
|
+
? "timeout"
|
|
14
|
+
: "waiting"
|
|
15
|
+
: "actionable";
|
|
16
|
+
const instructions = [...planned.instructions];
|
|
17
|
+
if (result.quotaWarning && planned.action !== "wait" && planned.action !== "cancel") {
|
|
18
|
+
instructions.push(buildQuotaAwareContinuation(result.quotaWarning, `${instructions.length + 1}. After completing the stack action,`));
|
|
19
|
+
}
|
|
20
|
+
return { ...result, reason, nextAction: planned.action, instructions };
|
|
21
|
+
}
|
|
22
|
+
function planStack(result, mergeRequested) {
|
|
23
|
+
const open = result.prs.filter((item) => item.state === "OPEN");
|
|
24
|
+
if (open.length === 0) {
|
|
25
|
+
return { action: "cancel", instructions: ["1. Stop — every selected PR is terminal."] };
|
|
26
|
+
}
|
|
27
|
+
const lastOpenPosition = positionOf(result, open.at(-1).pr);
|
|
28
|
+
const closedBelowOpen = result.prs.find((item) => item.state === "CLOSED" && positionOf(result, item.pr) < lastOpenPosition);
|
|
29
|
+
if (closedBelowOpen) {
|
|
30
|
+
return {
|
|
31
|
+
action: "escalate",
|
|
32
|
+
instructions: [
|
|
33
|
+
`1. PR #${closedBelowOpen.pr} is closed without merging below an open stack layer. Stop stack merge and rebase operations here; the closed dependency must be restored or the higher branches rebuilt on a valid base.`,
|
|
34
|
+
"2. Ask the stack owner which recovery path to take, then rerun the same aggregate `--stack` selector after the stack is repaired.",
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const gap = result.stackAncestry?.[0];
|
|
39
|
+
const firstOpen = open[0];
|
|
40
|
+
if (firstOpen.mergeStateStatus === "BEHIND")
|
|
41
|
+
return rebaseWholeStack(result, firstOpen);
|
|
42
|
+
if (mergeRequested) {
|
|
43
|
+
const mergeTarget = readyLowerStackTarget(open, result.stackAncestry ?? []);
|
|
44
|
+
if (mergeTarget) {
|
|
45
|
+
const stackNumber = result.selection.kind === "stack" ? result.selection.stackNumber : 0;
|
|
46
|
+
return {
|
|
47
|
+
action: "merge",
|
|
48
|
+
instructions: [
|
|
49
|
+
`1. The contiguous ready lower stack ends at PR #${mergeTarget.pr}. Merge the native stack through that PR with \`gh stack merge --squash ${mergeTarget.pr}\`; verify that the selector names PR #${mergeTarget.pr} in stack #${stackNumber} before running it. This includes still-open lower layers and leaves higher layers open.`,
|
|
50
|
+
"2. After GitHub completes the stack merge and updates the remaining branches, rerun the same aggregate `--stack` selector. If an ancestry mismatch remains, follow the rebase instructions returned then.",
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (firstOpen.action === "wait") {
|
|
55
|
+
return waitingStack(result);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const firstWork = open.find((item) => ["fix_code", "mark_ready", "escalate"].includes(item.action));
|
|
59
|
+
if (firstWork && (!gap || positionOf(result, firstWork.pr) <= positionOf(result, gap.childPr))) {
|
|
60
|
+
return pollOneLayer(firstWork);
|
|
61
|
+
}
|
|
62
|
+
if (gap) {
|
|
63
|
+
return {
|
|
64
|
+
action: "fix_code",
|
|
65
|
+
instructions: [
|
|
66
|
+
`1. PR #${gap.childPr} still records base \`${gap.childBaseRefName}\` at \`${gap.childBaseRefOid}\`, while parent PR #${gap.parentPr} now ends at \`${gap.parentHeadRefName}\` \`${gap.parentHeadRefOid}\`. From a clean checkout of \`${result.repo}\`, check out the parent stack branch \`${gap.parentHeadRefName}\`.`,
|
|
67
|
+
"2. Rebase the upstack branches onto that parent with `gh stack rebase --upstack --no-trunk`, resolve any conflicts, and push the rewritten branches with `gh stack push`.",
|
|
68
|
+
"3. Rerun the same aggregate `--stack` selector and follow the next returned action.",
|
|
69
|
+
],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const behind = open.find((item) => item.mergeStateStatus === "BEHIND");
|
|
73
|
+
if (behind)
|
|
74
|
+
return rebaseWholeStack(result, behind);
|
|
75
|
+
if (firstWork)
|
|
76
|
+
return pollOneLayer(firstWork);
|
|
77
|
+
if (!mergeRequested && open.every((item) => item.action === "cancel")) {
|
|
78
|
+
return {
|
|
79
|
+
action: "cancel",
|
|
80
|
+
instructions: ["1. Stop — every open stack layer is ready and the stack is linear."],
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
return waitingStack(result);
|
|
84
|
+
}
|
|
85
|
+
function rebaseWholeStack(result, behind) {
|
|
86
|
+
return {
|
|
87
|
+
action: "fix_code",
|
|
88
|
+
instructions: [
|
|
89
|
+
`1. GitHub reports PR #${behind.pr} is behind its base \`${behind.baseRefName}\`. From a clean checkout of \`${result.repo}\`, check out its stack branch \`${behind.headRefName}\`.`,
|
|
90
|
+
"2. Rebase that native stack from its trunk with `gh stack rebase`, resolving any conflicts.",
|
|
91
|
+
"3. Push the updated stack with `gh stack push` and rerun the same aggregate `--stack` selector.",
|
|
92
|
+
],
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function readyLowerStackTarget(open, gaps) {
|
|
96
|
+
const mismatchedChildren = new Set(gaps.map((gap) => gap.childPr));
|
|
97
|
+
let target;
|
|
98
|
+
for (const item of open) {
|
|
99
|
+
if (mismatchedChildren.has(item.pr) || item.action !== "merge")
|
|
100
|
+
break;
|
|
101
|
+
target = item;
|
|
102
|
+
}
|
|
103
|
+
return target;
|
|
104
|
+
}
|
|
105
|
+
function positionOf(result, pr) {
|
|
106
|
+
return result.prs.find((item) => item.pr === pr)?.stack?.position ?? Number.MAX_SAFE_INTEGER;
|
|
107
|
+
}
|
|
108
|
+
function pollOneLayer(item) {
|
|
109
|
+
if (!item.pollCommand) {
|
|
110
|
+
return {
|
|
111
|
+
action: "escalate",
|
|
112
|
+
instructions: [
|
|
113
|
+
`1. PR #${item.pr} needs attention, but GitHub returned no one-PR poll command.`,
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
action: item.action,
|
|
119
|
+
instructions: [
|
|
120
|
+
`1. Work on the lowest actionable layer, PR #${item.pr}: run \`${item.pollCommand}\`.`,
|
|
121
|
+
"2. Follow that one-PR poll's `## Instructions` until it returns `CANCEL` or `ESCALATE`.",
|
|
122
|
+
"3. Rerun the aggregate `--stack` selector before acting on a higher layer.",
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
function waitingStack(result) {
|
|
127
|
+
if (result.quotaWarning) {
|
|
128
|
+
return {
|
|
129
|
+
action: "wait",
|
|
130
|
+
instructions: [
|
|
131
|
+
buildQuotaAwareContinuation(result.quotaWarning, "1. This native stack is non-terminal. Before continuing,"),
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
action: "wait",
|
|
137
|
+
instructions: ["1. Recheck this native stack after the lowest open layer changes state."],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function summaryStatusSignature(result) {
|
|
2
|
+
return JSON.stringify({
|
|
3
|
+
nextAction: result.nextAction,
|
|
4
|
+
stackAncestry: result.stackAncestry,
|
|
5
|
+
prs: result.prs.map((item) => ({
|
|
6
|
+
pr: item.pr,
|
|
7
|
+
action: item.action,
|
|
8
|
+
state: item.state,
|
|
9
|
+
mergeable: item.mergeable,
|
|
10
|
+
mergeStateStatus: item.mergeStateStatus,
|
|
11
|
+
reviewDecision: item.reviewDecision,
|
|
12
|
+
checks: item.checks,
|
|
13
|
+
review: item.review,
|
|
14
|
+
})),
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -4,12 +4,13 @@ import { getRepoInfo } from "../github/client.mjs";
|
|
|
4
4
|
import { withApiTelemetryScope, summarizeApiTelemetry } from "../github/api-telemetry.mjs";
|
|
5
5
|
import { fetchPollSummary } from "../github/poll-summary.mjs";
|
|
6
6
|
import { sleep } from "../util/sleep.mjs";
|
|
7
|
-
import { graphqlQuotaPollIntervalMs, pollGraphQlRetryAfterMs } from "./poll-quota.mjs";
|
|
8
|
-
import {
|
|
7
|
+
import { aggregateQuotaWarning, graphqlQuotaPollIntervalMs, pollGraphQlRetryAfterMs, } from "./poll-quota.mjs";
|
|
8
|
+
import { withPollSummaryInstructions } from "./poll-summary-instructions.mjs";
|
|
9
|
+
import { summaryStatusSignature } from "./poll-summary-signature.mjs";
|
|
9
10
|
const MAX_TIMER_MS = 2 ** 31 - 1;
|
|
10
11
|
const TIMER_DRIFT_TOLERANCE_MS = 500;
|
|
11
12
|
export function runPollSummary(opts) {
|
|
12
|
-
return withApiTelemetryScope(async () => attachUsage(await runPollSummaryCore(opts)));
|
|
13
|
+
return withApiTelemetryScope(async () => attachUsage(await runPollSummaryCore(opts), opts.merge));
|
|
13
14
|
}
|
|
14
15
|
export function runAggregatePoll(opts) {
|
|
15
16
|
return withApiTelemetryScope(() => runAggregatePollCore(opts));
|
|
@@ -19,13 +20,14 @@ async function runPollSummaryCore(opts) {
|
|
|
19
20
|
const fetched = await fetchPollSummary(opts, repo);
|
|
20
21
|
const allTerminal = fetched.prs.every((item) => item.action === "cancel");
|
|
21
22
|
const actionable = fetched.prs.some((item) => item.action !== "wait" && item.action !== "cancel");
|
|
22
|
-
return {
|
|
23
|
+
return withPollSummaryInstructions({
|
|
23
24
|
mode: "summary",
|
|
24
25
|
repo: `${repo.owner}/${repo.name}`,
|
|
25
26
|
selection: fetched.selection,
|
|
26
27
|
reason: allTerminal ? "all_terminal" : actionable ? "actionable" : "waiting",
|
|
27
28
|
prs: fetched.prs,
|
|
28
|
-
|
|
29
|
+
...(fetched.stackAncestry?.length && { stackAncestry: fetched.stackAncestry }),
|
|
30
|
+
}, opts.merge === true);
|
|
29
31
|
}
|
|
30
32
|
async function runAggregatePollCore(opts) {
|
|
31
33
|
const intervalMs = Math.min(opts.intervalSeconds * 1000, MAX_TIMER_MS);
|
|
@@ -60,7 +62,7 @@ async function runAggregatePollCore(opts) {
|
|
|
60
62
|
...explicit,
|
|
61
63
|
reason: "all_terminal",
|
|
62
64
|
...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
|
|
63
|
-
});
|
|
65
|
+
}, opts.merge);
|
|
64
66
|
}
|
|
65
67
|
}
|
|
66
68
|
const retryMs = opts.untilTerminal ? pollGraphQlRetryAfterMs(error) : null;
|
|
@@ -71,9 +73,15 @@ async function runAggregatePollCore(opts) {
|
|
|
71
73
|
await sleep(retryMs);
|
|
72
74
|
continue;
|
|
73
75
|
}
|
|
74
|
-
const allTerminal = last.
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
const allTerminal = last.selection.kind === "stack"
|
|
77
|
+
? last.nextAction === "cancel"
|
|
78
|
+
: last.prs.every((item) => item.action === "cancel");
|
|
79
|
+
const immediate = last.selection.kind === "stack"
|
|
80
|
+
? ["escalate", "merge", "mark_ready"].includes(last.nextAction ?? "wait")
|
|
81
|
+
: last.prs.some((item) => ["escalate", "merge", "mark_ready"].includes(item.action));
|
|
82
|
+
const hasFix = last.selection.kind === "stack"
|
|
83
|
+
? last.nextAction === "fix_code"
|
|
84
|
+
: last.prs.some((item) => item.action === "fix_code");
|
|
77
85
|
const warning = await aggregateQuotaWarning(last, quotaBands, opts.intervalSeconds);
|
|
78
86
|
if (warning)
|
|
79
87
|
pendingQuotaWarning = warning;
|
|
@@ -82,14 +90,14 @@ async function runAggregatePollCore(opts) {
|
|
|
82
90
|
...last,
|
|
83
91
|
reason: "all_terminal",
|
|
84
92
|
...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
|
|
85
|
-
});
|
|
93
|
+
}, opts.merge);
|
|
86
94
|
}
|
|
87
95
|
if (immediate) {
|
|
88
96
|
return attachUsage({
|
|
89
97
|
...last,
|
|
90
98
|
reason: "actionable",
|
|
91
99
|
...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
|
|
92
|
-
});
|
|
100
|
+
}, opts.merge);
|
|
93
101
|
}
|
|
94
102
|
if (hasFix) {
|
|
95
103
|
if (debounceMs === 0)
|
|
@@ -97,19 +105,19 @@ async function runAggregatePollCore(opts) {
|
|
|
97
105
|
...last,
|
|
98
106
|
reason: "actionable",
|
|
99
107
|
...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
|
|
100
|
-
});
|
|
108
|
+
}, opts.merge);
|
|
101
109
|
debounceUntil ??= Date.now() + debounceMs;
|
|
102
110
|
if (Date.now() >= debounceUntil)
|
|
103
111
|
return attachUsage({
|
|
104
112
|
...last,
|
|
105
113
|
reason: "actionable",
|
|
106
114
|
...(pendingQuotaWarning && { quotaWarning: pendingQuotaWarning }),
|
|
107
|
-
});
|
|
115
|
+
}, opts.merge);
|
|
108
116
|
}
|
|
109
117
|
else {
|
|
110
118
|
debounceUntil = null;
|
|
111
119
|
if (opts.untilTerminal && pendingQuotaWarning) {
|
|
112
|
-
return attachUsage({ ...last, quotaWarning: pendingQuotaWarning });
|
|
120
|
+
return attachUsage({ ...last, quotaWarning: pendingQuotaWarning }, opts.merge);
|
|
113
121
|
}
|
|
114
122
|
}
|
|
115
123
|
const elapsedMs = Date.now() - start;
|
|
@@ -119,7 +127,7 @@ async function runAggregatePollCore(opts) {
|
|
|
119
127
|
if (!opts.untilTerminal && debounceUntil === null) {
|
|
120
128
|
const remainingMs = timeoutMs - elapsedMs;
|
|
121
129
|
if (remainingMs <= 0 || remainingMs + TIMER_DRIFT_TOLERANCE_MS < sleepMs) {
|
|
122
|
-
return attachUsage({ ...last, reason: "timeout" });
|
|
130
|
+
return attachUsage({ ...last, reason: "timeout" }, opts.merge);
|
|
123
131
|
}
|
|
124
132
|
}
|
|
125
133
|
const statusSignature = summaryStatusSignature(last);
|
|
@@ -132,32 +140,10 @@ async function runAggregatePollCore(opts) {
|
|
|
132
140
|
await sleep(sleepMs);
|
|
133
141
|
}
|
|
134
142
|
}
|
|
135
|
-
async function aggregateQuotaWarning(result, bands, intervalSeconds) {
|
|
136
|
-
const usage = summarizeApiTelemetry()?.graphql;
|
|
137
|
-
const [owner, repo] = result.repo.split("/");
|
|
138
|
-
if (!usage || !owner || !repo)
|
|
139
|
-
return undefined;
|
|
140
|
-
return evaluateWorktreeGraphqlQuotaWarning({ owner, repo }, bands.map((band) => ({
|
|
141
|
-
...band,
|
|
142
|
-
pollIntervalMinutes: Math.max(band.pollIntervalMinutes, intervalSeconds / 60),
|
|
143
|
-
})), usage, true);
|
|
144
|
-
}
|
|
145
|
-
function summaryStatusSignature(result) {
|
|
146
|
-
return JSON.stringify(result.prs.map((item) => ({
|
|
147
|
-
pr: item.pr,
|
|
148
|
-
action: item.action,
|
|
149
|
-
state: item.state,
|
|
150
|
-
mergeable: item.mergeable,
|
|
151
|
-
mergeStateStatus: item.mergeStateStatus,
|
|
152
|
-
reviewDecision: item.reviewDecision,
|
|
153
|
-
checks: item.checks,
|
|
154
|
-
review: item.review,
|
|
155
|
-
})));
|
|
156
|
-
}
|
|
157
143
|
function isMissingStack(error) {
|
|
158
144
|
return (error instanceof ShepherdError && error.message.includes("not part of a native GitHub stack"));
|
|
159
145
|
}
|
|
160
|
-
function attachUsage(result) {
|
|
146
|
+
function attachUsage(result, mergeRequested) {
|
|
161
147
|
const apiUsage = summarizeApiTelemetry();
|
|
162
|
-
return apiUsage ? { ...result, apiUsage } : result;
|
|
148
|
+
return withPollSummaryInstructions(apiUsage ? { ...result, apiUsage } : result, mergeRequested === true);
|
|
163
149
|
}
|
|
@@ -55,7 +55,8 @@ export async function summarizePollSummaryPr(raw, repo, opts, viewerCanAdministe
|
|
|
55
55
|
...(Object.keys(checks).length > 0 && { checks }),
|
|
56
56
|
...(Object.keys(review).length > 0 && { review }),
|
|
57
57
|
...(stack && { stack }),
|
|
58
|
-
...(!["wait", "cancel"].includes(action) &&
|
|
58
|
+
...(!["wait", "cancel"].includes(action) &&
|
|
59
|
+
!(opts.stackPrNumber !== undefined && raw.stack && action === "merge") && {
|
|
59
60
|
pollCommand: buildPollCommand(repoName, raw.number, opts),
|
|
60
61
|
}),
|
|
61
62
|
};
|
|
@@ -35,9 +35,12 @@ export function routePollSummary(raw, checks, review, opts) {
|
|
|
35
35
|
if (opts.merge && raw.isInMergeQueue) {
|
|
36
36
|
return { action: "wait", reasons: ["already-in-merge-queue"] };
|
|
37
37
|
}
|
|
38
|
-
if (opts.merge && raw.stack) {
|
|
38
|
+
if (opts.merge && raw.stack && opts.stackPrNumber === undefined) {
|
|
39
39
|
return { action: "fix_code", reasons: ["authoritative-poll-required"] };
|
|
40
40
|
}
|
|
41
|
+
if (opts.merge && raw.stack) {
|
|
42
|
+
return { action: "merge", reasons: ["appears-ready"] };
|
|
43
|
+
}
|
|
41
44
|
if (opts.merge && !raw.stack)
|
|
42
45
|
return { action: "merge", reasons: ["appears-ready"] };
|
|
43
46
|
return { action: "cancel", reasons: ["appears-ready"] };
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import type { PollSummaryCommandOptions, PollSummaryItem, PollSummarySelection } from "../types.mts";
|
|
1
|
+
import type { PollSummaryCommandOptions, PollSummaryItem, PollSummarySelection, PollSummaryStackAncestry } from "../types.mts";
|
|
2
2
|
import { type RepoInfo } from "./client.mts";
|
|
3
3
|
export interface FetchedPollSummary {
|
|
4
4
|
selection: PollSummarySelection;
|
|
5
5
|
prs: PollSummaryItem[];
|
|
6
|
+
stackAncestry?: PollSummaryStackAncestry[];
|
|
6
7
|
}
|
|
7
8
|
export declare function fetchPollSummary(opts: PollSummaryCommandOptions, repo: RepoInfo): Promise<FetchedPollSummary>;
|
|
@@ -97,9 +97,28 @@ async function fetchStackSummary(opts, repo) {
|
|
|
97
97
|
throw new ShepherdError(`GitHub returned incomplete stack membership (${unique.size} of ${stackSize} entries)`, EXIT.TEMPFAIL);
|
|
98
98
|
}
|
|
99
99
|
const ordered = [...unique.values()].sort((left, right) => left.position - right.position);
|
|
100
|
+
const stackAncestry = [];
|
|
101
|
+
for (let index = 1; index < ordered.length; index++) {
|
|
102
|
+
const parent = ordered[index - 1].pullRequest;
|
|
103
|
+
const child = ordered[index].pullRequest;
|
|
104
|
+
if (parent.state !== "OPEN" || child.state !== "OPEN")
|
|
105
|
+
continue;
|
|
106
|
+
if (child.baseRefName === parent.headRefName && child.baseRefOid === parent.headRefOid) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
stackAncestry.push({
|
|
110
|
+
parentPr: parent.number,
|
|
111
|
+
parentHeadRefName: parent.headRefName,
|
|
112
|
+
parentHeadRefOid: parent.headRefOid,
|
|
113
|
+
childPr: child.number,
|
|
114
|
+
childBaseRefName: child.baseRefName,
|
|
115
|
+
childBaseRefOid: child.baseRefOid,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
100
118
|
return {
|
|
101
119
|
selection: { kind: "stack", anchor, stackNumber, stackSize },
|
|
102
120
|
prs: await Promise.all(ordered.map((entry) => summarizePollSummaryPr(entry.pullRequest, repo, opts, viewerCanAdminister))),
|
|
121
|
+
...(stackAncestry.length > 0 && { stackAncestry }),
|
|
103
122
|
};
|
|
104
123
|
}
|
|
105
124
|
function deduplicate(values) {
|
|
@@ -18,6 +18,15 @@ export interface PollSummaryReview {
|
|
|
18
18
|
actionable?: number;
|
|
19
19
|
incomplete?: true;
|
|
20
20
|
}
|
|
21
|
+
/** The two GitHub refs at an adjacent, still-open stack boundary. */
|
|
22
|
+
export interface PollSummaryStackAncestry {
|
|
23
|
+
parentPr: number;
|
|
24
|
+
parentHeadRefName: string;
|
|
25
|
+
parentHeadRefOid: string;
|
|
26
|
+
childPr: number;
|
|
27
|
+
childBaseRefName: string;
|
|
28
|
+
childBaseRefOid: string;
|
|
29
|
+
}
|
|
21
30
|
interface PollSummaryStack {
|
|
22
31
|
number: number;
|
|
23
32
|
size: number;
|
|
@@ -63,6 +72,11 @@ export interface PollSummaryResult {
|
|
|
63
72
|
selection: PollSummarySelection;
|
|
64
73
|
reason: "actionable" | "all_terminal" | "waiting" | "timeout";
|
|
65
74
|
prs: PollSummaryItem[];
|
|
75
|
+
/** Present only for native-stack boundaries whose recorded refs differ. */
|
|
76
|
+
stackAncestry?: PollSummaryStackAncestry[];
|
|
77
|
+
/** The next stack-level transition, which may differ from an individual row's hint. */
|
|
78
|
+
nextAction?: ShepherdAction;
|
|
79
|
+
instructions?: string[];
|
|
66
80
|
apiUsage?: ApiUsage;
|
|
67
81
|
quotaWarning?: GraphqlQuotaWarning;
|
|
68
82
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pr-shepherd",
|
|
3
|
-
"version": "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": "^
|
|
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"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: pr-shepherd
|
|
3
3
|
description: 'Create or iterate a GitHub pull request with pr-shepherd (MCP or CLI). Use for requests like "make a PR and use pr-shepherd", "iterate PR #123", or "run pr-shepherd until this PR is ready".'
|
|
4
4
|
user-invocable: true
|
|
5
|
-
argument-hint: "[PR number or URL] [--merge]"
|
|
5
|
+
argument-hint: "[PR number or URL | --stack PR] [--merge]"
|
|
6
6
|
allowed-tools: ["MCP", "Bash", "Read", "Grep", "Glob", "Edit", "Write"]
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -18,9 +18,9 @@ If the requested PR does not exist yet, review and commit the in-scope changes,
|
|
|
18
18
|
|
|
19
19
|
## Arguments: $ARGUMENTS
|
|
20
20
|
|
|
21
|
-
1. Parse optional PR numbers, repository-qualified `owner/repo#N` references, or GitHub PR URLs and an optional `--merge` flag from `$ARGUMENTS`; alternatively parse one `--stack PR` selector. Otherwise let pr-shepherd infer the current branch PR. Reject any remaining argument. Follow the target repository's local `AGENTS.md` and `CLAUDE.md` standards while making changes.
|
|
21
|
+
1. Parse optional PR numbers, repository-qualified `owner/repo#N` references, or GitHub PR URLs and an optional `--merge` flag from `$ARGUMENTS`; alternatively parse one `--stack PR` selector. A clear request to merge, land, or enqueue the selected PR or stack also opts into `--merge` without a literal flag; a request only to create or open a PR does not. When the user asks to shepherd or merge a native stack and supplies an anchor PR without a literal `--stack`, use that PR as the `--stack` selector. Otherwise let pr-shepherd infer the current branch PR. Reject any remaining argument. Follow the target repository's local `AGENTS.md` and `CLAUDE.md` standards while making changes.
|
|
22
22
|
|
|
23
|
-
2. For the CLI, convert supplied `owner/repo#N` references to `https://github.com/owner/repo/pull/N`; otherwise pass supplied URLs or bare numbers unchanged, then run `pr-shepherd [PR ...] --until-terminal`, or `pr-shepherd --stack PR --until-terminal` for a stack, omitting `[PR ...]` when none was supplied and appending `--merge` when requested. This command keeps ordinary `[WAIT]` and `[MARK_READY]` ticks inside the same invocation; aggregate selectors return when any row needs work or all rows are terminal. It also returns for a quota warning or an emitted `[MERGE]` command, which is non-terminal and must run before the next invocation. A qualified reference may name a fork or upstream repository: it is the GitHub target, while the current checkout continues to supply local git/config/rules context. Do not run `pr-shepherd iterate`. If the CLI is unavailable and the `iterate` MCP tool is available, first repository-qualify every supplied reference with its GitHub URL or `owner/repo#N`; resolve bare numbers through `gh pr view <number> --json url --jq .url`, and resolve an omitted target with `gh pr view --json url --jq .url`. If that does not produce the required qualified selector, stop and report that MCP cannot safely determine it. Otherwise call `iterate` with `pr`, `prs`, or `stack` as selected, plus `merge: true` when
|
|
23
|
+
2. For the CLI, convert supplied `owner/repo#N` references to `https://github.com/owner/repo/pull/N`; otherwise pass supplied URLs or bare numbers unchanged, then run `pr-shepherd [PR ...] --until-terminal`, or `pr-shepherd --stack PR --until-terminal` for a stack, omitting `[PR ...]` when none was supplied and appending `--merge` when requested. This command keeps ordinary `[WAIT]` and `[MARK_READY]` ticks inside the same invocation; aggregate selectors return when any row needs work or all rows are terminal. It also returns for a quota warning or an emitted `[MERGE]` command, which is non-terminal and must run before the next invocation. A qualified reference may name a fork or upstream repository: it is the GitHub target, while the current checkout continues to supply local git/config/rules context. Do not run `pr-shepherd iterate`. If the CLI is unavailable and the `iterate` MCP tool is available, first repository-qualify every supplied reference with its GitHub URL or `owner/repo#N`; resolve bare numbers through `gh pr view <number> --json url --jq .url`, and resolve an omitted target with `gh pr view --json url --jq .url`. If that does not produce the required qualified selector, stop and report that MCP cannot safely determine it. Otherwise call `iterate` with `pr`, `prs`, or `stack` as selected, plus `merge: true` when merge intent was requested, and print its full result.
|
|
24
24
|
|
|
25
25
|
3. Print the full result and follow every returned `## Instructions` step exactly. For CLI output, run each printed mutation command when instructed. For MCP output, use MCP `apply` and `build_suggestion_patches` with the same qualified PR reference; do not run a shell `pr-shepherd apply` command.
|
|
26
26
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: reduce-pr-noise
|
|
3
|
+
description: Reduce repetitive pr-shepherd output by configuring bot-comment classification rules or existing noise settings. Use for requests to silence a specific bot notice, quiet polling, trim CI logs, or tune comment visibility; use pr-shepherd for PR iteration itself.
|
|
4
|
+
user-invocable: true
|
|
5
|
+
argument-hint: "[noisy bot comment or output]"
|
|
6
|
+
allowed-tools: ["Bash", "Read", "Grep", "Glob", "Edit", "Write"]
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Reduce pr-shepherd noise
|
|
10
|
+
|
|
11
|
+
Identify the unwanted output from the request, a representative Shepherd result, and the current configuration. If a content-specific rule needs a message pattern and none is available, ask for an example before writing that rule.
|
|
12
|
+
|
|
13
|
+
- For a recurring bot message identified by its author and text, read [bot-comment classifiers](references/classifiers.md).
|
|
14
|
+
- For polling status, CI checks or logs, and broad comment visibility, read [noise settings](references/settings.md).
|
|
15
|
+
- Read both references only when the request needs both kinds of change.
|
|
16
|
+
|
|
17
|
+
Make the smallest change that addresses the observed noise. Use project-local files for project-specific policy; use the user's home configuration only when they request a personal default. Validate the match or setting and explain what will become less visible. If the user asks only how to configure it, give the relevant instructions without editing files.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Bot-comment classifiers
|
|
2
|
+
|
|
3
|
+
Use a classification rule when the unwanted item has a recognizable author and message pattern. Inspect a representative item first so the rule does not hide other feedback from the same bot. Prefer an existing settings control for polling- or output-wide behavior.
|
|
4
|
+
|
|
5
|
+
Place an `.mts` file in `.pr-shepherd/classification/` in the project. Shepherd uses the first classification directory on the working directory's ancestor chain; it stops at the home directory if reached, otherwise at the filesystem root. A home-level rule directory is not discovered when the project is outside the home tree. Rule directories do not merge. Files ending in `.ts`, `.mts`, `.mjs`, or `.js` load, except names beginning with `_` or `.`. An `.mts` rule can use erasable TypeScript syntax and `import type`, without transpilation-only features such as enums.
|
|
6
|
+
|
|
7
|
+
Each file default-exports a `ClassifyRule` from `pr-shepherd/classify`. A rule receives one of `review-thread`, `pr-comment`, `review-summary`, or `changes-requested`, with `author`, `authorType`, `body`, `id`, and optional `url` or thread `path`. Match the observed `kind`, login, and distinctive body text. For example:
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import type { ClassifyRule } from "pr-shepherd/classify";
|
|
11
|
+
|
|
12
|
+
const rule: ClassifyRule = (item) => {
|
|
13
|
+
if (item.kind !== "pr-comment" && item.kind !== "review-summary") return null;
|
|
14
|
+
if (item.author.toLowerCase() !== "gemini-code-assist") return null;
|
|
15
|
+
if (!/^You have reached your daily quota limit\b/i.test(item.body)) return null;
|
|
16
|
+
return { suppress: true };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export default rule;
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`suppress: true` removes a matched item from agent output. Add `autoResolve: true` only when the requested policy also calls for resolving its thread or minimizing its comment or review summary. It is unsupported for `changes-requested` reviews, whose dismissal needs a message. With both flags, `actions.autoMinimizeSuppressed: true` lets Shepherd perform the authorized mutation silently; when GitHub does not confirm capability, the item returns to normal first-look visibility. Matching rules combine their flags, so inspect existing rules before adding one.
|
|
23
|
+
|
|
24
|
+
Exercise the exported rule against the observed item and negative examples: a different author, kind, and substantive message from the same bot. Confirm only the intended item matches before enabling automatic resolution. After editing a rule, restart a persistent MCP server or long-running poll process; it caches loaded rule modules. A new CLI process loads the change. A classifier does not retroactively remove already displayed output.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Noise settings
|
|
2
|
+
|
|
3
|
+
Inspect the unwanted Shepherd output, any active `.pr-shepherdrc.yml` files, and the built-in defaults before editing. Shepherd deep-merges files from the user's home directory through the working directory: closer scalar values win, nested maps merge, and closer arrays replace farther arrays. Put shared policy in the project file and personal preferences in the home file. Preserve unrelated values and existing list entries when changing an array.
|
|
4
|
+
|
|
5
|
+
Choose the narrowest control for the observed source:
|
|
6
|
+
|
|
7
|
+
| Source | Setting | Effect |
|
|
8
|
+
| ----------------------------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
9
|
+
| Repeated unchanged `WAIT` status | `poll.quietStatus: true` or one-run `--quiet-status` | Hides unchanged polling snapshots; it does not affect single-tick `iterate` or MCP calls. |
|
|
10
|
+
| Repetitive CI log lines | `checks.ignoreLogLines` | Drops matching raw log lines from failure excerpts and annotation deduplication input. Patterns are regex source strings. |
|
|
11
|
+
| An irrelevant check context | `ignoreChecks` | Removes detailed check status and readiness, triage, and stall effects; names remain in the `**ignored**` rollup. Patterns are case-insensitive globs; `actions.neverCancelRuns` can keep matching Actions runs visible. |
|
|
12
|
+
| Non-human PR comments or review summaries | `iterate.minimizeComments: all`, `bots`, or `none` | Controls eligible minimization; `all` is already the default. Excluded items still appear on first look and after edits. |
|
|
13
|
+
| Non-human approval reviews | `iterate.minimizeApprovals: true` | Also makes eligible approvals minimizable; the default is `false`. |
|
|
14
|
+
|
|
15
|
+
For example, a project that wants quieter polling and to remove a known teardown line from CI excerpts can use:
|
|
16
|
+
|
|
17
|
+
```yaml
|
|
18
|
+
poll:
|
|
19
|
+
quietStatus: true
|
|
20
|
+
checks:
|
|
21
|
+
ignoreLogLines:
|
|
22
|
+
- "^\\[vitest-teardown\\]"
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Do not use `ignoreChecks` merely to hide a failing or required check: the check no longer contributes to Shepherd's readiness decision. `botUsernames` extends bot detection and handling, but configured-bot threads remain visible every tick until resolved, so it is not a general suppression setting. For a specific bot message, use the classifier guide instead. `actions.autoMinimizeSuppressed` only changes the treatment of classifier matches that set both `suppress` and `autoResolve` and is already `true` by default.
|
|
26
|
+
|
|
27
|
+
After editing, validate the YAML and regex syntax, check that the intended config file wins in the cascade, and compare representative output with the expected effect. If a live PR is used to verify behavior, account for any review mutation the configured action may authorize.
|