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,173 @@
1
+ /**
2
+ * CLI argument-parsing helpers extracted from cli.mts for testability.
3
+ * Note: parseCommonArgs calls loadConfig() for cache TTL defaults.
4
+ */
5
+ import { parseArgs } from "node:util";
6
+ import { loadConfig } from "../config/load.mjs";
7
+ import { deriveVerdict } from "../commands/status.mjs";
8
+ // Flags that consume the next argument as their value (used for PR-number
9
+ // detection only — prevents a flag's value from being mistaken for a PR number).
10
+ const FLAGS_WITH_VALUES = new Set([
11
+ "--format",
12
+ "--cache-ttl",
13
+ "--last-push-time",
14
+ "--ready-delay",
15
+ "--cooldown-seconds",
16
+ "--require-sha",
17
+ "--message",
18
+ ]);
19
+ // ---------------------------------------------------------------------------
20
+ // Strict integer parsing
21
+ // ---------------------------------------------------------------------------
22
+ export function parseIntStrict(value, flag) {
23
+ if (!/^-?\d+$/.test(value.trim())) {
24
+ throw new Error(`Invalid value for ${flag}: "${value}" is not an integer`);
25
+ }
26
+ return parseInt(value, 10);
27
+ }
28
+ export function parseCommonArgs(args) {
29
+ const config = loadConfig();
30
+ const { values, tokens } = parseArgs({
31
+ args,
32
+ strict: false,
33
+ allowPositionals: true,
34
+ tokens: true,
35
+ options: {
36
+ format: { type: "string" },
37
+ "cache-ttl": { type: "string" },
38
+ "no-cache": { type: "boolean" },
39
+ },
40
+ });
41
+ const format = (values.format ?? "text");
42
+ const noCache = (values["no-cache"] ?? false);
43
+ const cacheTtlStr = values["cache-ttl"];
44
+ const cacheTtlSeconds = cacheTtlStr
45
+ ? parseIntStrict(cacheTtlStr, "--cache-ttl")
46
+ : config.cache.ttlSeconds;
47
+ // Build the set of arg indices consumed by global flags so we can strip
48
+ // them from `extra`. Subcommand-specific flags are left untouched.
49
+ const consumedIndices = new Set();
50
+ for (const tok of tokens ?? []) {
51
+ if (tok.kind === "option" &&
52
+ (tok.name === "format" || tok.name === "cache-ttl" || tok.name === "no-cache")) {
53
+ consumedIndices.add(tok.index);
54
+ // When the value is a separate arg (--flag value, not --flag=value),
55
+ // inlineValue is false and the value occupies tok.index + 1.
56
+ if ("inlineValue" in tok && tok.inlineValue === false && tok.value != null) {
57
+ consumedIndices.add(tok.index + 1);
58
+ }
59
+ }
60
+ }
61
+ // Find the first positional arg that looks like a PR number, skipping values
62
+ // that belong to flags in FLAGS_WITH_VALUES (subcommand flags included).
63
+ const skipForPrDetect = new Set();
64
+ for (let i = 0; i < args.length; i += 1) {
65
+ const arg = args[i];
66
+ if (FLAGS_WITH_VALUES.has(arg)) {
67
+ skipForPrDetect.add(i);
68
+ if (i + 1 < args.length)
69
+ skipForPrDetect.add(i + 1);
70
+ i += 1;
71
+ }
72
+ else {
73
+ const eqIdx = arg.indexOf("=");
74
+ if (eqIdx > 0 && FLAGS_WITH_VALUES.has(arg.slice(0, eqIdx))) {
75
+ skipForPrDetect.add(i);
76
+ }
77
+ }
78
+ }
79
+ const prIndex = args.findIndex((a, index) => !skipForPrDetect.has(index) && !a.startsWith("--") && /^\d+$/.test(a));
80
+ const prNumber = prIndex !== -1 ? parseInt(args[prIndex], 10) : undefined;
81
+ // Remove consumed global-flag indices (and the PR number itself) from extra.
82
+ if (prIndex !== -1) {
83
+ consumedIndices.add(prIndex);
84
+ }
85
+ const extra = args.filter((_, i) => !consumedIndices.has(i));
86
+ return {
87
+ prNumber,
88
+ global: { format, noCache, cacheTtlSeconds },
89
+ extra,
90
+ };
91
+ }
92
+ /** Get the value of a flag like `--flag value` or `--flag=value`. */
93
+ export function getFlag(args, name) {
94
+ for (let i = 0; i < args.length; i++) {
95
+ const arg = args[i];
96
+ if (arg === name && i + 1 < args.length)
97
+ return args[i + 1];
98
+ if (arg.startsWith(`${name}=`))
99
+ return arg.slice(name.length + 1);
100
+ }
101
+ return null;
102
+ }
103
+ export function hasFlag(args, name) {
104
+ return args.includes(name);
105
+ }
106
+ export function parseList(value) {
107
+ if (!value)
108
+ return [];
109
+ return value
110
+ .split(",")
111
+ .map((s) => s.trim())
112
+ .filter(Boolean);
113
+ }
114
+ export function parseStatusPrNumbers(args) {
115
+ const prNumbers = [];
116
+ for (let i = 0; i < args.length; i += 1) {
117
+ const arg = args[i];
118
+ if (FLAGS_WITH_VALUES.has(arg)) {
119
+ i += 1;
120
+ continue;
121
+ }
122
+ if (arg.startsWith("--"))
123
+ continue;
124
+ const n = parseInt(arg, 10);
125
+ if (Number.isFinite(n))
126
+ prNumbers.push(n);
127
+ }
128
+ return prNumbers;
129
+ }
130
+ // ---------------------------------------------------------------------------
131
+ // Duration parsing
132
+ // ---------------------------------------------------------------------------
133
+ export function parseDurationToMinutes(s, defaultMinutes) {
134
+ const m = /^(\d+)(m|min|minutes?|h|hours?)?$/.exec(s.trim());
135
+ if (!m)
136
+ return defaultMinutes ?? loadConfig().watch.readyDelayMinutes;
137
+ const n = parseInt(m[1], 10);
138
+ const unit = m[2] ?? "m";
139
+ if (unit.startsWith("h"))
140
+ return n * 60;
141
+ return n;
142
+ }
143
+ // ---------------------------------------------------------------------------
144
+ // Exit code mapping
145
+ // ---------------------------------------------------------------------------
146
+ export function statusToExitCode(status) {
147
+ switch (status) {
148
+ case "READY":
149
+ return 0;
150
+ case "IN_PROGRESS":
151
+ return 2;
152
+ case "UNRESOLVED_COMMENTS":
153
+ return 3;
154
+ default:
155
+ return 1;
156
+ }
157
+ }
158
+ export function iterateActionToExitCode(action) {
159
+ switch (action) {
160
+ case "fix_code":
161
+ case "rebase":
162
+ return 1;
163
+ case "cancel":
164
+ return 2;
165
+ case "escalate":
166
+ return 3;
167
+ default:
168
+ return 0;
169
+ }
170
+ }
171
+ export function deriveSimpleReady(s) {
172
+ return deriveVerdict(s) === "READY";
173
+ }
package/bin/cli.mjs ADDED
@@ -0,0 +1,204 @@
1
+ /**
2
+ * CLI argument parsing and subcommand dispatch for pr-shepherd.
3
+ *
4
+ * Usage:
5
+ * pr-shepherd check [PR] [--format text|json] [--no-cache] [--cache-ttl N]
6
+ * pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
7
+ * [--dismiss-review-ids Q] [--message MSG] [--require-sha SHA]
8
+ * [--last-push-time N]
9
+ * pr-shepherd iterate [PR] [--cooldown-seconds N] [--ready-delay Nm] [--last-push-time N]
10
+ * pr-shepherd status PR1 [PR2 …]
11
+ */
12
+ import { runCheck } from "./commands/check.mjs";
13
+ import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
14
+ import { runIterate } from "./commands/iterate.mjs";
15
+ import { runStatus, formatStatusTable } from "./commands/status.mjs";
16
+ import { getRepoInfo } from "./github/client.mjs";
17
+ import { formatJson } from "./reporters/json.mjs";
18
+ import { formatText } from "./reporters/text.mjs";
19
+ import { loadConfig } from "./config/load.mjs";
20
+ import { parseCommonArgs, getFlag, hasFlag, parseList, parseStatusPrNumbers, parseDurationToMinutes, parseIntStrict, statusToExitCode, iterateActionToExitCode, deriveSimpleReady, } from "./cli/args.mjs";
21
+ // ---------------------------------------------------------------------------
22
+ // Entry
23
+ // ---------------------------------------------------------------------------
24
+ export async function main(argv) {
25
+ const args = argv.slice(2); // strip node + script path
26
+ const subcommand = args[0];
27
+ switch (subcommand) {
28
+ case "check":
29
+ await handleCheck(args.slice(1));
30
+ break;
31
+ case "resolve":
32
+ await handleResolve(args.slice(1));
33
+ break;
34
+ case "iterate":
35
+ await handleIterate(args.slice(1));
36
+ break;
37
+ case "status":
38
+ await handleStatus(args.slice(1));
39
+ break;
40
+ default:
41
+ process.stderr.write(`Unknown subcommand: ${subcommand ?? "(none)"}\n`);
42
+ process.stderr.write("Usage: pr-shepherd <check|resolve|iterate|status> [options]\n");
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+ }
47
+ // ---------------------------------------------------------------------------
48
+ // Subcommand handlers
49
+ // ---------------------------------------------------------------------------
50
+ async function handleCheck(args) {
51
+ const { prNumber, global: globalOpts } = parseCommonArgs(args);
52
+ const report = await runCheck({ ...globalOpts, prNumber, autoResolve: false });
53
+ const output = globalOpts.format === "json" ? formatJson(report) : formatText(report);
54
+ process.stdout.write(`${output}\n`);
55
+ process.exitCode = statusToExitCode(report.status);
56
+ return;
57
+ }
58
+ async function handleResolve(args) {
59
+ const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
60
+ const resolveThreadIds = parseList(getFlag(extra, "--resolve-thread-ids"));
61
+ const minimizeCommentIds = parseList(getFlag(extra, "--minimize-comment-ids"));
62
+ const dismissReviewIds = parseList(getFlag(extra, "--dismiss-review-ids"));
63
+ const dismissMessage = getFlag(extra, "--message") ?? undefined;
64
+ const requireSha = getFlag(extra, "--require-sha") ?? undefined;
65
+ const fetchMode = hasFlag(extra, "--fetch") ||
66
+ (resolveThreadIds.length === 0 &&
67
+ minimizeCommentIds.length === 0 &&
68
+ dismissReviewIds.length === 0);
69
+ if (fetchMode) {
70
+ const result = await runResolveFetch({ ...globalOpts, prNumber });
71
+ process.stdout.write(globalOpts.format === "json"
72
+ ? `${JSON.stringify(result, null, 2)}\n`
73
+ : formatFetchResult(result));
74
+ }
75
+ else {
76
+ const result = await runResolveMutate({
77
+ ...globalOpts,
78
+ prNumber,
79
+ resolveThreadIds,
80
+ minimizeCommentIds,
81
+ dismissReviewIds,
82
+ dismissMessage,
83
+ requireSha,
84
+ });
85
+ process.stdout.write(globalOpts.format === "json"
86
+ ? `${JSON.stringify(result, null, 2)}\n`
87
+ : formatMutateResult(result));
88
+ }
89
+ }
90
+ async function handleIterate(args) {
91
+ const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
92
+ const lastPushTimeStr = getFlag(extra, "--last-push-time");
93
+ const lastPushTime = lastPushTimeStr
94
+ ? parseIntStrict(lastPushTimeStr, "--last-push-time")
95
+ : undefined;
96
+ const readyDelayStr = getFlag(extra, "--ready-delay");
97
+ const cfg = loadConfig();
98
+ const readyDelaySeconds = parseDurationToMinutes(readyDelayStr ?? "", cfg.watch.readyDelayMinutes) * 60;
99
+ const cooldownSecondsStr = getFlag(extra, "--cooldown-seconds");
100
+ const cooldownSeconds = cooldownSecondsStr
101
+ ? parseIntStrict(cooldownSecondsStr, "--cooldown-seconds")
102
+ : cfg.iterate.cooldownSeconds;
103
+ const noAutoRerun = hasFlag(extra, "--no-auto-rerun");
104
+ const noAutoMarkReady = hasFlag(extra, "--no-auto-mark-ready");
105
+ const noAutoCancelActionable = hasFlag(extra, "--no-auto-cancel-actionable");
106
+ const result = await runIterate({
107
+ ...globalOpts,
108
+ prNumber,
109
+ lastPushTime,
110
+ readyDelaySeconds,
111
+ cooldownSeconds,
112
+ noAutoRerun,
113
+ noAutoMarkReady,
114
+ noAutoCancelActionable,
115
+ });
116
+ if (globalOpts.format === "json") {
117
+ process.stdout.write(`${JSON.stringify(result)}\n`);
118
+ }
119
+ else {
120
+ process.stdout.write(`${formatIterateResult(result)}\n`);
121
+ }
122
+ process.exitCode = iterateActionToExitCode(result.action);
123
+ return;
124
+ }
125
+ async function handleStatus(args) {
126
+ const { global: globalOpts } = parseCommonArgs(args);
127
+ const prNumbers = parseStatusPrNumbers(args);
128
+ if (prNumbers.length === 0) {
129
+ process.stderr.write("Usage: pr-shepherd status PR1 [PR2 …]\n");
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ const repo = await getRepoInfo();
134
+ const summaries = await runStatus({ ...globalOpts, prNumbers });
135
+ const output = globalOpts.format === "json"
136
+ ? JSON.stringify(summaries, null, 2)
137
+ : formatStatusTable(summaries, `${repo.owner}/${repo.name}`);
138
+ process.stdout.write(`${output}\n`);
139
+ const allReady = summaries.every((s) => deriveSimpleReady(s));
140
+ process.exitCode = allReady ? 0 : 1;
141
+ return;
142
+ }
143
+ // ---------------------------------------------------------------------------
144
+ // Output formatters
145
+ // ---------------------------------------------------------------------------
146
+ function formatFetchResult(result) {
147
+ const lines = [];
148
+ if (result.actionableThreads.length > 0) {
149
+ lines.push(`\nActionable Review Threads (${result.actionableThreads.length}):`);
150
+ for (const t of result.actionableThreads) {
151
+ lines.push(` - threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author}): ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
152
+ }
153
+ }
154
+ if (result.actionableComments.length > 0) {
155
+ lines.push(`\nActionable PR Comments (${result.actionableComments.length}):`);
156
+ for (const c of result.actionableComments) {
157
+ lines.push(` - commentId=${c.id} (@${c.author}): ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
158
+ }
159
+ }
160
+ if (result.changesRequestedReviews.length > 0) {
161
+ lines.push(`\nPending CHANGES_REQUESTED reviews (${result.changesRequestedReviews.length}):`);
162
+ for (const r of result.changesRequestedReviews) {
163
+ lines.push(` - reviewId=${r.id} (@${r.author})`);
164
+ }
165
+ }
166
+ const total = result.actionableThreads.length +
167
+ result.actionableComments.length +
168
+ result.changesRequestedReviews.length;
169
+ lines.push(`\nSummary: ${total === 0 ? "0 actionable — all threads resolved/minimized" : `${total} actionable item(s)`}`);
170
+ return `${lines.join("\n")}\n`;
171
+ }
172
+ function formatMutateResult(result) {
173
+ const lines = [];
174
+ if (result.resolvedThreads.length)
175
+ lines.push(`Resolved threads (${result.resolvedThreads.length}): ${result.resolvedThreads.join(", ")}`);
176
+ if (result.minimizedComments.length)
177
+ lines.push(`Minimized comments (${result.minimizedComments.length}): ${result.minimizedComments.join(", ")}`);
178
+ if (result.dismissedReviews.length)
179
+ lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
180
+ if (result.errors.length)
181
+ lines.push(`Errors:\n ${result.errors.join("\n ")}`);
182
+ return `${lines.join("\n")}\n`;
183
+ }
184
+ function formatIterateResult(result) {
185
+ const base = `PR #${result.pr} [${result.action.toUpperCase()}] status=${result.status} merge=${result.mergeStateStatus}`;
186
+ switch (result.action) {
187
+ case "cooldown":
188
+ return `${base} (cooldown: CI still starting)`;
189
+ case "wait":
190
+ return `${base} (${result.remainingSeconds}s until cancel)`;
191
+ case "cancel":
192
+ return `${base} (ready-delay elapsed)`;
193
+ case "fix_code":
194
+ return `${base} threads=${result.fix.threads.length} comments=${result.fix.comments.length} checks=${result.fix.checks.length} cancelled=${result.cancelled.length}`;
195
+ case "rerun_ci":
196
+ return `${base} reran=${result.reran.join(",")}`;
197
+ case "rebase":
198
+ return `${base} (branch is behind main)`;
199
+ case "mark_ready":
200
+ return `${base} markedReady=${result.markedReady}`;
201
+ case "escalate":
202
+ return `${base} triggers=${result.escalate.triggers.join(",")} — ${result.escalate.suggestion}`;
203
+ }
204
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `shepherd check [PR]`
3
+ *
4
+ * Read-only snapshot of PR status. Fetches CI + comments + merge status in
5
+ * one GraphQL request, applies all classifiers, and returns a ShepherdReport.
6
+ *
7
+ * Exit codes:
8
+ * 0 READY — all checks passed, no unresolved threads, CLEAN merge status.
9
+ * 1 FAILING — one or more CI checks failed.
10
+ * 2 IN_PROGRESS — CI checks still running.
11
+ * 3 UNRESOLVED_COMMENTS — CI ok but actionable threads remain.
12
+ * 1 (also) BLOCKED/CONFLICTS/UNKNOWN merge status.
13
+ */
14
+ import { fetchPrBatch } from "../github/batch.mjs";
15
+ import { getRepoInfo, getCurrentPrNumber, getMergeableState } from "../github/client.mjs";
16
+ import { cacheGet, cacheSet } from "../cache/file-cache.mjs";
17
+ import { classifyChecks, getCiVerdict } from "../checks/classify.mjs";
18
+ import { triageFailingChecks } from "../checks/triage.mjs";
19
+ import { getOutdatedThreads } from "../comments/outdated.mjs";
20
+ import { autoResolveOutdated } from "../comments/resolve.mjs";
21
+ import { deriveMergeStatus } from "../merge-status/derive.mjs";
22
+ export async function runCheck(opts) {
23
+ const repo = await getRepoInfo();
24
+ const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
25
+ if (prNumber === null) {
26
+ throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
27
+ }
28
+ const cacheKey = { owner: repo.owner, repo: repo.name, pr: prNumber, shape: "check" };
29
+ // When autoResolve is enabled the command will mutate (resolve threads, minimize
30
+ // comments) — always bypass cache so we act on fresh data, not a stale snapshot.
31
+ const cacheOpts = {
32
+ disabled: opts.noCache || opts.autoResolve,
33
+ ttlSeconds: opts.cacheTtlSeconds,
34
+ };
35
+ // Try cache first.
36
+ let batchData = await cacheGet(cacheKey, cacheOpts);
37
+ if (batchData === null) {
38
+ const result = await fetchPrBatch(prNumber, repo);
39
+ batchData = result.data;
40
+ // Don't cache UNKNOWN merge state — it's transient and would poison the
41
+ // cache for the full TTL window, causing stale UNKNOWN on the next sweep.
42
+ if (batchData.mergeable !== "UNKNOWN" && batchData.mergeStateStatus !== "UNKNOWN") {
43
+ await cacheSet(cacheKey, batchData, cacheOpts);
44
+ }
45
+ }
46
+ // GraphQL sometimes returns UNKNOWN for mergeable/mergeStateStatus while the
47
+ // REST API already has the correct value. Fall back to REST in that case.
48
+ // Skip for non-OPEN PRs — REST also returns UNKNOWN for merged/closed PRs.
49
+ if ((batchData.state ?? "OPEN") === "OPEN" &&
50
+ (batchData.mergeable === "UNKNOWN" || batchData.mergeStateStatus === "UNKNOWN")) {
51
+ const restState = await getMergeableState(prNumber, repo.owner, repo.name);
52
+ batchData = { ...batchData, ...restState };
53
+ }
54
+ // Classify checks.
55
+ const classifiedChecks = classifyChecks(batchData.checks);
56
+ const verdict = getCiVerdict(classifiedChecks);
57
+ const passing = classifiedChecks.filter((c) => c.category === "passed");
58
+ const failing = classifiedChecks.filter((c) => c.category === "failing");
59
+ const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
60
+ const skipped = classifiedChecks.filter((c) => c.category === "skipped");
61
+ const filtered = classifiedChecks.filter((c) => c.category === "filtered");
62
+ // Triage failures (fetch logs) — skipped when caller will short-circuit before needing failureKind.
63
+ const triaged = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing) : failing;
64
+ // Resolve threads and comments.
65
+ const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved);
66
+ const visibleComments = batchData.comments.filter((c) => !c.isMinimized);
67
+ // Auto-resolve outdated threads.
68
+ const outdated = getOutdatedThreads(unresolvedThreads);
69
+ let autoResolved = [];
70
+ let autoResolveErrors = [];
71
+ if (opts.autoResolve && outdated.length > 0) {
72
+ const { resolved: resolvedIds, errors } = await autoResolveOutdated(outdated.map((t) => t.id));
73
+ autoResolved = outdated.filter((t) => resolvedIds.includes(t.id));
74
+ autoResolveErrors = errors;
75
+ }
76
+ const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated);
77
+ // Actionable: all active threads and all visible comments (no classification — LLM handles triage).
78
+ const actionableThreads = activeThreads;
79
+ const actionableComments = visibleComments;
80
+ // Derive merge status.
81
+ const mergeStatus = deriveMergeStatus(batchData);
82
+ // Derive blockedByFilteredCheck ghost state.
83
+ const blockedByFilteredCheck = mergeStatus.status === "BLOCKED" &&
84
+ !verdict.anyFailing &&
85
+ !verdict.anyInProgress &&
86
+ verdict.filteredNames.length > 0;
87
+ // Compute overall status.
88
+ const status = computeStatus(verdict, actionableThreads.length, actionableComments.length, mergeStatus.status, batchData.changesRequestedReviews.length);
89
+ return {
90
+ pr: prNumber,
91
+ repo: `${repo.owner}/${repo.name}`,
92
+ status,
93
+ mergeStatus,
94
+ checks: {
95
+ passing,
96
+ failing: triaged,
97
+ inProgress: inProgress,
98
+ skipped,
99
+ filtered,
100
+ filteredNames: verdict.filteredNames,
101
+ blockedByFilteredCheck,
102
+ },
103
+ threads: {
104
+ actionable: actionableThreads,
105
+ autoResolved,
106
+ autoResolveErrors,
107
+ },
108
+ comments: {
109
+ actionable: actionableComments,
110
+ },
111
+ changesRequestedReviews: batchData.changesRequestedReviews,
112
+ lastPushTime: opts.lastPushTime,
113
+ };
114
+ }
115
+ // ---------------------------------------------------------------------------
116
+ // Helpers
117
+ // ---------------------------------------------------------------------------
118
+ function computeStatus(verdict, unresolvedThreads, unresolvedComments, mergeStatus, changesRequestedReviews) {
119
+ // Merge conflicts are always terminal regardless of CI state.
120
+ if (mergeStatus === "CONFLICTS")
121
+ return "FAILING";
122
+ // Check CI state before merge-blocking states: BLOCKED/UNSTABLE/BEHIND are
123
+ // often caused by CI not having passed yet, so they shouldn't mask IN_PROGRESS.
124
+ if (verdict.anyFailing)
125
+ return "FAILING";
126
+ if (verdict.anyInProgress)
127
+ return "IN_PROGRESS";
128
+ if (mergeStatus === "BLOCKED" || mergeStatus === "UNSTABLE" || mergeStatus === "BEHIND")
129
+ return "FAILING";
130
+ if (mergeStatus === "UNKNOWN")
131
+ return "UNKNOWN";
132
+ if (changesRequestedReviews > 0)
133
+ return "UNRESOLVED_COMMENTS";
134
+ if (unresolvedThreads > 0 || unresolvedComments > 0)
135
+ return "UNRESOLVED_COMMENTS";
136
+ // DRAFT is treated the same as CLEAN for readiness — marking the PR ready resolves it.
137
+ if ((mergeStatus === "CLEAN" || mergeStatus === "DRAFT") && verdict.allPassed)
138
+ return "READY";
139
+ return "UNKNOWN";
140
+ }