pr-shepherd 0.22.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 (41) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +86 -125
  3. package/bin/cli/args.mjs +2 -0
  4. package/bin/cli/fix-formatter.mjs +2 -1
  5. package/bin/cli/formatters.mjs +3 -45
  6. package/bin/cli/handlers.mjs +22 -1
  7. package/bin/cli/help-command-pages.mjs +27 -2
  8. package/bin/cli/help-top-page.mjs +2 -0
  9. package/bin/cli/list-formatters.mjs +3 -1
  10. package/bin/cli/mark-files-as-viewed-flags.mjs +34 -0
  11. package/bin/cli/mark-files-as-viewed-formatter.mjs +52 -0
  12. package/bin/cli/mutate-formatter.mjs +50 -0
  13. package/bin/cli-parser.mjs +11 -2
  14. package/bin/cli-parser.test-support.mjs +6 -1
  15. package/bin/commands/check.mjs +20 -55
  16. package/bin/commands/commit-suggestion-instruction.mjs +1 -1
  17. package/bin/commands/iterate/classify.mjs +25 -30
  18. package/bin/commands/iterate/fix-code.mjs +21 -17
  19. package/bin/commands/iterate/render.mjs +3 -3
  20. package/bin/commands/iterate-thread-test-support.mjs +18 -0
  21. package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +7 -3
  22. package/bin/commands/mark-files-as-viewed.mjs +220 -0
  23. package/bin/commands/resolve-mutate.mjs +41 -4
  24. package/bin/commands/resolve.mjs +15 -76
  25. package/bin/commands/resolve.test-support.mjs +2 -0
  26. package/bin/commands/shepherd-journal.mjs +1 -1
  27. package/bin/comments/authors.mjs +14 -0
  28. package/bin/comments/minimize-policy.mjs +6 -3
  29. package/bin/comments/pending-ops.mjs +6 -0
  30. package/bin/comments/resolve.mjs +28 -11
  31. package/bin/comments/resolve.test-support.mjs +6 -1
  32. package/bin/comments/review-visibility.mjs +14 -0
  33. package/bin/comments/thread-visibility.mjs +60 -0
  34. package/bin/comments/visible-comments.mjs +1 -1
  35. package/bin/github/batch-parser-helpers.mjs +3 -4
  36. package/bin/github/batch-parsers.mjs +6 -6
  37. package/bin/reporters/agent.mjs +1 -0
  38. package/bin/threads/transcript.mjs +6 -6
  39. package/package.json +1 -1
  40. package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
  41. package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +31 -0
@@ -0,0 +1,220 @@
1
+ /* eslint-disable max-lines */
2
+ import { graphql, graphqlWithRateLimit, getCurrentPrNumber, getRepoInfo, } from "../github/client.mjs";
3
+ import { paginateForward } from "../github/pagination.mjs";
4
+ import { isRateLimitMessage, rateLimitFromError, rateLimitFromGraphQlResult, } from "../comments/rate-limit.mjs";
5
+ const FILES_QUERY = `query PullRequestFiles($owner: String!, $repo: String!, $pr: Int!, $filesCursor: String) {
6
+ repository(owner: $owner, name: $repo) {
7
+ pullRequest(number: $pr) {
8
+ id
9
+ number
10
+ files(first: 100, after: $filesCursor) {
11
+ pageInfo {
12
+ hasNextPage
13
+ endCursor
14
+ }
15
+ nodes {
16
+ path
17
+ viewerViewedState
18
+ }
19
+ }
20
+ }
21
+ }
22
+ }`;
23
+ const TEST_FILE_RE = /(^|\/)(tests?|__tests__|spec)(\/|$)|\.(test|spec)\.[cm]?[jt]sx?$|_tests?\.rs$|(^|\/)tests?\.rs$/i;
24
+ const BULK_CHUNK_SIZE = 10;
25
+ export async function runMarkFilesAsViewed(opts) {
26
+ const repo = await getRepoInfo();
27
+ const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
28
+ if (!prNumber)
29
+ throw new Error("No PR number provided and no current branch PR found");
30
+ const matchPatterns = opts.matchPatterns ?? [];
31
+ const matchRegexes = matchPatterns.map((pattern) => compilePattern(pattern));
32
+ const fetched = await fetchPullRequestFiles(prNumber, repo);
33
+ const selected = selectChangedFiles(fetched.files, {
34
+ files: opts.files,
35
+ tests: opts.tests === true,
36
+ matchPatterns,
37
+ matchRegexes,
38
+ });
39
+ const result = {
40
+ repo: `${repo.owner}/${repo.name}`,
41
+ prNumber,
42
+ pullRequestId: fetched.pullRequestId,
43
+ requestedPaths: opts.files,
44
+ testSelector: opts.tests === true,
45
+ matchPatterns,
46
+ matchedPaths: selected.matchedPaths,
47
+ markedPaths: [],
48
+ alreadyViewedPaths: selected.alreadyViewedPaths,
49
+ missingPaths: selected.missingPaths,
50
+ unmatchedSelectors: selected.unmatchedSelectors,
51
+ errors: [],
52
+ };
53
+ await bulkMarkFilesAsViewed(fetched.pullRequestId, selected.pathsToMark, result);
54
+ return result;
55
+ }
56
+ async function fetchPullRequestFiles(pr, repo) {
57
+ const first = await graphql(FILES_QUERY, {
58
+ owner: repo.owner,
59
+ repo: repo.name,
60
+ pr,
61
+ });
62
+ const raw = first.data.repository?.pullRequest;
63
+ if (!raw)
64
+ throw new Error(`PR #${pr} not found`);
65
+ let files = raw.files.nodes;
66
+ if (raw.files.pageInfo.hasNextPage && raw.files.pageInfo.endCursor) {
67
+ const extra = await paginateForward(async (cursor) => {
68
+ const res = await graphql(FILES_QUERY, {
69
+ owner: repo.owner,
70
+ repo: repo.name,
71
+ pr,
72
+ ...(cursor ? { filesCursor: cursor } : {}),
73
+ });
74
+ const pr2 = res.data.repository?.pullRequest;
75
+ if (!pr2)
76
+ throw new Error(`PR #${pr} not found`);
77
+ return pr2.files;
78
+ }, raw.files.pageInfo.endCursor);
79
+ files = [...files, ...extra];
80
+ }
81
+ return { pullRequestId: raw.id, files };
82
+ }
83
+ function compilePattern(pattern) {
84
+ try {
85
+ return new RegExp(pattern, "i");
86
+ }
87
+ catch (e) {
88
+ const msg = e instanceof Error ? e.message : String(e);
89
+ throw new Error(`Invalid --match regex ${JSON.stringify(pattern)}: ${msg}`);
90
+ }
91
+ }
92
+ function selectChangedFiles(changedFiles, opts) {
93
+ const byPath = new Map(changedFiles.map((f) => [f.path, f]));
94
+ const matched = new Set();
95
+ const missingPaths = [];
96
+ const unmatchedSelectors = [];
97
+ for (const path of opts.files) {
98
+ if (byPath.has(path))
99
+ matched.add(path);
100
+ else
101
+ missingPaths.push(path);
102
+ }
103
+ if (opts.tests) {
104
+ let matchedAny = false;
105
+ for (const file of changedFiles) {
106
+ if (TEST_FILE_RE.test(file.path)) {
107
+ matched.add(file.path);
108
+ matchedAny = true;
109
+ }
110
+ }
111
+ if (!matchedAny)
112
+ unmatchedSelectors.push("--tests");
113
+ }
114
+ for (let i = 0; i < opts.matchRegexes.length; i += 1) {
115
+ let matchedAny = false;
116
+ const regex = opts.matchRegexes[i];
117
+ for (const file of changedFiles) {
118
+ if (regex.test(file.path)) {
119
+ matched.add(file.path);
120
+ matchedAny = true;
121
+ }
122
+ }
123
+ if (!matchedAny)
124
+ unmatchedSelectors.push(`--match ${opts.matchPatterns[i]}`);
125
+ }
126
+ const matchedPaths = [...matched];
127
+ const alreadyViewedPaths = matchedPaths.filter((path) => byPath.get(path)?.viewerViewedState === "VIEWED");
128
+ const alreadyViewedSet = new Set(alreadyViewedPaths);
129
+ const pathsToMark = matchedPaths.filter((path) => !alreadyViewedSet.has(path));
130
+ return { matchedPaths, alreadyViewedPaths, missingPaths, unmatchedSelectors, pathsToMark };
131
+ }
132
+ function buildBulkMutation(paths) {
133
+ const ops = paths.map((path, i) => ` m${i}: markFileAsViewed(input: { pullRequestId: $pullRequestId, path: ${JSON.stringify(path)} }) { pullRequest { id } }`);
134
+ return `mutation BulkMarkFilesAsViewed($pullRequestId: ID!) {\n${ops.join("\n")}\n}`;
135
+ }
136
+ async function bulkMarkFilesAsViewed(pullRequestId, paths, result) {
137
+ for (let i = 0; i < paths.length; i += BULK_CHUNK_SIZE) {
138
+ const chunk = paths.slice(i, i + BULK_CHUNK_SIZE);
139
+ // eslint-disable-next-line no-await-in-loop
140
+ const stopped = await bulkMarkFilesAsViewedChunk(pullRequestId, chunk, result, i + BULK_CHUNK_SIZE < paths.length);
141
+ if (stopped) {
142
+ const markedSet = new Set(result.markedPaths);
143
+ result.unmarkedPaths = paths.slice(i).filter((path) => !markedSet.has(path));
144
+ return;
145
+ }
146
+ }
147
+ }
148
+ async function bulkMarkFilesAsViewedChunk(pullRequestId, paths, result, hasPendingAfter) {
149
+ if (paths.length === 0)
150
+ return false;
151
+ let data = {};
152
+ let graphQlErrors = [];
153
+ let suppressCurrentChunkErrors = false;
154
+ let rateLimitStop;
155
+ try {
156
+ const resp = await graphqlWithRateLimit(buildBulkMutation(paths), {
157
+ pullRequestId,
158
+ });
159
+ data = resp.data;
160
+ graphQlErrors = (resp.errors ?? []);
161
+ const messages = graphQlErrors.map((e) => e.message);
162
+ suppressCurrentChunkErrors = messages.some(isRateLimitMessage);
163
+ rateLimitStop = rateLimitFromGraphQlResult(messages, {
164
+ rateLimit: resp.rateLimit,
165
+ retryAfterSeconds: resp.retryAfterSeconds,
166
+ stopOnZeroRemaining: hasPendingAfter,
167
+ });
168
+ }
169
+ catch (err) {
170
+ const msg = err instanceof Error ? err.message : String(err);
171
+ const stop = rateLimitFromError(err, msg);
172
+ if (stop) {
173
+ result.errors.push(`rate limit: ${stop.message}`);
174
+ result.rateLimit = stop;
175
+ return true;
176
+ }
177
+ for (const path of paths)
178
+ result.errors.push(`${path}: ${msg}`);
179
+ return false;
180
+ }
181
+ const errorMessagesByAlias = mapAliasErrors(graphQlErrors);
182
+ for (let i = 0; i < paths.length; i += 1) {
183
+ const alias = `m${i}`;
184
+ const m = data[alias];
185
+ if (m?.pullRequest?.id === pullRequestId) {
186
+ result.markedPaths.push(paths[i]);
187
+ }
188
+ else if (!suppressCurrentChunkErrors) {
189
+ result.errors.push(`${paths[i]}: ${errorMessagesByAlias.get(alias) ?? "mark returned null"}`);
190
+ }
191
+ }
192
+ if (rateLimitStop) {
193
+ result.errors.push(`rate limit: ${rateLimitStop.message}`);
194
+ result.rateLimit = rateLimitStop;
195
+ return true;
196
+ }
197
+ return false;
198
+ }
199
+ function mapAliasErrors(errors) {
200
+ const out = new Map();
201
+ for (const error of errors) {
202
+ if (!Array.isArray(error.path))
203
+ continue;
204
+ const alias = error.path.find((part) => typeof part === "string" && isMarkAlias(part));
205
+ if (typeof alias === "string")
206
+ out.set(alias, error.message);
207
+ }
208
+ return out;
209
+ }
210
+ function isMarkAlias(value) {
211
+ if (!value.startsWith("m"))
212
+ return false;
213
+ if (value.length === 1)
214
+ return false;
215
+ for (const char of value.slice(1)) {
216
+ if (char < "0" || char > "9")
217
+ return false;
218
+ }
219
+ return true;
220
+ }
@@ -1,16 +1,53 @@
1
1
  import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { applyResolveOptions } from "../comments/resolve.mjs";
3
+ import { fetchPrBatch } from "../github/batch.mjs";
4
+ import { isHumanAuthor } from "../comments/authors.mjs";
5
+ import { markSeen } from "../state/seen-comments.mjs";
6
+ import { threadTranscriptBody } from "../threads/transcript.mjs";
3
7
  export async function runResolveMutate(opts) {
4
8
  const repo = await getRepoInfo();
5
9
  const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
6
10
  if (prNumber === null) {
7
11
  throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
8
12
  }
9
- return applyResolveOptions(prNumber, repo, {
10
- resolveThreadIds: opts.resolveThreadIds,
11
- minimizeCommentIds: opts.minimizeCommentIds,
12
- dismissReviewIds: opts.dismissReviewIds,
13
+ const { data } = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true });
14
+ const threadById = new Map(data.reviewThreads.map((t) => [t.id, t]));
15
+ const humanThreadIds = new Set(data.reviewThreads.filter(isHumanAuthor).map((t) => t.id));
16
+ const humanCommentIds = new Set(data.comments.filter(isHumanAuthor).map((c) => c.id));
17
+ const humanReviewIds = new Set([...data.reviewSummaries, ...data.approvedReviews, ...data.changesRequestedReviews]
18
+ .filter(isHumanAuthor)
19
+ .map((r) => r.id));
20
+ const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
21
+ const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id));
22
+ const replyThreadIds = opts.replyThreadIds?.filter((id) => humanThreadIds.has(id));
23
+ const skippedNonHumanReplies = (opts.replyThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
24
+ const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id));
25
+ const skippedHumanMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => humanCommentIds.has(id) || humanReviewIds.has(id));
26
+ const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id));
27
+ const skippedHumanDismissals = (opts.dismissReviewIds ?? []).filter((id) => humanReviewIds.has(id));
28
+ const result = await applyResolveOptions(prNumber, repo, {
29
+ resolveThreadIds,
30
+ replyThreadIds,
31
+ minimizeCommentIds,
32
+ dismissReviewIds,
13
33
  dismissMessage: opts.dismissMessage,
14
34
  requireSha: opts.requireSha,
15
35
  });
36
+ if (skippedHumanResolves.length > 0)
37
+ result.skippedHumanResolves = skippedHumanResolves;
38
+ if (skippedHumanMinimizes.length > 0)
39
+ result.skippedHumanMinimizes = skippedHumanMinimizes;
40
+ if (skippedHumanDismissals.length > 0)
41
+ result.skippedHumanDismissals = skippedHumanDismissals;
42
+ if (skippedNonHumanReplies.length > 0)
43
+ result.skippedNonHumanReplies = skippedNonHumanReplies;
44
+ if (opts.dismissMessage) {
45
+ await Promise.all(result.repliedThreads.map((id) => {
46
+ const thread = threadById.get(id);
47
+ if (!thread)
48
+ return Promise.resolve();
49
+ return markSeen({ owner: repo.owner, repo: repo.name, pr: prNumber }, id, threadTranscriptBody(thread, [opts.dismissMessage]));
50
+ }));
51
+ }
52
+ return result;
16
53
  }
@@ -1,13 +1,13 @@
1
1
  import { getRepoInfo, getCurrentPrNumber } from "../github/client.mjs";
2
2
  import { fetchPrBatch } from "../github/batch.mjs";
3
- import { getOutdatedThreads } from "../comments/outdated.mjs";
4
- import { autoResolveOutdated } from "../comments/resolve.mjs";
5
3
  import { loadConfig } from "../config/load.mjs";
6
4
  import { classifyVisibleComments } from "../comments/visible-comments.mjs";
7
5
  import { extractSuggestion } from "../suggestions/extract.mjs";
8
6
  import { buildFetchInstructions } from "./resolve-instructions.mjs";
9
7
  import { loadSeenMap, markSeen, classifyItem } from "../state/seen-comments.mjs";
10
8
  import { threadTranscriptBody } from "../threads/transcript.mjs";
9
+ import { classifyThreadVisibility } from "../comments/thread-visibility.mjs";
10
+ import { classifyReviewsForDisplay } from "../comments/review-visibility.mjs";
11
11
  export { runResolveMutate } from "./resolve-mutate.mjs";
12
12
  export async function runResolveFetch(opts) {
13
13
  const repo = await getRepoInfo();
@@ -17,39 +17,9 @@ export async function runResolveFetch(opts) {
17
17
  }
18
18
  const { data } = await fetchPrBatch(prNumber, repo);
19
19
  const stateKey = { owner: repo.owner, repo: repo.name, pr: prNumber };
20
- const unresolvedThreads = data.reviewThreads.filter((t) => !t.isResolved);
21
- const outdatedCandidates = data.reviewThreads.filter((t) => t.isOutdated);
22
- const resolvedCandidates = data.reviewThreads.filter((t) => t.isResolved && !t.isOutdated);
23
- const minimizedThreadCandidates = data.reviewThreads.filter((t) => t.isMinimized && !t.isResolved && !t.isOutdated);
24
20
  const minimizedCommentCandidates = data.comments.filter((c) => c.isMinimized);
25
21
  const seenMap = await loadSeenMap(stateKey);
26
- const unseenOutdated = [];
27
- const editedOutdated = [];
28
- for (const t of outdatedCandidates) {
29
- const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
30
- if (cls === "new")
31
- unseenOutdated.push(t);
32
- else if (cls === "edited")
33
- editedOutdated.push(t);
34
- }
35
- const unseenResolved = [];
36
- const editedResolved = [];
37
- for (const t of resolvedCandidates) {
38
- const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
39
- if (cls === "new")
40
- unseenResolved.push(t);
41
- else if (cls === "edited")
42
- editedResolved.push(t);
43
- }
44
- const unseenMinimizedThreads = [];
45
- const editedMinimizedThreads = [];
46
- for (const t of minimizedThreadCandidates) {
47
- const cls = classifyItem(t.id, threadTranscriptBody(t), seenMap);
48
- if (cls === "new")
49
- unseenMinimizedThreads.push(t);
50
- else if (cls === "edited")
51
- editedMinimizedThreads.push(t);
52
- }
22
+ const threadVisibility = classifyThreadVisibility(data.reviewThreads, seenMap);
53
23
  const unseenMinimizedComments = [];
54
24
  const editedMinimizedComments = [];
55
25
  for (const c of minimizedCommentCandidates) {
@@ -59,52 +29,19 @@ export async function runResolveFetch(opts) {
59
29
  else if (cls === "edited")
60
30
  editedMinimizedComments.push(c);
61
31
  }
62
- const outdated = getOutdatedThreads(unresolvedThreads);
63
- const autoResolvedIds = new Set();
64
- if (outdated.length > 0) {
65
- const { resolved: resolvedIds, errors } = await autoResolveOutdated(outdated.map((t) => t.id));
66
- for (const id of resolvedIds)
67
- autoResolvedIds.add(id);
68
- if (errors.length > 0) {
69
- process.stderr.write(`pr-shepherd: auto-resolve outdated threads failed (continuing): ${errors.join(", ")}\n`);
70
- }
71
- }
72
- const activeThreads = unresolvedThreads.filter((t) => !t.isOutdated && !t.isMinimized);
73
- const resolutionOnlyThreads = unresolvedThreads.filter((t) => !autoResolvedIds.has(t.id) && (t.isOutdated || t.isMinimized));
74
32
  const cfg = loadConfig();
75
33
  const visibleCommentClassification = classifyVisibleComments(data.comments, seenMap, cfg.iterate?.minimizeComments);
76
- const actionableThreads = activeThreads.map(({ isResolved: _r, isOutdated: _o, ...rest }) => {
34
+ const changesRequestedReviewVisibility = classifyReviewsForDisplay(data.changesRequestedReviews, seenMap);
35
+ const reviewSummaryVisibility = cfg.resolve.fetchReviewSummaries
36
+ ? classifyReviewsForDisplay(data.reviewSummaries, seenMap)
37
+ : { visible: [], toMarkSeen: [] };
38
+ const actionableThreads = threadVisibility.activeThreads.map(({ isResolved: _r, isOutdated: _o, ...rest }) => {
77
39
  const thread = rest;
78
40
  const suggestion = extractSuggestion(rest);
79
41
  if (suggestion)
80
42
  thread.suggestion = suggestion;
81
43
  return thread;
82
44
  });
83
- const firstLookThreads = [
84
- ...unseenOutdated.map((t) => ({
85
- ...t,
86
- firstLookStatus: "outdated",
87
- autoResolved: autoResolvedIds.has(t.id),
88
- })),
89
- ...editedOutdated.map((t) => ({
90
- ...t,
91
- firstLookStatus: "outdated",
92
- autoResolved: autoResolvedIds.has(t.id),
93
- edited: true,
94
- })),
95
- ...unseenResolved.map((t) => ({ ...t, firstLookStatus: "resolved" })),
96
- ...editedResolved.map((t) => ({
97
- ...t,
98
- firstLookStatus: "resolved",
99
- edited: true,
100
- })),
101
- ...unseenMinimizedThreads.map((t) => ({ ...t, firstLookStatus: "minimized" })),
102
- ...editedMinimizedThreads.map((t) => ({
103
- ...t,
104
- firstLookStatus: "minimized",
105
- edited: true,
106
- })),
107
- ];
108
45
  const firstLookComments = [
109
46
  ...unseenMinimizedComments.map((c) => ({ ...c, firstLookStatus: "minimized" })),
110
47
  ...editedMinimizedComments.map((c) => ({
@@ -115,19 +52,21 @@ export async function runResolveFetch(opts) {
115
52
  ];
116
53
  // Mark new and edited items as seen (best-effort — markSeen never throws).
117
54
  await Promise.allSettled([
118
- ...firstLookThreads.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
55
+ ...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
119
56
  ...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
120
57
  ...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
58
+ ...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
59
+ ...reviewSummaryVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
121
60
  ]);
122
61
  const result = {
123
62
  prNumber,
124
63
  actionableThreads,
125
- resolutionOnlyThreads,
126
- firstLookThreads,
64
+ resolutionOnlyThreads: threadVisibility.resolutionOnlyThreads,
65
+ firstLookThreads: threadVisibility.firstLookThreads,
127
66
  actionableComments: visibleCommentClassification.actionable,
128
67
  firstLookComments,
129
- changesRequestedReviews: data.changesRequestedReviews,
130
- reviewSummaries: cfg.resolve.fetchReviewSummaries ? data.reviewSummaries : [],
68
+ changesRequestedReviews: changesRequestedReviewVisibility.visible,
69
+ reviewSummaries: reviewSummaryVisibility.visible,
131
70
  commitSuggestionsEnabled: cfg.actions.commitSuggestions,
132
71
  };
133
72
  return { ...result, instructions: buildFetchInstructions(prNumber, result) };
@@ -102,8 +102,10 @@ function makeComment(overrides = {}) {
102
102
  export function registerHooks() {
103
103
  beforeEach(() => {
104
104
  vi.clearAllMocks();
105
+ mockFetchPrBatch.mockResolvedValue({ data: makeBatchData() });
105
106
  mockAutoResolveOutdated.mockResolvedValue({ resolved: [], errors: [] });
106
107
  mockApplyResolveOptions.mockResolvedValue({
108
+ repliedThreads: [],
107
109
  resolvedThreads: [],
108
110
  minimizedComments: [],
109
111
  dismissedReviews: [],
@@ -1,7 +1,7 @@
1
1
  export const SHEPHERD_JOURNAL_SECTION = "## Shepherd Journal";
2
2
  export const SHEPHERD_JOURNAL_SECTION_PATTERN = /^##\s+Shepherd\s+Journal$/;
3
3
  export const SHEPHERD_JOURNAL_APPEND_HINT = "If this section already exists, append your entries under it instead of creating a duplicate heading.";
4
- export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review the bodies shown under `## Review summaries (first look)` — you are seeing these for the first time. Any IDs eligible for minimization are already included in the resolve command's `--minimize-comment-ids`; if any warrants a Shepherd Journal note, append it before running resolve.";
4
+ export const SHEPHERD_JOURNAL_FIRST_LOOK_GUIDANCE = "Review the bodies shown under `## Review summaries (first look)` — you are seeing these for the first time. Eligible non-human IDs, when present, are already included in the resolve command's `--minimize-comment-ids`; if any warrants a Shepherd Journal note, append it before running resolve.";
5
5
  export function buildShepherdJournalInstruction(prNumber, itemReferenceGuidance) {
6
6
  return [
7
7
  `For any large decisions or rejections you made this iteration, add or update a \`${SHEPHERD_JOURNAL_SECTION}\` section in the PR description (\`gh pr edit ${prNumber} --body …\`) summarizing each decision.`,
@@ -0,0 +1,14 @@
1
+ export function isBotLogin(login) {
2
+ return (login ?? "").toLowerCase().includes("[bot]");
3
+ }
4
+ export function normalizeAuthorType(typeName, login) {
5
+ if (isBotLogin(login))
6
+ return "Bot";
7
+ if (typeName === "User" || typeName === "Bot")
8
+ return typeName;
9
+ return "Unknown";
10
+ }
11
+ export function isHumanAuthor(author) {
12
+ const login = author.author ?? author.login ?? "";
13
+ return author.authorType === "User" && !isBotLogin(login);
14
+ }
@@ -1,12 +1,15 @@
1
- export function shouldMinimizeAuthor(authorType, policy) {
1
+ import { isHumanAuthor } from "./authors.mjs";
2
+ export function shouldMinimizeAuthor(authorType, policy, author) {
3
+ if (isHumanAuthor({ author, authorType }))
4
+ return false;
2
5
  switch (policy) {
3
6
  case undefined:
4
7
  case "all":
5
- return true;
8
+ return authorType !== "User";
6
9
  case "bots":
7
10
  return authorType === "Bot";
8
11
  case "users":
9
- return authorType === "User";
12
+ return false;
10
13
  case "none":
11
14
  return false;
12
15
  default:
@@ -1,7 +1,11 @@
1
1
  export function setPendingOps(result, ops) {
2
2
  const resolved = new Set(result.resolvedThreads);
3
+ const replied = new Set(result.repliedThreads);
3
4
  const minimized = new Set(result.minimizedComments);
4
5
  const dismissed = new Set(result.dismissedReviews);
6
+ const unrepliedThreads = ops
7
+ .filter((op) => op.kind === "p" && !replied.has(op.id))
8
+ .map((op) => op.id);
5
9
  const unresolvedThreads = ops
6
10
  .filter((op) => op.kind === "r" && !resolved.has(op.id))
7
11
  .map((op) => op.id);
@@ -11,6 +15,8 @@ export function setPendingOps(result, ops) {
11
15
  const undismissedReviews = ops
12
16
  .filter((op) => op.kind === "d" && !dismissed.has(op.id))
13
17
  .map((op) => op.id);
18
+ if (unrepliedThreads.length > 0)
19
+ result.unrepliedThreads = unrepliedThreads;
14
20
  if (unresolvedThreads.length > 0)
15
21
  result.unresolvedThreads = unresolvedThreads;
16
22
  if (unminimizedComments.length > 0)
@@ -25,12 +25,14 @@ function dismissReviewNonDismissibleMessage(id) {
25
25
  }
26
26
  export async function applyResolveOptions(pr, repo, opts) {
27
27
  const resolveThreadIds = dedupeIds(opts.resolveThreadIds ?? []);
28
+ const replyThreadIds = dedupeIds(opts.replyThreadIds ?? []);
28
29
  const minimizeCommentIds = opts.minimizeCommentIds ?? [];
29
30
  const dismissReviewIds = dedupeIds(opts.dismissReviewIds ?? []);
30
31
  const minimizeCommentIdSet = new Set(minimizeCommentIds);
31
32
  const filteredDismissReviewIds = dismissReviewIds.filter((id) => !minimizeCommentIdSet.has(id));
32
33
  const overlappingDismissIds = dismissReviewIds.filter((id) => minimizeCommentIdSet.has(id));
33
34
  const result = {
35
+ repliedThreads: [],
34
36
  resolvedThreads: [],
35
37
  minimizedComments: [],
36
38
  dismissedReviews: [],
@@ -42,31 +44,35 @@ export async function applyResolveOptions(pr, repo, opts) {
42
44
  result.skippedDismissals.push(id);
43
45
  }
44
46
  }
45
- if (filteredDismissReviewIds.length > 0 && !opts.dismissMessage) {
46
- throw new Error("--message is required when dismissing reviews");
47
+ if ((filteredDismissReviewIds.length > 0 || replyThreadIds.length > 0) && !opts.dismissMessage) {
48
+ throw new Error("--message is required when replying to threads or dismissing reviews");
47
49
  }
48
50
  if (opts.requireSha) {
49
51
  // Verify GitHub received the commit before resolving — prevents auto-merge
50
52
  // before reviewers see the fix.
51
53
  await waitForSha(pr, repo, opts.requireSha);
52
54
  }
53
- await bulkApply(resolveThreadIds, minimizeCommentIds, filteredDismissReviewIds, opts.dismissMessage ?? "", result);
55
+ await bulkApply(replyThreadIds, resolveThreadIds, minimizeCommentIds, filteredDismissReviewIds, opts.dismissMessage ?? "", result);
54
56
  return result;
55
57
  }
56
58
  export async function autoResolveOutdated(threadIds) {
57
59
  const result = {
60
+ repliedThreads: [],
58
61
  resolvedThreads: [],
59
62
  minimizedComments: [],
60
63
  dismissedReviews: [],
61
64
  errors: [],
62
65
  };
63
- await bulkApply(threadIds, [], [], "", result);
66
+ await bulkApply([], threadIds, [], [], "", result);
64
67
  return { resolved: result.resolvedThreads, errors: result.errors };
65
68
  }
66
69
  // Keep mutation batches small so rate-limit stops leave a precise pending list.
67
70
  const BULK_CHUNK_SIZE = 10;
68
- function buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage) {
71
+ function buildBulkMutation(replyIds, resolveIds, minimizeIds, dismissIds, dismissMessage) {
69
72
  const ops = [];
73
+ for (let i = 0; i < replyIds.length; i++) {
74
+ ops.push(` p${i}: addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: ${JSON.stringify(replyIds[i])}, body: ${JSON.stringify(dismissMessage)} }) { comment { id } }`);
75
+ }
70
76
  for (let i = 0; i < resolveIds.length; i++) {
71
77
  ops.push(` r${i}: resolveReviewThread(input: { threadId: ${JSON.stringify(resolveIds[i])} }) { thread { isResolved } }`);
72
78
  }
@@ -78,8 +84,9 @@ function buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage)
78
84
  }
79
85
  return `mutation BulkApply {\n${ops.join("\n")}\n}`;
80
86
  }
81
- async function bulkApply(resolveIds, minimizeIds, dismissIds, dismissMessage, result) {
87
+ async function bulkApply(replyIds, resolveIds, minimizeIds, dismissIds, dismissMessage, result) {
82
88
  const allOps = [
89
+ ...replyIds.map((id) => ({ kind: "p", id })),
83
90
  ...resolveIds.map((id) => ({ kind: "r", id })),
84
91
  ...minimizeIds.map((id) => ({ kind: "m", id })),
85
92
  ...dismissIds.map((id) => ({ kind: "d", id })),
@@ -87,17 +94,15 @@ async function bulkApply(resolveIds, minimizeIds, dismissIds, dismissMessage, re
87
94
  for (let i = 0; i < allOps.length; i += BULK_CHUNK_SIZE) {
88
95
  const chunk = allOps.slice(i, i + BULK_CHUNK_SIZE);
89
96
  // eslint-disable-next-line no-await-in-loop
90
- const stopped = await bulkApplyChunk(chunk.filter((o) => o.kind === "r").map((o) => o.id), chunk.filter((o) => o.kind === "m").map((o) => o.id), chunk.filter((o) => o.kind === "d").map((o) => o.id), dismissMessage, result, i + BULK_CHUNK_SIZE < allOps.length);
97
+ const stopped = await bulkApplyChunk(chunk.filter((o) => o.kind === "r").map((o) => o.id), chunk.filter((o) => o.kind === "p").map((o) => o.id), chunk.filter((o) => o.kind === "m").map((o) => o.id), chunk.filter((o) => o.kind === "d").map((o) => o.id), dismissMessage, result, i + BULK_CHUNK_SIZE < allOps.length);
91
98
  if (stopped) {
92
99
  setPendingOps(result, allOps.slice(i));
93
100
  return;
94
101
  }
95
102
  }
96
103
  }
97
- async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessage, result, hasPendingAfter) {
98
- if (resolveIds.length === 0 && minimizeIds.length === 0 && dismissIds.length === 0)
99
- return false;
100
- const doc = buildBulkMutation(resolveIds, minimizeIds, dismissIds, dismissMessage);
104
+ async function bulkApplyChunk(resolveIds, replyIds, minimizeIds, dismissIds, dismissMessage, result, hasPendingAfter) {
105
+ const doc = buildBulkMutation(replyIds, resolveIds, minimizeIds, dismissIds, dismissMessage);
101
106
  let data = {};
102
107
  let graphQlErrors = [];
103
108
  let rateLimitStop;
@@ -122,6 +127,8 @@ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessag
122
127
  result.rateLimit = stop;
123
128
  return true;
124
129
  }
130
+ for (const id of replyIds)
131
+ result.errors.push(`${id}: ${msg}`);
125
132
  for (const id of resolveIds)
126
133
  result.errors.push(`${id}: ${msg}`);
127
134
  for (const id of minimizeIds)
@@ -130,6 +137,16 @@ async function bulkApplyChunk(resolveIds, minimizeIds, dismissIds, dismissMessag
130
137
  result.errors.push(`${id}: ${msg}`);
131
138
  return false;
132
139
  }
140
+ for (let i = 0; i < replyIds.length; i++) {
141
+ const id = replyIds[i];
142
+ if (id === undefined)
143
+ continue;
144
+ const p = data[`p${i}`];
145
+ if (p?.comment?.id)
146
+ result.repliedThreads.push(id);
147
+ else if (!suppressCurrentChunkErrors)
148
+ result.errors.push(`${id}: reply returned null or comment not created`);
149
+ }
133
150
  for (let i = 0; i < resolveIds.length; i++) {
134
151
  const r = data[`r${i}`];
135
152
  if (r?.thread?.isResolved === true)
@@ -15,9 +15,14 @@ const REPO = { owner: "owner", name: "repo" };
15
15
  function makeBulkResponse(doc) {
16
16
  const str = typeof doc === "string" ? doc : "";
17
17
  const data = {};
18
- for (const [, alias] of str.matchAll(/^\s+([a-z]\d+):/gm)) {
18
+ for (const match of str.matchAll(/^\s+([a-z]\d+):/gm)) {
19
+ const alias = match[1];
20
+ if (alias === undefined)
21
+ continue;
19
22
  if (alias.startsWith("r"))
20
23
  data[alias] = { thread: { isResolved: true } };
24
+ else if (alias.startsWith("p"))
25
+ data[alias] = { comment: { id: `${alias}-comment` } };
21
26
  else if (alias.startsWith("m"))
22
27
  data[alias] = { minimizedComment: { isMinimized: true } };
23
28
  else if (alias.startsWith("d"))
@@ -0,0 +1,14 @@
1
+ import { classifyItem } from "../state/seen-comments.mjs";
2
+ export function classifyReviewsForDisplay(reviews, seenMap) {
3
+ const visible = [];
4
+ const toMarkSeen = [];
5
+ for (const review of reviews) {
6
+ const cls = classifyItem(review.id, review.body, seenMap);
7
+ if (cls === "unchanged")
8
+ continue;
9
+ const rendered = cls === "edited" ? { ...review, edited: true } : review;
10
+ visible.push(rendered);
11
+ toMarkSeen.push(review);
12
+ }
13
+ return { visible, toMarkSeen };
14
+ }