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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +86 -125
- package/bin/cli/args.mjs +2 -0
- package/bin/cli/fix-formatter.mjs +2 -1
- package/bin/cli/formatters.mjs +3 -45
- package/bin/cli/handlers.mjs +22 -1
- package/bin/cli/help-command-pages.mjs +27 -2
- package/bin/cli/help-top-page.mjs +2 -0
- package/bin/cli/list-formatters.mjs +3 -1
- package/bin/cli/mark-files-as-viewed-flags.mjs +34 -0
- package/bin/cli/mark-files-as-viewed-formatter.mjs +52 -0
- package/bin/cli/mutate-formatter.mjs +50 -0
- package/bin/cli-parser.mjs +11 -2
- package/bin/cli-parser.test-support.mjs +6 -1
- package/bin/commands/check.mjs +20 -55
- package/bin/commands/commit-suggestion-instruction.mjs +1 -1
- package/bin/commands/iterate/classify.mjs +25 -30
- package/bin/commands/iterate/fix-code.mjs +21 -17
- package/bin/commands/iterate/render.mjs +3 -3
- package/bin/commands/iterate-thread-test-support.mjs +18 -0
- package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +7 -3
- package/bin/commands/mark-files-as-viewed.mjs +220 -0
- package/bin/commands/resolve-mutate.mjs +41 -4
- package/bin/commands/resolve.mjs +15 -76
- package/bin/commands/resolve.test-support.mjs +2 -0
- package/bin/commands/shepherd-journal.mjs +1 -1
- package/bin/comments/authors.mjs +14 -0
- package/bin/comments/minimize-policy.mjs +6 -3
- package/bin/comments/pending-ops.mjs +6 -0
- package/bin/comments/resolve.mjs +28 -11
- package/bin/comments/resolve.test-support.mjs +6 -1
- package/bin/comments/review-visibility.mjs +14 -0
- package/bin/comments/thread-visibility.mjs +60 -0
- package/bin/comments/visible-comments.mjs +1 -1
- package/bin/github/batch-parser-helpers.mjs +3 -4
- package/bin/github/batch-parsers.mjs +6 -6
- package/bin/reporters/agent.mjs +1 -0
- package/bin/threads/transcript.mjs +6 -6
- package/package.json +1 -1
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- 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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
}
|
package/bin/commands/resolve.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
...
|
|
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:
|
|
130
|
-
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.
|
|
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
|
-
|
|
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
|
|
8
|
+
return authorType !== "User";
|
|
6
9
|
case "bots":
|
|
7
10
|
return authorType === "Bot";
|
|
8
11
|
case "users":
|
|
9
|
-
return
|
|
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)
|
package/bin/comments/resolve.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|