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,153 @@
1
+ /**
2
+ * CLI argument-parsing helpers extracted from cli.mts for testability.
3
+ * Note: parseCommonArgs calls loadConfig() for cache TTL defaults.
4
+ */
5
+ import { loadConfig } from "../config/load.mjs";
6
+ import { deriveVerdict } from "../commands/status.mjs";
7
+ // Flags that consume the next argument as their value.
8
+ const FLAGS_WITH_VALUES = new Set([
9
+ "--format",
10
+ "--cache-ttl",
11
+ "--last-push-time",
12
+ "--ready-delay",
13
+ "--cooldown-seconds",
14
+ "--require-sha",
15
+ "--message",
16
+ ]);
17
+ export function parseCommonArgs(args) {
18
+ const config = loadConfig();
19
+ const format = (getFlag(args, "--format") ?? "text");
20
+ const noCache = hasFlag(args, "--no-cache");
21
+ const cacheTtlStr = getFlag(args, "--cache-ttl");
22
+ const cacheTtlSeconds = cacheTtlStr ? parseInt(cacheTtlStr, 10) : config.cache.ttlSeconds;
23
+ // Only global flags are stripped from `extra`; subcommand-specific flags
24
+ // must remain so handlers like handleIterate/handleResolve can read them.
25
+ const globalFlagsWithValues = new Set(["--format", "--cache-ttl"]);
26
+ const skipForPrDetect = new Set(); // indices to skip when finding PR number
27
+ const excludeFromExtra = new Set(); // indices to strip from extra (global only)
28
+ for (let i = 0; i < args.length; i += 1) {
29
+ const arg = args[i];
30
+ if (arg === "--no-cache") {
31
+ skipForPrDetect.add(i);
32
+ excludeFromExtra.add(i);
33
+ }
34
+ else if (globalFlagsWithValues.has(arg)) {
35
+ skipForPrDetect.add(i);
36
+ excludeFromExtra.add(i);
37
+ if (i + 1 < args.length) {
38
+ skipForPrDetect.add(i + 1);
39
+ excludeFromExtra.add(i + 1);
40
+ }
41
+ i += 1;
42
+ }
43
+ else if (FLAGS_WITH_VALUES.has(arg)) {
44
+ skipForPrDetect.add(i);
45
+ if (i + 1 < args.length)
46
+ skipForPrDetect.add(i + 1);
47
+ i += 1;
48
+ }
49
+ else {
50
+ const eqIdx = arg.indexOf("=");
51
+ if (eqIdx > 0) {
52
+ const flagName = arg.slice(0, eqIdx);
53
+ if (FLAGS_WITH_VALUES.has(flagName)) {
54
+ skipForPrDetect.add(i);
55
+ if (globalFlagsWithValues.has(flagName))
56
+ excludeFromExtra.add(i);
57
+ }
58
+ }
59
+ }
60
+ }
61
+ // First non-skipped positional arg that looks like a PR number.
62
+ const prArg = args.find((a, index) => !skipForPrDetect.has(index) && !a.startsWith("--") && /^\d+$/.test(a));
63
+ const prNumber = prArg ? parseInt(prArg, 10) : undefined;
64
+ // Only strip global flags from extra — subcommand flags are passed through.
65
+ const extra = args.filter((_, index) => !excludeFromExtra.has(index));
66
+ return {
67
+ prNumber,
68
+ global: { format, noCache, cacheTtlSeconds },
69
+ extra,
70
+ };
71
+ }
72
+ /** Get the value of a flag like `--flag value` or `--flag=value`. */
73
+ export function getFlag(args, name) {
74
+ for (let i = 0; i < args.length; i++) {
75
+ const arg = args[i];
76
+ if (arg === name && i + 1 < args.length)
77
+ return args[i + 1];
78
+ if (arg.startsWith(`${name}=`))
79
+ return arg.slice(name.length + 1);
80
+ }
81
+ return null;
82
+ }
83
+ export function hasFlag(args, name) {
84
+ return args.includes(name);
85
+ }
86
+ export function parseList(value) {
87
+ if (!value)
88
+ return [];
89
+ return value
90
+ .split(",")
91
+ .map((s) => s.trim())
92
+ .filter(Boolean);
93
+ }
94
+ export function parseStatusPrNumbers(args) {
95
+ const prNumbers = [];
96
+ for (let i = 0; i < args.length; i += 1) {
97
+ const arg = args[i];
98
+ if (FLAGS_WITH_VALUES.has(arg)) {
99
+ i += 1;
100
+ continue;
101
+ }
102
+ if (arg.startsWith("--"))
103
+ continue;
104
+ const n = parseInt(arg, 10);
105
+ if (Number.isFinite(n))
106
+ prNumbers.push(n);
107
+ }
108
+ return prNumbers;
109
+ }
110
+ // ---------------------------------------------------------------------------
111
+ // Duration parsing
112
+ // ---------------------------------------------------------------------------
113
+ export function parseDurationToMinutes(s, defaultMinutes) {
114
+ const m = /^(\d+)(m|min|minutes?|h|hours?)?$/.exec(s.trim());
115
+ if (!m)
116
+ return defaultMinutes ?? loadConfig().watch.readyDelayMinutes;
117
+ const n = parseInt(m[1], 10);
118
+ const unit = m[2] ?? "m";
119
+ if (unit.startsWith("h"))
120
+ return n * 60;
121
+ return n;
122
+ }
123
+ // ---------------------------------------------------------------------------
124
+ // Exit code mapping
125
+ // ---------------------------------------------------------------------------
126
+ export function statusToExitCode(status) {
127
+ switch (status) {
128
+ case "READY":
129
+ return 0;
130
+ case "IN_PROGRESS":
131
+ return 2;
132
+ case "UNRESOLVED_COMMENTS":
133
+ return 3;
134
+ default:
135
+ return 1;
136
+ }
137
+ }
138
+ export function iterateActionToExitCode(action) {
139
+ switch (action) {
140
+ case "fix_code":
141
+ case "rebase":
142
+ return 1;
143
+ case "cancel":
144
+ return 2;
145
+ case "escalate":
146
+ return 3;
147
+ default:
148
+ return 0;
149
+ }
150
+ }
151
+ export function deriveSimpleReady(s) {
152
+ return deriveVerdict(s) === "READY";
153
+ }
package/dist/cli.mjs ADDED
@@ -0,0 +1,203 @@
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, 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.exit(1);
44
+ }
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // Subcommand handlers
48
+ // ---------------------------------------------------------------------------
49
+ async function handleCheck(args) {
50
+ const { prNumber, global: globalOpts } = parseCommonArgs(args);
51
+ const report = await runCheck({ ...globalOpts, prNumber, autoResolve: false });
52
+ const output = globalOpts.format === "json" ? formatJson(report) : formatText(report);
53
+ process.stdout.write(`${output}\n`);
54
+ process.exit(statusToExitCode(report.status));
55
+ }
56
+ async function handleResolve(args) {
57
+ const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
58
+ const resolveThreadIds = parseList(getFlag(extra, "--resolve-thread-ids"));
59
+ const minimizeCommentIds = parseList(getFlag(extra, "--minimize-comment-ids"));
60
+ const dismissReviewIds = parseList(getFlag(extra, "--dismiss-review-ids"));
61
+ const dismissMessage = getFlag(extra, "--message") ?? undefined;
62
+ const requireSha = getFlag(extra, "--require-sha") ?? undefined;
63
+ const fetchMode = hasFlag(extra, "--fetch") ||
64
+ (resolveThreadIds.length === 0 &&
65
+ minimizeCommentIds.length === 0 &&
66
+ dismissReviewIds.length === 0);
67
+ if (fetchMode) {
68
+ const result = await runResolveFetch({ ...globalOpts, prNumber });
69
+ process.stdout.write(globalOpts.format === "json"
70
+ ? `${JSON.stringify(result, null, 2)}\n`
71
+ : formatFetchResult(result));
72
+ }
73
+ else {
74
+ const result = await runResolveMutate({
75
+ ...globalOpts,
76
+ prNumber,
77
+ resolveThreadIds,
78
+ minimizeCommentIds,
79
+ dismissReviewIds,
80
+ dismissMessage,
81
+ requireSha,
82
+ });
83
+ process.stdout.write(globalOpts.format === "json"
84
+ ? `${JSON.stringify(result, null, 2)}\n`
85
+ : formatMutateResult(result));
86
+ }
87
+ }
88
+ async function handleIterate(args) {
89
+ const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
90
+ const lastPushTimeStr = getFlag(extra, "--last-push-time");
91
+ const lastPushTime = lastPushTimeStr ? parseInt(lastPushTimeStr, 10) : undefined;
92
+ const readyDelayStr = getFlag(extra, "--ready-delay");
93
+ const cfg = loadConfig();
94
+ const readyDelaySeconds = parseDurationToMinutes(readyDelayStr ?? "", cfg.watch.readyDelayMinutes) * 60;
95
+ const cooldownSecondsStr = getFlag(extra, "--cooldown-seconds");
96
+ const cooldownSeconds = cooldownSecondsStr
97
+ ? parseInt(cooldownSecondsStr, 10)
98
+ : cfg.iterate.cooldownSeconds;
99
+ const noAutoRerun = hasFlag(extra, "--no-auto-rerun");
100
+ const noAutoMarkReady = hasFlag(extra, "--no-auto-mark-ready");
101
+ const noAutoCancelActionable = hasFlag(extra, "--no-auto-cancel-actionable");
102
+ const result = await runIterate({
103
+ ...globalOpts,
104
+ prNumber,
105
+ lastPushTime,
106
+ readyDelaySeconds,
107
+ cooldownSeconds,
108
+ noAutoRerun,
109
+ noAutoMarkReady,
110
+ noAutoCancelActionable,
111
+ });
112
+ if (globalOpts.format === "json") {
113
+ process.stdout.write(`${JSON.stringify(result)}\n`);
114
+ }
115
+ else {
116
+ process.stdout.write(`${formatIterateResult(result)}\n`);
117
+ }
118
+ process.exit(iterateActionToExitCode(result.action));
119
+ }
120
+ async function handleStatus(args) {
121
+ const { global: globalOpts } = parseCommonArgs(args);
122
+ const prNumbers = parseStatusPrNumbers(args);
123
+ if (prNumbers.length === 0) {
124
+ process.stderr.write("Usage: pr-shepherd status PR1 [PR2 …]\n");
125
+ process.exit(1);
126
+ }
127
+ const repo = await getRepoInfo();
128
+ const summaries = await runStatus({ ...globalOpts, prNumbers });
129
+ const output = globalOpts.format === "json"
130
+ ? JSON.stringify(summaries, null, 2)
131
+ : formatStatusTable(summaries, `${repo.owner}/${repo.name}`);
132
+ process.stdout.write(`${output}\n`);
133
+ const allReady = summaries.every((s) => deriveSimpleReady(s));
134
+ process.exit(allReady ? 0 : 1);
135
+ }
136
+ // ---------------------------------------------------------------------------
137
+ // Output formatters
138
+ // ---------------------------------------------------------------------------
139
+ function formatFetchResult(result) {
140
+ const lines = [];
141
+ if (result.autoResolved.length > 0) {
142
+ lines.push(`Auto-resolved outdated (${result.autoResolved.length}):`);
143
+ for (const t of result.autoResolved) {
144
+ lines.push(` - threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author})`);
145
+ }
146
+ }
147
+ if (result.actionableThreads.length > 0) {
148
+ lines.push(`\nActionable Review Threads (${result.actionableThreads.length}):`);
149
+ for (const t of result.actionableThreads) {
150
+ lines.push(` - threadId=${t.id} ${t.path ?? ""}:${t.line ?? "?"} (@${t.author}): ${t.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
151
+ }
152
+ }
153
+ if (result.actionableComments.length > 0) {
154
+ lines.push(`\nActionable PR Comments (${result.actionableComments.length}):`);
155
+ for (const c of result.actionableComments) {
156
+ lines.push(` - commentId=${c.id} (@${c.author}): ${c.body.split("\n")[0]?.slice(0, 100) ?? ""}`);
157
+ }
158
+ }
159
+ if (result.changesRequestedReviews.length > 0) {
160
+ lines.push(`\nPending CHANGES_REQUESTED reviews (${result.changesRequestedReviews.length}):`);
161
+ for (const r of result.changesRequestedReviews) {
162
+ lines.push(` - reviewId=${r.id} (@${r.author})`);
163
+ }
164
+ }
165
+ const total = result.actionableThreads.length +
166
+ result.actionableComments.length +
167
+ result.changesRequestedReviews.length;
168
+ lines.push(`\nSummary: ${total === 0 ? "0 actionable — all threads resolved/minimized" : `${total} actionable item(s)`}`);
169
+ return `${lines.join("\n")}\n`;
170
+ }
171
+ function formatMutateResult(result) {
172
+ const lines = [];
173
+ if (result.resolvedThreads.length)
174
+ lines.push(`Resolved threads (${result.resolvedThreads.length}): ${result.resolvedThreads.join(", ")}`);
175
+ if (result.minimizedComments.length)
176
+ lines.push(`Minimized comments (${result.minimizedComments.length}): ${result.minimizedComments.join(", ")}`);
177
+ if (result.dismissedReviews.length)
178
+ lines.push(`Dismissed reviews (${result.dismissedReviews.length}): ${result.dismissedReviews.join(", ")}`);
179
+ if (result.errors.length)
180
+ lines.push(`Errors:\n ${result.errors.join("\n ")}`);
181
+ return `${lines.join("\n")}\n`;
182
+ }
183
+ function formatIterateResult(result) {
184
+ const base = `PR #${result.pr} [${result.action.toUpperCase()}] status=${result.status} merge=${result.mergeStateStatus}`;
185
+ switch (result.action) {
186
+ case "cooldown":
187
+ return `${base} (cooldown: CI still starting)`;
188
+ case "wait":
189
+ return `${base} (${result.remainingSeconds}s until cancel)`;
190
+ case "cancel":
191
+ return `${base} (ready-delay elapsed)`;
192
+ case "fix_code":
193
+ return `${base} threads=${result.fix.threads.length} comments=${result.fix.comments.length} checks=${result.fix.checks.length} cancelled=${result.cancelled.length}`;
194
+ case "rerun_ci":
195
+ return `${base} reran=${result.reran.join(",")}`;
196
+ case "rebase":
197
+ return `${base} (branch is behind main)`;
198
+ case "mark_ready":
199
+ return `${base} markedReady=${result.markedReady}`;
200
+ case "escalate":
201
+ return `${base} triggers=${result.escalate.triggers.join(",")} — ${result.escalate.suggestion}`;
202
+ }
203
+ }
@@ -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
+ }