pr-shepherd 0.5.2 → 0.7.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +85 -25
- package/bin/checks/triage.mjs +35 -20
- package/bin/cli.mjs +96 -14
- package/bin/commands/check.mjs +26 -9
- package/bin/commands/iterate.mjs +365 -38
- package/bin/commands/resolve.mjs +4 -1
- package/bin/config/load.mjs +5 -0
- package/bin/config.json +2 -5
- package/bin/github/batch.mjs +36 -8
- package/bin/github/client.mjs +39 -110
- package/bin/github/gql/batch-pr.gql +23 -2
- package/bin/github/gql/mark-pr-ready.gql +7 -0
- package/bin/github/gql/pr-number-by-branch.gql +9 -0
- package/bin/github/http.mjs +145 -0
- package/bin/github/queries.mjs +4 -0
- package/package.json +1 -1
- package/plugin/skills/monitor/SKILL.md +24 -67
- package/plugin/skills/resolve/SKILL.md +27 -12
package/bin/commands/iterate.mjs
CHANGED
|
@@ -27,6 +27,8 @@ import { runCheck } from "./check.mjs";
|
|
|
27
27
|
import { triageFailingChecks } from "../checks/triage.mjs";
|
|
28
28
|
import { updateReadyDelay } from "./ready-delay.mjs";
|
|
29
29
|
import { getCurrentPrNumber } from "../github/client.mjs";
|
|
30
|
+
import { rest, graphql } from "../github/http.mjs";
|
|
31
|
+
import { MARK_PR_READY_MUTATION } from "../github/queries.mjs";
|
|
30
32
|
import { readFixAttempts, writeFixAttempts } from "../cache/fix-attempts.mjs";
|
|
31
33
|
import { toAgentThread, toAgentComment, toAgentChecks } from "../reporters/agent.mjs";
|
|
32
34
|
import { loadConfig } from "../config/load.mjs";
|
|
@@ -61,6 +63,8 @@ export async function runIterate(opts) {
|
|
|
61
63
|
shouldCancel: false,
|
|
62
64
|
remainingSeconds: readyDelaySeconds,
|
|
63
65
|
summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0 },
|
|
66
|
+
baseBranch: "",
|
|
67
|
+
log: "SKIP: CI still starting — waiting for first check to appear",
|
|
64
68
|
};
|
|
65
69
|
}
|
|
66
70
|
// Step 2: Sweep — fetch CI + comments + merge status, auto-resolve outdated.
|
|
@@ -72,6 +76,7 @@ export async function runIterate(opts) {
|
|
|
72
76
|
});
|
|
73
77
|
// Step 2.5: Cancel if PR is merged or closed — no longer actionable.
|
|
74
78
|
if (report.mergeStatus.state !== "OPEN") {
|
|
79
|
+
const state = report.mergeStatus.state.toLowerCase();
|
|
75
80
|
return {
|
|
76
81
|
pr: report.pr,
|
|
77
82
|
repo: report.repo,
|
|
@@ -83,7 +88,9 @@ export async function runIterate(opts) {
|
|
|
83
88
|
remainingSeconds: 0,
|
|
84
89
|
state: report.mergeStatus.state,
|
|
85
90
|
summary: buildSummary(report),
|
|
91
|
+
baseBranch: report.baseBranch,
|
|
86
92
|
action: "cancel",
|
|
93
|
+
log: `CANCEL: PR #${report.pr} is ${state} — stopping monitor`,
|
|
87
94
|
};
|
|
88
95
|
}
|
|
89
96
|
// Step 3: Ready-delay state machine.
|
|
@@ -104,14 +111,22 @@ export async function runIterate(opts) {
|
|
|
104
111
|
shouldCancel: readyState.shouldCancel,
|
|
105
112
|
remainingSeconds: readyState.remainingSeconds,
|
|
106
113
|
summary: buildSummary(report),
|
|
114
|
+
baseBranch: report.baseBranch,
|
|
107
115
|
};
|
|
108
116
|
// Step 3 cont.: cancel if ready-delay elapsed.
|
|
109
117
|
if (readyState.shouldCancel) {
|
|
110
|
-
return {
|
|
118
|
+
return {
|
|
119
|
+
...base,
|
|
120
|
+
action: "cancel",
|
|
121
|
+
log: `CANCEL: PR #${base.pr} has been ready for review — ready-delay elapsed, stopping monitor`,
|
|
122
|
+
};
|
|
111
123
|
}
|
|
112
124
|
// Triage failing checks now that we know we need failureKind for steps 4–6.
|
|
113
125
|
if (report.checks.failing.length > 0) {
|
|
114
|
-
const triaged = await triageFailingChecks(report.checks.failing
|
|
126
|
+
const triaged = await triageFailingChecks(report.checks.failing, {
|
|
127
|
+
owner: repoOwner,
|
|
128
|
+
name: repoName,
|
|
129
|
+
});
|
|
115
130
|
report = { ...report, checks: { ...report.checks, failing: triaged } };
|
|
116
131
|
}
|
|
117
132
|
// Step 4: Actionable work — fix comments, review requests, CI failures, and merge
|
|
@@ -134,16 +149,20 @@ export async function runIterate(opts) {
|
|
|
134
149
|
// Escalation checks — surface ambiguous situations instead of looping forever.
|
|
135
150
|
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.comments.actionable, report.changesRequestedReviews, actionableChecks, attempts.threadAttempts, report.mergeStatus.status === "CONFLICTS");
|
|
136
151
|
if (escalateTriggers.triggers.length > 0) {
|
|
152
|
+
const escalateBase = {
|
|
153
|
+
triggers: escalateTriggers.triggers,
|
|
154
|
+
unresolvedThreads: report.threads.actionable.map(toAgentThread),
|
|
155
|
+
ambiguousComments: report.comments.actionable.map(toAgentComment),
|
|
156
|
+
changesRequestedReviews: report.changesRequestedReviews,
|
|
157
|
+
attemptHistory: escalateTriggers.thrashHistory,
|
|
158
|
+
suggestion: buildEscalateSuggestion(escalateTriggers.triggers),
|
|
159
|
+
};
|
|
137
160
|
return {
|
|
138
161
|
...base,
|
|
139
162
|
action: "escalate",
|
|
140
163
|
escalate: {
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
ambiguousComments: report.comments.actionable.map(toAgentComment),
|
|
144
|
-
changesRequestedReviews: report.changesRequestedReviews,
|
|
145
|
-
attemptHistory: escalateTriggers.thrashHistory,
|
|
146
|
-
suggestion: buildEscalateSuggestion(escalateTriggers.triggers),
|
|
164
|
+
...escalateBase,
|
|
165
|
+
humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
|
|
147
166
|
},
|
|
148
167
|
};
|
|
149
168
|
}
|
|
@@ -158,17 +177,51 @@ export async function runIterate(opts) {
|
|
|
158
177
|
const uniqueRunIds = [
|
|
159
178
|
...new Set(actionableChecks.map((c) => c.runId).filter((id) => id !== null)),
|
|
160
179
|
];
|
|
161
|
-
const results = await Promise.all(uniqueRunIds.map((id) => tryCancelRun(id)));
|
|
180
|
+
const results = await Promise.all(uniqueRunIds.map((id) => tryCancelRun(id, repoOwner, repoName)));
|
|
162
181
|
cancelled = results.filter((id) => id !== null);
|
|
163
182
|
}
|
|
183
|
+
const baseLookup = validateBaseBranch(report.baseBranch);
|
|
184
|
+
const threads = report.threads.actionable.map(toAgentThread);
|
|
185
|
+
const { actionable: actionableComments, noiseIds: noiseCommentIds } = classifyComments(report.comments.actionable.map(toAgentComment));
|
|
186
|
+
const checks = toAgentChecks(actionableChecks);
|
|
187
|
+
const { changesRequestedReviews } = report;
|
|
188
|
+
const allCommentIds = [...actionableComments.map((c) => c.id), ...noiseCommentIds];
|
|
189
|
+
const resolveCommand = buildResolveCommand(threads, actionableComments, allCommentIds, changesRequestedReviews, checks, prNumber);
|
|
190
|
+
const hasConflicts = report.mergeStatus.status === "CONFLICTS";
|
|
191
|
+
// Guard: if the emitted flow requires a push (code fixes or conflict
|
|
192
|
+
// resolution rebase) but we could not confirm the PR's base branch, refuse
|
|
193
|
+
// to emit fix_code — a wrong-base rebase would rewrite history onto the
|
|
194
|
+
// wrong target. Escalate for human direction.
|
|
195
|
+
if (baseLookup.isFallback && (resolveCommand.requiresHeadSha || hasConflicts)) {
|
|
196
|
+
const fallbackEscalateBase = {
|
|
197
|
+
triggers: ["base-branch-unknown"],
|
|
198
|
+
unresolvedThreads: threads,
|
|
199
|
+
ambiguousComments: actionableComments,
|
|
200
|
+
changesRequestedReviews,
|
|
201
|
+
suggestion: buildEscalateSuggestion(["base-branch-unknown"], baseLookup.failureReason),
|
|
202
|
+
};
|
|
203
|
+
return {
|
|
204
|
+
...base,
|
|
205
|
+
action: "escalate",
|
|
206
|
+
escalate: {
|
|
207
|
+
...fallbackEscalateBase,
|
|
208
|
+
humanMessage: buildEscalateHumanMessage(fallbackEscalateBase, prNumber),
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
const instructions = buildFixInstructions(threads, actionableComments, checks, changesRequestedReviews, baseLookup.branch, resolveCommand, hasConflicts, prNumber);
|
|
164
213
|
return {
|
|
165
214
|
...base,
|
|
215
|
+
baseBranch: baseLookup.branch,
|
|
166
216
|
action: "fix_code",
|
|
167
217
|
fix: {
|
|
168
|
-
threads
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
218
|
+
threads,
|
|
219
|
+
actionableComments,
|
|
220
|
+
noiseCommentIds,
|
|
221
|
+
checks,
|
|
222
|
+
changesRequestedReviews,
|
|
223
|
+
resolveCommand,
|
|
224
|
+
instructions,
|
|
172
225
|
},
|
|
173
226
|
cancelled,
|
|
174
227
|
};
|
|
@@ -176,17 +229,63 @@ export async function runIterate(opts) {
|
|
|
176
229
|
// Step 5: Transient failures (timeout / infrastructure) — no actionable work, no conflicts.
|
|
177
230
|
const transientChecks = report.checks.failing.filter((f) => f.failureKind === "timeout" || f.failureKind === "infrastructure");
|
|
178
231
|
if (transientChecks.length > 0 && !opts.noAutoRerun) {
|
|
179
|
-
//
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
232
|
+
// Group checks by runId — multiple failed steps can share one run.
|
|
233
|
+
const runMap = new Map();
|
|
234
|
+
for (const c of transientChecks) {
|
|
235
|
+
if (c.runId === null)
|
|
236
|
+
continue;
|
|
237
|
+
const existing = runMap.get(c.runId);
|
|
238
|
+
if (existing) {
|
|
239
|
+
existing.checkNames.push(c.name);
|
|
240
|
+
}
|
|
241
|
+
else {
|
|
242
|
+
runMap.set(c.runId, {
|
|
243
|
+
runId: c.runId,
|
|
244
|
+
checkNames: [c.name],
|
|
245
|
+
failureKind: c.failureKind,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const reran = [...runMap.values()];
|
|
250
|
+
await Promise.all(reran.map(({ runId }) => rest("POST", `/repos/${repoOwner}/${repoName}/actions/runs/${runId}/rerun-failed-jobs`)));
|
|
251
|
+
const runSummaries = reran.map(({ runId, checkNames, failureKind }) => `${runId} (${checkNames.join(", ")} — ${failureKind})`);
|
|
252
|
+
return {
|
|
253
|
+
...base,
|
|
254
|
+
action: "rerun_ci",
|
|
255
|
+
reran,
|
|
256
|
+
log: `RERAN ${reran.length} CI run${reran.length === 1 ? "" : "s"}: ${runSummaries.join(", ")}`,
|
|
257
|
+
};
|
|
185
258
|
}
|
|
186
259
|
// Step 6: Flaky + behind — rebase needed.
|
|
187
260
|
const hasFlaky = report.checks.failing.some((f) => f.failureKind === "flaky");
|
|
188
261
|
if (hasFlaky && report.mergeStatus.status === "BEHIND" && config.actions.autoRebase) {
|
|
189
|
-
|
|
262
|
+
const baseLookup = validateBaseBranch(report.baseBranch);
|
|
263
|
+
if (baseLookup.isFallback) {
|
|
264
|
+
const fallbackEscalateBase = {
|
|
265
|
+
triggers: ["base-branch-unknown"],
|
|
266
|
+
unresolvedThreads: [],
|
|
267
|
+
ambiguousComments: [],
|
|
268
|
+
changesRequestedReviews: [],
|
|
269
|
+
suggestion: buildEscalateSuggestion(["base-branch-unknown"], baseLookup.failureReason),
|
|
270
|
+
};
|
|
271
|
+
return {
|
|
272
|
+
...base,
|
|
273
|
+
action: "escalate",
|
|
274
|
+
escalate: {
|
|
275
|
+
...fallbackEscalateBase,
|
|
276
|
+
humanMessage: buildEscalateHumanMessage(fallbackEscalateBase, prNumber),
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
...base,
|
|
282
|
+
baseBranch: baseLookup.branch,
|
|
283
|
+
action: "rebase",
|
|
284
|
+
rebase: {
|
|
285
|
+
reason: `Branch is behind ${baseLookup.branch} — rebasing to pick up latest changes and clear flaky failures`,
|
|
286
|
+
shellScript: buildRebaseShellScript(baseLookup.branch),
|
|
287
|
+
},
|
|
288
|
+
};
|
|
190
289
|
}
|
|
191
290
|
// Step 7: Mark ready for review.
|
|
192
291
|
// Draft PRs often report mergeStateStatus === 'DRAFT' rather than 'CLEAN' until
|
|
@@ -199,11 +298,20 @@ export async function runIterate(opts) {
|
|
|
199
298
|
!readyState.shouldCancel &&
|
|
200
299
|
report.mergeStatus.isDraft;
|
|
201
300
|
if (canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady) {
|
|
202
|
-
await
|
|
203
|
-
return {
|
|
301
|
+
await graphql(MARK_PR_READY_MUTATION, { pullRequestId: report.nodeId });
|
|
302
|
+
return {
|
|
303
|
+
...base,
|
|
304
|
+
action: "mark_ready",
|
|
305
|
+
markedReady: true,
|
|
306
|
+
log: `MARKED READY: PR #${report.pr} converted from draft to ready for review`,
|
|
307
|
+
};
|
|
204
308
|
}
|
|
205
309
|
// Step 8: Nothing to do.
|
|
206
|
-
return {
|
|
310
|
+
return {
|
|
311
|
+
...base,
|
|
312
|
+
action: "wait",
|
|
313
|
+
log: buildWaitLog(base),
|
|
314
|
+
};
|
|
207
315
|
}
|
|
208
316
|
// ---------------------------------------------------------------------------
|
|
209
317
|
// Helpers
|
|
@@ -225,27 +333,18 @@ async function getLastCommitTime() {
|
|
|
225
333
|
return 0;
|
|
226
334
|
}
|
|
227
335
|
}
|
|
228
|
-
async function runGhCommand(args) {
|
|
229
|
-
try {
|
|
230
|
-
await execFile("gh", args);
|
|
231
|
-
}
|
|
232
|
-
catch (err) {
|
|
233
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
234
|
-
throw new Error(`gh ${args.join(" ")} failed: ${msg}`, { cause: err });
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
336
|
// Best-effort: cancelling a completed run is a no-op, not an error.
|
|
238
|
-
async function tryCancelRun(runId) {
|
|
337
|
+
async function tryCancelRun(runId, owner, repo) {
|
|
239
338
|
try {
|
|
240
|
-
await
|
|
339
|
+
await rest("POST", `/repos/${owner}/${repo}/actions/runs/${runId}/cancel`);
|
|
241
340
|
return runId;
|
|
242
341
|
}
|
|
243
342
|
catch (err) {
|
|
244
343
|
const msg = err instanceof Error ? err.message : String(err);
|
|
245
|
-
//
|
|
246
|
-
if (/already completed|cannot cancel a workflow run that is completed/i.test(msg))
|
|
344
|
+
// GitHub returns 409 when the run reached a terminal state — expected, not worth logging.
|
|
345
|
+
if (/409|already completed|cannot cancel a workflow run that is completed/i.test(msg))
|
|
247
346
|
return null;
|
|
248
|
-
process.stderr.write(`pr-shepherd:
|
|
347
|
+
process.stderr.write(`pr-shepherd: cancel run ${runId} failed (ignored): ${msg}\n`);
|
|
249
348
|
return null;
|
|
250
349
|
}
|
|
251
350
|
}
|
|
@@ -287,7 +386,235 @@ function checkEscalateTriggers(actionableThreads, actionableComments, changesReq
|
|
|
287
386
|
: undefined,
|
|
288
387
|
};
|
|
289
388
|
}
|
|
290
|
-
|
|
389
|
+
/**
|
|
390
|
+
* Validate the base branch name from the GraphQL batch (`report.baseBranch`)
|
|
391
|
+
* and fall back safely if it's missing/unsafe. The branch is interpolated into
|
|
392
|
+
* shell commands by `buildRebaseShellScript` and `buildFixInstructions`, so we
|
|
393
|
+
* reject anything outside `[A-Za-z0-9._/-]` to prevent shell injection.
|
|
394
|
+
*
|
|
395
|
+
* Previously a separate `gh pr view --json baseRefName` subprocess — eliminated
|
|
396
|
+
* per review feedback since the batch GraphQL query now returns it directly.
|
|
397
|
+
*/
|
|
398
|
+
function validateBaseBranch(raw) {
|
|
399
|
+
const trimmed = raw.trim();
|
|
400
|
+
if (trimmed === "") {
|
|
401
|
+
return {
|
|
402
|
+
branch: "main",
|
|
403
|
+
isFallback: true,
|
|
404
|
+
failureReason: "GraphQL batch returned an empty base branch name",
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
if (!/^[A-Za-z0-9._/-]+$/.test(trimmed)) {
|
|
408
|
+
return {
|
|
409
|
+
branch: "main",
|
|
410
|
+
isFallback: true,
|
|
411
|
+
failureReason: `base branch ${JSON.stringify(trimmed)} contains unsafe characters`,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
return { branch: trimmed, isFallback: false };
|
|
415
|
+
}
|
|
416
|
+
function buildRebaseShellScript(baseBranch) {
|
|
417
|
+
return [
|
|
418
|
+
`if ! git diff --quiet || ! git diff --cached --quiet; then`,
|
|
419
|
+
` echo "SKIP rebase: dirty worktree (uncommitted changes present)"`,
|
|
420
|
+
` exit 1`,
|
|
421
|
+
`fi`,
|
|
422
|
+
`git fetch origin && git rebase origin/${baseBranch} && git push --force-with-lease`,
|
|
423
|
+
].join("\n");
|
|
424
|
+
}
|
|
425
|
+
// Patterns that indicate a comment is bot-generated noise rather than actionable feedback.
|
|
426
|
+
// Conservative: only match explicit known patterns to avoid accidentally suppressing real reviews.
|
|
427
|
+
const NOISE_PATTERNS = [
|
|
428
|
+
/you have reached your daily quota/i,
|
|
429
|
+
/please wait up to \d+ hours?/i,
|
|
430
|
+
/rate[\s\-]?limit(?:ed)?\s*[—\-:]\s*try again/i,
|
|
431
|
+
/resuming (monitoring|watch|checking)/i,
|
|
432
|
+
/restarting (monitoring|watch)/i,
|
|
433
|
+
];
|
|
434
|
+
function isNoiseComment(comment) {
|
|
435
|
+
return NOISE_PATTERNS.some((p) => p.test(comment.body));
|
|
436
|
+
}
|
|
437
|
+
function classifyComments(comments) {
|
|
438
|
+
const actionable = [];
|
|
439
|
+
const noiseIds = [];
|
|
440
|
+
for (const c of comments) {
|
|
441
|
+
if (isNoiseComment(c)) {
|
|
442
|
+
noiseIds.push(c.id);
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
actionable.push(c);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
return { actionable, noiseIds };
|
|
449
|
+
}
|
|
450
|
+
function buildResolveCommand(threads, actionableComments, allCommentIds, reviews, checks, prNumber) {
|
|
451
|
+
const argv = ["npx", "pr-shepherd", "resolve", String(prNumber)];
|
|
452
|
+
if (threads.length > 0) {
|
|
453
|
+
argv.push("--resolve-thread-ids", threads.map((t) => t.id).join(","));
|
|
454
|
+
}
|
|
455
|
+
if (allCommentIds.length > 0) {
|
|
456
|
+
argv.push("--minimize-comment-ids", allCommentIds.join(","));
|
|
457
|
+
}
|
|
458
|
+
const hasDismiss = reviews.length > 0;
|
|
459
|
+
if (hasDismiss) {
|
|
460
|
+
argv.push("--dismiss-review-ids", reviews.map((r) => r.id).join(","));
|
|
461
|
+
argv.push("--message", "$DISMISS_MESSAGE");
|
|
462
|
+
}
|
|
463
|
+
// A push happens when there is code to change — threads, actionable comments, CI checks, or reviews.
|
|
464
|
+
// Noise-only comment minimization skips commit/push, so requiresHeadSha must be false.
|
|
465
|
+
const requiresHeadSha = threads.length > 0 || actionableComments.length > 0 || checks.length > 0 || reviews.length > 0;
|
|
466
|
+
// hasMutations = we appended at least one of --resolve-thread-ids,
|
|
467
|
+
// --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
|
|
468
|
+
// (rather than derived from argv.length) so callers don't couple to the
|
|
469
|
+
// base-argv shape.
|
|
470
|
+
const hasMutations = threads.length > 0 || allCommentIds.length > 0 || reviews.length > 0;
|
|
471
|
+
return { argv, requiresHeadSha, requiresDismissMessage: hasDismiss, hasMutations };
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Render a ResolveCommand as a single-line command string for the monitor loop
|
|
475
|
+
* to print or execute. This is NOT a general-purpose POSIX escaper — it wraps
|
|
476
|
+
* the two known placeholders ($DISMISS_MESSAGE, $HEAD_SHA) and any whitespace-
|
|
477
|
+
* bearing arg in double quotes so multi-word values don't split across flags.
|
|
478
|
+
*
|
|
479
|
+
* Contract for callers substituting placeholders: replace the entire quoted
|
|
480
|
+
* token (including the surrounding `"`) with a properly shell-quoted literal.
|
|
481
|
+
* Do not splice raw text inside the existing quotes — the output would then
|
|
482
|
+
* re-expand `$…` / `$(…)` / embedded `"` and break.
|
|
483
|
+
*/
|
|
484
|
+
export function renderResolveCommand(rc) {
|
|
485
|
+
// `$HEAD_SHA` is never in `rc.argv` — it is appended pre-quoted below when
|
|
486
|
+
// `requiresHeadSha`. Only `$DISMISS_MESSAGE` (or whitespace-bearing values)
|
|
487
|
+
// need quoting here.
|
|
488
|
+
const needsQuoting = (arg) => arg === "$DISMISS_MESSAGE" || /\s/.test(arg);
|
|
489
|
+
const parts = rc.argv.map((a) => (needsQuoting(a) ? `"${a}"` : a));
|
|
490
|
+
if (rc.requiresHeadSha) {
|
|
491
|
+
parts.push("--require-sha", '"$HEAD_SHA"');
|
|
492
|
+
}
|
|
493
|
+
return parts.join(" ");
|
|
494
|
+
}
|
|
495
|
+
function buildFixInstructions(threads, actionableComments, checks, reviews, baseBranch, resolveCommand, hasConflicts, prNumber) {
|
|
496
|
+
const instructions = [];
|
|
497
|
+
if (threads.length > 0 || actionableComments.length > 0) {
|
|
498
|
+
instructions.push(`Apply code fixes: read and edit each file referenced under \`## Review threads\` and \`## Actionable comments\` above.`);
|
|
499
|
+
}
|
|
500
|
+
// Mirror the truthiness checks in `formatIterateResult` (cli.mts) so each
|
|
501
|
+
// AgentCheck maps to the same bullet shape here as there: runId → runId
|
|
502
|
+
// bullet, else detailsUrl → external bullet, else `(no runId)` bullet.
|
|
503
|
+
const checksWithRunId = checks.filter((c) => c.runId);
|
|
504
|
+
const externalChecks = checks.filter((c) => !c.runId && c.detailsUrl);
|
|
505
|
+
const bareChecks = checks.filter((c) => !c.runId && !c.detailsUrl);
|
|
506
|
+
if (checksWithRunId.length > 0) {
|
|
507
|
+
instructions.push(`For each bullet in \`## Failing checks\` whose backticked locator is a numeric runId (GitHub Actions): run \`gh run view <runId> --log-failed\`, identify the failure, and apply the fix.`);
|
|
508
|
+
}
|
|
509
|
+
if (externalChecks.length > 0) {
|
|
510
|
+
instructions.push(`For each bullet in \`## Failing checks\` starting with \`external\` (external status check): open the linked URL in a browser to inspect the failure — \`gh run view\` cannot fetch logs for external checks.`);
|
|
511
|
+
}
|
|
512
|
+
if (bareChecks.length > 0) {
|
|
513
|
+
instructions.push(`For each bullet in \`## Failing checks\` starting with \`(no runId)\`: there is no run or details URL to inspect. Escalate these to a human — they require manual investigation outside the pr-shepherd flow.`);
|
|
514
|
+
}
|
|
515
|
+
if (reviews.length > 0) {
|
|
516
|
+
instructions.push(`For each bullet under \`## Changes-requested reviews\` above: read the review body and apply the requested changes.`);
|
|
517
|
+
}
|
|
518
|
+
const hasCodeChanges = threads.length > 0 || actionableComments.length > 0 || checks.length > 0 || reviews.length > 0;
|
|
519
|
+
const needsPush = hasCodeChanges || hasConflicts;
|
|
520
|
+
if (hasCodeChanges) {
|
|
521
|
+
instructions.push(`Commit changed files: \`git add <files> && git commit -m "<descriptive message>"\``);
|
|
522
|
+
instructions.push(`Keep the PR title and description current: if the changes alter the PR's scope or intent, run \`gh pr edit ${prNumber} --title "<new title>" --body "<new body>"\` to reflect them. Skip if the existing title/body still accurately describe the PR.`);
|
|
523
|
+
}
|
|
524
|
+
if (needsPush) {
|
|
525
|
+
const captureHint = resolveCommand.requiresHeadSha
|
|
526
|
+
? ` — capture \`HEAD_SHA=$(git rev-parse HEAD)\``
|
|
527
|
+
: "";
|
|
528
|
+
if (hasConflicts) {
|
|
529
|
+
instructions.push(`Rebase with conflict resolution: run \`git fetch origin && git rebase origin/${baseBranch}\`. If the rebase halts with conflicts, edit the conflicted files to resolve them, \`git add <files>\`, then \`git rebase --continue\`. Repeat until the rebase completes, then \`git push --force-with-lease\`${captureHint}.`);
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
instructions.push(`Rebase and push: \`git fetch origin && git rebase origin/${baseBranch} && git push --force-with-lease\`${captureHint}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
// Only tell the agent to run `resolve:` if the command actually mutates
|
|
536
|
+
// GitHub state. A CONFLICTS-only flow has nothing to mutate on GitHub.
|
|
537
|
+
if (resolveCommand.hasMutations) {
|
|
538
|
+
const substituteParts = [];
|
|
539
|
+
if (resolveCommand.requiresHeadSha) {
|
|
540
|
+
substituteParts.push(`"$HEAD_SHA" with the pushed commit SHA`);
|
|
541
|
+
}
|
|
542
|
+
if (resolveCommand.requiresDismissMessage) {
|
|
543
|
+
substituteParts.push(`$DISMISS_MESSAGE with a one-sentence description of what you changed`);
|
|
544
|
+
}
|
|
545
|
+
const substituteHint = substituteParts.length > 0 ? `, substituting ${substituteParts.join(" and ")}` : "";
|
|
546
|
+
instructions.push(`Run the \`resolve:\` command shown above${substituteHint}.`);
|
|
547
|
+
}
|
|
548
|
+
return instructions;
|
|
549
|
+
}
|
|
550
|
+
function buildWaitLog(base) {
|
|
551
|
+
const { summary, mergeStateStatus, remainingSeconds } = base;
|
|
552
|
+
const parts = [`WAIT: ${summary.passing} passing, ${summary.inProgress} in-progress`];
|
|
553
|
+
switch (mergeStateStatus) {
|
|
554
|
+
case "BEHIND":
|
|
555
|
+
parts.push("branch is behind base");
|
|
556
|
+
break;
|
|
557
|
+
case "BLOCKED":
|
|
558
|
+
parts.push("blocked by pending reviews or required status checks");
|
|
559
|
+
break;
|
|
560
|
+
case "DRAFT":
|
|
561
|
+
parts.push("PR is a draft");
|
|
562
|
+
break;
|
|
563
|
+
case "UNSTABLE":
|
|
564
|
+
parts.push("some checks are unstable");
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
if (remainingSeconds > 0) {
|
|
568
|
+
parts.push(`${remainingSeconds}s until auto-cancel`);
|
|
569
|
+
}
|
|
570
|
+
return parts.join(" — ");
|
|
571
|
+
}
|
|
572
|
+
function buildEscalateHumanMessage(escalate, pr) {
|
|
573
|
+
const lines = [];
|
|
574
|
+
lines.push("⚠️ /pr-shepherd:monitor paused — needs human direction");
|
|
575
|
+
lines.push("");
|
|
576
|
+
lines.push(`**Triggers:** ${escalate.triggers.map((t) => `\`${t}\``).join(", ")}`);
|
|
577
|
+
lines.push("");
|
|
578
|
+
lines.push(escalate.suggestion);
|
|
579
|
+
const hasItems = escalate.unresolvedThreads.length > 0 ||
|
|
580
|
+
escalate.changesRequestedReviews.length > 0 ||
|
|
581
|
+
escalate.ambiguousComments.length > 0;
|
|
582
|
+
if (hasItems) {
|
|
583
|
+
lines.push("");
|
|
584
|
+
lines.push("## Items needing attention");
|
|
585
|
+
for (const t of escalate.unresolvedThreads) {
|
|
586
|
+
const loc = t.path ? `\`${t.path}:${t.line ?? "?"}\`` : "(no location)";
|
|
587
|
+
const firstLine = t.body.split("\n")[0] ?? "";
|
|
588
|
+
lines.push(`- thread \`${t.id}\` — ${loc} (@${t.author}): ${firstLine}`);
|
|
589
|
+
}
|
|
590
|
+
for (const r of escalate.changesRequestedReviews) {
|
|
591
|
+
const firstLine = r.body.split("\n")[0] ?? "";
|
|
592
|
+
lines.push(`- review \`${r.id}\` (@${r.author}): ${firstLine}`);
|
|
593
|
+
}
|
|
594
|
+
for (const c of escalate.ambiguousComments) {
|
|
595
|
+
const firstLine = c.body.split("\n")[0] ?? "";
|
|
596
|
+
lines.push(`- comment \`${c.id}\` (@${c.author}): ${firstLine}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if (escalate.attemptHistory && escalate.attemptHistory.length > 0) {
|
|
600
|
+
lines.push("");
|
|
601
|
+
lines.push("## Fix attempts");
|
|
602
|
+
for (const a of escalate.attemptHistory) {
|
|
603
|
+
lines.push(`- thread \`${a.threadId}\` attempted ${a.attempts} times`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
lines.push("");
|
|
607
|
+
lines.push("---");
|
|
608
|
+
lines.push("");
|
|
609
|
+
lines.push(`Run \`/pr-shepherd:check ${pr}\` to see current state.`);
|
|
610
|
+
lines.push(`After fixing manually, rerun \`/pr-shepherd:monitor ${pr}\` to resume.`);
|
|
611
|
+
return lines.join("\n");
|
|
612
|
+
}
|
|
613
|
+
function buildEscalateSuggestion(triggers, failureReason) {
|
|
614
|
+
if (triggers.includes("base-branch-unknown")) {
|
|
615
|
+
const reason = failureReason ? ` (${failureReason})` : "";
|
|
616
|
+
return `Could not determine the PR's base branch${reason} — refusing to emit a rebase that could force-push onto the wrong base. Run the rebase manually against the PR's real target branch.`;
|
|
617
|
+
}
|
|
291
618
|
if (triggers.includes("fix-thrash")) {
|
|
292
619
|
return "Same thread(s) attempted multiple times without resolution — fix manually then rerun /pr-shepherd:monitor";
|
|
293
620
|
}
|
package/bin/commands/resolve.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
|
|
|
17
17
|
import { fetchPrBatch } from "../github/batch.mjs";
|
|
18
18
|
import { getOutdatedThreads } from "../comments/outdated.mjs";
|
|
19
19
|
import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mjs";
|
|
20
|
+
import { loadConfig } from "../config/load.mjs";
|
|
20
21
|
/**
|
|
21
22
|
* Fetch mode: auto-resolve outdated threads and return all active items for LLM triage.
|
|
22
23
|
*/
|
|
@@ -39,10 +40,12 @@ export async function runResolveFetch(opts) {
|
|
|
39
40
|
}
|
|
40
41
|
}
|
|
41
42
|
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
|
|
43
|
+
const cfg = loadConfig();
|
|
42
44
|
return {
|
|
43
|
-
actionableThreads: activeThreads.map(({ isResolved, isOutdated, ...rest }) => rest),
|
|
45
|
+
actionableThreads: activeThreads.map(({ isResolved: _r, isOutdated: _o, ...rest }) => rest),
|
|
44
46
|
actionableComments: visibleComments,
|
|
45
47
|
changesRequestedReviews: data.changesRequestedReviews,
|
|
48
|
+
reviewSummaries: cfg.resolve.fetchReviewSummaries ? data.reviewSummaries : [],
|
|
46
49
|
};
|
|
47
50
|
}
|
|
48
51
|
/**
|
package/bin/config/load.mjs
CHANGED
|
@@ -41,6 +41,11 @@ function deepMerge(base, override) {
|
|
|
41
41
|
// ---------------------------------------------------------------------------
|
|
42
42
|
function applyCompat(raw) {
|
|
43
43
|
const out = { ...raw };
|
|
44
|
+
// Removed top-level sections — warn and strip.
|
|
45
|
+
if ("execution" in out) {
|
|
46
|
+
process.stderr.write(`pr-shepherd: config section "execution" (maxBufferMb, triageLogBufferMb) has been removed and has no effect.\n`);
|
|
47
|
+
delete out["execution"];
|
|
48
|
+
}
|
|
44
49
|
// Removed keys — warn and strip.
|
|
45
50
|
for (const gone of ["baseBranch", "minimizeBots", "cancelCiOnFailure", "autoMinimize"]) {
|
|
46
51
|
if (gone in out) {
|
package/bin/config.json
CHANGED
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"shaPoll": {
|
|
18
18
|
"intervalMs": 2000,
|
|
19
19
|
"maxAttempts": 10
|
|
20
|
-
}
|
|
20
|
+
},
|
|
21
|
+
"fetchReviewSummaries": true
|
|
21
22
|
},
|
|
22
23
|
"checks": {
|
|
23
24
|
"ciTriggerEvents": ["pull_request", "pull_request_target"],
|
|
@@ -40,10 +41,6 @@
|
|
|
40
41
|
"mergeStatus": {
|
|
41
42
|
"blockingReviewerLogins": ["copilot"]
|
|
42
43
|
},
|
|
43
|
-
"execution": {
|
|
44
|
-
"maxBufferMb": 10,
|
|
45
|
-
"triageLogBufferMb": 5
|
|
46
|
-
},
|
|
47
44
|
"actions": {
|
|
48
45
|
"autoResolveOutdated": true,
|
|
49
46
|
"autoRebase": true,
|
package/bin/github/batch.mjs
CHANGED
|
@@ -60,23 +60,41 @@ export async function fetchPrBatch(pr, repo) {
|
|
|
60
60
|
}, raw.comments.pageInfo.startCursor);
|
|
61
61
|
rawCommentNodes = [...extra, ...rawCommentNodes];
|
|
62
62
|
}
|
|
63
|
-
// Paginate reviews backward if the first page is incomplete.
|
|
64
|
-
let rawReviewNodes = raw.
|
|
65
|
-
if (raw.
|
|
63
|
+
// Paginate CHANGES_REQUESTED reviews backward if the first page is incomplete.
|
|
64
|
+
let rawReviewNodes = raw.changesRequestedReviews.nodes;
|
|
65
|
+
if (raw.changesRequestedReviews.pageInfo.hasPreviousPage &&
|
|
66
|
+
raw.changesRequestedReviews.pageInfo.startCursor) {
|
|
66
67
|
const extra = await paginateBackward(async (cursor) => {
|
|
67
68
|
const res = await graphql(BATCH_PR_QUERY, {
|
|
68
69
|
owner: repo.owner,
|
|
69
70
|
repo: repo.name,
|
|
70
71
|
pr,
|
|
71
|
-
...(cursor ? {
|
|
72
|
+
...(cursor ? { changesRequestedCursor: cursor } : {}),
|
|
72
73
|
});
|
|
73
74
|
const pr2 = res.data.repository.pullRequest;
|
|
74
75
|
if (!pr2)
|
|
75
76
|
throw new Error(`PR #${pr} not found`);
|
|
76
|
-
return pr2.
|
|
77
|
-
}, raw.
|
|
77
|
+
return pr2.changesRequestedReviews;
|
|
78
|
+
}, raw.changesRequestedReviews.pageInfo.startCursor);
|
|
78
79
|
rawReviewNodes = [...extra, ...rawReviewNodes];
|
|
79
80
|
}
|
|
81
|
+
// Paginate COMMENTED review summaries backward if the first page is incomplete.
|
|
82
|
+
let rawReviewSummaryNodes = raw.reviewSummaries.nodes;
|
|
83
|
+
if (raw.reviewSummaries.pageInfo.hasPreviousPage && raw.reviewSummaries.pageInfo.startCursor) {
|
|
84
|
+
const extra = await paginateBackward(async (cursor) => {
|
|
85
|
+
const res = await graphql(BATCH_PR_QUERY, {
|
|
86
|
+
owner: repo.owner,
|
|
87
|
+
repo: repo.name,
|
|
88
|
+
pr,
|
|
89
|
+
...(cursor ? { reviewSummariesCursor: cursor } : {}),
|
|
90
|
+
});
|
|
91
|
+
const pr2 = res.data.repository.pullRequest;
|
|
92
|
+
if (!pr2)
|
|
93
|
+
throw new Error(`PR #${pr} not found`);
|
|
94
|
+
return pr2.reviewSummaries;
|
|
95
|
+
}, raw.reviewSummaries.pageInfo.startCursor);
|
|
96
|
+
rawReviewSummaryNodes = [...extra, ...rawReviewSummaryNodes];
|
|
97
|
+
}
|
|
80
98
|
// Paginate check contexts forward if the first page is incomplete.
|
|
81
99
|
let rawCheckNodes = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.nodes ?? [];
|
|
82
100
|
const checksPageInfo = raw.commits.nodes[0]?.commit.statusCheckRollup?.contexts.pageInfo;
|
|
@@ -96,13 +114,13 @@ export async function fetchPrBatch(pr, repo) {
|
|
|
96
114
|
}, checksPageInfo.endCursor);
|
|
97
115
|
rawCheckNodes = [...rawCheckNodes, ...extra];
|
|
98
116
|
}
|
|
99
|
-
const data = parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawCheckNodes);
|
|
117
|
+
const data = parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawCheckNodes);
|
|
100
118
|
return { data, rateLimit: result.rateLimit };
|
|
101
119
|
}
|
|
102
120
|
// ---------------------------------------------------------------------------
|
|
103
121
|
// Parsers
|
|
104
122
|
// ---------------------------------------------------------------------------
|
|
105
|
-
function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawCheckNodes) {
|
|
123
|
+
function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawReviewSummaryNodes, rawCheckNodes) {
|
|
106
124
|
const reviewRequests = (raw.reviewRequests?.nodes ?? []).flatMap((n) => {
|
|
107
125
|
const login = n.requestedReviewer?.login ?? n.requestedReviewer?.name;
|
|
108
126
|
return login ? [{ login }] : [];
|
|
@@ -137,6 +155,13 @@ function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawChe
|
|
|
137
155
|
author: r.author?.login ?? "unknown",
|
|
138
156
|
body: r.body,
|
|
139
157
|
}));
|
|
158
|
+
const reviewSummaries = rawReviewSummaryNodes
|
|
159
|
+
.filter((r) => !r.isMinimized && r.body.trim() !== "")
|
|
160
|
+
.map((r) => ({
|
|
161
|
+
id: r.id,
|
|
162
|
+
author: r.author?.login ?? "unknown",
|
|
163
|
+
body: r.body,
|
|
164
|
+
}));
|
|
140
165
|
const checks = rawCheckNodes.flatMap((node) => {
|
|
141
166
|
if (node.__typename === "CheckRun") {
|
|
142
167
|
const event = node.checkSuite?.workflowRun?.event ?? null;
|
|
@@ -168,6 +193,7 @@ function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawChe
|
|
|
168
193
|
return [];
|
|
169
194
|
});
|
|
170
195
|
return {
|
|
196
|
+
nodeId: raw.id,
|
|
171
197
|
number: raw.number,
|
|
172
198
|
state: raw.state,
|
|
173
199
|
isDraft: raw.isDraft,
|
|
@@ -175,11 +201,13 @@ function parseRawPr(raw, rawThreadPages, rawCommentNodes, rawReviewNodes, rawChe
|
|
|
175
201
|
mergeStateStatus: raw.mergeStateStatus,
|
|
176
202
|
reviewDecision: (raw.reviewDecision ?? null),
|
|
177
203
|
headRefOid: raw.headRefOid,
|
|
204
|
+
baseRefName: raw.baseRefName,
|
|
178
205
|
reviewRequests,
|
|
179
206
|
latestReviews,
|
|
180
207
|
reviewThreads,
|
|
181
208
|
comments,
|
|
182
209
|
changesRequestedReviews,
|
|
210
|
+
reviewSummaries,
|
|
183
211
|
checks,
|
|
184
212
|
};
|
|
185
213
|
}
|