pr-shepherd 0.2.0 → 0.3.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 +8 -2
- package/README.md +126 -83
- package/dist/cache/file-cache.mjs +78 -0
- package/dist/cache/fix-attempts.mjs +67 -0
- package/dist/checks/classify.mjs +53 -0
- package/dist/checks/triage.mjs +77 -0
- package/dist/cli/args.mjs +153 -0
- package/dist/cli.mjs +203 -0
- package/dist/commands/check.mjs +140 -0
- package/dist/commands/iterate.mjs +295 -0
- package/dist/commands/ready-delay.mjs +87 -0
- package/dist/commands/resolve.mjs +64 -0
- package/dist/commands/status.mjs +107 -0
- package/{src/comments/outdated.mts → dist/comments/outdated.mjs} +2 -5
- package/dist/comments/resolve.mjs +113 -0
- package/dist/config/load.mjs +154 -0
- package/dist/github/batch.mjs +208 -0
- package/dist/github/client.mjs +153 -0
- package/{src/github/pagination.mts → dist/github/pagination.mjs} +26 -52
- package/{src/github/queries.mts → dist/github/queries.mjs} +1 -10
- package/{src/index.mts → dist/index.mjs} +3 -5
- package/dist/merge-status/derive.mjs +72 -0
- package/dist/reporters/agent.mjs +41 -0
- package/{src/reporters/json.mts → dist/reporters/json.mjs} +2 -5
- package/dist/reporters/text.mjs +111 -0
- package/dist/types.mjs +2 -0
- package/package.json +6 -6
- package/skills/check/SKILL.md +1 -1
- package/skills/monitor/SKILL.md +9 -5
- package/src/cache/file-cache.mts +0 -101
- package/src/cache/file-cache.test.mts +0 -91
- package/src/cache/fix-attempts.mts +0 -86
- package/src/checks/classify.mts +0 -80
- package/src/checks/classify.test.mts +0 -164
- package/src/checks/triage.mock.test.mts +0 -202
- package/src/checks/triage.mts +0 -88
- package/src/cli.mts +0 -423
- package/src/commands/check.mts +0 -188
- package/src/commands/iterate.mock.test.mts +0 -1111
- package/src/commands/iterate.mts +0 -371
- package/src/commands/ready-delay.mts +0 -117
- package/src/commands/ready-delay.test.mts +0 -116
- package/src/commands/resolve.mts +0 -92
- package/src/commands/status.mts +0 -173
- package/src/comments/resolve.mts +0 -179
- package/src/config/load.mts +0 -240
- package/src/github/batch.mts +0 -351
- package/src/github/client.mts +0 -207
- package/src/github/client.test.mts +0 -19
- package/src/github/pagination.test.mts +0 -140
- package/src/merge-status/derive.mts +0 -74
- package/src/merge-status/derive.test.mts +0 -130
- package/src/reporters/text.mts +0 -140
- package/src/types.mts +0 -309
- /package/{src → dist}/config.json +0 -0
- /package/{src → dist}/github/gql/batch-pr.gql +0 -0
- /package/{src → dist}/github/gql/dismiss-review.gql +0 -0
- /package/{src → dist}/github/gql/minimize-comment.gql +0 -0
- /package/{src → dist}/github/gql/multi-pr-status-paged.gql +0 -0
- /package/{src → dist}/github/gql/multi-pr-status.gql +0 -0
- /package/{src → dist}/github/gql/resolve-thread.gql +0 -0
- /package/{src/util/path-segment.mts → dist/util/path-segment.mjs} +0 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shepherd iterate [PR]`
|
|
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 { readFixAttempts, writeFixAttempts } from "../cache/fix-attempts.mjs";
|
|
31
|
+
import { toAgentThread, toAgentComment, toAgentChecks } from "../reporters/agent.mjs";
|
|
32
|
+
import { loadConfig } from "../config/load.mjs";
|
|
33
|
+
const execFile = promisify(execFileCb);
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Public API
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
export async function runIterate(opts) {
|
|
38
|
+
const config = loadConfig();
|
|
39
|
+
const cooldownSeconds = opts.cooldownSeconds ?? config.iterate.cooldownSeconds;
|
|
40
|
+
const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
|
|
41
|
+
// Resolve prNumber early so the cooldown result carries a valid PR number.
|
|
42
|
+
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
43
|
+
if (prNumber === null) {
|
|
44
|
+
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
45
|
+
}
|
|
46
|
+
const optsWithPr = { ...opts, prNumber };
|
|
47
|
+
// Step 1: Cooldown — skip if last commit is too fresh.
|
|
48
|
+
const lastCommitTime = await getLastCommitTime();
|
|
49
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
50
|
+
if (nowSeconds - lastCommitTime < cooldownSeconds) {
|
|
51
|
+
// We don't have a report yet — return a minimal cooldown result.
|
|
52
|
+
return {
|
|
53
|
+
action: "cooldown",
|
|
54
|
+
pr: prNumber,
|
|
55
|
+
repo: "",
|
|
56
|
+
status: "UNKNOWN",
|
|
57
|
+
state: "UNKNOWN",
|
|
58
|
+
mergeStateStatus: "UNKNOWN",
|
|
59
|
+
copilotReviewInProgress: false,
|
|
60
|
+
isDraft: false,
|
|
61
|
+
shouldCancel: false,
|
|
62
|
+
remainingSeconds: readyDelaySeconds,
|
|
63
|
+
summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0 },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
// Step 2: Sweep — fetch CI + comments + merge status, auto-resolve outdated.
|
|
67
|
+
// skipTriage defers log fetching until we know we'll need failureKind (steps 4–6).
|
|
68
|
+
let report = await runCheck({
|
|
69
|
+
...optsWithPr,
|
|
70
|
+
autoResolve: config.actions.autoResolveOutdated,
|
|
71
|
+
skipTriage: true,
|
|
72
|
+
});
|
|
73
|
+
// Step 2.5: Cancel if PR is merged or closed — no longer actionable.
|
|
74
|
+
if (report.mergeStatus.state !== "OPEN") {
|
|
75
|
+
return {
|
|
76
|
+
pr: report.pr,
|
|
77
|
+
repo: report.repo,
|
|
78
|
+
status: report.status,
|
|
79
|
+
mergeStateStatus: report.mergeStatus.mergeStateStatus,
|
|
80
|
+
copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
|
|
81
|
+
isDraft: report.mergeStatus.isDraft,
|
|
82
|
+
shouldCancel: true,
|
|
83
|
+
remainingSeconds: 0,
|
|
84
|
+
state: report.mergeStatus.state,
|
|
85
|
+
summary: buildSummary(report),
|
|
86
|
+
action: "cancel",
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// Step 3: Ready-delay state machine.
|
|
90
|
+
const [repoOwner, repoName] = report.repo.split("/");
|
|
91
|
+
const isReady = report.status === "READY";
|
|
92
|
+
const readyState = await updateReadyDelay(report.pr, isReady, readyDelaySeconds, repoOwner, repoName);
|
|
93
|
+
const base = {
|
|
94
|
+
pr: report.pr,
|
|
95
|
+
repo: report.repo,
|
|
96
|
+
status: report.status,
|
|
97
|
+
state: report.mergeStatus.state,
|
|
98
|
+
mergeStateStatus: report.mergeStatus.mergeStateStatus,
|
|
99
|
+
copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
|
|
100
|
+
isDraft: report.mergeStatus.isDraft,
|
|
101
|
+
shouldCancel: readyState.shouldCancel,
|
|
102
|
+
remainingSeconds: readyState.remainingSeconds,
|
|
103
|
+
summary: buildSummary(report),
|
|
104
|
+
};
|
|
105
|
+
// Step 3 cont.: cancel if ready-delay elapsed.
|
|
106
|
+
if (readyState.shouldCancel) {
|
|
107
|
+
return { ...base, action: "cancel" };
|
|
108
|
+
}
|
|
109
|
+
// Triage failing checks now that we know we need failureKind for steps 4–6.
|
|
110
|
+
if (report.checks.failing.length > 0) {
|
|
111
|
+
const triaged = await triageFailingChecks(report.checks.failing);
|
|
112
|
+
report = { ...report, checks: { ...report.checks, failing: triaged } };
|
|
113
|
+
}
|
|
114
|
+
// Step 4: Actionable work — fix comments, review requests, CI failures, and merge
|
|
115
|
+
// conflicts all in one push. CONFLICTS is included here because the fix_code handler
|
|
116
|
+
// already runs fetch+rebase+push, so conflicts are resolved as part of that flow.
|
|
117
|
+
const actionableChecks = report.checks.failing.filter((f) => f.failureKind === "actionable");
|
|
118
|
+
const hasActionableWork = report.threads.actionable.length > 0 ||
|
|
119
|
+
report.comments.actionable.length > 0 ||
|
|
120
|
+
report.changesRequestedReviews.length > 0 ||
|
|
121
|
+
actionableChecks.length > 0 ||
|
|
122
|
+
report.mergeStatus.status === "CONFLICTS";
|
|
123
|
+
if (hasActionableWork) {
|
|
124
|
+
// Load fix-attempt counts, resetting if HEAD SHA changed (new commit pushed).
|
|
125
|
+
const headSha = await getCurrentHeadSha();
|
|
126
|
+
const attemptsKey = { owner: repoOwner, repo: repoName, pr: prNumber };
|
|
127
|
+
const stored = await readFixAttempts(attemptsKey);
|
|
128
|
+
const attempts = stored?.headSha === headSha
|
|
129
|
+
? stored
|
|
130
|
+
: { headSha, threadAttempts: {} };
|
|
131
|
+
// Escalation checks — surface ambiguous situations instead of looping forever.
|
|
132
|
+
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.comments.actionable, report.changesRequestedReviews, actionableChecks, attempts.threadAttempts, report.mergeStatus.status === "CONFLICTS");
|
|
133
|
+
if (escalateTriggers.triggers.length > 0) {
|
|
134
|
+
return {
|
|
135
|
+
...base,
|
|
136
|
+
action: "escalate",
|
|
137
|
+
escalate: {
|
|
138
|
+
triggers: escalateTriggers.triggers,
|
|
139
|
+
unresolvedThreads: report.threads.actionable.map(toAgentThread),
|
|
140
|
+
ambiguousComments: report.comments.actionable.map(toAgentComment),
|
|
141
|
+
changesRequestedReviews: report.changesRequestedReviews,
|
|
142
|
+
attemptHistory: escalateTriggers.thrashHistory,
|
|
143
|
+
suggestion: buildEscalateSuggestion(escalateTriggers.triggers),
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
// Increment attempt counts for this dispatch cycle.
|
|
148
|
+
const newThreadAttempts = { ...attempts.threadAttempts };
|
|
149
|
+
for (const t of report.threads.actionable) {
|
|
150
|
+
newThreadAttempts[t.id] = (newThreadAttempts[t.id] ?? 0) + 1;
|
|
151
|
+
}
|
|
152
|
+
await writeFixAttempts(attemptsKey, { headSha, threadAttempts: newThreadAttempts });
|
|
153
|
+
let cancelled = [];
|
|
154
|
+
if (!opts.noAutoCancelActionable) {
|
|
155
|
+
const uniqueRunIds = [
|
|
156
|
+
...new Set(actionableChecks.map((c) => c.runId).filter((id) => id !== null)),
|
|
157
|
+
];
|
|
158
|
+
const results = await Promise.all(uniqueRunIds.map((id) => tryCancelRun(id)));
|
|
159
|
+
cancelled = results.filter((id) => id !== null);
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
...base,
|
|
163
|
+
action: "fix_code",
|
|
164
|
+
fix: {
|
|
165
|
+
threads: report.threads.actionable.map(toAgentThread),
|
|
166
|
+
comments: report.comments.actionable.map(toAgentComment),
|
|
167
|
+
checks: toAgentChecks(actionableChecks),
|
|
168
|
+
changesRequestedReviews: report.changesRequestedReviews,
|
|
169
|
+
},
|
|
170
|
+
cancelled,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
// Step 5: Transient failures (timeout / infrastructure) — no actionable work, no conflicts.
|
|
174
|
+
const transientChecks = report.checks.failing.filter((f) => f.failureKind === "timeout" || f.failureKind === "infrastructure");
|
|
175
|
+
if (transientChecks.length > 0 && !opts.noAutoRerun) {
|
|
176
|
+
// Deduplicate runIds — multiple failed steps can share the same runId.
|
|
177
|
+
const uniqueRunIds = [
|
|
178
|
+
...new Set(transientChecks.map((c) => c.runId).filter((id) => id !== null)),
|
|
179
|
+
];
|
|
180
|
+
await Promise.all(uniqueRunIds.map((runId) => runGhCommand(["run", "rerun", runId, "--failed"])));
|
|
181
|
+
return { ...base, action: "rerun_ci", reran: uniqueRunIds };
|
|
182
|
+
}
|
|
183
|
+
// Step 6: Flaky + behind — rebase needed.
|
|
184
|
+
const hasFlaky = report.checks.failing.some((f) => f.failureKind === "flaky");
|
|
185
|
+
if (hasFlaky && report.mergeStatus.status === "BEHIND" && config.actions.autoRebase) {
|
|
186
|
+
return { ...base, action: "rebase" };
|
|
187
|
+
}
|
|
188
|
+
// Step 7: Mark ready for review.
|
|
189
|
+
// Draft PRs often report mergeStateStatus === 'DRAFT' rather than 'CLEAN' until
|
|
190
|
+
// they're explicitly marked ready, so we allow either state when isDraft is true.
|
|
191
|
+
const mergeStateAllowsMarkReady = report.mergeStatus.mergeStateStatus === "CLEAN" ||
|
|
192
|
+
(report.mergeStatus.mergeStateStatus === "DRAFT" && report.mergeStatus.isDraft);
|
|
193
|
+
const canMarkReady = report.status === "READY" &&
|
|
194
|
+
mergeStateAllowsMarkReady &&
|
|
195
|
+
!report.mergeStatus.copilotReviewInProgress &&
|
|
196
|
+
!readyState.shouldCancel &&
|
|
197
|
+
report.mergeStatus.isDraft;
|
|
198
|
+
if (canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady) {
|
|
199
|
+
await runGhCommand(["pr", "ready", String(report.pr)]);
|
|
200
|
+
return { ...base, action: "mark_ready", markedReady: true };
|
|
201
|
+
}
|
|
202
|
+
// Step 8: Nothing to do.
|
|
203
|
+
return { ...base, action: "wait" };
|
|
204
|
+
}
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// Helpers
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
function buildSummary(report) {
|
|
209
|
+
return {
|
|
210
|
+
passing: report.checks.passing.length,
|
|
211
|
+
skipped: report.checks.skipped.length,
|
|
212
|
+
filtered: report.checks.filtered.length,
|
|
213
|
+
inProgress: report.checks.inProgress.length,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
async function getLastCommitTime() {
|
|
217
|
+
try {
|
|
218
|
+
const { stdout } = await execFile("git", ["log", "-1", "--format=%ct", "HEAD"]);
|
|
219
|
+
return parseInt(stdout.trim(), 10);
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
async function runGhCommand(args) {
|
|
226
|
+
try {
|
|
227
|
+
await execFile("gh", args);
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
231
|
+
throw new Error(`gh ${args.join(" ")} failed: ${msg}`, { cause: err });
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Best-effort: cancelling a completed run is a no-op, not an error.
|
|
235
|
+
async function tryCancelRun(runId) {
|
|
236
|
+
try {
|
|
237
|
+
await execFile("gh", ["run", "cancel", runId]);
|
|
238
|
+
return runId;
|
|
239
|
+
}
|
|
240
|
+
catch (err) {
|
|
241
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
242
|
+
process.stderr.write(`pr-shepherd: gh run cancel ${runId} failed (ignored): ${msg}\n`);
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
async function getCurrentHeadSha() {
|
|
247
|
+
try {
|
|
248
|
+
const { stdout } = await execFile("git", ["rev-parse", "HEAD"]);
|
|
249
|
+
return stdout.trim();
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
return "unknown";
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function checkEscalateTriggers(actionableThreads, actionableComments, changesRequestedReviews, actionableChecks, threadAttempts, hasConflicts) {
|
|
256
|
+
const triggers = [];
|
|
257
|
+
const maxAttempts = loadConfig().iterate.fixAttemptsPerThread;
|
|
258
|
+
// Trigger 1: fix thrash — same thread dispatched too many times without resolving.
|
|
259
|
+
const thrashThreads = actionableThreads.filter((t) => (threadAttempts[t.id] ?? 0) >= maxAttempts);
|
|
260
|
+
if (thrashThreads.length > 0) {
|
|
261
|
+
triggers.push("fix-thrash");
|
|
262
|
+
}
|
|
263
|
+
// Trigger 2: PR-level CHANGES_REQUESTED with no inline threads/comments/CI to act on.
|
|
264
|
+
// Skip when there are merge conflicts — fix_code handles conflict resolution, not escalation.
|
|
265
|
+
if (changesRequestedReviews.length > 0 &&
|
|
266
|
+
actionableThreads.length === 0 &&
|
|
267
|
+
actionableComments.length === 0 &&
|
|
268
|
+
actionableChecks.length === 0 &&
|
|
269
|
+
!hasConflicts) {
|
|
270
|
+
triggers.push("pr-level-changes-requested");
|
|
271
|
+
}
|
|
272
|
+
// Trigger 3: actionable thread has no file/line — cannot locate code to edit.
|
|
273
|
+
const unlocatable = actionableThreads.filter((t) => t.path === null || t.line === null);
|
|
274
|
+
if (unlocatable.length > 0) {
|
|
275
|
+
triggers.push("thread-missing-location");
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
triggers,
|
|
279
|
+
thrashHistory: thrashThreads.length > 0
|
|
280
|
+
? thrashThreads.map((t) => ({ threadId: t.id, attempts: threadAttempts[t.id] ?? 0 }))
|
|
281
|
+
: undefined,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
function buildEscalateSuggestion(triggers) {
|
|
285
|
+
if (triggers.includes("fix-thrash")) {
|
|
286
|
+
return "Same thread(s) attempted multiple times without resolution — fix manually then rerun /pr-shepherd:monitor";
|
|
287
|
+
}
|
|
288
|
+
if (triggers.includes("pr-level-changes-requested")) {
|
|
289
|
+
return "Reviewer requested changes but left no inline comments — read the review and act manually";
|
|
290
|
+
}
|
|
291
|
+
if (triggers.includes("thread-missing-location")) {
|
|
292
|
+
return "Review thread has no file/line reference — cannot locate code to edit automatically";
|
|
293
|
+
}
|
|
294
|
+
return "Ambiguous state — inspect the PR and act manually";
|
|
295
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ready-delay state machine for the shepherd iterate loop.
|
|
3
|
+
*
|
|
4
|
+
* When all READY conditions hold, shepherd writes a `ready-since.txt` marker
|
|
5
|
+
* to the cache dir. The loop continues until the PR has been READY for
|
|
6
|
+
* `readyDelaySeconds` consecutively. Any not-READY result resets the timer.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|
9
|
+
import { join, dirname } from "node:path";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { SAFE_SEGMENT } from "../util/path-segment.mjs";
|
|
12
|
+
/**
|
|
13
|
+
* Update the ready-delay state machine and return the current decision.
|
|
14
|
+
*
|
|
15
|
+
* Call this at the end of each sweep iteration:
|
|
16
|
+
* - If `isReady == true`: start or continue the ready timer.
|
|
17
|
+
* - If `isReady == false`: reset the timer.
|
|
18
|
+
*
|
|
19
|
+
* When `shouldCancel == true`, the slash command should invoke `/loop cancel`.
|
|
20
|
+
*/
|
|
21
|
+
export async function updateReadyDelay(prNumber, isReady, readyDelaySeconds, owner, repo) {
|
|
22
|
+
const markerPath = readySincePath(prNumber, owner, repo);
|
|
23
|
+
if (!isReady) {
|
|
24
|
+
// Reset the timer.
|
|
25
|
+
await safeUnlink(markerPath);
|
|
26
|
+
return { isReady: false, shouldCancel: false, remainingSeconds: readyDelaySeconds };
|
|
27
|
+
}
|
|
28
|
+
// PR is READY — check or create the marker.
|
|
29
|
+
const now = Math.floor(Date.now() / 1000);
|
|
30
|
+
let readySince;
|
|
31
|
+
try {
|
|
32
|
+
const raw = await readFile(markerPath, "utf8");
|
|
33
|
+
readySince = parseInt(raw.trim(), 10);
|
|
34
|
+
// Reset if the stored value is not finite or is in the future (clock skew,
|
|
35
|
+
// corrupted file, or manual edit). A future timestamp would produce a
|
|
36
|
+
// negative elapsed value and an inflated remainingSeconds.
|
|
37
|
+
if (!Number.isFinite(readySince) || readySince > now) {
|
|
38
|
+
readySince = now;
|
|
39
|
+
await safeWriteFile(markerPath, String(now));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Marker doesn't exist yet — create it.
|
|
44
|
+
readySince = now;
|
|
45
|
+
await safeWriteFile(markerPath, String(now));
|
|
46
|
+
}
|
|
47
|
+
const elapsed = now - readySince;
|
|
48
|
+
const remaining = readyDelaySeconds - elapsed;
|
|
49
|
+
if (remaining <= 0) {
|
|
50
|
+
// Leave the marker in place so future sweeps also return shouldCancel:true
|
|
51
|
+
// until the PR drops out of READY state (which resets via safeUnlink above).
|
|
52
|
+
return { isReady: true, shouldCancel: true, remainingSeconds: 0 };
|
|
53
|
+
}
|
|
54
|
+
return { isReady: true, shouldCancel: false, remainingSeconds: remaining };
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
function readySincePath(pr, owner, repo) {
|
|
60
|
+
for (const [field, value] of [
|
|
61
|
+
["owner", owner],
|
|
62
|
+
["repo", repo],
|
|
63
|
+
]) {
|
|
64
|
+
if (!SAFE_SEGMENT.test(value)) {
|
|
65
|
+
throw new Error(`Invalid path segment "${field}": ${value}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const base = process.env["PR_SHEPHERD_CACHE_DIR"] ?? join(tmpdir(), "pr-shepherd-cache");
|
|
69
|
+
return join(base, `${owner}-${repo}`, String(pr), "ready-since.txt");
|
|
70
|
+
}
|
|
71
|
+
async function safeUnlink(path) {
|
|
72
|
+
try {
|
|
73
|
+
await unlink(path);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Ignore — file may not exist.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async function safeWriteFile(path, content) {
|
|
80
|
+
try {
|
|
81
|
+
await mkdir(dirname(path), { recursive: true });
|
|
82
|
+
await writeFile(path, content, "utf8");
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Best-effort.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shepherd resolve [PR] [flags]`
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
*
|
|
6
|
+
* Fetch mode (--fetch or no mutation flags):
|
|
7
|
+
* Auto-resolves outdated threads and returns all active threads,
|
|
8
|
+
* visible comments, and CHANGES_REQUESTED reviews for LLM triage.
|
|
9
|
+
* Sonnet reads this output, applies code fixes, pushes, then calls
|
|
10
|
+
* resolve in mutation mode to resolve/minimize/dismiss by ID.
|
|
11
|
+
*
|
|
12
|
+
* Mutation mode (--resolve-thread-ids, --minimize-comment-ids, --dismiss-review-ids):
|
|
13
|
+
* Resolves/minimizes/dismisses by ID. Optionally verifies the push
|
|
14
|
+
* has landed on GitHub before mutating (--require-sha).
|
|
15
|
+
*/
|
|
16
|
+
import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
|
|
17
|
+
import { fetchPrBatch } from "../github/batch.mjs";
|
|
18
|
+
import { getOutdatedThreads } from "../comments/outdated.mjs";
|
|
19
|
+
import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mjs";
|
|
20
|
+
/**
|
|
21
|
+
* Fetch mode: auto-resolve outdated threads and return all active items for LLM triage.
|
|
22
|
+
*/
|
|
23
|
+
export async function runResolveFetch(opts) {
|
|
24
|
+
const repo = await getRepoInfo();
|
|
25
|
+
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
26
|
+
if (prNumber === null) {
|
|
27
|
+
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
28
|
+
}
|
|
29
|
+
// Always bypass cache for resolve — we need fresh data before mutating.
|
|
30
|
+
const { data } = await fetchPrBatch(prNumber, repo);
|
|
31
|
+
const unresolvedThreads = data.reviewThreads.filter((t) => !t.isResolved);
|
|
32
|
+
const visibleComments = data.comments.filter((c) => !c.isMinimized);
|
|
33
|
+
// Auto-resolve outdated.
|
|
34
|
+
const outdated = getOutdatedThreads(unresolvedThreads);
|
|
35
|
+
let autoResolved = [];
|
|
36
|
+
if (outdated.length > 0) {
|
|
37
|
+
const { resolved: resolvedIds } = await autoResolveOutdated(outdated.map((t) => t.id));
|
|
38
|
+
autoResolved = outdated.filter((t) => resolvedIds.includes(t.id));
|
|
39
|
+
}
|
|
40
|
+
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
|
|
41
|
+
return {
|
|
42
|
+
autoResolved,
|
|
43
|
+
actionableThreads: activeThreads,
|
|
44
|
+
actionableComments: visibleComments,
|
|
45
|
+
changesRequestedReviews: data.changesRequestedReviews,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Mutation mode: resolve/minimize/dismiss by ID.
|
|
50
|
+
*/
|
|
51
|
+
export async function runResolveMutate(opts) {
|
|
52
|
+
const repo = await getRepoInfo();
|
|
53
|
+
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
54
|
+
if (prNumber === null) {
|
|
55
|
+
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
56
|
+
}
|
|
57
|
+
return applyResolveOptions(prNumber, repo, {
|
|
58
|
+
resolveThreadIds: opts.resolveThreadIds,
|
|
59
|
+
minimizeCommentIds: opts.minimizeCommentIds,
|
|
60
|
+
dismissReviewIds: opts.dismissReviewIds,
|
|
61
|
+
dismissMessage: opts.dismissMessage,
|
|
62
|
+
requireSha: opts.requireSha,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shepherd status PR1 [PR2 PR3 …]`
|
|
3
|
+
*
|
|
4
|
+
* Fetches readiness status for one or more PRs and prints a table.
|
|
5
|
+
* Uses a separate lightweight GraphQL query (MULTI_PR_STATUS_QUERY) per PR
|
|
6
|
+
* rather than the heavy batch query, since we only need summary data.
|
|
7
|
+
*
|
|
8
|
+
* Exit code: 0 if all PRs are READY, non-zero otherwise.
|
|
9
|
+
*/
|
|
10
|
+
import { graphql, getRepoInfo } from "../github/client.mjs";
|
|
11
|
+
import { MULTI_PR_STATUS_QUERY, MULTI_PR_STATUS_QUERY_WITH_CURSOR } from "../github/queries.mjs";
|
|
12
|
+
export async function runStatus(opts) {
|
|
13
|
+
const repo = await getRepoInfo();
|
|
14
|
+
const summaries = await Promise.all(opts.prNumbers.map((pr) => fetchSummary(pr, repo.owner, repo.name)));
|
|
15
|
+
return summaries;
|
|
16
|
+
}
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Internal
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
async function fetchSummary(pr, owner, repo) {
|
|
21
|
+
const result = await graphql(MULTI_PR_STATUS_QUERY, {
|
|
22
|
+
owner,
|
|
23
|
+
repo,
|
|
24
|
+
pr,
|
|
25
|
+
});
|
|
26
|
+
const p = result.data.repository.pullRequest;
|
|
27
|
+
if (!p) {
|
|
28
|
+
throw new Error(`PR #${pr} not found in ${owner}/${repo}`);
|
|
29
|
+
}
|
|
30
|
+
let allNodes = p.reviewThreads.nodes;
|
|
31
|
+
// If the response was truncated, fetch additional pages to get the full count.
|
|
32
|
+
if (p.reviewThreads.totalCount > p.reviewThreads.nodes.length) {
|
|
33
|
+
// Fetch additional pages backward until we have all threads.
|
|
34
|
+
let cursor = p.reviewThreads.pageInfo?.startCursor ?? null;
|
|
35
|
+
while (cursor !== null) {
|
|
36
|
+
// eslint-disable-next-line no-await-in-loop
|
|
37
|
+
const extra = await graphql(MULTI_PR_STATUS_QUERY_WITH_CURSOR, {
|
|
38
|
+
owner,
|
|
39
|
+
repo,
|
|
40
|
+
pr,
|
|
41
|
+
cursor,
|
|
42
|
+
});
|
|
43
|
+
const p2 = extra.data.repository.pullRequest;
|
|
44
|
+
if (!p2)
|
|
45
|
+
break;
|
|
46
|
+
allNodes = [...p2.reviewThreads.nodes, ...allNodes];
|
|
47
|
+
if (!p2.reviewThreads.pageInfo?.hasPreviousPage || !p2.reviewThreads.pageInfo.startCursor) {
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
cursor = p2.reviewThreads.pageInfo.startCursor;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const unresolvedThreads = allNodes.filter((n) => !n.isResolved).length;
|
|
54
|
+
const ciState = p.commits.nodes[0]?.commit.statusCheckRollup?.state ?? null;
|
|
55
|
+
// If we still have fewer nodes than totalCount, report truncation.
|
|
56
|
+
const threadsTruncated = p.reviewThreads.totalCount > allNodes.length;
|
|
57
|
+
return {
|
|
58
|
+
number: p.number,
|
|
59
|
+
title: p.title,
|
|
60
|
+
state: p.state,
|
|
61
|
+
isDraft: p.isDraft,
|
|
62
|
+
mergeStateStatus: p.mergeStateStatus,
|
|
63
|
+
reviewDecision: p.reviewDecision,
|
|
64
|
+
unresolvedThreads,
|
|
65
|
+
ciState,
|
|
66
|
+
threadsTruncated,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Output helpers
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
export function formatStatusTable(summaries, repoFull) {
|
|
73
|
+
const lines = [`\n# ${repoFull} — PR status (${summaries.length})\n`];
|
|
74
|
+
for (const s of summaries) {
|
|
75
|
+
const verdict = deriveVerdict(s);
|
|
76
|
+
const ciLabel = s.ciState ?? "—";
|
|
77
|
+
const title = s.title.slice(0, 50);
|
|
78
|
+
const truncNote = s.threadsTruncated
|
|
79
|
+
? " (threads truncated — run shepherd check for full count)"
|
|
80
|
+
: "";
|
|
81
|
+
lines.push(`PR #${String(s.number).padEnd(5)} ${title.padEnd(52)} ${verdict.padEnd(12)} ${ciLabel}${truncNote}`);
|
|
82
|
+
}
|
|
83
|
+
return lines.join("\n");
|
|
84
|
+
}
|
|
85
|
+
export function deriveVerdict(s) {
|
|
86
|
+
if (s.state === "MERGED")
|
|
87
|
+
return "MERGED";
|
|
88
|
+
if (s.state === "CLOSED")
|
|
89
|
+
return "CLOSED";
|
|
90
|
+
if (s.isDraft)
|
|
91
|
+
return "DRAFT";
|
|
92
|
+
if (s.mergeStateStatus === "CLEAN" &&
|
|
93
|
+
s.unresolvedThreads === 0 &&
|
|
94
|
+
s.ciState === "SUCCESS" &&
|
|
95
|
+
s.reviewDecision !== "CHANGES_REQUESTED") {
|
|
96
|
+
return "READY";
|
|
97
|
+
}
|
|
98
|
+
if (s.mergeStateStatus === "BLOCKED")
|
|
99
|
+
return "BLOCKED";
|
|
100
|
+
if (s.mergeStateStatus === "DIRTY")
|
|
101
|
+
return "CONFLICTS";
|
|
102
|
+
if (s.ciState === "PENDING" || s.ciState === "EXPECTED")
|
|
103
|
+
return "IN PROGRESS";
|
|
104
|
+
if (s.ciState === "FAILURE" || s.ciState === "ERROR")
|
|
105
|
+
return "FAILING";
|
|
106
|
+
return s.mergeStateStatus;
|
|
107
|
+
}
|
|
@@ -9,10 +9,7 @@
|
|
|
9
9
|
* enough that the comment no longer points to a live diff line. These threads
|
|
10
10
|
* are visually collapsed on GitHub and are safe to resolve programmatically.
|
|
11
11
|
*/
|
|
12
|
-
|
|
13
|
-
import type { ReviewThread } from "../types.mts";
|
|
14
|
-
|
|
15
12
|
/** Returns the subset of threads that should be auto-resolved as outdated. */
|
|
16
|
-
export function getOutdatedThreads(threads
|
|
17
|
-
|
|
13
|
+
export function getOutdatedThreads(threads) {
|
|
14
|
+
return threads.filter((t) => t.isOutdated && !t.isResolved);
|
|
18
15
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Batched mutations for resolving threads, minimizing comments, and dismissing reviews.
|
|
3
|
+
*
|
|
4
|
+
* The three mutation types (resolve / minimize / dismiss) are run sequentially so total
|
|
5
|
+
* in-flight mutations never exceed CONCURRENCY at once, keeping us well within GitHub's
|
|
6
|
+
* secondary rate-limit window.
|
|
7
|
+
*
|
|
8
|
+
* Push-before-resolve safety:
|
|
9
|
+
* When `requireSha` is set, shepherd verifies that GitHub has received that
|
|
10
|
+
* commit before issuing any resolve/dismiss mutations. It polls up to 20 seconds.
|
|
11
|
+
* If the push hasn't landed, shepherd throws rather than resolving prematurely
|
|
12
|
+
* (which could allow auto-merge before reviewers see the fix).
|
|
13
|
+
*/
|
|
14
|
+
import { graphql, getPrHeadSha } from "../github/client.mjs";
|
|
15
|
+
import { RESOLVE_THREAD_MUTATION, MINIMIZE_COMMENT_MUTATION, DISMISS_REVIEW_MUTATION, } from "../github/queries.mjs";
|
|
16
|
+
import { loadConfig } from "../config/load.mjs";
|
|
17
|
+
const config = loadConfig();
|
|
18
|
+
const CONCURRENCY = config.resolve.concurrency;
|
|
19
|
+
const SHA_POLL_INTERVAL_MS = config.resolve.shaPoll.intervalMs;
|
|
20
|
+
const SHA_POLL_MAX_ATTEMPTS = config.resolve.shaPoll.maxAttempts;
|
|
21
|
+
/**
|
|
22
|
+
* Execute all requested resolve/minimize/dismiss mutations.
|
|
23
|
+
*
|
|
24
|
+
* @throws Error if `requireSha` is set and GitHub hasn't received that commit
|
|
25
|
+
* within the polling window.
|
|
26
|
+
*/
|
|
27
|
+
export async function applyResolveOptions(pr, repo, opts) {
|
|
28
|
+
// Require --message when dismissing reviews.
|
|
29
|
+
if ((opts.dismissReviewIds?.length ?? 0) > 0 && !opts.dismissMessage) {
|
|
30
|
+
throw new Error("--message is required when dismissing reviews");
|
|
31
|
+
}
|
|
32
|
+
// Safety check: verify the push landed before resolving.
|
|
33
|
+
if (opts.requireSha) {
|
|
34
|
+
await waitForSha(pr, repo, opts.requireSha);
|
|
35
|
+
}
|
|
36
|
+
const result = {
|
|
37
|
+
resolvedThreads: [],
|
|
38
|
+
minimizedComments: [],
|
|
39
|
+
dismissedReviews: [],
|
|
40
|
+
errors: [],
|
|
41
|
+
};
|
|
42
|
+
await runBatched(opts.resolveThreadIds ?? [], (id) => resolveThread(id), result.resolvedThreads, result.errors);
|
|
43
|
+
await runBatched(opts.minimizeCommentIds ?? [], (id) => minimizeComment(id, "RESOLVED"), result.minimizedComments, result.errors);
|
|
44
|
+
await runBatched(opts.dismissReviewIds ?? [], (id) => dismissReview(id, opts.dismissMessage), result.dismissedReviews, result.errors);
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Auto-resolve a batch of outdated threads via the resolveReviewThread mutation.
|
|
49
|
+
*/
|
|
50
|
+
export async function autoResolveOutdated(threadIds) {
|
|
51
|
+
const resolved = [];
|
|
52
|
+
const errors = [];
|
|
53
|
+
await runBatched(threadIds, (id) => resolveThread(id), resolved, errors);
|
|
54
|
+
return { resolved, errors };
|
|
55
|
+
}
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// Mutation helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
async function resolveThread(threadId) {
|
|
60
|
+
await graphql(RESOLVE_THREAD_MUTATION, { threadId });
|
|
61
|
+
}
|
|
62
|
+
async function minimizeComment(commentId, classifier) {
|
|
63
|
+
await graphql(MINIMIZE_COMMENT_MUTATION, { commentId, classifier });
|
|
64
|
+
}
|
|
65
|
+
async function dismissReview(reviewId, message) {
|
|
66
|
+
await graphql(DISMISS_REVIEW_MUTATION, { reviewId, message });
|
|
67
|
+
}
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Concurrency helper
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
async function runBatched(ids, fn, successList, errorList) {
|
|
72
|
+
// Process in chunks of CONCURRENCY.
|
|
73
|
+
for (let i = 0; i < ids.length; i += CONCURRENCY) {
|
|
74
|
+
const chunk = ids.slice(i, i + CONCURRENCY);
|
|
75
|
+
// eslint-disable-next-line no-await-in-loop
|
|
76
|
+
await Promise.all(chunk.map(async (id) => {
|
|
77
|
+
try {
|
|
78
|
+
await fn(id);
|
|
79
|
+
successList.push(id);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
errorList.push(`${id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
83
|
+
}
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// SHA polling
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
async function waitForSha(pr, repo, expectedSha) {
|
|
91
|
+
for (let attempt = 0; attempt < SHA_POLL_MAX_ATTEMPTS; attempt++) {
|
|
92
|
+
try {
|
|
93
|
+
// eslint-disable-next-line no-await-in-loop
|
|
94
|
+
const currentSha = await getPrHeadSha(pr, repo.owner, repo.name);
|
|
95
|
+
if (currentSha === expectedSha)
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
// Transient network / 5xx error — keep polling unless this is the last attempt.
|
|
100
|
+
if (attempt === SHA_POLL_MAX_ATTEMPTS - 1)
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
if (attempt < SHA_POLL_MAX_ATTEMPTS - 1) {
|
|
104
|
+
// eslint-disable-next-line no-await-in-loop
|
|
105
|
+
await sleep(SHA_POLL_INTERVAL_MS);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// Total actual wait = (SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS (no sleep after last poll).
|
|
109
|
+
throw new Error(`Timeout: GitHub PR #${pr} head SHA has not updated to ${expectedSha} after ${((SHA_POLL_MAX_ATTEMPTS - 1) * SHA_POLL_INTERVAL_MS) / 1000}s. Push may still be in transit — retry shortly.`);
|
|
110
|
+
}
|
|
111
|
+
function sleep(ms) {
|
|
112
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
113
|
+
}
|