pr-shepherd 0.7.0 → 0.8.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 +37 -302
- package/bin/checks/classify.mjs +5 -4
- package/bin/checks/triage.mjs +76 -62
- package/bin/cli/args.mjs +29 -61
- package/bin/cli/exit-codes.mjs +39 -0
- package/bin/cli/fix-formatter.mjs +76 -0
- package/bin/cli/formatters.mjs +108 -0
- package/bin/cli/handlers.mjs +138 -0
- package/bin/cli/iterate-formatter.mjs +78 -0
- package/bin/cli-parser.iterate-fixtures.mjs +65 -0
- package/bin/cli-parser.mjs +110 -0
- package/bin/commands/check-status.mjs +35 -0
- package/bin/commands/check.mjs +14 -61
- package/bin/commands/commit-suggestion.mjs +159 -0
- package/bin/commands/iterate/classify.mjs +77 -0
- package/bin/commands/iterate/escalate.mjs +124 -0
- package/bin/commands/iterate/fix-code.mjs +97 -0
- package/bin/commands/iterate/helpers.mjs +103 -0
- package/bin/commands/iterate/index.mjs +122 -0
- package/bin/commands/iterate/render.mjs +119 -0
- package/bin/commands/iterate/stall.mjs +65 -0
- package/bin/commands/iterate/steps.mjs +31 -0
- package/bin/commands/iterate.mjs +2 -628
- package/bin/commands/monitor.mjs +78 -0
- package/bin/commands/ready-delay.mjs +3 -4
- package/bin/commands/resolve-instructions.mjs +39 -0
- package/bin/commands/resolve.mjs +34 -3
- package/bin/commands/status.mjs +7 -0
- package/bin/comments/resolve.mjs +1 -1
- package/bin/config/load.mjs +17 -113
- package/bin/config.json +10 -22
- package/bin/github/batch-parsers.mjs +140 -0
- package/bin/github/batch-raw-types.mjs +2 -0
- package/bin/github/batch.mjs +34 -129
- package/bin/github/client.mjs +47 -9
- package/bin/github/gql/batch-pr.gql +20 -0
- package/bin/github/http.mjs +32 -30
- package/bin/index.mjs +15 -2
- package/bin/merge-status/derive.mjs +11 -11
- package/bin/reporters/agent.mjs +13 -4
- package/bin/reporters/check-instructions.mjs +65 -0
- package/bin/reporters/json.mjs +3 -2
- package/bin/reporters/text.mjs +108 -61
- package/bin/{cache → state}/fix-attempts.mjs +3 -3
- package/bin/state/iterate-stall.mjs +74 -0
- package/bin/suggestions/parse.mjs +119 -0
- package/bin/suggestions/patch.mjs +52 -0
- package/bin/types/github.mjs +2 -0
- package/bin/types/iterate.mjs +2 -0
- package/bin/types/report.mjs +2 -0
- package/bin/types.mjs +3 -1
- package/package.json +3 -3
- package/plugin/skills/check/SKILL.md +15 -48
- package/plugin/skills/monitor/SKILL.md +11 -64
- package/plugin/skills/resolve/SKILL.md +10 -76
- package/bin/cache/file-cache.mjs +0 -79
- package/bin/cli.mjs +0 -286
package/bin/commands/iterate.mjs
CHANGED
|
@@ -1,628 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* One-shot iteration that rolls cooldown + sweep + deterministic dispatch
|
|
5
|
-
* into a single call, emitting compact JSON.
|
|
6
|
-
*
|
|
7
|
-
* Decision order:
|
|
8
|
-
* 1. cooldown — last commit is < cooldownSeconds old
|
|
9
|
-
* 2. sweep — fetch CI + comments + merge status, auto-resolve outdated
|
|
10
|
-
* 2.5 cancel — state !== OPEN (PR merged or closed)
|
|
11
|
-
* 3. cancel — readyState.shouldCancel
|
|
12
|
-
* 4. fix_code — actionable threads, comments, CI failures, CHANGES_REQUESTED, or CONFLICTS
|
|
13
|
-
* (fix_code handler does fetch+rebase+push — all actionable work in one push)
|
|
14
|
-
* 5. rerun_ci — timeout / infrastructure failures only (no actionable work, no conflicts)
|
|
15
|
-
* 6. rebase — flaky failure + branch BEHIND
|
|
16
|
-
* 7. mark_ready — READY + CLEAN + draft + not shouldCancel (converts draft to ready)
|
|
17
|
-
* 8. wait — nothing to do
|
|
18
|
-
*
|
|
19
|
-
* Exit codes:
|
|
20
|
-
* 0 wait / cooldown / rerun_ci / mark_ready
|
|
21
|
-
* 1 fix_code / rebase
|
|
22
|
-
* 2 cancel
|
|
23
|
-
*/
|
|
24
|
-
import { execFile as execFileCb } from "node:child_process";
|
|
25
|
-
import { promisify } from "node:util";
|
|
26
|
-
import { runCheck } from "./check.mjs";
|
|
27
|
-
import { triageFailingChecks } from "../checks/triage.mjs";
|
|
28
|
-
import { updateReadyDelay } from "./ready-delay.mjs";
|
|
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";
|
|
32
|
-
import { readFixAttempts, writeFixAttempts } from "../cache/fix-attempts.mjs";
|
|
33
|
-
import { toAgentThread, toAgentComment, toAgentChecks } from "../reporters/agent.mjs";
|
|
34
|
-
import { loadConfig } from "../config/load.mjs";
|
|
35
|
-
const execFile = promisify(execFileCb);
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
// Public API
|
|
38
|
-
// ---------------------------------------------------------------------------
|
|
39
|
-
export async function runIterate(opts) {
|
|
40
|
-
const config = loadConfig();
|
|
41
|
-
const cooldownSeconds = opts.cooldownSeconds ?? config.iterate.cooldownSeconds;
|
|
42
|
-
const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
|
|
43
|
-
// Resolve prNumber early so the cooldown result carries a valid PR number.
|
|
44
|
-
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
45
|
-
if (prNumber === null) {
|
|
46
|
-
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
47
|
-
}
|
|
48
|
-
const optsWithPr = { ...opts, prNumber };
|
|
49
|
-
// Step 1: Cooldown — skip if last commit is too fresh.
|
|
50
|
-
const lastCommitTime = await getLastCommitTime();
|
|
51
|
-
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
52
|
-
if (nowSeconds - lastCommitTime < cooldownSeconds) {
|
|
53
|
-
// We don't have a report yet — return a minimal cooldown result.
|
|
54
|
-
return {
|
|
55
|
-
action: "cooldown",
|
|
56
|
-
pr: prNumber,
|
|
57
|
-
repo: "",
|
|
58
|
-
status: "UNKNOWN",
|
|
59
|
-
state: "UNKNOWN",
|
|
60
|
-
mergeStateStatus: "UNKNOWN",
|
|
61
|
-
copilotReviewInProgress: false,
|
|
62
|
-
isDraft: false,
|
|
63
|
-
shouldCancel: false,
|
|
64
|
-
remainingSeconds: readyDelaySeconds,
|
|
65
|
-
summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0 },
|
|
66
|
-
baseBranch: "",
|
|
67
|
-
log: "SKIP: CI still starting — waiting for first check to appear",
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
// Step 2: Sweep — fetch CI + comments + merge status, auto-resolve outdated.
|
|
71
|
-
// skipTriage defers log fetching until we know we'll need failureKind (steps 4–6).
|
|
72
|
-
let report = await runCheck({
|
|
73
|
-
...optsWithPr,
|
|
74
|
-
autoResolve: config.actions.autoResolveOutdated,
|
|
75
|
-
skipTriage: true,
|
|
76
|
-
});
|
|
77
|
-
// Step 2.5: Cancel if PR is merged or closed — no longer actionable.
|
|
78
|
-
if (report.mergeStatus.state !== "OPEN") {
|
|
79
|
-
const state = report.mergeStatus.state.toLowerCase();
|
|
80
|
-
return {
|
|
81
|
-
pr: report.pr,
|
|
82
|
-
repo: report.repo,
|
|
83
|
-
status: report.status,
|
|
84
|
-
mergeStateStatus: report.mergeStatus.mergeStateStatus,
|
|
85
|
-
copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
|
|
86
|
-
isDraft: report.mergeStatus.isDraft,
|
|
87
|
-
shouldCancel: true,
|
|
88
|
-
remainingSeconds: 0,
|
|
89
|
-
state: report.mergeStatus.state,
|
|
90
|
-
summary: buildSummary(report),
|
|
91
|
-
baseBranch: report.baseBranch,
|
|
92
|
-
action: "cancel",
|
|
93
|
-
log: `CANCEL: PR #${report.pr} is ${state} — stopping monitor`,
|
|
94
|
-
};
|
|
95
|
-
}
|
|
96
|
-
// Step 3: Ready-delay state machine.
|
|
97
|
-
const [repoOwner, repoName] = report.repo.split("/");
|
|
98
|
-
if (!repoOwner || !repoName) {
|
|
99
|
-
throw new Error(`Unexpected repo format: "${report.repo}" (expected "owner/name")`);
|
|
100
|
-
}
|
|
101
|
-
const isReady = report.status === "READY";
|
|
102
|
-
const readyState = await updateReadyDelay(report.pr, isReady, readyDelaySeconds, repoOwner, repoName);
|
|
103
|
-
const base = {
|
|
104
|
-
pr: report.pr,
|
|
105
|
-
repo: report.repo,
|
|
106
|
-
status: report.status,
|
|
107
|
-
state: report.mergeStatus.state,
|
|
108
|
-
mergeStateStatus: report.mergeStatus.mergeStateStatus,
|
|
109
|
-
copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
|
|
110
|
-
isDraft: report.mergeStatus.isDraft,
|
|
111
|
-
shouldCancel: readyState.shouldCancel,
|
|
112
|
-
remainingSeconds: readyState.remainingSeconds,
|
|
113
|
-
summary: buildSummary(report),
|
|
114
|
-
baseBranch: report.baseBranch,
|
|
115
|
-
};
|
|
116
|
-
// Step 3 cont.: cancel if ready-delay elapsed.
|
|
117
|
-
if (readyState.shouldCancel) {
|
|
118
|
-
return {
|
|
119
|
-
...base,
|
|
120
|
-
action: "cancel",
|
|
121
|
-
log: `CANCEL: PR #${base.pr} has been ready for review — ready-delay elapsed, stopping monitor`,
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
// Triage failing checks now that we know we need failureKind for steps 4–6.
|
|
125
|
-
if (report.checks.failing.length > 0) {
|
|
126
|
-
const triaged = await triageFailingChecks(report.checks.failing, {
|
|
127
|
-
owner: repoOwner,
|
|
128
|
-
name: repoName,
|
|
129
|
-
});
|
|
130
|
-
report = { ...report, checks: { ...report.checks, failing: triaged } };
|
|
131
|
-
}
|
|
132
|
-
// Step 4: Actionable work — fix comments, review requests, CI failures, and merge
|
|
133
|
-
// conflicts all in one push. CONFLICTS is included here because the fix_code handler
|
|
134
|
-
// already runs fetch+rebase+push, so conflicts are resolved as part of that flow.
|
|
135
|
-
const actionableChecks = report.checks.failing.filter((f) => f.failureKind === "actionable");
|
|
136
|
-
const hasActionableWork = report.threads.actionable.length > 0 ||
|
|
137
|
-
report.comments.actionable.length > 0 ||
|
|
138
|
-
report.changesRequestedReviews.length > 0 ||
|
|
139
|
-
actionableChecks.length > 0 ||
|
|
140
|
-
report.mergeStatus.status === "CONFLICTS";
|
|
141
|
-
if (hasActionableWork) {
|
|
142
|
-
// Load fix-attempt counts, resetting if HEAD SHA changed (new commit pushed).
|
|
143
|
-
const headSha = await getCurrentHeadSha();
|
|
144
|
-
const attemptsKey = { owner: repoOwner, repo: repoName, pr: prNumber };
|
|
145
|
-
const stored = await readFixAttempts(attemptsKey);
|
|
146
|
-
const attempts = stored?.headSha === headSha
|
|
147
|
-
? stored
|
|
148
|
-
: { headSha, threadAttempts: {} };
|
|
149
|
-
// Escalation checks — surface ambiguous situations instead of looping forever.
|
|
150
|
-
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.comments.actionable, report.changesRequestedReviews, actionableChecks, attempts.threadAttempts, report.mergeStatus.status === "CONFLICTS");
|
|
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
|
-
};
|
|
160
|
-
return {
|
|
161
|
-
...base,
|
|
162
|
-
action: "escalate",
|
|
163
|
-
escalate: {
|
|
164
|
-
...escalateBase,
|
|
165
|
-
humanMessage: buildEscalateHumanMessage(escalateBase, prNumber),
|
|
166
|
-
},
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
// Increment attempt counts for this dispatch cycle.
|
|
170
|
-
const newThreadAttempts = { ...attempts.threadAttempts };
|
|
171
|
-
for (const t of report.threads.actionable) {
|
|
172
|
-
newThreadAttempts[t.id] = (newThreadAttempts[t.id] ?? 0) + 1;
|
|
173
|
-
}
|
|
174
|
-
await writeFixAttempts(attemptsKey, { headSha, threadAttempts: newThreadAttempts });
|
|
175
|
-
let cancelled = [];
|
|
176
|
-
if (!opts.noAutoCancelActionable) {
|
|
177
|
-
const uniqueRunIds = [
|
|
178
|
-
...new Set(actionableChecks.map((c) => c.runId).filter((id) => id !== null)),
|
|
179
|
-
];
|
|
180
|
-
const results = await Promise.all(uniqueRunIds.map((id) => tryCancelRun(id, repoOwner, repoName)));
|
|
181
|
-
cancelled = results.filter((id) => id !== null);
|
|
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);
|
|
213
|
-
return {
|
|
214
|
-
...base,
|
|
215
|
-
baseBranch: baseLookup.branch,
|
|
216
|
-
action: "fix_code",
|
|
217
|
-
fix: {
|
|
218
|
-
threads,
|
|
219
|
-
actionableComments,
|
|
220
|
-
noiseCommentIds,
|
|
221
|
-
checks,
|
|
222
|
-
changesRequestedReviews,
|
|
223
|
-
resolveCommand,
|
|
224
|
-
instructions,
|
|
225
|
-
},
|
|
226
|
-
cancelled,
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
// Step 5: Transient failures (timeout / infrastructure) — no actionable work, no conflicts.
|
|
230
|
-
const transientChecks = report.checks.failing.filter((f) => f.failureKind === "timeout" || f.failureKind === "infrastructure");
|
|
231
|
-
if (transientChecks.length > 0 && !opts.noAutoRerun) {
|
|
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
|
-
};
|
|
258
|
-
}
|
|
259
|
-
// Step 6: Flaky + behind — rebase needed.
|
|
260
|
-
const hasFlaky = report.checks.failing.some((f) => f.failureKind === "flaky");
|
|
261
|
-
if (hasFlaky && report.mergeStatus.status === "BEHIND" && config.actions.autoRebase) {
|
|
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
|
-
};
|
|
289
|
-
}
|
|
290
|
-
// Step 7: Mark ready for review.
|
|
291
|
-
// Draft PRs often report mergeStateStatus === 'DRAFT' rather than 'CLEAN' until
|
|
292
|
-
// they're explicitly marked ready, so we allow either state when isDraft is true.
|
|
293
|
-
const mergeStateAllowsMarkReady = report.mergeStatus.mergeStateStatus === "CLEAN" ||
|
|
294
|
-
(report.mergeStatus.mergeStateStatus === "DRAFT" && report.mergeStatus.isDraft);
|
|
295
|
-
const canMarkReady = report.status === "READY" &&
|
|
296
|
-
mergeStateAllowsMarkReady &&
|
|
297
|
-
!report.mergeStatus.copilotReviewInProgress &&
|
|
298
|
-
!readyState.shouldCancel &&
|
|
299
|
-
report.mergeStatus.isDraft;
|
|
300
|
-
if (canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady) {
|
|
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
|
-
};
|
|
308
|
-
}
|
|
309
|
-
// Step 8: Nothing to do.
|
|
310
|
-
return {
|
|
311
|
-
...base,
|
|
312
|
-
action: "wait",
|
|
313
|
-
log: buildWaitLog(base),
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
// ---------------------------------------------------------------------------
|
|
317
|
-
// Helpers
|
|
318
|
-
// ---------------------------------------------------------------------------
|
|
319
|
-
function buildSummary(report) {
|
|
320
|
-
return {
|
|
321
|
-
passing: report.checks.passing.length,
|
|
322
|
-
skipped: report.checks.skipped.length,
|
|
323
|
-
filtered: report.checks.filtered.length,
|
|
324
|
-
inProgress: report.checks.inProgress.length,
|
|
325
|
-
};
|
|
326
|
-
}
|
|
327
|
-
async function getLastCommitTime() {
|
|
328
|
-
try {
|
|
329
|
-
const { stdout } = await execFile("git", ["log", "-1", "--format=%ct", "HEAD"]);
|
|
330
|
-
return parseInt(stdout.trim(), 10);
|
|
331
|
-
}
|
|
332
|
-
catch {
|
|
333
|
-
return 0;
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
// Best-effort: cancelling a completed run is a no-op, not an error.
|
|
337
|
-
async function tryCancelRun(runId, owner, repo) {
|
|
338
|
-
try {
|
|
339
|
-
await rest("POST", `/repos/${owner}/${repo}/actions/runs/${runId}/cancel`);
|
|
340
|
-
return runId;
|
|
341
|
-
}
|
|
342
|
-
catch (err) {
|
|
343
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
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))
|
|
346
|
-
return null;
|
|
347
|
-
process.stderr.write(`pr-shepherd: cancel run ${runId} failed (ignored): ${msg}\n`);
|
|
348
|
-
return null;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
async function getCurrentHeadSha() {
|
|
352
|
-
try {
|
|
353
|
-
const { stdout } = await execFile("git", ["rev-parse", "HEAD"]);
|
|
354
|
-
return stdout.trim();
|
|
355
|
-
}
|
|
356
|
-
catch {
|
|
357
|
-
return "unknown";
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
function checkEscalateTriggers(actionableThreads, actionableComments, changesRequestedReviews, actionableChecks, threadAttempts, hasConflicts) {
|
|
361
|
-
const triggers = [];
|
|
362
|
-
const maxAttempts = loadConfig().iterate.fixAttemptsPerThread;
|
|
363
|
-
// Trigger 1: fix thrash — same thread dispatched too many times without resolving.
|
|
364
|
-
const thrashThreads = actionableThreads.filter((t) => (threadAttempts[t.id] ?? 0) >= maxAttempts);
|
|
365
|
-
if (thrashThreads.length > 0) {
|
|
366
|
-
triggers.push("fix-thrash");
|
|
367
|
-
}
|
|
368
|
-
// Trigger 2: PR-level CHANGES_REQUESTED with no inline threads/comments/CI to act on.
|
|
369
|
-
// Skip when there are merge conflicts — fix_code handles conflict resolution, not escalation.
|
|
370
|
-
if (changesRequestedReviews.length > 0 &&
|
|
371
|
-
actionableThreads.length === 0 &&
|
|
372
|
-
actionableComments.length === 0 &&
|
|
373
|
-
actionableChecks.length === 0 &&
|
|
374
|
-
!hasConflicts) {
|
|
375
|
-
triggers.push("pr-level-changes-requested");
|
|
376
|
-
}
|
|
377
|
-
// Trigger 3: actionable thread has no file/line — cannot locate code to edit.
|
|
378
|
-
const unlocatable = actionableThreads.filter((t) => t.path === null || t.line === null);
|
|
379
|
-
if (unlocatable.length > 0) {
|
|
380
|
-
triggers.push("thread-missing-location");
|
|
381
|
-
}
|
|
382
|
-
return {
|
|
383
|
-
triggers,
|
|
384
|
-
thrashHistory: thrashThreads.length > 0
|
|
385
|
-
? thrashThreads.map((t) => ({ threadId: t.id, attempts: threadAttempts[t.id] ?? 0 }))
|
|
386
|
-
: undefined,
|
|
387
|
-
};
|
|
388
|
-
}
|
|
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
|
-
}
|
|
618
|
-
if (triggers.includes("fix-thrash")) {
|
|
619
|
-
return "Same thread(s) attempted multiple times without resolution — fix manually then rerun /pr-shepherd:monitor";
|
|
620
|
-
}
|
|
621
|
-
if (triggers.includes("pr-level-changes-requested")) {
|
|
622
|
-
return "Reviewer requested changes but left no inline comments — read the review and act manually";
|
|
623
|
-
}
|
|
624
|
-
if (triggers.includes("thread-missing-location")) {
|
|
625
|
-
return "Review thread has no file/line reference — cannot locate code to edit automatically";
|
|
626
|
-
}
|
|
627
|
-
return "Ambiguous state — inspect the PR and act manually";
|
|
628
|
-
}
|
|
1
|
+
export { runIterate } from "./iterate/index.mjs";
|
|
2
|
+
export { renderResolveCommand } from "./iterate/render.mjs";
|