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