pr-shepherd 0.22.0 → 0.24.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 +87 -125
- package/bin/cli/args.mjs +2 -0
- package/bin/cli/fix-formatter.mjs +3 -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/iterate-instructions.mjs +1 -1
- package/bin/cli/list-formatters.mjs +5 -2
- 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/commands/check.mjs +25 -56
- package/bin/commands/commit-suggestion-instruction.mjs +1 -1
- package/bin/commands/iterate/classify.mjs +32 -31
- package/bin/commands/iterate/fix-code.mjs +23 -19
- package/bin/commands/iterate/index.mjs +4 -1
- package/bin/commands/iterate/render.mjs +6 -3
- package/bin/commands/iterate/stall.mjs +1 -1
- package/bin/commands/mark-files-as-viewed.mjs +220 -0
- package/bin/commands/resolve-instructions.mjs +5 -2
- package/bin/commands/resolve-mutate.mjs +49 -4
- package/bin/commands/resolve.mjs +21 -78
- package/bin/commands/shepherd-journal.mjs +1 -1
- package/bin/comments/authors.mjs +32 -0
- package/bin/comments/minimize-policy.mjs +8 -4
- package/bin/comments/pending-ops.mjs +6 -0
- package/bin/comments/resolve.mjs +28 -11
- package/bin/comments/review-thread-markers.mjs +18 -0
- package/bin/comments/review-visibility.mjs +14 -0
- package/bin/comments/thread-visibility.mjs +60 -0
- package/bin/comments/visible-comments.mjs +2 -2
- package/bin/config/load.mjs +7 -0
- package/bin/config.json +14 -0
- package/bin/github/batch-parser-helpers.mjs +3 -4
- package/bin/github/batch-parsers.mjs +8 -6
- package/bin/github/client.mjs +1 -2
- package/bin/github/gql/batch-pr.gql +3 -0
- package/bin/reporters/agent.mjs +2 -0
- package/bin/state/seen-comments.mjs +40 -4
- package/bin/threads/transcript.mjs +6 -6
- package/package.json +8 -4
- package/plugins/pr-shepherd/.codex-plugin/plugin.json +1 -1
- package/plugins/pr-shepherd/skills/mark-files-as-viewed/SKILL.md +31 -0
- package/bin/checks/triage.test-support.mjs +0 -60
- package/bin/cli/iterate-lean.test-support.mjs +0 -3
- package/bin/cli-parser.clean.test-support.mjs +0 -44
- package/bin/cli-parser.commit-suggestion.test-support.mjs +0 -65
- package/bin/cli-parser.iterate-fix.test-support.mjs +0 -43
- package/bin/cli-parser.iterate-fixtures.mjs +0 -75
- package/bin/cli-parser.iterate.test-support.mjs +0 -44
- package/bin/cli-parser.test-support.mjs +0 -42
- package/bin/commands/check.test-support.mjs +0 -147
- package/bin/commands/clean.test-support.mjs +0 -47
- package/bin/commands/commit-suggestion.apply.test-support.mjs +0 -87
- package/bin/commands/commit-suggestion.test-support.mjs +0 -112
- package/bin/commands/iterate-stall.test-support.mjs +0 -24
- package/bin/commands/iterate-test-support.mjs +0 -150
- package/bin/commands/iterate.fix-code-in-progress.test-support.mjs +0 -123
- package/bin/commands/poll.test-support.mjs +0 -77
- package/bin/commands/resolve.test-support.mjs +0 -114
- package/bin/commands/shepherd-journal.test-support.mjs +0 -7
- package/bin/comments/outdated.mjs +0 -15
- package/bin/comments/resolve.test-support.mjs +0 -39
- package/bin/github/batch-parsers.test-support.mjs +0 -66
- package/bin/github/batch.test-support.mjs +0 -66
- package/bin/github/client.test-support.mjs +0 -55
- package/bin/github/http.test-support.mjs +0 -51
- package/bin/state/seen-comments.test-support.mjs +0 -19
- package/bin/suggestions/patch.test-support.mjs +0 -2
|
@@ -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
|
+
}
|
|
@@ -51,8 +51,11 @@ export function buildFetchInstructions(prNumber, result) {
|
|
|
51
51
|
: reviewSummaries.length > 0
|
|
52
52
|
? ` Review-summary IDs (\`PRR_…\` from \`## Review summaries\`) go into \`--minimize-comment-ids\`.`
|
|
53
53
|
: "";
|
|
54
|
-
const resolveCommand = `${buildPrShepherdCommand(["resolve", String(prNumber)]).text} [--resolve-thread-ids <ids>] [--minimize-comment-ids <ids>] [--dismiss-review-ids <ids> --message "<reason>"]`;
|
|
55
|
-
|
|
54
|
+
const resolveCommand = `${buildPrShepherdCommand(["resolve", String(prNumber)]).text} [--reply-thread-ids <ids>] [--resolve-thread-ids <ids>] [--minimize-comment-ids <ids>] [--dismiss-review-ids <ids>] [--message "<reason>"]`;
|
|
55
|
+
if (actionableThreads.length > 0 || resolutionOnlyThreads.length > 0) {
|
|
56
|
+
instructions.push(`Do not reply to your own thread comments. If the latest visible comment in a thread is your own prior Shepherd reply, leave that thread out of \`--reply-thread-ids\`.`);
|
|
57
|
+
}
|
|
58
|
+
instructions.push(`Run \`${resolveCommand}\` with only the non-empty flag subsets. Skip the command entirely if all ID lists are empty.${requireShaHint}${dismissNote}`);
|
|
56
59
|
instructions.push(buildShepherdJournalInstruction(prNumber, SHEPHERD_JOURNAL_REFERENCE_GUIDANCE_THREADS_AND_COMMENTS_IN_ITEMS));
|
|
57
60
|
instructions.push(`Report: echo the CLI's mutation output, then one line per Acknowledged item: \`Acknowledged <id> (@<author>): <reason>\`. If any fetched item was neither resolved nor acknowledged, stop and escalate: "<N> item(s) fetched but not acted on or acknowledged — need human direction before closing".`);
|
|
58
61
|
return instructions;
|
|
@@ -1,16 +1,61 @@
|
|
|
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 { loadConfig } from "../config/load.mjs";
|
|
5
|
+
import { isConfiguredBotAuthor, isHumanAuthor, normalizeBotUsernames, } from "../comments/authors.mjs";
|
|
6
|
+
import { markReplySeen } from "../state/seen-comments.mjs";
|
|
7
|
+
import { threadTranscriptBody } from "../threads/transcript.mjs";
|
|
3
8
|
export async function runResolveMutate(opts) {
|
|
4
9
|
const repo = await getRepoInfo();
|
|
5
10
|
const prNumber = opts.prNumber ?? (await getCurrentPrNumber());
|
|
6
11
|
if (prNumber === null) {
|
|
7
12
|
throw new Error("No open PR found for current branch. Pass a PR number explicitly.");
|
|
8
13
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
const { data } = await fetchPrBatch(prNumber, repo, { paginateApprovedReviews: true });
|
|
15
|
+
const config = loadConfig();
|
|
16
|
+
const botUsernames = normalizeBotUsernames(config.botUsernames);
|
|
17
|
+
const threadById = new Map(data.reviewThreads.map((t) => [t.id, t]));
|
|
18
|
+
const humanThreadIds = new Set(data.reviewThreads
|
|
19
|
+
.filter((t) => isHumanAuthor(t) && !isConfiguredBotAuthor(t, botUsernames))
|
|
20
|
+
.map((t) => t.id));
|
|
21
|
+
const humanCommentIds = new Set(data.comments
|
|
22
|
+
.filter((c) => isHumanAuthor(c) && !isConfiguredBotAuthor(c, botUsernames))
|
|
23
|
+
.map((c) => c.id));
|
|
24
|
+
const humanReviewIds = new Set([...data.reviewSummaries, ...data.approvedReviews, ...data.changesRequestedReviews]
|
|
25
|
+
.filter((r) => isHumanAuthor(r) && !isConfiguredBotAuthor(r, botUsernames))
|
|
26
|
+
.map((r) => r.id));
|
|
27
|
+
const resolveThreadIds = (opts.resolveThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
|
|
28
|
+
const skippedHumanResolves = (opts.resolveThreadIds ?? []).filter((id) => humanThreadIds.has(id));
|
|
29
|
+
const replyThreadIds = opts.replyThreadIds?.filter((id) => humanThreadIds.has(id));
|
|
30
|
+
const skippedNonHumanReplies = (opts.replyThreadIds ?? []).filter((id) => !humanThreadIds.has(id));
|
|
31
|
+
const minimizeCommentIds = (opts.minimizeCommentIds ?? []).filter((id) => !humanCommentIds.has(id) && !humanReviewIds.has(id));
|
|
32
|
+
const skippedHumanMinimizes = (opts.minimizeCommentIds ?? []).filter((id) => humanCommentIds.has(id) || humanReviewIds.has(id));
|
|
33
|
+
const dismissReviewIds = (opts.dismissReviewIds ?? []).filter((id) => !humanReviewIds.has(id));
|
|
34
|
+
const skippedHumanDismissals = (opts.dismissReviewIds ?? []).filter((id) => humanReviewIds.has(id));
|
|
35
|
+
const result = await applyResolveOptions(prNumber, repo, {
|
|
36
|
+
resolveThreadIds,
|
|
37
|
+
replyThreadIds,
|
|
38
|
+
minimizeCommentIds,
|
|
39
|
+
dismissReviewIds,
|
|
13
40
|
dismissMessage: opts.dismissMessage,
|
|
14
41
|
requireSha: opts.requireSha,
|
|
15
42
|
});
|
|
43
|
+
if (skippedHumanResolves.length > 0)
|
|
44
|
+
result.skippedHumanResolves = skippedHumanResolves;
|
|
45
|
+
if (skippedHumanMinimizes.length > 0)
|
|
46
|
+
result.skippedHumanMinimizes = skippedHumanMinimizes;
|
|
47
|
+
if (skippedHumanDismissals.length > 0)
|
|
48
|
+
result.skippedHumanDismissals = skippedHumanDismissals;
|
|
49
|
+
if (skippedNonHumanReplies.length > 0)
|
|
50
|
+
result.skippedNonHumanReplies = skippedNonHumanReplies;
|
|
51
|
+
if (opts.dismissMessage) {
|
|
52
|
+
await Promise.all(result.repliedThreads.map((id) => {
|
|
53
|
+
const thread = threadById.get(id);
|
|
54
|
+
if (!thread)
|
|
55
|
+
return Promise.resolve();
|
|
56
|
+
const previousBody = threadTranscriptBody(thread);
|
|
57
|
+
return markReplySeen({ owner: repo.owner, repo: repo.name, pr: prNumber }, id, previousBody, threadTranscriptBody(thread, [opts.dismissMessage]), opts.dismissMessage);
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
16
61
|
}
|
package/bin/commands/resolve.mjs
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
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
|
+
import { markReviewInlineThreadMarkers } from "../comments/review-thread-markers.mjs";
|
|
12
|
+
import { normalizeBotUsernames } from "../comments/authors.mjs";
|
|
11
13
|
export { runResolveMutate } from "./resolve-mutate.mjs";
|
|
12
14
|
export async function runResolveFetch(opts) {
|
|
13
15
|
const repo = await getRepoInfo();
|
|
@@ -17,39 +19,11 @@ export async function runResolveFetch(opts) {
|
|
|
17
19
|
}
|
|
18
20
|
const { data } = await fetchPrBatch(prNumber, repo);
|
|
19
21
|
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
22
|
const minimizedCommentCandidates = data.comments.filter((c) => c.isMinimized);
|
|
25
23
|
const seenMap = await loadSeenMap(stateKey);
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
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
|
-
}
|
|
24
|
+
const cfg = loadConfig();
|
|
25
|
+
const botUsernames = normalizeBotUsernames(cfg.botUsernames);
|
|
26
|
+
const threadVisibility = classifyThreadVisibility(data.reviewThreads, seenMap, botUsernames);
|
|
53
27
|
const unseenMinimizedComments = [];
|
|
54
28
|
const editedMinimizedComments = [];
|
|
55
29
|
for (const c of minimizedCommentCandidates) {
|
|
@@ -59,52 +33,18 @@ export async function runResolveFetch(opts) {
|
|
|
59
33
|
else if (cls === "edited")
|
|
60
34
|
editedMinimizedComments.push(c);
|
|
61
35
|
}
|
|
62
|
-
const
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
const cfg = loadConfig();
|
|
75
|
-
const visibleCommentClassification = classifyVisibleComments(data.comments, seenMap, cfg.iterate?.minimizeComments);
|
|
76
|
-
const actionableThreads = activeThreads.map(({ isResolved: _r, isOutdated: _o, ...rest }) => {
|
|
36
|
+
const visibleCommentClassification = classifyVisibleComments(data.comments, seenMap, cfg.iterate?.minimizeComments, botUsernames);
|
|
37
|
+
const changesRequestedReviewVisibility = classifyReviewsForDisplay(data.changesRequestedReviews, seenMap);
|
|
38
|
+
const reviewSummaryVisibility = cfg.resolve.fetchReviewSummaries
|
|
39
|
+
? classifyReviewsForDisplay(data.reviewSummaries, seenMap)
|
|
40
|
+
: { visible: [], toMarkSeen: [] };
|
|
41
|
+
const actionableThreads = threadVisibility.activeThreads.map(({ isResolved: _r, isOutdated: _o, ...rest }) => {
|
|
77
42
|
const thread = rest;
|
|
78
43
|
const suggestion = extractSuggestion(rest);
|
|
79
44
|
if (suggestion)
|
|
80
45
|
thread.suggestion = suggestion;
|
|
81
46
|
return thread;
|
|
82
47
|
});
|
|
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
48
|
const firstLookComments = [
|
|
109
49
|
...unseenMinimizedComments.map((c) => ({ ...c, firstLookStatus: "minimized" })),
|
|
110
50
|
...editedMinimizedComments.map((c) => ({
|
|
@@ -115,19 +55,22 @@ export async function runResolveFetch(opts) {
|
|
|
115
55
|
];
|
|
116
56
|
// Mark new and edited items as seen (best-effort — markSeen never throws).
|
|
117
57
|
await Promise.allSettled([
|
|
118
|
-
...
|
|
58
|
+
...threadVisibility.toMarkSeen.map((t) => markSeen(stateKey, t.id, threadTranscriptBody(t))),
|
|
119
59
|
...firstLookComments.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
120
60
|
...visibleCommentClassification.toMarkSeen.map((c) => markSeen(stateKey, c.id, c.body)),
|
|
61
|
+
...changesRequestedReviewVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
62
|
+
...reviewSummaryVisibility.toMarkSeen.map((r) => markSeen(stateKey, r.id, r.body)),
|
|
121
63
|
]);
|
|
64
|
+
await markReviewInlineThreadMarkers(stateKey, data.reviewThreads);
|
|
122
65
|
const result = {
|
|
123
66
|
prNumber,
|
|
124
67
|
actionableThreads,
|
|
125
|
-
resolutionOnlyThreads,
|
|
126
|
-
firstLookThreads,
|
|
68
|
+
resolutionOnlyThreads: threadVisibility.resolutionOnlyThreads,
|
|
69
|
+
firstLookThreads: threadVisibility.firstLookThreads,
|
|
127
70
|
actionableComments: visibleCommentClassification.actionable,
|
|
128
71
|
firstLookComments,
|
|
129
|
-
changesRequestedReviews:
|
|
130
|
-
reviewSummaries:
|
|
72
|
+
changesRequestedReviews: changesRequestedReviewVisibility.visible,
|
|
73
|
+
reviewSummaries: reviewSummaryVisibility.visible,
|
|
131
74
|
commitSuggestionsEnabled: cfg.actions.commitSuggestions,
|
|
132
75
|
};
|
|
133
76
|
return { ...result, instructions: buildFetchInstructions(prNumber, result) };
|
|
@@ -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,32 @@
|
|
|
1
|
+
function isBotLogin(login) {
|
|
2
|
+
return (login ?? "").toLowerCase().includes("[bot]");
|
|
3
|
+
}
|
|
4
|
+
function normalizeBotUsername(login) {
|
|
5
|
+
return (login ?? "")
|
|
6
|
+
.trim()
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.replace(/\[bot\]$/i, "");
|
|
9
|
+
}
|
|
10
|
+
export function normalizeBotUsernames(botUsernames = []) {
|
|
11
|
+
return new Set((botUsernames ?? []).map((name) => normalizeBotUsername(name)));
|
|
12
|
+
}
|
|
13
|
+
export function normalizeAuthorType(typeName, login) {
|
|
14
|
+
if (isBotLogin(login))
|
|
15
|
+
return "Bot";
|
|
16
|
+
if (typeName === "User" || typeName === "Bot")
|
|
17
|
+
return typeName;
|
|
18
|
+
return "Unknown";
|
|
19
|
+
}
|
|
20
|
+
export function isHumanAuthor(author) {
|
|
21
|
+
const login = author.author ?? author.login ?? "";
|
|
22
|
+
return author.authorType === "User" && !isBotLogin(login);
|
|
23
|
+
}
|
|
24
|
+
export function isConfiguredBotAuthor(author, botUsernames = new Set()) {
|
|
25
|
+
const login = author.author ?? author.login ?? "";
|
|
26
|
+
if (author.authorType === "Bot" || isBotLogin(login))
|
|
27
|
+
return true;
|
|
28
|
+
const normalized = normalizeBotUsername(login);
|
|
29
|
+
if (normalized === "")
|
|
30
|
+
return false;
|
|
31
|
+
return botUsernames.has(normalized);
|
|
32
|
+
}
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
|
|
1
|
+
import { isConfiguredBotAuthor, isHumanAuthor } from "./authors.mjs";
|
|
2
|
+
export function shouldMinimizeAuthor(authorType, policy, author, botUsernames = new Set()) {
|
|
3
|
+
const isConfiguredBot = isConfiguredBotAuthor({ author, authorType }, botUsernames);
|
|
4
|
+
if (isHumanAuthor({ author, authorType }) && !isConfiguredBot)
|
|
5
|
+
return false;
|
|
2
6
|
switch (policy) {
|
|
3
7
|
case undefined:
|
|
4
8
|
case "all":
|
|
5
|
-
return
|
|
9
|
+
return authorType !== "User" || isConfiguredBot;
|
|
6
10
|
case "bots":
|
|
7
|
-
return
|
|
11
|
+
return isConfiguredBot;
|
|
8
12
|
case "users":
|
|
9
|
-
return
|
|
13
|
+
return false;
|
|
10
14
|
case "none":
|
|
11
15
|
return false;
|
|
12
16
|
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)
|