pr-shepherd 0.21.0 → 0.23.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/plugin.json +1 -1
  2. package/README.md +87 -124
  3. package/bin/checks/triage.mjs +1 -0
  4. package/bin/cli/args.mjs +2 -0
  5. package/bin/cli/fix-formatter.mjs +38 -9
  6. package/bin/cli/formatters.mjs +3 -45
  7. package/bin/cli/handlers.mjs +22 -1
  8. package/bin/cli/help-command-pages.mjs +27 -2
  9. package/bin/cli/help-top-page.mjs +2 -0
  10. package/bin/cli/iterate-lean.mjs +4 -0
  11. package/bin/cli/list-formatters.mjs +55 -4
  12. package/bin/cli/mark-files-as-viewed-flags.mjs +34 -0
  13. package/bin/cli/mark-files-as-viewed-formatter.mjs +52 -0
  14. package/bin/cli/mutate-formatter.mjs +50 -0
  15. package/bin/cli-parser.mjs +11 -2
  16. package/bin/cli-parser.test-support.mjs +6 -1
  17. package/bin/commands/check-annotations.mjs +40 -0
  18. package/bin/commands/check.mjs +25 -57
  19. package/bin/commands/check.test-support.mjs +9 -2
  20. package/bin/commands/commit-suggestion-instruction.mjs +1 -1
  21. package/bin/commands/iterate/classify.mjs +25 -30
  22. package/bin/commands/iterate/escalate.mjs +16 -2
  23. package/bin/commands/iterate/fix-code.mjs +29 -18
  24. package/bin/commands/iterate/helpers.mjs +1 -0
  25. package/bin/commands/iterate/render.mjs +9 -3
  26. package/bin/commands/iterate/stall.mjs +43 -2
  27. package/bin/commands/iterate-test-support.mjs +1 -1
  28. package/bin/commands/iterate-thread-test-support.mjs +18 -0
  29. package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +10 -1
  30. package/bin/commands/mark-files-as-viewed.mjs +220 -0
  31. package/bin/commands/resolve-mutate.mjs +41 -4
  32. package/bin/commands/resolve.mjs +16 -76
  33. package/bin/commands/resolve.test-support.mjs +2 -0
  34. package/bin/commands/shepherd-journal.mjs +1 -1
  35. package/bin/comments/authors.mjs +14 -0
  36. package/bin/comments/minimize-policy.mjs +6 -3
  37. package/bin/comments/pending-ops.mjs +6 -0
  38. package/bin/comments/resolve.mjs +28 -11
  39. package/bin/comments/resolve.test-support.mjs +6 -1
  40. package/bin/comments/review-visibility.mjs +14 -0
  41. package/bin/comments/thread-visibility.mjs +60 -0
  42. package/bin/comments/visible-comments.mjs +1 -1
  43. package/bin/config.json +1 -1
  44. package/bin/github/batch-parser-helpers.mjs +3 -4
  45. package/bin/github/batch-parsers.mjs +35 -8
  46. package/bin/github/batch.mjs +2 -0
  47. package/bin/github/check-annotations.mjs +75 -0
  48. package/bin/github/client.test-support.mjs +55 -0
  49. package/bin/github/gql/batch-pr.gql +15 -2
  50. package/bin/github/gql/check-run-annotations.gql +32 -0
  51. package/bin/github/gql/review-thread-comments.gql +27 -0
  52. package/bin/github/queries.mjs +4 -0
  53. package/bin/github/thread-comments.mjs +34 -0
  54. package/bin/reporters/agent.mjs +27 -0
  55. package/bin/state/seen-comments.test-support.mjs +19 -0
  56. package/bin/threads/transcript.mjs +31 -0
  57. package/bin/types/agent-thread.mjs +1 -0
  58. package/bin/types/check-annotations.mjs +1 -0
  59. package/bin/types/review-thread.mjs +1 -0
  60. package/bin/types.mjs +3 -0
  61. package/package.json +1 -1
  62. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  63. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +31 -0
  64. package/plugins/pr-shepherd/skills/pr-shepherd/SKILL.md +1 -1
@@ -1,4 +1,5 @@
1
1
  import { renderLineRange, renderSuggestionBlock } from "./suggestion-renderer.mjs";
2
+ import { threadComments } from "../threads/transcript.mjs";
2
3
  const BODY_PREVIEW_MAX = 100;
3
4
  export function renderAuthor(author, authorType) {
4
5
  return authorType ? `@${author} · ${authorType}` : `@${author}`;
@@ -26,13 +27,62 @@ export function renderThreadBullet(t, opts = {}) {
26
27
  ? `\`${t.path}:${renderLineRange(t.startLine ?? undefined, t.line ?? null)}\``
27
28
  : "`(no location)`";
28
29
  const suggestionMarker = t.suggestion ? " [suggestion]" : "";
30
+ const editedMarker = t.edited && !opts.suppressEditedMarker ? " [edited since first look]" : "";
29
31
  const statusSuffix = opts.statusTag ? ` ${opts.statusTag}` : "";
30
- const bodySuffix = opts.noBody ? "" : `: ${renderBodyPreview(t.body)}`;
31
- const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${statusSuffix}${bodySuffix}`;
32
+ const bulletLine = `- \`threadId=${t.id}\`${link} ${loc} (${renderAuthor(t.author, t.authorType)})${suggestionMarker}${editedMarker}${statusSuffix}`;
33
+ if (!opts.noBody && (!t.comments || t.comments.length === 0)) {
34
+ const legacyLine = `${bulletLine}: ${renderBodyPreview(t.body)}`;
35
+ return t.suggestion && opts.renderSuggestion
36
+ ? `${legacyLine}\n${renderSuggestionBlock(t.suggestion)}`
37
+ : legacyLine;
38
+ }
39
+ const parts = [bulletLine];
40
+ if (!opts.noBody) {
41
+ parts.push(renderThreadCommentBullets(t));
42
+ }
32
43
  if (t.suggestion && opts.renderSuggestion) {
33
- return `${bulletLine}\n${renderSuggestionBlock(t.suggestion)}`;
44
+ parts.push(renderSuggestionBlock(t.suggestion));
34
45
  }
35
- return bulletLine;
46
+ return parts.join("\n");
47
+ }
48
+ export function renderThreadConversation(t) {
49
+ if (!t.comments || t.comments.length === 0)
50
+ return blockquote(t.body);
51
+ return threadComments(t)
52
+ .map((c) => {
53
+ const heading = c.id
54
+ ? c.url
55
+ ? `#### [commentId=${c.id}](${c.url}) (${renderAuthor(c.author, c.authorType)})`
56
+ : `#### \`commentId=${c.id}\` (${renderAuthor(c.author, c.authorType)})`
57
+ : `#### (${renderAuthor(c.author, c.authorType)})`;
58
+ return `${heading}\n\n${blockquote(c.body)}`;
59
+ })
60
+ .join("\n\n");
61
+ }
62
+ export function blockquote(body) {
63
+ return body
64
+ .replace(/\r\n/g, "\n")
65
+ .split("\n")
66
+ .map((line) => (line === "" ? ">" : `> ${line}`))
67
+ .join("\n");
68
+ }
69
+ function renderThreadCommentBullets(t) {
70
+ return threadComments(t)
71
+ .map((c) => {
72
+ const link = c.url ? ` [↗](${c.url})` : "";
73
+ const id = c.id ? `\`commentId=${c.id}\`` : "comment";
74
+ return [
75
+ ` - ${id}${link} (${renderAuthor(c.author, c.authorType)})`,
76
+ indentBlockquote(c.body, " "),
77
+ ].join("\n");
78
+ })
79
+ .join("\n");
80
+ }
81
+ function indentBlockquote(body, indent) {
82
+ return blockquote(body)
83
+ .split("\n")
84
+ .map((line) => `${indent}${line}`)
85
+ .join("\n");
36
86
  }
37
87
  export function renderCommentBullet(c, opts = {}) {
38
88
  const link = c.url ? ` [↗](${c.url})` : "";
@@ -62,6 +112,7 @@ export function buildFirstLookBullets(firstLookThreads, resolutionOnlyIds, first
62
112
  bullets.push(renderThreadBullet(t, {
63
113
  statusTag: renderFirstLookStatusTag(t),
64
114
  noBody: resolutionOnlyIds.has(t.id),
115
+ suppressEditedMarker: true,
65
116
  }));
66
117
  }
67
118
  for (const c of firstLookComments) {
@@ -0,0 +1,34 @@
1
+ import { hasFlag } from "./args.mjs";
2
+ export function parseMarkFilesAsViewedArgs(args) {
3
+ const files = [];
4
+ const matchPatterns = [];
5
+ const tests = hasFlag(args, "--tests");
6
+ for (let i = 0; i < args.length; i += 1) {
7
+ const arg = args[i];
8
+ if (arg === "--tests")
9
+ continue;
10
+ if (arg === "--match") {
11
+ const value = args[i + 1];
12
+ if (value === undefined || value.startsWith("--")) {
13
+ return { ok: false, error: "--match requires a regex value" };
14
+ }
15
+ matchPatterns.push(value);
16
+ i += 1;
17
+ continue;
18
+ }
19
+ if (arg.startsWith("--match=")) {
20
+ const value = arg.slice("--match=".length);
21
+ if (value === "")
22
+ return { ok: false, error: "--match requires a regex value" };
23
+ matchPatterns.push(value);
24
+ continue;
25
+ }
26
+ if (arg.startsWith("--"))
27
+ return { ok: false, error: `unknown flag: "${arg}"` };
28
+ files.push(arg);
29
+ }
30
+ if (files.length === 0 && !tests && matchPatterns.length === 0) {
31
+ return { ok: false, error: "provide at least one file, --tests, or --match <regex>" };
32
+ }
33
+ return { ok: true, files, tests, matchPatterns };
34
+ }
@@ -0,0 +1,52 @@
1
+ export function formatMarkFilesAsViewedResult(result) {
2
+ const lines = [];
3
+ lines.push(`# PR #${result.prNumber} — Mark files as viewed (${result.markedPaths.length} marked)`);
4
+ lines.push("");
5
+ lines.push(`repo: ${result.repo}`);
6
+ appendPathSection(lines, "Matched files", result.matchedPaths);
7
+ appendPathSection(lines, "Marked viewed", result.markedPaths);
8
+ appendPathSection(lines, "Already viewed", result.alreadyViewedPaths);
9
+ appendPathSection(lines, "Missing from PR diff", result.missingPaths);
10
+ appendTextSection(lines, "Unmatched selectors", result.unmatchedSelectors);
11
+ if (result.rateLimit) {
12
+ const details = [
13
+ result.rateLimit.retryAfterSeconds !== undefined
14
+ ? `retry after ${result.rateLimit.retryAfterSeconds}s`
15
+ : null,
16
+ result.rateLimit.remaining !== undefined && result.rateLimit.limit !== undefined
17
+ ? `remaining ${result.rateLimit.remaining}/${result.rateLimit.limit}`
18
+ : null,
19
+ result.rateLimit.resetAt !== undefined
20
+ ? `reset at ${new Date(result.rateLimit.resetAt * 1000).toISOString()}`
21
+ : null,
22
+ ]
23
+ .filter(Boolean)
24
+ .join(", ");
25
+ lines.push("");
26
+ lines.push(`Stopped: GitHub rate limit hit — ${result.rateLimit.message}${details ? ` (${details})` : ""}`);
27
+ }
28
+ appendPathSection(lines, "Not marked due to rate limit", result.unmarkedPaths ?? []);
29
+ const errors = result.rateLimit
30
+ ? result.errors.filter((e) => !e.startsWith("rate limit:"))
31
+ : result.errors;
32
+ appendTextSection(lines, "Errors", errors);
33
+ if (result.matchedPaths.length === 0) {
34
+ lines.push("");
35
+ lines.push("No files matched.");
36
+ }
37
+ return lines.join("\n");
38
+ }
39
+ function appendPathSection(lines, label, paths) {
40
+ if (paths.length === 0)
41
+ return;
42
+ lines.push("");
43
+ lines.push(`## ${label} (${paths.length})`);
44
+ lines.push(paths.map((path) => `- \`${path}\``).join("\n"));
45
+ }
46
+ function appendTextSection(lines, label, values) {
47
+ if (values.length === 0)
48
+ return;
49
+ lines.push("");
50
+ lines.push(`## ${label} (${values.length})`);
51
+ lines.push(values.map((value) => `- ${value}`).join("\n"));
52
+ }
@@ -0,0 +1,50 @@
1
+ function pushIds(lines, label, ids) {
2
+ if (ids?.length)
3
+ lines.push(`${label} (${ids.length}): ${ids.join(", ")}`);
4
+ }
5
+ function formatRateLimit(result) {
6
+ const rateLimit = result.rateLimit;
7
+ if (rateLimit) {
8
+ const details = [
9
+ typeof rateLimit.retryAfterSeconds === "number"
10
+ ? `retry after ${rateLimit.retryAfterSeconds}s`
11
+ : null,
12
+ typeof rateLimit.remaining === "number" && typeof rateLimit.limit === "number"
13
+ ? `remaining ${rateLimit.remaining}/${rateLimit.limit}`
14
+ : null,
15
+ typeof rateLimit.resetAt === "number"
16
+ ? `reset at ${new Date(rateLimit.resetAt * 1000).toISOString()}`
17
+ : null,
18
+ ]
19
+ .filter(Boolean)
20
+ .join(", ");
21
+ const detailSuffix = details ? ` (${details})` : "";
22
+ return `Stopped: GitHub rate limit hit — ${rateLimit.message}${detailSuffix}`;
23
+ }
24
+ return null;
25
+ }
26
+ export function formatMutateResult(result) {
27
+ const lines = [];
28
+ pushIds(lines, "Replied to threads", result.repliedThreads);
29
+ pushIds(lines, "Resolved threads", result.resolvedThreads);
30
+ pushIds(lines, "Minimized comments", result.minimizedComments);
31
+ pushIds(lines, "Dismissed reviews", result.dismissedReviews);
32
+ pushIds(lines, "Skipped dismissals", result.skippedDismissals);
33
+ pushIds(lines, "Skipped human thread resolves", result.skippedHumanResolves);
34
+ pushIds(lines, "Skipped human minimizes", result.skippedHumanMinimizes);
35
+ pushIds(lines, "Skipped human review dismissals", result.skippedHumanDismissals);
36
+ pushIds(lines, "Skipped non-human/unknown thread replies", result.skippedNonHumanReplies);
37
+ const rateLimit = formatRateLimit(result);
38
+ if (rateLimit)
39
+ lines.push(rateLimit);
40
+ pushIds(lines, "Not replied due to rate limit", result.unrepliedThreads);
41
+ pushIds(lines, "Not resolved due to rate limit", result.unresolvedThreads);
42
+ pushIds(lines, "Not minimized due to rate limit", result.unminimizedComments);
43
+ pushIds(lines, "Not dismissed due to rate limit", result.undismissedReviews);
44
+ const errors = result.rateLimit
45
+ ? result.errors.filter((e) => !e.startsWith("rate limit:"))
46
+ : result.errors;
47
+ if (errors.length)
48
+ lines.push(`Errors:\n ${errors.join("\n ")}`);
49
+ return lines.join("\n");
50
+ }
@@ -7,9 +7,12 @@
7
7
  * [--stall-timeout <duration>] [--no-auto-mark-ready]
8
8
  * [--no-auto-cancel-actionable]
9
9
  * pr-shepherd resolve [PR] [--fetch] [--resolve-thread-ids A,B] [--minimize-comment-ids X,Y]
10
- * [--dismiss-review-ids Q] [--message MSG] [--require-sha SHA]
10
+ * [--reply-thread-ids A,B] [--dismiss-review-ids Q]
11
+ * [--message MSG] [--require-sha SHA]
11
12
  * pr-shepherd commit-suggestion [PR] --thread-id ID --message MSG [--description DESC]
12
13
  * [--format text|json]
14
+ * pr-shepherd mark-files-as-viewed [PR] [files...] [--tests] [--match REGEX]
15
+ * [--format text|json]
13
16
  * pr-shepherd iterate [PR] [--format text|json] [--ready-delay Nm]
14
17
  * [--stall-timeout <duration>] [--no-auto-mark-ready]
15
18
  * [--no-auto-cancel-actionable]
@@ -25,7 +28,7 @@ import { parseCommonArgs, getFlag, hasFlag, parseList } from "./cli/args.mjs";
25
28
  import { isDefaultPollInvocation, validateDefaultPollArgs } from "./cli/default-poll.mjs";
26
29
  import { USAGE, maybePrintHelp } from "./cli/help.mjs";
27
30
  import { formatFetchResult, formatMutateResult } from "./cli/formatters.mjs";
28
- import { handleClean, handleCommitSuggestion, handleIterate } from "./cli/handlers.mjs";
31
+ import { handleClean, handleCommitSuggestion, handleIterate, handleMarkFilesAsViewed, } from "./cli/handlers.mjs";
29
32
  import { handlePoll } from "./cli/poll-handler.mjs";
30
33
  import { setupLog } from "./log/setup.mjs";
31
34
  // ---------------------------------------------------------------------------
@@ -75,6 +78,9 @@ export async function main(argv) {
75
78
  case "commit-suggestion":
76
79
  await handleCommitSuggestion(args.slice(1));
77
80
  break;
81
+ case "mark-files-as-viewed":
82
+ await handleMarkFilesAsViewed(args.slice(1));
83
+ break;
78
84
  case "iterate":
79
85
  await handleIterate(args.slice(1));
80
86
  break;
@@ -119,12 +125,14 @@ async function handleLogFile(args) {
119
125
  async function handleResolve(args) {
120
126
  const { prNumber, global: globalOpts, extra } = parseCommonArgs(args);
121
127
  const resolveThreadIds = parseList(getFlag(extra, "--resolve-thread-ids"));
128
+ const replyThreadIds = parseList(getFlag(extra, "--reply-thread-ids"));
122
129
  const minimizeCommentIds = parseList(getFlag(extra, "--minimize-comment-ids"));
123
130
  const dismissReviewIds = parseList(getFlag(extra, "--dismiss-review-ids"));
124
131
  const dismissMessage = getFlag(extra, "--message") ?? undefined;
125
132
  const requireSha = getFlag(extra, "--require-sha") ?? undefined;
126
133
  const fetchMode = hasFlag(extra, "--fetch") ||
127
134
  (resolveThreadIds.length === 0 &&
135
+ replyThreadIds.length === 0 &&
128
136
  minimizeCommentIds.length === 0 &&
129
137
  dismissReviewIds.length === 0);
130
138
  if (fetchMode) {
@@ -138,6 +146,7 @@ async function handleResolve(args) {
138
146
  ...globalOpts,
139
147
  prNumber,
140
148
  resolveThreadIds,
149
+ replyThreadIds,
141
150
  minimizeCommentIds,
142
151
  dismissReviewIds,
143
152
  dismissMessage,
@@ -10,6 +10,9 @@ vi.mock("./commands/log-file.mts", () => ({
10
10
  vi.mock("./commands/commit-suggestion.mts", () => ({
11
11
  runCommitSuggestion: vi.fn(),
12
12
  }));
13
+ vi.mock("./commands/mark-files-as-viewed.mts", () => ({
14
+ runMarkFilesAsViewed: vi.fn(),
15
+ }));
13
16
  vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
14
17
  const actual = await importOriginal();
15
18
  return { ...actual, runIterate: vi.fn() };
@@ -17,9 +20,11 @@ vi.mock("./commands/iterate/index.mts", async (importOriginal) => {
17
20
  import { main } from "./cli-parser.mjs";
18
21
  import { runLogFile } from "./commands/log-file.mjs";
19
22
  import { runResolveFetch, runResolveMutate } from "./commands/resolve.mjs";
23
+ import { runMarkFilesAsViewed } from "./commands/mark-files-as-viewed.mjs";
20
24
  const mockRunResolveFetch = vi.mocked(runResolveFetch);
21
25
  const mockRunResolveMutate = vi.mocked(runResolveMutate);
22
26
  const mockRunLogFile = vi.mocked(runLogFile);
27
+ const mockRunMarkFilesAsViewed = vi.mocked(runMarkFilesAsViewed);
23
28
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
29
  let stdoutSpy;
25
30
  let stderrSpy;
@@ -39,4 +44,4 @@ export function registerHooks() {
39
44
  stderrSpy.mockRestore();
40
45
  });
41
46
  }
42
- export { getStdout, main, mockRunLogFile, mockRunResolveFetch, mockRunResolveMutate, readFileSync, runLogFile, runResolveFetch, runResolveMutate, stderrSpy, stdoutSpy, };
47
+ export { getStdout, main, mockRunLogFile, mockRunMarkFilesAsViewed, mockRunResolveFetch, mockRunResolveMutate, readFileSync, runLogFile, runResolveFetch, runResolveMutate, stderrSpy, stdoutSpy, };
@@ -0,0 +1,40 @@
1
+ import { fetchCheckRunAnnotations } from "../github/check-annotations.mjs";
2
+ export async function attachUnseenCheckAnnotations(checks, seenMap, prNumber) {
3
+ const checksWithAnnotations = [];
4
+ for (const check of checks) {
5
+ // eslint-disable-next-line no-await-in-loop
6
+ checksWithAnnotations.push(await attachForCheck(check, seenMap, prNumber));
7
+ }
8
+ return checksWithAnnotations;
9
+ }
10
+ async function attachForCheck(check, seenMap, prNumber) {
11
+ if (check.id == null)
12
+ return check;
13
+ let annotations;
14
+ try {
15
+ annotations = await fetchCheckRunAnnotations(check.id);
16
+ }
17
+ catch (err) {
18
+ const msg = err instanceof Error ? err.message : String(err);
19
+ process.stderr.write(`pr-shepherd: annotation fetch failed for PR #${prNumber} check "${check.name}" (ignored): ${msg}\n`);
20
+ return check;
21
+ }
22
+ const unseen = annotations.filter((a) => !seenMap.has(a.id));
23
+ if (unseen.length === 0)
24
+ return check;
25
+ return { ...check, annotations: unseen };
26
+ }
27
+ export function annotationMarkerBody(a) {
28
+ return JSON.stringify({
29
+ path: a.path,
30
+ startLine: a.startLine,
31
+ endLine: a.endLine,
32
+ startColumn: a.startColumn,
33
+ endColumn: a.endColumn,
34
+ level: a.level,
35
+ title: a.title,
36
+ message: a.message,
37
+ rawDetails: a.rawDetails,
38
+ blobUrl: a.blobUrl,
39
+ });
40
+ }
@@ -3,15 +3,17 @@ import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
3
3
  import { classifyChecks, getCiVerdict } from "../checks/classify.mjs";
4
4
  import { mergeStartupFailureChecks } from "../checks/startup-failures.mjs";
5
5
  import { fetchStartupFailureChecks, triageFailingChecks } from "../checks/triage.mjs";
6
- import { getOutdatedThreads } from "../comments/outdated.mjs";
7
- import { autoResolveOutdated } from "../comments/resolve.mjs";
8
6
  import { deriveMergeStatus } from "../merge-status/derive.mjs";
9
7
  import { loadConfig } from "../config/load.mjs";
10
8
  import { classifyVisibleComments } from "../comments/visible-comments.mjs";
11
9
  import { computeStatus } from "./check-status.mjs";
10
+ import { attachUnseenCheckAnnotations } from "./check-annotations.mjs";
12
11
  import { buildTerminalReport } from "./check-terminal-report.mjs";
13
12
  import { isBlockedByFilteredCheck, refreshReadyMergeability, refreshUnknownMergeability, } from "./ready-mergeability.mjs";
14
13
  import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
14
+ import { threadTranscriptBody } from "../threads/transcript.mjs";
15
+ import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
16
+ import { classifyReviewsForDisplay } from "../comments/review-visibility.mjs";
15
17
  export async function runCheck(opts) {
16
18
  const repo = await getRepoInfo();
17
19
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
@@ -38,53 +40,13 @@ export async function runCheck(opts) {
38
40
  const inProgress = classifiedChecks.filter((c) => c.category === "in_progress");
39
41
  const skipped = classifiedChecks.filter((c) => c.category === "skipped");
40
42
  const filtered = classifiedChecks.filter((c) => c.category === "filtered");
41
- const triaged = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
43
+ const triagedBase = failing.length > 0 && !opts.skipTriage ? await triageFailingChecks(failing, repo) : failing;
42
44
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
43
- const unresolvedThreads = batchData.reviewThreads.filter((t) => !t.isResolved);
44
- const outdated = getOutdatedThreads(unresolvedThreads);
45
- let autoResolved = [];
46
- let autoResolveErrors = [];
47
- if (opts.autoResolve && outdated.length > 0) {
48
- const { resolved: resolvedIds, errors } = await autoResolveOutdated(outdated.map((t) => t.id));
49
- const resolvedIdsSet = new Set(resolvedIds); // ⚡ Bolt optimization: O(1) lookup
50
- autoResolved = outdated.filter((t) => resolvedIdsSet.has(t.id));
51
- autoResolveErrors = errors;
52
- }
53
- const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated && !t.isMinimized);
54
- const outdatedCandidates = batchData.reviewThreads.filter((t) => t.isOutdated);
55
- const resolvedCandidates = batchData.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
56
- const minimizedThreadCandidates = batchData.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
57
- const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
58
45
  const seenMap = await loadSeenMap(stateKey);
46
+ const triaged = await attachUnseenCheckAnnotations(triagedBase, seenMap, prNumber);
47
+ const minimizedCommentCandidates = batchData.comments.filter((c) => c.isMinimized);
59
48
  const visibleCommentClassification = classifyVisibleComments(batchData.comments, seenMap, config.iterate.minimizeComments);
60
- const autoResolvedIds = new Set(autoResolved.map((t) => t.id));
61
- const firstLookThreads = [
62
- ...outdatedCandidates.flatMap((t) => {
63
- const cls = classifyItem(t.id, t.body, seenMap);
64
- if (cls === "unchanged")
65
- return [];
66
- const base = {
67
- ...t,
68
- firstLookStatus: "outdated",
69
- autoResolved: autoResolvedIds.has(t.id),
70
- };
71
- return cls === "edited" ? [{ ...base, edited: true }] : [base];
72
- }),
73
- ...resolvedCandidates.flatMap((t) => {
74
- const cls = classifyItem(t.id, t.body, seenMap);
75
- if (cls === "unchanged")
76
- return [];
77
- const base = { ...t, firstLookStatus: "resolved" };
78
- return cls === "edited" ? [{ ...base, edited: true }] : [base];
79
- }),
80
- ...minimizedThreadCandidates.flatMap((t) => {
81
- const cls = classifyItem(t.id, t.body, seenMap);
82
- if (cls === "unchanged")
83
- return [];
84
- const base = { ...t, firstLookStatus: "minimized" };
85
- return cls === "edited" ? [{ ...base, edited: true }] : [base];
86
- }),
87
- ];
49
+ const threadVisibility = classifyThreadVisibility(batchData.reviewThreads, seenMap);
88
50
  const firstLookComments = minimizedCommentCandidates.flatMap((c) => {
89
51
  const cls = classifyItem(c.id, c.body, seenMap);
90
52
  if (cls === "unchanged")
@@ -104,16 +66,22 @@ export async function runCheck(opts) {
104
66
  else
105
67
  seenSummaries.push(r);
106
68
  }
69
+ const changesRequestedReviewVisibility = classifyReviewsForDisplay(batchData.changesRequestedReviews, seenMap);
70
+ const approvedReviewVisibility = classifyReviewsForDisplay(batchData.approvedReviews, seenMap);
107
71
  await Promise.allSettled([
108
- ...firstLookThreads.map((t) => markSeen(stateKey, t.id, t.body)),
109
72
  ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
73
+ ...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
110
74
  ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
111
75
  ...[...firstLookSummaries, ...editedSummaries].map((r) => markSeen(stateKey, r.id, r.body)),
76
+ ...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
77
+ ...approvedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
112
78
  ]);
113
- const resolutionOnlyThreads = unresolvedThreads.filter((t) => !autoResolvedIds.has(t.id) && (t.isOutdated || t.isMinimized));
114
- let status = computeStatus(verdict, activeThreads.length + resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, batchData.changesRequestedReviews.length);
79
+ const changesRequestedReviews = changesRequestedReviewVisibility.visible;
80
+ const changesRequestedReviewCount = batchData.changesRequestedReviews.length;
81
+ const approvedReviews = approvedReviewVisibility.visible;
82
+ let status = computeStatus(verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length, mergeStatus, changesRequestedReviewCount);
115
83
  if (status === "READY" && !didRefreshMergeability) {
116
- const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, activeThreads.length + resolutionOnlyThreads.length, visibleCommentClassification.actionable.length);
84
+ const refreshed = await refreshReadyMergeability(prNumber, repo, batchData, verdict, threadVisibility.activeThreads.length + threadVisibility.resolutionOnlyThreads.length, visibleCommentClassification.actionable.length);
117
85
  batchData = refreshed.batchData;
118
86
  mergeStatus = refreshed.mergeStatus;
119
87
  status = refreshed.status;
@@ -136,22 +104,22 @@ export async function runCheck(opts) {
136
104
  blockedByFilteredCheck,
137
105
  },
138
106
  threads: {
139
- actionable: activeThreads,
140
- resolutionOnly: resolutionOnlyThreads,
141
- autoResolved,
142
- autoResolveErrors,
143
- firstLook: firstLookThreads,
107
+ actionable: threadVisibility.activeThreads,
108
+ resolutionOnly: threadVisibility.resolutionOnlyThreads,
109
+ autoResolved: [],
110
+ autoResolveErrors: [],
111
+ firstLook: threadVisibility.firstLookThreads,
144
112
  },
145
113
  comments: {
146
114
  actionable: visibleCommentClassification.actionable,
147
115
  minimizeIds: visibleCommentClassification.minimizeIds,
148
116
  firstLook: firstLookComments,
149
117
  },
150
- changesRequestedReviews: batchData.changesRequestedReviews,
118
+ changesRequestedReviews,
151
119
  reviewSummaries: seenSummaries,
152
120
  firstLookSummaries,
153
121
  editedSummaries,
154
- approvedReviews: batchData.approvedReviews,
122
+ approvedReviews,
155
123
  branchProtection: batchData.branchProtection,
156
124
  };
157
125
  }
@@ -9,6 +9,9 @@ vi.mock("../checks/triage.mts", () => ({
9
9
  triageFailingChecks: vi.fn((checks) => Promise.resolve(checks)),
10
10
  fetchStartupFailureChecks: vi.fn().mockResolvedValue([]),
11
11
  }));
12
+ vi.mock("../github/check-annotations.mts", () => ({
13
+ fetchCheckRunAnnotations: vi.fn().mockResolvedValue([]),
14
+ }));
12
15
  vi.mock("../comments/resolve.mts", () => ({
13
16
  autoResolveOutdated: vi.fn().mockResolvedValue({ resolved: [], errors: [] }),
14
17
  }));
@@ -26,6 +29,7 @@ import { runCheck } from "./check.mjs";
26
29
  import { fetchPrBatch } from "../github/batch.mjs";
27
30
  import { getCurrentPrNumber, getMergeableState } from "../github/client.mjs";
28
31
  import { fetchStartupFailureChecks, triageFailingChecks } from "../checks/triage.mjs";
32
+ import { fetchCheckRunAnnotations } from "../github/check-annotations.mjs";
29
33
  import { loadSeenMap, markSeen, hashBody } from "../state/seen-comments.mjs";
30
34
  import { autoResolveOutdated } from "../comments/resolve.mjs";
31
35
  const mockFetchPrBatch = vi.mocked(fetchPrBatch);
@@ -33,6 +37,7 @@ const mockGetCurrentPrNumber = vi.mocked(getCurrentPrNumber);
33
37
  const mockGetMergeableState = vi.mocked(getMergeableState);
34
38
  const mockTriageFailingChecks = vi.mocked(triageFailingChecks);
35
39
  const mockFetchStartupFailureChecks = vi.mocked(fetchStartupFailureChecks);
40
+ const mockFetchCheckRunAnnotations = vi.mocked(fetchCheckRunAnnotations);
36
41
  const mockLoadSeenMap = vi.mocked(loadSeenMap);
37
42
  const mockMarkSeen = vi.mocked(markSeen);
38
43
  const mockAutoResolveOutdated = vi.mocked(autoResolveOutdated);
@@ -41,7 +46,7 @@ function defaultConfig() {
41
46
  return {
42
47
  iterate: {
43
48
  fixAttemptsPerThread: 3,
44
- stallTimeoutMinutes: 30,
49
+ stallTimeoutMinutes: 60,
45
50
  minimizeApprovals: false,
46
51
  minimizeComments: "all",
47
52
  },
@@ -63,6 +68,7 @@ function defaultConfig() {
63
68
  }
64
69
  function makeCheck(overrides = {}) {
65
70
  return {
71
+ id: null,
66
72
  name: "tests",
67
73
  status: "COMPLETED",
68
74
  conclusion: "SUCCESS",
@@ -135,6 +141,7 @@ export function registerHooks() {
135
141
  mockFetchPrBatch.mockResolvedValue({ data: makeBatchData() });
136
142
  mockGetMergeableState.mockResolvedValue({ mergeable: "MERGEABLE", mergeStateStatus: "CLEAN" });
137
143
  mockFetchStartupFailureChecks.mockResolvedValue([]);
144
+ mockFetchCheckRunAnnotations.mockResolvedValue([]);
138
145
  });
139
146
  }
140
- export { BASE_OPTS, autoResolveOutdated, defaultConfig, fetchPrBatch, fetchStartupFailureChecks, getCurrentPrNumber, getMergeableState, hashBody, loadSeenMap, makeBatchData, makeCheck, makeComment, makeThread, markSeen, mockAutoResolveOutdated, mockFetchPrBatch, mockFetchStartupFailureChecks, mockGetCurrentPrNumber, mockGetMergeableState, mockLoadConfig, mockLoadSeenMap, mockMarkSeen, mockTriageFailingChecks, runCheck, triageFailingChecks, };
147
+ export { BASE_OPTS, autoResolveOutdated, defaultConfig, fetchPrBatch, fetchStartupFailureChecks, fetchCheckRunAnnotations, getCurrentPrNumber, getMergeableState, hashBody, loadSeenMap, makeBatchData, makeCheck, makeComment, makeThread, markSeen, mockAutoResolveOutdated, mockFetchPrBatch, mockFetchStartupFailureChecks, mockFetchCheckRunAnnotations, mockGetCurrentPrNumber, mockGetMergeableState, mockLoadConfig, mockLoadSeenMap, mockMarkSeen, mockTriageFailingChecks, runCheck, triageFailingChecks, };
@@ -19,5 +19,5 @@ export function buildCommitSuggestionInstruction(prNumber, sectionName, includeD
19
19
  const driftHint = includeDriftHint
20
20
  ? " If the patch fails to apply (drift since the suggestion was written), fall through to the manual fix step."
21
21
  : " If the patch fails to apply, fall through to the manual-edit step.";
22
- return `For each thread marked \`[suggestion]\` under \`${sectionName}\`: run \`${command}\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Include the thread ID in \`--resolve-thread-ids\` in the resolve command below (the thread is not auto-resolved).${driftHint} Do not retry the same command.`;
22
+ return `For each thread marked \`[suggestion]\` under \`${sectionName}\`: run \`${command}\` to retrieve the patch and suggested commit. The CLI does not mutate the working tree — apply the patch yourself (run \`git apply\` with the diff shown, or edit the file directly using the line range), then stage the listed file and run the suggested \`git commit\` from the \`## Instructions\` section. Human-authored thread IDs are replied to by the resolve command below; Shepherd does not auto-resolve them.${driftHint} Do not retry the same command.`;
23
23
  }
@@ -1,5 +1,6 @@
1
1
  import { buildPrShepherdCommand } from "../../cli/runner.mjs";
2
2
  import { shouldMinimizeAuthor } from "../../comments/minimize-policy.mjs";
3
+ import { isHumanAuthor } from "../../comments/authors.mjs";
3
4
  function dedupeIds(ids) {
4
5
  const seen = new Set();
5
6
  const out = [];
@@ -16,18 +17,21 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
16
17
  // they are already minimized server-side (body changed after minimize was applied).
17
18
  // First-look bodies are rendered so the agent sees them before the minimize happens.
18
19
  const minimizeIds = [...summaries.firstLook, ...summaries.seen]
19
- .filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments))
20
+ .filter((r) => shouldMinimizeAuthor(r.authorType, minimizeComments, r.author))
20
21
  .map((r) => r.id);
21
22
  if (minimizeApprovals) {
23
+ const surfacedApprovals = [];
22
24
  for (const r of approvals) {
23
- if (shouldMinimizeAuthor(r.authorType, minimizeComments))
25
+ if (shouldMinimizeAuthor(r.authorType, minimizeComments, r.author))
24
26
  minimizeIds.push(r.id);
27
+ else
28
+ surfacedApprovals.push(r);
25
29
  }
26
30
  return {
27
31
  minimizeIds,
28
32
  firstLookSummaries: summaries.firstLook,
29
33
  editedSummaries: summaries.edited,
30
- surfacedApprovals: [],
34
+ surfacedApprovals,
31
35
  };
32
36
  }
33
37
  return {
@@ -37,45 +41,36 @@ export function classifyReviewSummaries(summaries, approvals, minimizeApprovals,
37
41
  surfacedApprovals: approvals,
38
42
  };
39
43
  }
40
- export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, reviews, checks, prNumber) {
44
+ export function buildResolveCommand(threads, resolutionOnlyThreads, allCommentIds, _reviews, checks, prNumber) {
41
45
  const argv = buildPrShepherdCommand(["resolve", String(prNumber)]).argv;
42
- const resolveThreadIds = dedupeIds(threads.map((t) => t.id));
43
- const threadIds = dedupeIds([...resolveThreadIds, ...resolutionOnlyThreads.map((t) => t.id)]);
44
- if (threadIds.length > 0) {
45
- argv.push("--resolve-thread-ids", threadIds.join(","));
46
+ const allThreads = [...threads, ...resolutionOnlyThreads];
47
+ const replyThreadIds = dedupeIds(allThreads.filter(isHumanAuthor).map((t) => t.id));
48
+ const resolveThreadIds = dedupeIds(allThreads.filter((t) => !isHumanAuthor(t)).map((t) => t.id));
49
+ if (replyThreadIds.length > 0) {
50
+ argv.push("--reply-thread-ids", replyThreadIds.join(","));
51
+ argv.push("--message", "$DISMISS_MESSAGE");
52
+ }
53
+ if (resolveThreadIds.length > 0) {
54
+ argv.push("--resolve-thread-ids", resolveThreadIds.join(","));
46
55
  }
47
56
  if (allCommentIds.length > 0) {
48
57
  argv.push("--minimize-comment-ids", allCommentIds.join(","));
49
58
  }
50
- const commentIdSet = new Set(allCommentIds);
51
- const filteredReviewIds = [];
52
- const droppedDismissReviewIds = [];
53
- for (const review of reviews) {
54
- if (commentIdSet.has(review.id))
55
- droppedDismissReviewIds.push(review.id);
56
- else
57
- filteredReviewIds.push(review.id);
58
- }
59
- const hasDismiss = filteredReviewIds.length > 0;
60
- if (hasDismiss) {
61
- argv.push("--dismiss-review-ids", filteredReviewIds.join(","));
62
- argv.push("--message", "$DISMISS_MESSAGE");
63
- }
64
- // hasMutations = we appended at least one of --resolve-thread-ids,
65
- // --minimize-comment-ids, or --dismiss-review-ids. Returned explicitly
59
+ // hasMutations = we appended at least one reply, resolve, or minimize mutation. Returned explicitly
66
60
  // (rather than derived from argv.length) so callers don't couple to the
67
61
  // base-argv shape.
68
- const hasMutations = threadIds.length > 0 || allCommentIds.length > 0 || filteredReviewIds.length > 0;
62
+ const hasMutations = replyThreadIds.length > 0 || resolveThreadIds.length > 0 || allCommentIds.length > 0;
69
63
  // `requiresHeadSha` is only added when this resolve command includes a
70
- // mutation that can race with a moving HEAD: resolving actionable threads,
71
- // dismissing CHANGES_REQUESTED reviews, or addressing failing checks.
72
- const hasCodeMutations = hasMutations && (threads.length > 0 || checks.length > 0 || filteredReviewIds.length > 0);
64
+ // mutation that can race with a moving HEAD: replying after actionable
65
+ // thread fixes or addressing failing checks.
66
+ const hasCodeMutations = hasMutations && (threads.length > 0 || checks.length > 0);
73
67
  const requiresHeadSha = hasCodeMutations;
74
68
  return {
75
69
  argv,
76
70
  requiresHeadSha,
77
- requiresDismissMessage: hasDismiss,
78
- ...(droppedDismissReviewIds.length > 0 ? { droppedDismissReviewIds } : undefined),
71
+ requiresDismissMessage: replyThreadIds.length > 0,
72
+ ...(replyThreadIds.length > 0 ? { replyThreadIds } : undefined),
73
+ ...(resolveThreadIds.length > 0 ? { resolveThreadIds } : undefined),
79
74
  hasMutations,
80
75
  };
81
76
  }