pr-shepherd 0.2.0 → 0.4.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/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +8 -2
- package/README.md +128 -83
- package/bin/cache/file-cache.mjs +79 -0
- package/bin/cache/fix-attempts.mjs +67 -0
- package/bin/checks/classify.mjs +53 -0
- package/bin/checks/triage.mjs +77 -0
- package/bin/cli/args.mjs +173 -0
- package/bin/cli.mjs +204 -0
- package/bin/commands/check.mjs +140 -0
- package/bin/commands/iterate.mjs +301 -0
- package/bin/commands/ready-delay.mjs +87 -0
- package/bin/commands/resolve.mjs +64 -0
- package/bin/commands/status.mjs +107 -0
- package/{src/comments/outdated.mts → bin/comments/outdated.mjs} +2 -5
- package/bin/comments/resolve.mjs +111 -0
- package/bin/config/load.mjs +158 -0
- package/bin/github/batch.mjs +208 -0
- package/bin/github/client.mjs +152 -0
- package/{src/github/pagination.mts → bin/github/pagination.mjs} +26 -52
- package/{src/github/queries.mts → bin/github/queries.mjs} +1 -10
- package/{src/index.mts → bin/index.mjs} +3 -5
- package/bin/merge-status/derive.mjs +72 -0
- package/bin/pr-shepherd +2 -0
- package/bin/reporters/agent.mjs +41 -0
- package/{src/reporters/json.mts → bin/reporters/json.mjs} +2 -5
- package/bin/reporters/text.mjs +111 -0
- package/bin/types.mjs +2 -0
- package/package.json +9 -9
- package/skills/check/SKILL.md +12 -14
- 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 → bin}/config.json +0 -0
- /package/{src → bin}/github/gql/batch-pr.gql +0 -0
- /package/{src → bin}/github/gql/dismiss-review.gql +0 -0
- /package/{src → bin}/github/gql/minimize-comment.gql +0 -0
- /package/{src → bin}/github/gql/multi-pr-status-paged.gql +0 -0
- /package/{src → bin}/github/gql/multi-pr-status.gql +0 -0
- /package/{src → bin}/github/gql/resolve-thread.gql +0 -0
- /package/{src/util/path-segment.mts → bin/util/path-segment.mjs} +0 -0
|
@@ -0,0 +1,301 @@
|
|
|
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
|
+
if (!repoOwner || !repoName) {
|
|
92
|
+
throw new Error(`Unexpected repo format: "${report.repo}" (expected "owner/name")`);
|
|
93
|
+
}
|
|
94
|
+
const isReady = report.status === "READY";
|
|
95
|
+
const readyState = await updateReadyDelay(report.pr, isReady, readyDelaySeconds, repoOwner, repoName);
|
|
96
|
+
const base = {
|
|
97
|
+
pr: report.pr,
|
|
98
|
+
repo: report.repo,
|
|
99
|
+
status: report.status,
|
|
100
|
+
state: report.mergeStatus.state,
|
|
101
|
+
mergeStateStatus: report.mergeStatus.mergeStateStatus,
|
|
102
|
+
copilotReviewInProgress: report.mergeStatus.copilotReviewInProgress,
|
|
103
|
+
isDraft: report.mergeStatus.isDraft,
|
|
104
|
+
shouldCancel: readyState.shouldCancel,
|
|
105
|
+
remainingSeconds: readyState.remainingSeconds,
|
|
106
|
+
summary: buildSummary(report),
|
|
107
|
+
};
|
|
108
|
+
// Step 3 cont.: cancel if ready-delay elapsed.
|
|
109
|
+
if (readyState.shouldCancel) {
|
|
110
|
+
return { ...base, action: "cancel" };
|
|
111
|
+
}
|
|
112
|
+
// Triage failing checks now that we know we need failureKind for steps 4–6.
|
|
113
|
+
if (report.checks.failing.length > 0) {
|
|
114
|
+
const triaged = await triageFailingChecks(report.checks.failing);
|
|
115
|
+
report = { ...report, checks: { ...report.checks, failing: triaged } };
|
|
116
|
+
}
|
|
117
|
+
// Step 4: Actionable work — fix comments, review requests, CI failures, and merge
|
|
118
|
+
// conflicts all in one push. CONFLICTS is included here because the fix_code handler
|
|
119
|
+
// already runs fetch+rebase+push, so conflicts are resolved as part of that flow.
|
|
120
|
+
const actionableChecks = report.checks.failing.filter((f) => f.failureKind === "actionable");
|
|
121
|
+
const hasActionableWork = report.threads.actionable.length > 0 ||
|
|
122
|
+
report.comments.actionable.length > 0 ||
|
|
123
|
+
report.changesRequestedReviews.length > 0 ||
|
|
124
|
+
actionableChecks.length > 0 ||
|
|
125
|
+
report.mergeStatus.status === "CONFLICTS";
|
|
126
|
+
if (hasActionableWork) {
|
|
127
|
+
// Load fix-attempt counts, resetting if HEAD SHA changed (new commit pushed).
|
|
128
|
+
const headSha = await getCurrentHeadSha();
|
|
129
|
+
const attemptsKey = { owner: repoOwner, repo: repoName, pr: prNumber };
|
|
130
|
+
const stored = await readFixAttempts(attemptsKey);
|
|
131
|
+
const attempts = stored?.headSha === headSha
|
|
132
|
+
? stored
|
|
133
|
+
: { headSha, threadAttempts: {} };
|
|
134
|
+
// Escalation checks — surface ambiguous situations instead of looping forever.
|
|
135
|
+
const escalateTriggers = checkEscalateTriggers(report.threads.actionable, report.comments.actionable, report.changesRequestedReviews, actionableChecks, attempts.threadAttempts, report.mergeStatus.status === "CONFLICTS");
|
|
136
|
+
if (escalateTriggers.triggers.length > 0) {
|
|
137
|
+
return {
|
|
138
|
+
...base,
|
|
139
|
+
action: "escalate",
|
|
140
|
+
escalate: {
|
|
141
|
+
triggers: escalateTriggers.triggers,
|
|
142
|
+
unresolvedThreads: report.threads.actionable.map(toAgentThread),
|
|
143
|
+
ambiguousComments: report.comments.actionable.map(toAgentComment),
|
|
144
|
+
changesRequestedReviews: report.changesRequestedReviews,
|
|
145
|
+
attemptHistory: escalateTriggers.thrashHistory,
|
|
146
|
+
suggestion: buildEscalateSuggestion(escalateTriggers.triggers),
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
// Increment attempt counts for this dispatch cycle.
|
|
151
|
+
const newThreadAttempts = { ...attempts.threadAttempts };
|
|
152
|
+
for (const t of report.threads.actionable) {
|
|
153
|
+
newThreadAttempts[t.id] = (newThreadAttempts[t.id] ?? 0) + 1;
|
|
154
|
+
}
|
|
155
|
+
await writeFixAttempts(attemptsKey, { headSha, threadAttempts: newThreadAttempts });
|
|
156
|
+
let cancelled = [];
|
|
157
|
+
if (!opts.noAutoCancelActionable) {
|
|
158
|
+
const uniqueRunIds = [
|
|
159
|
+
...new Set(actionableChecks.map((c) => c.runId).filter((id) => id !== null)),
|
|
160
|
+
];
|
|
161
|
+
const results = await Promise.all(uniqueRunIds.map((id) => tryCancelRun(id)));
|
|
162
|
+
cancelled = results.filter((id) => id !== null);
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
...base,
|
|
166
|
+
action: "fix_code",
|
|
167
|
+
fix: {
|
|
168
|
+
threads: report.threads.actionable.map(toAgentThread),
|
|
169
|
+
comments: report.comments.actionable.map(toAgentComment),
|
|
170
|
+
checks: toAgentChecks(actionableChecks),
|
|
171
|
+
changesRequestedReviews: report.changesRequestedReviews,
|
|
172
|
+
},
|
|
173
|
+
cancelled,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// Step 5: Transient failures (timeout / infrastructure) — no actionable work, no conflicts.
|
|
177
|
+
const transientChecks = report.checks.failing.filter((f) => f.failureKind === "timeout" || f.failureKind === "infrastructure");
|
|
178
|
+
if (transientChecks.length > 0 && !opts.noAutoRerun) {
|
|
179
|
+
// Deduplicate runIds — multiple failed steps can share the same runId.
|
|
180
|
+
const uniqueRunIds = [
|
|
181
|
+
...new Set(transientChecks.map((c) => c.runId).filter((id) => id !== null)),
|
|
182
|
+
];
|
|
183
|
+
await Promise.all(uniqueRunIds.map((runId) => runGhCommand(["run", "rerun", runId, "--failed"])));
|
|
184
|
+
return { ...base, action: "rerun_ci", reran: uniqueRunIds };
|
|
185
|
+
}
|
|
186
|
+
// Step 6: Flaky + behind — rebase needed.
|
|
187
|
+
const hasFlaky = report.checks.failing.some((f) => f.failureKind === "flaky");
|
|
188
|
+
if (hasFlaky && report.mergeStatus.status === "BEHIND" && config.actions.autoRebase) {
|
|
189
|
+
return { ...base, action: "rebase" };
|
|
190
|
+
}
|
|
191
|
+
// Step 7: Mark ready for review.
|
|
192
|
+
// Draft PRs often report mergeStateStatus === 'DRAFT' rather than 'CLEAN' until
|
|
193
|
+
// they're explicitly marked ready, so we allow either state when isDraft is true.
|
|
194
|
+
const mergeStateAllowsMarkReady = report.mergeStatus.mergeStateStatus === "CLEAN" ||
|
|
195
|
+
(report.mergeStatus.mergeStateStatus === "DRAFT" && report.mergeStatus.isDraft);
|
|
196
|
+
const canMarkReady = report.status === "READY" &&
|
|
197
|
+
mergeStateAllowsMarkReady &&
|
|
198
|
+
!report.mergeStatus.copilotReviewInProgress &&
|
|
199
|
+
!readyState.shouldCancel &&
|
|
200
|
+
report.mergeStatus.isDraft;
|
|
201
|
+
if (canMarkReady && !opts.noAutoMarkReady && config.actions.autoMarkReady) {
|
|
202
|
+
await runGhCommand(["pr", "ready", String(report.pr)]);
|
|
203
|
+
return { ...base, action: "mark_ready", markedReady: true };
|
|
204
|
+
}
|
|
205
|
+
// Step 8: Nothing to do.
|
|
206
|
+
return { ...base, action: "wait" };
|
|
207
|
+
}
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Helpers
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
function buildSummary(report) {
|
|
212
|
+
return {
|
|
213
|
+
passing: report.checks.passing.length,
|
|
214
|
+
skipped: report.checks.skipped.length,
|
|
215
|
+
filtered: report.checks.filtered.length,
|
|
216
|
+
inProgress: report.checks.inProgress.length,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
async function getLastCommitTime() {
|
|
220
|
+
try {
|
|
221
|
+
const { stdout } = await execFile("git", ["log", "-1", "--format=%ct", "HEAD"]);
|
|
222
|
+
return parseInt(stdout.trim(), 10);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
async function runGhCommand(args) {
|
|
229
|
+
try {
|
|
230
|
+
await execFile("gh", args);
|
|
231
|
+
}
|
|
232
|
+
catch (err) {
|
|
233
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
234
|
+
throw new Error(`gh ${args.join(" ")} failed: ${msg}`, { cause: err });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Best-effort: cancelling a completed run is a no-op, not an error.
|
|
238
|
+
async function tryCancelRun(runId) {
|
|
239
|
+
try {
|
|
240
|
+
await execFile("gh", ["run", "cancel", runId]);
|
|
241
|
+
return runId;
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
245
|
+
// gh returns this when the run reached a terminal state — expected, not worth logging.
|
|
246
|
+
if (/already completed|cannot cancel a workflow run that is completed/i.test(msg))
|
|
247
|
+
return null;
|
|
248
|
+
process.stderr.write(`pr-shepherd: gh run cancel ${runId} failed (ignored): ${msg}\n`);
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
async function getCurrentHeadSha() {
|
|
253
|
+
try {
|
|
254
|
+
const { stdout } = await execFile("git", ["rev-parse", "HEAD"]);
|
|
255
|
+
return stdout.trim();
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
return "unknown";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function checkEscalateTriggers(actionableThreads, actionableComments, changesRequestedReviews, actionableChecks, threadAttempts, hasConflicts) {
|
|
262
|
+
const triggers = [];
|
|
263
|
+
const maxAttempts = loadConfig().iterate.fixAttemptsPerThread;
|
|
264
|
+
// Trigger 1: fix thrash — same thread dispatched too many times without resolving.
|
|
265
|
+
const thrashThreads = actionableThreads.filter((t) => (threadAttempts[t.id] ?? 0) >= maxAttempts);
|
|
266
|
+
if (thrashThreads.length > 0) {
|
|
267
|
+
triggers.push("fix-thrash");
|
|
268
|
+
}
|
|
269
|
+
// Trigger 2: PR-level CHANGES_REQUESTED with no inline threads/comments/CI to act on.
|
|
270
|
+
// Skip when there are merge conflicts — fix_code handles conflict resolution, not escalation.
|
|
271
|
+
if (changesRequestedReviews.length > 0 &&
|
|
272
|
+
actionableThreads.length === 0 &&
|
|
273
|
+
actionableComments.length === 0 &&
|
|
274
|
+
actionableChecks.length === 0 &&
|
|
275
|
+
!hasConflicts) {
|
|
276
|
+
triggers.push("pr-level-changes-requested");
|
|
277
|
+
}
|
|
278
|
+
// Trigger 3: actionable thread has no file/line — cannot locate code to edit.
|
|
279
|
+
const unlocatable = actionableThreads.filter((t) => t.path === null || t.line === null);
|
|
280
|
+
if (unlocatable.length > 0) {
|
|
281
|
+
triggers.push("thread-missing-location");
|
|
282
|
+
}
|
|
283
|
+
return {
|
|
284
|
+
triggers,
|
|
285
|
+
thrashHistory: thrashThreads.length > 0
|
|
286
|
+
? thrashThreads.map((t) => ({ threadId: t.id, attempts: threadAttempts[t.id] ?? 0 }))
|
|
287
|
+
: undefined,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
function buildEscalateSuggestion(triggers) {
|
|
291
|
+
if (triggers.includes("fix-thrash")) {
|
|
292
|
+
return "Same thread(s) attempted multiple times without resolution — fix manually then rerun /pr-shepherd:monitor";
|
|
293
|
+
}
|
|
294
|
+
if (triggers.includes("pr-level-changes-requested")) {
|
|
295
|
+
return "Reviewer requested changes but left no inline comments — read the review and act manually";
|
|
296
|
+
}
|
|
297
|
+
if (triggers.includes("thread-missing-location")) {
|
|
298
|
+
return "Review thread has no file/line reference — cannot locate code to edit automatically";
|
|
299
|
+
}
|
|
300
|
+
return "Ambiguous state — inspect the PR and act manually";
|
|
301
|
+
}
|
|
@@ -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
|
+
if (outdated.length > 0) {
|
|
36
|
+
const { errors } = await autoResolveOutdated(outdated.map((t) => t.id));
|
|
37
|
+
if (errors.length > 0) {
|
|
38
|
+
throw new Error(`Failed to auto-resolve outdated threads: ${errors.join(", ")}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
|
|
42
|
+
return {
|
|
43
|
+
actionableThreads: activeThreads.map(({ isResolved, isOutdated, ...rest }) => rest),
|
|
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
|
}
|