pr-shepherd 0.2.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 +14 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/marketplace.json +8 -0
- package/package.json +62 -0
- package/skills/check/SKILL.md +70 -0
- package/skills/monitor/SKILL.md +108 -0
- package/skills/resolve/SKILL.md +85 -0
- package/src/cache/file-cache.mts +101 -0
- package/src/cache/file-cache.test.mts +91 -0
- package/src/cache/fix-attempts.mts +86 -0
- package/src/checks/classify.mts +80 -0
- package/src/checks/classify.test.mts +164 -0
- package/src/checks/triage.mock.test.mts +202 -0
- package/src/checks/triage.mts +88 -0
- package/src/cli.mts +423 -0
- package/src/commands/check.mts +188 -0
- package/src/commands/iterate.mock.test.mts +1111 -0
- package/src/commands/iterate.mts +371 -0
- package/src/commands/ready-delay.mts +117 -0
- package/src/commands/ready-delay.test.mts +116 -0
- package/src/commands/resolve.mts +92 -0
- package/src/commands/status.mts +173 -0
- package/src/comments/outdated.mts +18 -0
- package/src/comments/resolve.mts +179 -0
- package/src/config/load.mts +240 -0
- package/src/config.json +52 -0
- package/src/github/batch.mts +351 -0
- package/src/github/client.mts +207 -0
- package/src/github/client.test.mts +19 -0
- package/src/github/gql/batch-pr.gql +130 -0
- package/src/github/gql/dismiss-review.gql +7 -0
- package/src/github/gql/minimize-comment.gql +7 -0
- package/src/github/gql/multi-pr-status-paged.gql +31 -0
- package/src/github/gql/multi-pr-status.gql +32 -0
- package/src/github/gql/resolve-thread.gql +7 -0
- package/src/github/pagination.mts +86 -0
- package/src/github/pagination.test.mts +140 -0
- package/src/github/queries.mts +30 -0
- package/src/index.mts +17 -0
- package/src/merge-status/derive.mts +74 -0
- package/src/merge-status/derive.test.mts +130 -0
- package/src/reporters/json.mts +12 -0
- package/src/reporters/text.mts +140 -0
- package/src/types.mts +309 -0
- package/src/util/path-segment.mts +2 -0
|
@@ -0,0 +1,371 @@
|
|
|
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
|
+
|
|
25
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
26
|
+
import { promisify } from "node:util";
|
|
27
|
+
import { runCheck } from "./check.mts";
|
|
28
|
+
import { triageFailingChecks } from "../checks/triage.mts";
|
|
29
|
+
import { updateReadyDelay } from "./ready-delay.mts";
|
|
30
|
+
import { getCurrentPrNumber } from "../github/client.mts";
|
|
31
|
+
import { readFixAttempts, writeFixAttempts } from "../cache/fix-attempts.mts";
|
|
32
|
+
import type {
|
|
33
|
+
EscalateDetails,
|
|
34
|
+
IterateCommandOptions,
|
|
35
|
+
IterateResult,
|
|
36
|
+
IterateResultBase,
|
|
37
|
+
IterateResultSummary,
|
|
38
|
+
PrComment,
|
|
39
|
+
ReviewThread,
|
|
40
|
+
Review,
|
|
41
|
+
TriagedCheck,
|
|
42
|
+
} from "../types.mts";
|
|
43
|
+
import { loadConfig } from "../config/load.mts";
|
|
44
|
+
|
|
45
|
+
const execFile = promisify(execFileCb);
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Public API
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export async function runIterate(opts: IterateCommandOptions): Promise<IterateResult> {
|
|
52
|
+
const config = loadConfig();
|
|
53
|
+
const cooldownSeconds = opts.cooldownSeconds ?? config.iterate.cooldownSeconds;
|
|
54
|
+
const readyDelaySeconds = opts.readyDelaySeconds ?? config.watch.readyDelayMinutes * 60;
|
|
55
|
+
|
|
56
|
+
// Resolve prNumber early so the cooldown result carries a valid PR number.
|
|
57
|
+
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
58
|
+
if (prNumber === null) {
|
|
59
|
+
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
60
|
+
}
|
|
61
|
+
const optsWithPr = { ...opts, prNumber };
|
|
62
|
+
|
|
63
|
+
// Step 1: Cooldown — skip if last commit is too fresh.
|
|
64
|
+
const lastCommitTime = await getLastCommitTime();
|
|
65
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
66
|
+
if (nowSeconds - lastCommitTime < cooldownSeconds) {
|
|
67
|
+
// We don't have a report yet — return a minimal cooldown result.
|
|
68
|
+
return {
|
|
69
|
+
action: "cooldown",
|
|
70
|
+
pr: prNumber,
|
|
71
|
+
repo: "",
|
|
72
|
+
status: "UNKNOWN",
|
|
73
|
+
state: "UNKNOWN" as const,
|
|
74
|
+
mergeStateStatus: "UNKNOWN",
|
|
75
|
+
copilotReviewInProgress: false,
|
|
76
|
+
isDraft: false,
|
|
77
|
+
shouldCancel: false,
|
|
78
|
+
remainingSeconds: readyDelaySeconds,
|
|
79
|
+
summary: { passing: 0, skipped: 0, filtered: 0, inProgress: 0 },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Step 2: Sweep — fetch CI + comments + merge status, auto-resolve outdated.
|
|
84
|
+
// skipTriage defers log fetching until we know we'll need failureKind (steps 4–6).
|
|
85
|
+
let report = await runCheck({
|
|
86
|
+
...optsWithPr,
|
|
87
|
+
autoResolve: config.actions.autoResolveOutdated,
|
|
88
|
+
skipTriage: true,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Step 2.5: Cancel if PR is merged or closed — no longer actionable.
|
|
92
|
+
if (report.mergeStatus.state !== "OPEN") {
|
|
93
|
+
return {
|
|
94
|
+
pr: report.pr,
|
|
95
|
+
repo: report.repo,
|
|
96
|
+
status: report.status,
|
|
97
|
+
mergeStateStatus: report.mergeStatus.mergeStateStatus,
|
|
98
|
+
copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
|
|
99
|
+
isDraft: report.mergeStatus.isDraft,
|
|
100
|
+
shouldCancel: true,
|
|
101
|
+
remainingSeconds: 0,
|
|
102
|
+
state: report.mergeStatus.state,
|
|
103
|
+
summary: buildSummary(report),
|
|
104
|
+
action: "cancel",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Step 3: Ready-delay state machine.
|
|
109
|
+
const [repoOwner, repoName] = report.repo.split("/");
|
|
110
|
+
const isReady = report.status === "READY";
|
|
111
|
+
const readyState = await updateReadyDelay(
|
|
112
|
+
report.pr,
|
|
113
|
+
isReady,
|
|
114
|
+
readyDelaySeconds,
|
|
115
|
+
repoOwner!,
|
|
116
|
+
repoName!,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const base: IterateResultBase = {
|
|
120
|
+
pr: report.pr,
|
|
121
|
+
repo: report.repo,
|
|
122
|
+
status: report.status,
|
|
123
|
+
state: report.mergeStatus.state,
|
|
124
|
+
mergeStateStatus: report.mergeStatus.mergeStateStatus,
|
|
125
|
+
copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
|
|
126
|
+
isDraft: report.mergeStatus.isDraft,
|
|
127
|
+
shouldCancel: readyState.shouldCancel,
|
|
128
|
+
remainingSeconds: readyState.remainingSeconds,
|
|
129
|
+
summary: buildSummary(report),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// Step 3 cont.: cancel if ready-delay elapsed.
|
|
133
|
+
if (readyState.shouldCancel) {
|
|
134
|
+
return { ...base, action: "cancel" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Triage failing checks now that we know we need failureKind for steps 4–6.
|
|
138
|
+
if (report.checks.failing.length > 0) {
|
|
139
|
+
const triaged = await triageFailingChecks(report.checks.failing);
|
|
140
|
+
report = { ...report, checks: { ...report.checks, failing: triaged } };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Step 4: Actionable work — fix comments, review requests, CI failures, and merge
|
|
144
|
+
// conflicts all in one push. CONFLICTS is included here because the fix_code handler
|
|
145
|
+
// already runs fetch+rebase+push, so conflicts are resolved as part of that flow.
|
|
146
|
+
const actionableChecks = report.checks.failing.filter((f) => f.failureKind === "actionable");
|
|
147
|
+
const hasActionableWork =
|
|
148
|
+
report.threads.actionable.length > 0 ||
|
|
149
|
+
report.comments.actionable.length > 0 ||
|
|
150
|
+
report.changesRequestedReviews.length > 0 ||
|
|
151
|
+
actionableChecks.length > 0 ||
|
|
152
|
+
report.mergeStatus.status === "CONFLICTS";
|
|
153
|
+
|
|
154
|
+
if (hasActionableWork) {
|
|
155
|
+
// Load fix-attempt counts, resetting if HEAD SHA changed (new commit pushed).
|
|
156
|
+
const headSha = await getCurrentHeadSha();
|
|
157
|
+
const attemptsKey = { owner: repoOwner!, repo: repoName!, pr: prNumber };
|
|
158
|
+
const stored = await readFixAttempts(attemptsKey);
|
|
159
|
+
const attempts =
|
|
160
|
+
stored?.headSha === headSha
|
|
161
|
+
? stored
|
|
162
|
+
: { headSha, threadAttempts: {} as Record<string, number> };
|
|
163
|
+
|
|
164
|
+
// Escalation checks — surface ambiguous situations instead of looping forever.
|
|
165
|
+
const escalateTriggers = checkEscalateTriggers(
|
|
166
|
+
report.threads.actionable,
|
|
167
|
+
report.comments.actionable,
|
|
168
|
+
report.changesRequestedReviews,
|
|
169
|
+
actionableChecks,
|
|
170
|
+
attempts.threadAttempts,
|
|
171
|
+
report.mergeStatus.status === "CONFLICTS",
|
|
172
|
+
);
|
|
173
|
+
if (escalateTriggers.triggers.length > 0) {
|
|
174
|
+
return {
|
|
175
|
+
...base,
|
|
176
|
+
action: "escalate",
|
|
177
|
+
escalate: {
|
|
178
|
+
triggers: escalateTriggers.triggers,
|
|
179
|
+
unresolvedThreads: report.threads.actionable,
|
|
180
|
+
ambiguousComments: report.comments.actionable,
|
|
181
|
+
changesRequestedReviews: report.changesRequestedReviews,
|
|
182
|
+
attemptHistory: escalateTriggers.thrashHistory,
|
|
183
|
+
suggestion: buildEscalateSuggestion(escalateTriggers.triggers),
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Increment attempt counts for this dispatch cycle.
|
|
189
|
+
const newThreadAttempts = { ...attempts.threadAttempts };
|
|
190
|
+
for (const t of report.threads.actionable) {
|
|
191
|
+
newThreadAttempts[t.id] = (newThreadAttempts[t.id] ?? 0) + 1;
|
|
192
|
+
}
|
|
193
|
+
await writeFixAttempts(attemptsKey, { headSha, threadAttempts: newThreadAttempts });
|
|
194
|
+
|
|
195
|
+
let cancelled: string[] = [];
|
|
196
|
+
if (!opts.noAutoCancelActionable) {
|
|
197
|
+
const uniqueRunIds = [
|
|
198
|
+
...new Set(actionableChecks.map((c) => c.runId).filter((id): id is string => id !== null)),
|
|
199
|
+
];
|
|
200
|
+
const results = await Promise.all(uniqueRunIds.map((id) => tryCancelRun(id)));
|
|
201
|
+
cancelled = results.filter((id): id is string => id !== null);
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
...base,
|
|
205
|
+
action: "fix_code",
|
|
206
|
+
fix: {
|
|
207
|
+
threads: report.threads.actionable,
|
|
208
|
+
comments: report.comments.actionable,
|
|
209
|
+
checks: actionableChecks,
|
|
210
|
+
changesRequestedReviews: report.changesRequestedReviews,
|
|
211
|
+
},
|
|
212
|
+
cancelled,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Step 5: Transient failures (timeout / infrastructure) — no actionable work, no conflicts.
|
|
217
|
+
const transientChecks = report.checks.failing.filter(
|
|
218
|
+
(f) => f.failureKind === "timeout" || f.failureKind === "infrastructure",
|
|
219
|
+
);
|
|
220
|
+
if (transientChecks.length > 0 && !opts.noAutoRerun) {
|
|
221
|
+
// Deduplicate runIds — multiple failed steps can share the same runId.
|
|
222
|
+
const uniqueRunIds = [
|
|
223
|
+
...new Set(transientChecks.map((c) => c.runId).filter((id) => id !== null)),
|
|
224
|
+
];
|
|
225
|
+
await Promise.all(
|
|
226
|
+
uniqueRunIds.map((runId) => runGhCommand(["run", "rerun", runId, "--failed"])),
|
|
227
|
+
);
|
|
228
|
+
return { ...base, action: "rerun_ci", reran: uniqueRunIds };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Step 6: Flaky + behind — rebase needed.
|
|
232
|
+
const hasFlaky = report.checks.failing.some((f) => f.failureKind === "flaky");
|
|
233
|
+
if (hasFlaky && report.mergeStatus.status === "BEHIND" && config.actions.autoRebase) {
|
|
234
|
+
return { ...base, action: "rebase" };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Step 7: Mark ready for review.
|
|
238
|
+
// Draft PRs often report mergeStateStatus === 'DRAFT' rather than 'CLEAN' until
|
|
239
|
+
// they're explicitly marked ready, so we allow either state when isDraft is true.
|
|
240
|
+
const mergeStateAllowsMarkReady =
|
|
241
|
+
report.mergeStatus.mergeStateStatus === "CLEAN" ||
|
|
242
|
+
(report.mergeStatus.mergeStateStatus === "DRAFT" && report.mergeStatus.isDraft);
|
|
243
|
+
const canMarkReady =
|
|
244
|
+
report.status === "READY" &&
|
|
245
|
+
mergeStateAllowsMarkReady &&
|
|
246
|
+
!report.mergeStatus.copilotReviewInProgress &&
|
|
247
|
+
!readyState.shouldCancel &&
|
|
248
|
+
report.mergeStatus.isDraft;
|
|
249
|
+
|
|
250
|
+
if (canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady) {
|
|
251
|
+
await runGhCommand(["pr", "ready", String(report.pr)]);
|
|
252
|
+
return { ...base, action: "mark_ready", markedReady: true };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Step 8: Nothing to do.
|
|
256
|
+
return { ...base, action: "wait" };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Helpers
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
function buildSummary(report: Awaited<ReturnType<typeof runCheck>>): IterateResultSummary {
|
|
264
|
+
return {
|
|
265
|
+
passing: report.checks.passing.length,
|
|
266
|
+
skipped: report.checks.skipped.length,
|
|
267
|
+
filtered: report.checks.filtered.length,
|
|
268
|
+
inProgress: report.checks.inProgress.length,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function getLastCommitTime(): Promise<number> {
|
|
273
|
+
try {
|
|
274
|
+
const { stdout } = await execFile("git", ["log", "-1", "--format=%ct", "HEAD"]);
|
|
275
|
+
return parseInt(stdout.trim(), 10);
|
|
276
|
+
} catch {
|
|
277
|
+
return 0;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function runGhCommand(args: string[]): Promise<void> {
|
|
282
|
+
try {
|
|
283
|
+
await execFile("gh", args);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
286
|
+
throw new Error(`gh ${args.join(" ")} failed: ${msg}`, { cause: err });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Best-effort: cancelling a completed run is a no-op, not an error.
|
|
291
|
+
async function tryCancelRun(runId: string): Promise<string | null> {
|
|
292
|
+
try {
|
|
293
|
+
await execFile("gh", ["run", "cancel", runId]);
|
|
294
|
+
return runId;
|
|
295
|
+
} catch (err) {
|
|
296
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
297
|
+
process.stderr.write(`pr-shepherd: gh run cancel ${runId} failed (ignored): ${msg}\n`);
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function getCurrentHeadSha(): Promise<string> {
|
|
303
|
+
try {
|
|
304
|
+
const { stdout } = await execFile("git", ["rev-parse", "HEAD"]);
|
|
305
|
+
return stdout.trim();
|
|
306
|
+
} catch {
|
|
307
|
+
return "unknown";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
interface EscalateCheck {
|
|
312
|
+
triggers: string[];
|
|
313
|
+
thrashHistory?: EscalateDetails["attemptHistory"];
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function checkEscalateTriggers(
|
|
317
|
+
actionableThreads: ReviewThread[],
|
|
318
|
+
actionableComments: PrComment[],
|
|
319
|
+
changesRequestedReviews: Review[],
|
|
320
|
+
actionableChecks: TriagedCheck[],
|
|
321
|
+
threadAttempts: Record<string, number>,
|
|
322
|
+
hasConflicts: boolean,
|
|
323
|
+
): EscalateCheck {
|
|
324
|
+
const triggers: string[] = [];
|
|
325
|
+
const maxAttempts = loadConfig().iterate.fixAttemptsPerThread;
|
|
326
|
+
|
|
327
|
+
// Trigger 1: fix thrash — same thread dispatched too many times without resolving.
|
|
328
|
+
const thrashThreads = actionableThreads.filter((t) => (threadAttempts[t.id] ?? 0) >= maxAttempts);
|
|
329
|
+
if (thrashThreads.length > 0) {
|
|
330
|
+
triggers.push("fix-thrash");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Trigger 2: PR-level CHANGES_REQUESTED with no inline threads/comments/CI to act on.
|
|
334
|
+
// Skip when there are merge conflicts — fix_code handles conflict resolution, not escalation.
|
|
335
|
+
if (
|
|
336
|
+
changesRequestedReviews.length > 0 &&
|
|
337
|
+
actionableThreads.length === 0 &&
|
|
338
|
+
actionableComments.length === 0 &&
|
|
339
|
+
actionableChecks.length === 0 &&
|
|
340
|
+
!hasConflicts
|
|
341
|
+
) {
|
|
342
|
+
triggers.push("pr-level-changes-requested");
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Trigger 3: actionable thread has no file/line — cannot locate code to edit.
|
|
346
|
+
const unlocatable = actionableThreads.filter((t) => t.path === null || t.line === null);
|
|
347
|
+
if (unlocatable.length > 0) {
|
|
348
|
+
triggers.push("thread-missing-location");
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return {
|
|
352
|
+
triggers,
|
|
353
|
+
thrashHistory:
|
|
354
|
+
thrashThreads.length > 0
|
|
355
|
+
? thrashThreads.map((t) => ({ threadId: t.id, attempts: threadAttempts[t.id] ?? 0 }))
|
|
356
|
+
: undefined,
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function buildEscalateSuggestion(triggers: string[]): string {
|
|
361
|
+
if (triggers.includes("fix-thrash")) {
|
|
362
|
+
return "Same thread(s) attempted multiple times without resolution — fix manually then rerun /pr-shepherd:monitor";
|
|
363
|
+
}
|
|
364
|
+
if (triggers.includes("pr-level-changes-requested")) {
|
|
365
|
+
return "Reviewer requested changes but left no inline comments — read the review and act manually";
|
|
366
|
+
}
|
|
367
|
+
if (triggers.includes("thread-missing-location")) {
|
|
368
|
+
return "Review thread has no file/line reference — cannot locate code to edit automatically";
|
|
369
|
+
}
|
|
370
|
+
return "Ambiguous state — inspect the PR and act manually";
|
|
371
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
|
|
9
|
+
import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { SAFE_SEGMENT } from "../util/path-segment.mts";
|
|
13
|
+
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Ready-delay state machine
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
export interface ReadyDelayState {
|
|
19
|
+
isReady: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* When true, the loop should cancel itself — the PR has been READY for
|
|
22
|
+
* longer than the configured ready-delay.
|
|
23
|
+
*/
|
|
24
|
+
shouldCancel: boolean;
|
|
25
|
+
/** How many seconds remain in the ready-delay cooldown. */
|
|
26
|
+
remainingSeconds: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Update the ready-delay state machine and return the current decision.
|
|
31
|
+
*
|
|
32
|
+
* Call this at the end of each sweep iteration:
|
|
33
|
+
* - If `isReady == true`: start or continue the ready timer.
|
|
34
|
+
* - If `isReady == false`: reset the timer.
|
|
35
|
+
*
|
|
36
|
+
* When `shouldCancel == true`, the slash command should invoke `/loop cancel`.
|
|
37
|
+
*/
|
|
38
|
+
export async function updateReadyDelay(
|
|
39
|
+
prNumber: number,
|
|
40
|
+
isReady: boolean,
|
|
41
|
+
readyDelaySeconds: number,
|
|
42
|
+
owner: string,
|
|
43
|
+
repo: string,
|
|
44
|
+
): Promise<ReadyDelayState> {
|
|
45
|
+
const markerPath = readySincePath(prNumber, owner, repo);
|
|
46
|
+
|
|
47
|
+
if (!isReady) {
|
|
48
|
+
// Reset the timer.
|
|
49
|
+
await safeUnlink(markerPath);
|
|
50
|
+
return { isReady: false, shouldCancel: false, remainingSeconds: readyDelaySeconds };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// PR is READY — check or create the marker.
|
|
54
|
+
const now = Math.floor(Date.now() / 1000);
|
|
55
|
+
let readySince: number;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const raw = await readFile(markerPath, "utf8");
|
|
59
|
+
readySince = parseInt(raw.trim(), 10);
|
|
60
|
+
// Reset if the stored value is not finite or is in the future (clock skew,
|
|
61
|
+
// corrupted file, or manual edit). A future timestamp would produce a
|
|
62
|
+
// negative elapsed value and an inflated remainingSeconds.
|
|
63
|
+
if (!Number.isFinite(readySince) || readySince > now) {
|
|
64
|
+
readySince = now;
|
|
65
|
+
await safeWriteFile(markerPath, String(now));
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// Marker doesn't exist yet — create it.
|
|
69
|
+
readySince = now;
|
|
70
|
+
await safeWriteFile(markerPath, String(now));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const elapsed = now - readySince;
|
|
74
|
+
const remaining = readyDelaySeconds - elapsed;
|
|
75
|
+
|
|
76
|
+
if (remaining <= 0) {
|
|
77
|
+
// Leave the marker in place so future sweeps also return shouldCancel:true
|
|
78
|
+
// until the PR drops out of READY state (which resets via safeUnlink above).
|
|
79
|
+
return { isReady: true, shouldCancel: true, remainingSeconds: 0 };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { isReady: true, shouldCancel: false, remainingSeconds: remaining };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Helpers
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
function readySincePath(pr: number, owner: string, repo: string): string {
|
|
90
|
+
for (const [field, value] of [
|
|
91
|
+
["owner", owner],
|
|
92
|
+
["repo", repo],
|
|
93
|
+
] as const) {
|
|
94
|
+
if (!SAFE_SEGMENT.test(value)) {
|
|
95
|
+
throw new Error(`Invalid path segment "${field}": ${value}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const base = process.env["PR_SHEPHERD_CACHE_DIR"] ?? join(tmpdir(), "pr-shepherd-cache");
|
|
99
|
+
return join(base, `${owner}-${repo}`, String(pr), "ready-since.txt");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function safeUnlink(path: string): Promise<void> {
|
|
103
|
+
try {
|
|
104
|
+
await unlink(path);
|
|
105
|
+
} catch {
|
|
106
|
+
// Ignore — file may not exist.
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function safeWriteFile(path: string, content: string): Promise<void> {
|
|
111
|
+
try {
|
|
112
|
+
await mkdir(dirname(path), { recursive: true });
|
|
113
|
+
await writeFile(path, content, "utf8");
|
|
114
|
+
} catch {
|
|
115
|
+
// Best-effort.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { mkdtemp, rm, readFile } from "node:fs/promises";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { updateReadyDelay } from "./ready-delay.mts";
|
|
6
|
+
|
|
7
|
+
const OWNER = "test-owner";
|
|
8
|
+
const REPO = "test-repo";
|
|
9
|
+
const PR = 42;
|
|
10
|
+
const DELAY = 600; // 10 minutes
|
|
11
|
+
|
|
12
|
+
let cacheDir: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
cacheDir = await mkdtemp(join(tmpdir(), "shepherd-watch-test-"));
|
|
16
|
+
process.env["PR_SHEPHERD_CACHE_DIR"] = cacheDir;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(async () => {
|
|
20
|
+
delete process.env["PR_SHEPHERD_CACHE_DIR"];
|
|
21
|
+
await rm(cacheDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("updateReadyDelay", () => {
|
|
25
|
+
it("returns isReady:false and resets remainingSeconds when not ready", async () => {
|
|
26
|
+
const state = await updateReadyDelay(PR, false, DELAY, OWNER, REPO);
|
|
27
|
+
expect(state.isReady).toBe(false);
|
|
28
|
+
expect(state.shouldCancel).toBe(false);
|
|
29
|
+
expect(state.remainingSeconds).toBe(DELAY);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("starts a fresh countdown on first READY call", async () => {
|
|
33
|
+
const state = await updateReadyDelay(PR, true, DELAY, OWNER, REPO);
|
|
34
|
+
expect(state.isReady).toBe(true);
|
|
35
|
+
expect(state.shouldCancel).toBe(false);
|
|
36
|
+
expect(state.remainingSeconds).toBeGreaterThan(0);
|
|
37
|
+
expect(state.remainingSeconds).toBeLessThanOrEqual(DELAY);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("fires shouldCancel when delay has elapsed", async () => {
|
|
41
|
+
// Write a marker from the past (delay + 5 seconds ago)
|
|
42
|
+
const past = Math.floor(Date.now() / 1000) - DELAY - 5;
|
|
43
|
+
const markerPath = join(cacheDir, `${OWNER}-${REPO}`, String(PR), "ready-since.txt");
|
|
44
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
45
|
+
await mkdir(join(cacheDir, `${OWNER}-${REPO}`, String(PR)), { recursive: true });
|
|
46
|
+
await writeFile(markerPath, String(past), "utf8");
|
|
47
|
+
|
|
48
|
+
const state = await updateReadyDelay(PR, true, DELAY, OWNER, REPO);
|
|
49
|
+
expect(state.isReady).toBe(true);
|
|
50
|
+
expect(state.shouldCancel).toBe(true);
|
|
51
|
+
expect(state.remainingSeconds).toBe(0);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("keeps the marker file after shouldCancel fires so subsequent calls also return shouldCancel:true", async () => {
|
|
55
|
+
// Write a past marker
|
|
56
|
+
const past = Math.floor(Date.now() / 1000) - DELAY - 5;
|
|
57
|
+
const markerPath = join(cacheDir, `${OWNER}-${REPO}`, String(PR), "ready-since.txt");
|
|
58
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
59
|
+
await mkdir(join(cacheDir, `${OWNER}-${REPO}`, String(PR)), { recursive: true });
|
|
60
|
+
await writeFile(markerPath, String(past), "utf8");
|
|
61
|
+
|
|
62
|
+
// First call fires shouldCancel
|
|
63
|
+
const first = await updateReadyDelay(PR, true, DELAY, OWNER, REPO);
|
|
64
|
+
expect(first.shouldCancel).toBe(true);
|
|
65
|
+
|
|
66
|
+
// Marker file must still exist
|
|
67
|
+
const contents = await readFile(markerPath, "utf8");
|
|
68
|
+
expect(contents).toBe(String(past));
|
|
69
|
+
|
|
70
|
+
// Second call (simulating next cron tick) also returns shouldCancel:true
|
|
71
|
+
const second = await updateReadyDelay(PR, true, DELAY, OWNER, REPO);
|
|
72
|
+
expect(second.shouldCancel).toBe(true);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("resets the countdown when ready-since.txt contains a future timestamp (clock skew)", async () => {
|
|
76
|
+
// Write a marker far in the future (simulating clock skew or manual corruption).
|
|
77
|
+
const future = Math.floor(Date.now() / 1000) + 9999;
|
|
78
|
+
const markerPath = join(cacheDir, `${OWNER}-${REPO}`, String(PR), "ready-since.txt");
|
|
79
|
+
const { mkdir, writeFile } = await import("node:fs/promises");
|
|
80
|
+
await mkdir(join(cacheDir, `${OWNER}-${REPO}`, String(PR)), { recursive: true });
|
|
81
|
+
await writeFile(markerPath, String(future), "utf8");
|
|
82
|
+
|
|
83
|
+
const state = await updateReadyDelay(PR, true, DELAY, OWNER, REPO);
|
|
84
|
+
// Future timestamp must be reset to "now" — remaining should be ~DELAY.
|
|
85
|
+
expect(state.isReady).toBe(true);
|
|
86
|
+
expect(state.shouldCancel).toBe(false);
|
|
87
|
+
expect(state.remainingSeconds).toBeGreaterThan(0);
|
|
88
|
+
expect(state.remainingSeconds).toBeLessThanOrEqual(DELAY);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("resets the timer when PR drops out of READY state after shouldCancel", async () => {
|
|
92
|
+
const past = Math.floor(Date.now() / 1000) - DELAY - 5;
|
|
93
|
+
const markerPath = join(cacheDir, `${OWNER}-${REPO}`, String(PR), "ready-since.txt");
|
|
94
|
+
const { mkdir, writeFile, access } = await import("node:fs/promises");
|
|
95
|
+
await mkdir(join(cacheDir, `${OWNER}-${REPO}`, String(PR)), { recursive: true });
|
|
96
|
+
await writeFile(markerPath, String(past), "utf8");
|
|
97
|
+
|
|
98
|
+
// shouldCancel fires
|
|
99
|
+
await updateReadyDelay(PR, true, DELAY, OWNER, REPO);
|
|
100
|
+
|
|
101
|
+
// PR becomes not-ready (e.g. new review comment) — timer must reset
|
|
102
|
+
const reset = await updateReadyDelay(PR, false, DELAY, OWNER, REPO);
|
|
103
|
+
expect(reset.isReady).toBe(false);
|
|
104
|
+
expect(reset.shouldCancel).toBe(false);
|
|
105
|
+
|
|
106
|
+
// Marker file must be gone
|
|
107
|
+
await expect(access(markerPath)).rejects.toThrow();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("rejects when owner contains an invalid path segment character", async () => {
|
|
111
|
+
// owner contains '/' which is not in the allowed SAFE_SEGMENT charset.
|
|
112
|
+
await expect(updateReadyDelay(PR, true, DELAY, "owner/bad", "repo")).rejects.toThrow(
|
|
113
|
+
"Invalid path segment",
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
|
|
17
|
+
import { getRepoInfo, getCurrentPrNumber } from "../github/client.mts";
|
|
18
|
+
import { fetchPrBatch } from "../github/batch.mts";
|
|
19
|
+
import { getOutdatedThreads } from "../comments/outdated.mts";
|
|
20
|
+
import { autoResolveOutdated, applyResolveOptions } from "../comments/resolve.mts";
|
|
21
|
+
import type { GlobalOptions, ResolveOptions, ReviewThread, PrComment, Review } from "../types.mts";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Public API
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface FetchResult {
|
|
28
|
+
autoResolved: ReviewThread[];
|
|
29
|
+
actionableThreads: ReviewThread[];
|
|
30
|
+
actionableComments: PrComment[];
|
|
31
|
+
changesRequestedReviews: Review[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ResolveCommandOptions extends GlobalOptions {
|
|
35
|
+
/** When true, run in fetch mode regardless of other flags. */
|
|
36
|
+
fetch?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Fetch mode: auto-resolve outdated threads and return all active items for LLM triage.
|
|
41
|
+
*/
|
|
42
|
+
export async function runResolveFetch(opts: ResolveCommandOptions): Promise<FetchResult> {
|
|
43
|
+
const repo = await getRepoInfo();
|
|
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
|
+
|
|
49
|
+
// Always bypass cache for resolve — we need fresh data before mutating.
|
|
50
|
+
const { data } = await fetchPrBatch(prNumber, repo);
|
|
51
|
+
|
|
52
|
+
const unresolvedThreads = data.reviewThreads.filter((t) => !t.isResolved);
|
|
53
|
+
const visibleComments = data.comments.filter((c) => !c.isMinimized);
|
|
54
|
+
|
|
55
|
+
// Auto-resolve outdated.
|
|
56
|
+
const outdated = getOutdatedThreads(unresolvedThreads);
|
|
57
|
+
let autoResolved: ReviewThread[] = [];
|
|
58
|
+
if (outdated.length > 0) {
|
|
59
|
+
const { resolved: resolvedIds } = await autoResolveOutdated(outdated.map((t) => t.id));
|
|
60
|
+
autoResolved = outdated.filter((t) => resolvedIds.includes(t.id));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
autoResolved,
|
|
67
|
+
actionableThreads: activeThreads,
|
|
68
|
+
actionableComments: visibleComments,
|
|
69
|
+
changesRequestedReviews: data.changesRequestedReviews,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Mutation mode: resolve/minimize/dismiss by ID.
|
|
75
|
+
*/
|
|
76
|
+
export async function runResolveMutate(
|
|
77
|
+
opts: ResolveCommandOptions & ResolveOptions,
|
|
78
|
+
): Promise<import("../comments/resolve.mts").ResolveResult> {
|
|
79
|
+
const repo = await getRepoInfo();
|
|
80
|
+
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
81
|
+
if (prNumber === null) {
|
|
82
|
+
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return applyResolveOptions(prNumber, repo, {
|
|
86
|
+
resolveThreadIds: opts.resolveThreadIds,
|
|
87
|
+
minimizeCommentIds: opts.minimizeCommentIds,
|
|
88
|
+
dismissReviewIds: opts.dismissReviewIds,
|
|
89
|
+
dismissMessage: opts.dismissMessage,
|
|
90
|
+
requireSha: opts.requireSha,
|
|
91
|
+
});
|
|
92
|
+
}
|