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.
Files changed (62) hide show
  1. package/.claude-plugin/plugin.json +8 -2
  2. package/README.md +126 -83
  3. package/dist/cache/file-cache.mjs +78 -0
  4. package/dist/cache/fix-attempts.mjs +67 -0
  5. package/dist/checks/classify.mjs +53 -0
  6. package/dist/checks/triage.mjs +77 -0
  7. package/dist/cli/args.mjs +153 -0
  8. package/dist/cli.mjs +203 -0
  9. package/dist/commands/check.mjs +140 -0
  10. package/dist/commands/iterate.mjs +295 -0
  11. package/dist/commands/ready-delay.mjs +87 -0
  12. package/dist/commands/resolve.mjs +64 -0
  13. package/dist/commands/status.mjs +107 -0
  14. package/{src/comments/outdated.mts → dist/comments/outdated.mjs} +2 -5
  15. package/dist/comments/resolve.mjs +113 -0
  16. package/dist/config/load.mjs +154 -0
  17. package/dist/github/batch.mjs +208 -0
  18. package/dist/github/client.mjs +153 -0
  19. package/{src/github/pagination.mts → dist/github/pagination.mjs} +26 -52
  20. package/{src/github/queries.mts → dist/github/queries.mjs} +1 -10
  21. package/{src/index.mts → dist/index.mjs} +3 -5
  22. package/dist/merge-status/derive.mjs +72 -0
  23. package/dist/reporters/agent.mjs +41 -0
  24. package/{src/reporters/json.mts → dist/reporters/json.mjs} +2 -5
  25. package/dist/reporters/text.mjs +111 -0
  26. package/dist/types.mjs +2 -0
  27. package/package.json +6 -6
  28. package/skills/check/SKILL.md +1 -1
  29. package/skills/monitor/SKILL.md +9 -5
  30. package/src/cache/file-cache.mts +0 -101
  31. package/src/cache/file-cache.test.mts +0 -91
  32. package/src/cache/fix-attempts.mts +0 -86
  33. package/src/checks/classify.mts +0 -80
  34. package/src/checks/classify.test.mts +0 -164
  35. package/src/checks/triage.mock.test.mts +0 -202
  36. package/src/checks/triage.mts +0 -88
  37. package/src/cli.mts +0 -423
  38. package/src/commands/check.mts +0 -188
  39. package/src/commands/iterate.mock.test.mts +0 -1111
  40. package/src/commands/iterate.mts +0 -371
  41. package/src/commands/ready-delay.mts +0 -117
  42. package/src/commands/ready-delay.test.mts +0 -116
  43. package/src/commands/resolve.mts +0 -92
  44. package/src/commands/status.mts +0 -173
  45. package/src/comments/resolve.mts +0 -179
  46. package/src/config/load.mts +0 -240
  47. package/src/github/batch.mts +0 -351
  48. package/src/github/client.mts +0 -207
  49. package/src/github/client.test.mts +0 -19
  50. package/src/github/pagination.test.mts +0 -140
  51. package/src/merge-status/derive.mts +0 -74
  52. package/src/merge-status/derive.test.mts +0 -130
  53. package/src/reporters/text.mts +0 -140
  54. package/src/types.mts +0 -309
  55. /package/{src → dist}/config.json +0 -0
  56. /package/{src → dist}/github/gql/batch-pr.gql +0 -0
  57. /package/{src → dist}/github/gql/dismiss-review.gql +0 -0
  58. /package/{src → dist}/github/gql/minimize-comment.gql +0 -0
  59. /package/{src → dist}/github/gql/multi-pr-status-paged.gql +0 -0
  60. /package/{src → dist}/github/gql/multi-pr-status.gql +0 -0
  61. /package/{src → dist}/github/gql/resolve-thread.gql +0 -0
  62. /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: ReviewThread[]): ReviewThread[] {
17
- return threads.filter((t) => t.isOutdated && !t.isResolved);
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
+ }