@in-the-loop-labs/pair-review 3.6.0 → 3.7.1

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 (67) hide show
  1. package/README.md +4 -0
  2. package/package.json +20 -15
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
  6. package/public/css/analysis-config.css +1807 -0
  7. package/public/css/pr.css +17 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +89 -44
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ChatPanel.js +11 -1
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/SuggestionNavigator.js +55 -10
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +58 -8
  18. package/public/js/modules/suggestion-manager.js +25 -1
  19. package/public/js/modules/tour-renderer.js +45 -5
  20. package/public/js/pr.js +703 -171
  21. package/public/js/repo-links.js +328 -0
  22. package/public/js/utils/provider-model.js +88 -0
  23. package/public/js/utils/scroll-into-view.js +164 -0
  24. package/public/js/utils/storage-keys.js +50 -0
  25. package/public/local.html +10 -0
  26. package/public/pr.html +10 -0
  27. package/public/repo-settings.html +1 -0
  28. package/public/setup.html +2 -0
  29. package/src/ai/analyzer.js +125 -18
  30. package/src/ai/claude-provider.js +31 -3
  31. package/src/config.js +664 -10
  32. package/src/external/github-adapter.js +114 -25
  33. package/src/git/base-branch.js +11 -4
  34. package/src/github/client.js +482 -588
  35. package/src/github/errors.js +55 -0
  36. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  37. package/src/github/impl/graphql/pending-review.js +153 -0
  38. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  39. package/src/github/impl/graphql/stack-walker.js +210 -0
  40. package/src/github/impl/host/pending-review-comments.js +338 -0
  41. package/src/github/impl/rest/pending-review.js +251 -0
  42. package/src/github/impl/rest/review-lifecycle.js +226 -0
  43. package/src/github/impl/rest/stack-walker.js +309 -0
  44. package/src/github/operations/pending-review-comments.js +79 -0
  45. package/src/github/operations/pending-review.js +89 -0
  46. package/src/github/operations/review-lifecycle.js +126 -0
  47. package/src/github/operations/stack-walker.js +87 -0
  48. package/src/github/parser.js +230 -4
  49. package/src/github/stack-walker.js +14 -189
  50. package/src/links/repo-links.js +230 -0
  51. package/src/local-review.js +13 -4
  52. package/src/main.js +136 -32
  53. package/src/routes/analyses.js +30 -7
  54. package/src/routes/bulk-analysis-configs.js +295 -0
  55. package/src/routes/config.js +102 -2
  56. package/src/routes/external-comments.js +20 -10
  57. package/src/routes/github-collections.js +3 -1
  58. package/src/routes/local.js +101 -11
  59. package/src/routes/mcp.js +47 -4
  60. package/src/routes/pr.js +298 -68
  61. package/src/routes/setup.js +8 -3
  62. package/src/routes/stack-analysis.js +33 -9
  63. package/src/routes/worktrees.js +3 -2
  64. package/src/server.js +2 -0
  65. package/src/setup/pr-setup.js +37 -11
  66. package/src/setup/stack-setup.js +13 -3
  67. package/src/single-port.js +6 -3
@@ -0,0 +1,210 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ const logger = require('../../../utils/logger');
3
+
4
+ /**
5
+ * GraphQL implementation of the stack-walker area.
6
+ *
7
+ * Walks a GitHub PR stack by following the branch chain via GraphQL.
8
+ * Starting from a given PR, walks up toward trunk (following baseRefName)
9
+ * and down toward the tip (following headRefName) to discover the full
10
+ * stack. Returns the same ordered array (trunk -> ... -> tip) that
11
+ * `walkPRStack` historically returned.
12
+ */
13
+
14
+ const DEFAULT_TRUNK_BRANCHES = ['main', 'master', 'develop'];
15
+ const MAX_WALK_DEPTH = 20;
16
+
17
+ const FETCH_PR_QUERY = `
18
+ query($owner: String!, $repo: String!, $number: Int!) {
19
+ repository(owner: $owner, name: $repo) {
20
+ pullRequest(number: $number) {
21
+ number title baseRefName headRefName headRefOid state url
22
+ }
23
+ }
24
+ }
25
+ `;
26
+
27
+ const FIND_PRS_BY_HEAD_QUERY = `
28
+ query($owner: String!, $repo: String!, $branch: String!) {
29
+ repository(owner: $owner, name: $repo) {
30
+ pullRequests(headRefName: $branch, states: [OPEN, MERGED], first: 5, orderBy: {field: UPDATED_AT, direction: DESC}) {
31
+ nodes { number title baseRefName headRefName headRefOid state url }
32
+ }
33
+ }
34
+ }
35
+ `;
36
+
37
+ const FIND_PRS_BY_BASE_QUERY = `
38
+ query($owner: String!, $repo: String!, $branch: String!) {
39
+ repository(owner: $owner, name: $repo) {
40
+ pullRequests(baseRefName: $branch, states: [OPEN], first: 5, orderBy: {field: UPDATED_AT, direction: DESC}) {
41
+ nodes { number title baseRefName headRefName headRefOid state url }
42
+ }
43
+ }
44
+ }
45
+ `;
46
+
47
+ /**
48
+ * Select the best PR from a list of candidates for the same branch.
49
+ * Prefers OPEN over MERGED.
50
+ *
51
+ * @param {Array} prs - Array of PR nodes from GraphQL
52
+ * @returns {Object|null} The best candidate or null
53
+ */
54
+ function pickBestPR(prs) {
55
+ if (!prs || prs.length === 0) return null;
56
+ const open = prs.find(pr => pr.state === 'OPEN');
57
+ if (open) return open;
58
+ return prs[0];
59
+ }
60
+
61
+ /**
62
+ * Walk a GitHub PR stack using GraphQL.
63
+ *
64
+ * @param {Object} octokit - Octokit instance (must expose .graphql)
65
+ * @param {string} owner - Repository owner
66
+ * @param {string} repo - Repository name
67
+ * @param {number} prNumber - Starting PR number
68
+ * @param {Object} [_deps] - Optional dependency overrides for testing
69
+ * @param {string[]} [_deps.defaultBranches] - Branch names considered trunk
70
+ * @returns {Promise<Array>} Ordered stack from trunk to tip
71
+ */
72
+ async function walkPRStack(octokit, owner, repo, prNumber, _deps) {
73
+ const deps = { defaultBranches: DEFAULT_TRUNK_BRANCHES, ..._deps };
74
+ const graphql = octokit.graphql.bind(octokit);
75
+ const visited = new Set();
76
+
77
+ // Step 1: Fetch the starting PR
78
+ const startResult = await graphql(FETCH_PR_QUERY, { owner, repo, number: prNumber });
79
+ const startPR = startResult.repository?.pullRequest;
80
+ if (!startPR) {
81
+ throw new Error(`PR #${prNumber} not found in ${owner}/${repo}`);
82
+ }
83
+
84
+ logger.debug(`Stack walker: starting from PR #${startPR.number} (${startPR.headRefName} -> ${startPR.baseRefName})`);
85
+ visited.add(startPR.headRefName);
86
+
87
+ // Step 2: Walk UP toward trunk
88
+ const parents = [];
89
+ let currentBase = startPR.baseRefName;
90
+ let walkUpDepth = 0;
91
+
92
+ while (walkUpDepth < MAX_WALK_DEPTH) {
93
+ if (deps.defaultBranches.includes(currentBase)) {
94
+ break;
95
+ }
96
+ if (visited.has(currentBase)) {
97
+ logger.warn(`Stack walker: cycle detected at branch "${currentBase}", stopping upward walk`);
98
+ break;
99
+ }
100
+ visited.add(currentBase);
101
+
102
+ let parentPR;
103
+ try {
104
+ const result = await graphql(FIND_PRS_BY_HEAD_QUERY, { owner, repo, branch: currentBase });
105
+ const candidates = result.repository?.pullRequests?.nodes || [];
106
+ parentPR = pickBestPR(candidates);
107
+ } catch (err) {
108
+ logger.warn(`Stack walker: GraphQL error walking up at branch "${currentBase}": ${err.message}`);
109
+ break;
110
+ }
111
+
112
+ if (!parentPR) {
113
+ break;
114
+ }
115
+
116
+ parents.push({
117
+ branch: parentPR.headRefName,
118
+ isTrunk: false,
119
+ prNumber: parentPR.number,
120
+ title: parentPR.title,
121
+ state: parentPR.state,
122
+ url: parentPR.url,
123
+ headSha: parentPR.headRefOid,
124
+ });
125
+
126
+ currentBase = parentPR.baseRefName;
127
+ walkUpDepth++;
128
+ }
129
+
130
+ if (walkUpDepth >= MAX_WALK_DEPTH) {
131
+ logger.warn(`Stack walker: upward walk reached max depth of ${MAX_WALK_DEPTH}`);
132
+ }
133
+
134
+ const trunkBranch = currentBase;
135
+
136
+ // Step 3: Walk DOWN toward tip
137
+ const children = [];
138
+ let currentHead = startPR.headRefName;
139
+ let walkDownDepth = 0;
140
+
141
+ while (walkDownDepth < MAX_WALK_DEPTH) {
142
+ let childPR;
143
+ try {
144
+ const result = await graphql(FIND_PRS_BY_BASE_QUERY, { owner, repo, branch: currentHead });
145
+ const candidates = result.repository?.pullRequests?.nodes || [];
146
+ childPR = pickBestPR(candidates);
147
+ } catch (err) {
148
+ logger.warn(`Stack walker: GraphQL error walking down at branch "${currentHead}": ${err.message}`);
149
+ break;
150
+ }
151
+
152
+ if (!childPR) {
153
+ break;
154
+ }
155
+
156
+ if (visited.has(childPR.headRefName)) {
157
+ logger.warn(`Stack walker: cycle detected at branch "${childPR.headRefName}", stopping downward walk`);
158
+ break;
159
+ }
160
+ visited.add(childPR.headRefName);
161
+
162
+ children.push({
163
+ branch: childPR.headRefName,
164
+ isTrunk: false,
165
+ prNumber: childPR.number,
166
+ title: childPR.title,
167
+ state: childPR.state,
168
+ url: childPR.url,
169
+ headSha: childPR.headRefOid,
170
+ });
171
+
172
+ currentHead = childPR.headRefName;
173
+ walkDownDepth++;
174
+ }
175
+
176
+ if (walkDownDepth >= MAX_WALK_DEPTH) {
177
+ logger.warn(`Stack walker: downward walk reached max depth of ${MAX_WALK_DEPTH}`);
178
+ }
179
+
180
+ // Step 4: Assemble the ordered stack
181
+ const stack = [
182
+ { branch: trunkBranch, isTrunk: true },
183
+ ...parents.reverse(),
184
+ {
185
+ branch: startPR.headRefName,
186
+ isTrunk: false,
187
+ prNumber: startPR.number,
188
+ title: startPR.title,
189
+ state: startPR.state,
190
+ url: startPR.url,
191
+ headSha: startPR.headRefOid,
192
+ },
193
+ ...children,
194
+ ];
195
+
196
+ logger.debug(`Stack walker: found ${stack.length} entries (${stack.filter(e => !e.isTrunk).length} PRs)`);
197
+ return stack;
198
+ }
199
+
200
+ module.exports = {
201
+ walkPRStack,
202
+ DEFAULT_TRUNK_BRANCHES,
203
+ MAX_WALK_DEPTH,
204
+ // Exported for tests and impl-internal use only.
205
+ _queries: {
206
+ FETCH_PR_QUERY,
207
+ FIND_PRS_BY_HEAD_QUERY,
208
+ FIND_PRS_BY_BASE_QUERY
209
+ }
210
+ };
@@ -0,0 +1,338 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ const logger = require('../../../utils/logger');
3
+
4
+ /**
5
+ * Host-extension implementation of the `pending_review_comments` area.
6
+ *
7
+ * GitHub's REST API does not support adding inline comments to a *pending*
8
+ * draft review (the `addPullRequestReviewThread` GraphQL mutation has no
9
+ * REST equivalent). Alt-hosts that advertise a compatible extension expose
10
+ * this via a single HTTP POST that accepts a batch of comments.
11
+ *
12
+ * Documented generic contract:
13
+ *
14
+ * POST {api_host}/repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments
15
+ *
16
+ * Request body:
17
+ * {
18
+ * "comments": [
19
+ * { "path": "...", "body": "...", "side": "RIGHT",
20
+ * "line": 42, "start_line": 40, "start_side": "RIGHT",
21
+ * "subject_type": "line" | "file",
22
+ * "commit_id": "<PR head SHA>" },
23
+ * ...
24
+ * ]
25
+ * }
26
+ *
27
+ * `commit_id` is the PR head SHA. It is required by GitHub-compatible
28
+ * hosts that validate each comment like `pulls.createReviewComment`
29
+ * (which rejects a missing `commit_id` with a 422). It is sourced from
30
+ * `prContext.headSha` and omitted entirely when that value is absent.
31
+ *
32
+ * Response (HTTP 200, partial-success body):
33
+ * {
34
+ * "added": <number>,
35
+ * "failed": [ { "index": <number>, "error_message": "..." }, ... ]
36
+ * }
37
+ *
38
+ * Authorization: standard `Authorization: Bearer <token>` (attached by
39
+ * Octokit from the binding token).
40
+ *
41
+ * The host returns HTTP 200 with a partial-success body even when some
42
+ * comments fail. A non-empty `failed` array is treated as a partial
43
+ * failure: the returned shape matches the GraphQL impl so the caller
44
+ * cannot tell which transport ran.
45
+ *
46
+ * Endpoint override: hosts that diverge from the default may set
47
+ * `features.pending_review_comments_endpoint` to a template string
48
+ * containing `{owner}`, `{repo}`, `{pull_number}`, `{review_id}`
49
+ * placeholders. The template must be a relative path (starting with
50
+ * `/repos/` or similar) — absolute URLs are rejected at config validation.
51
+ *
52
+ * Note on `reviewId`: this argument is the *host's* review identifier
53
+ * (e.g. a numeric REST id), not a GraphQL node id. The REST/host
54
+ * `review_lifecycle` impl (Phase 4) returns this id when it creates the
55
+ * pending review; the caller passes it through unchanged.
56
+ *
57
+ * Note on `batchSize`: the original parameter is kept in the signature
58
+ * for API compatibility with the GraphQL impl, but the host endpoint
59
+ * accepts arbitrary batch sizes in a single call. We send all comments
60
+ * in one POST and let the server enforce its own limits. The argument
61
+ * is otherwise ignored.
62
+ */
63
+
64
+ const DEFAULT_ENDPOINT_TEMPLATE =
65
+ '/repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments';
66
+
67
+ const REQUIRED_PLACEHOLDERS = ['{owner}', '{repo}', '{pull_number}', '{review_id}'];
68
+
69
+ // Matches any of the four supported placeholder names. Used globally so
70
+ // that templates which repeat a placeholder (e.g. `{repo}` in both the
71
+ // path and a query string) get every occurrence substituted, not just
72
+ // the first. `validateRepoConfig()` only asserts each required
73
+ // placeholder appears *somewhere*, so a chained per-name single
74
+ // `String.replace` would leave later occurrences literal in the URL.
75
+ const PLACEHOLDER_RE = /\{(owner|repo|pull_number|review_id)\}/g;
76
+
77
+ /**
78
+ * Substitute placeholders in an endpoint template. URL-encodes each value
79
+ * so that an `owner` like `my org` or a repo containing a slash cannot
80
+ * break the path. The four required placeholders are validated at startup
81
+ * by `validateRepoConfig()`, so missing placeholders here would indicate
82
+ * a bug rather than user error — we still throw a clear error so the
83
+ * failure is loud rather than producing a malformed request path.
84
+ *
85
+ * All occurrences of each placeholder are replaced (global substitution),
86
+ * mirroring the behaviour of `substituteUrlTemplate` in
87
+ * `src/links/repo-links.js`.
88
+ *
89
+ * @param {string} template - Endpoint template with `{...}` placeholders
90
+ * @param {Object} values - { owner, repo, pull_number, review_id }
91
+ * @returns {string} Substituted endpoint path
92
+ */
93
+ function substituteEndpoint(template, values) {
94
+ return template.replace(PLACEHOLDER_RE, (_match, name) => {
95
+ const value = values ? values[name] : undefined;
96
+ if (value === undefined || value === null) {
97
+ throw new Error(
98
+ `Host pending_review_comments: endpoint template references {${name}} ` +
99
+ 'but no value was provided. This should have been caught by ' +
100
+ 'validateRepoConfig — please report this as a bug.'
101
+ );
102
+ }
103
+ return encodeURIComponent(String(value));
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Map an internal comment shape to the host-extension wire shape. The
109
+ * internal shape matches what callers already pass to the GraphQL impl:
110
+ * `{ path, line?, start_line?, side?, body, isFileLevel? }`.
111
+ *
112
+ * - File-level comments (no `line` or explicit `isFileLevel`) are sent
113
+ * with `subject_type: "file"`.
114
+ * - Line comments default to `side: "RIGHT"` to match GraphQL behaviour.
115
+ * - Range comments include `start_line` and `start_side` (defaulting
116
+ * `start_side` to the same side as the end line, matching GitHub's
117
+ * own REST conventions).
118
+ * - `commitId` (the PR head SHA) is added as `commit_id` to every wire
119
+ * comment when supplied. GitHub-compatible hosts validate comments like
120
+ * `pulls.createReviewComment` and reject a missing `commit_id` with a
121
+ * 422. When `commitId` is empty/undefined the field is omitted entirely
122
+ * (we never send `commit_id: undefined`).
123
+ *
124
+ * @param {Object} comment - Internal comment shape.
125
+ * @param {string} [commitId] - PR head SHA. Added as `commit_id` when a
126
+ * non-empty string; omitted otherwise.
127
+ */
128
+ function toWireComment(comment, commitId) {
129
+ const hasCommitId = typeof commitId === 'string' && commitId.length > 0;
130
+ const isFileLevel = comment.isFileLevel || !comment.line;
131
+ if (isFileLevel) {
132
+ const wire = {
133
+ path: comment.path,
134
+ body: comment.body,
135
+ subject_type: 'file'
136
+ };
137
+ if (hasCommitId) wire.commit_id = commitId;
138
+ return wire;
139
+ }
140
+ const side = comment.side || 'RIGHT';
141
+ const wire = {
142
+ path: comment.path,
143
+ body: comment.body,
144
+ side,
145
+ line: comment.line,
146
+ subject_type: 'line'
147
+ };
148
+ if (comment.start_line) {
149
+ wire.start_line = comment.start_line;
150
+ wire.start_side = comment.start_side || side;
151
+ }
152
+ if (hasCommitId) wire.commit_id = commitId;
153
+ return wire;
154
+ }
155
+
156
+ /**
157
+ * Add a list of comments to a pending review via the host extension.
158
+ *
159
+ * @param {Object} octokit - Octokit instance bound to the host's baseUrl.
160
+ * `octokit.request()` will attach `Authorization: Bearer <token>` from
161
+ * the binding token automatically.
162
+ * @param {Object} features - Feature-flag object from the host binding.
163
+ * May include `pending_review_comments_endpoint` to override the
164
+ * default endpoint path.
165
+ * @param {Object} prContext - `{ owner, repo, prNumber, headSha? }`.
166
+ * Required — the host endpoint is path-shaped, so the GraphQL node IDs
167
+ * are not sufficient on their own. `headSha` (the PR head SHA) is
168
+ * forwarded as each comment's `commit_id`, required by
169
+ * GitHub-compatible hosts; omitted when absent.
170
+ * @param {string} reviewId - The *host's* review identifier (returned
171
+ * by the host's M2 `review_lifecycle` impl, not a GraphQL node id).
172
+ * @param {Array} comments - Comments with `{ path, line?, start_line?,
173
+ * side?, body, isFileLevel? }`. Same shape the GraphQL impl accepts.
174
+ * @param {number} [_batchSize] - Ignored; kept for signature parity.
175
+ * @returns {Promise<{successCount: number, failed: boolean, failedDetails: string[]}>}
176
+ */
177
+ async function addCommentsInBatches(octokit, features, prContext, reviewId, comments, _batchSize) {
178
+ if (!comments || comments.length === 0) {
179
+ return { successCount: 0, failed: false, failedDetails: [] };
180
+ }
181
+
182
+ if (!prContext || typeof prContext !== 'object') {
183
+ throw new Error(
184
+ 'Host pending_review_comments: prContext is required ' +
185
+ '({ owner, repo, prNumber }). The host endpoint is path-shaped, ' +
186
+ 'so GraphQL node IDs alone are not sufficient.'
187
+ );
188
+ }
189
+ const { owner, repo, prNumber } = prContext;
190
+ if (!owner || !repo || prNumber === undefined || prNumber === null) {
191
+ throw new Error(
192
+ 'Host pending_review_comments: prContext must include owner, repo, and prNumber.'
193
+ );
194
+ }
195
+
196
+ // Resolve the *numeric* review id. The host endpoint is REST-shaped:
197
+ // it identifies a review by its numeric database id, not its GraphQL
198
+ // node id. Prefer `prContext.reviewId` (set by the orchestration in
199
+ // `client.js` from the `databaseId` returned by addPullRequestReview)
200
+ // and fall back to the positional `reviewId` argument only when it is
201
+ // itself numeric. If only a node id was supplied, fail fast with a
202
+ // clear message so regressions surface immediately rather than as a
203
+ // 404 from the host.
204
+ let resolvedReviewId = null;
205
+ if (prContext && (typeof prContext.reviewId === 'number' || typeof prContext.reviewId === 'string')) {
206
+ const fromCtx = String(prContext.reviewId);
207
+ if (/^\d+$/.test(fromCtx)) {
208
+ resolvedReviewId = fromCtx;
209
+ }
210
+ }
211
+ if (resolvedReviewId === null) {
212
+ if (typeof reviewId === 'number') {
213
+ resolvedReviewId = String(reviewId);
214
+ } else if (typeof reviewId === 'string' && /^\d+$/.test(reviewId)) {
215
+ resolvedReviewId = reviewId;
216
+ } else if (!reviewId && reviewId !== 0) {
217
+ throw new Error('Host pending_review_comments: reviewId is required.');
218
+ } else {
219
+ throw new Error(
220
+ `Host extension addCommentsInBatches requires a numeric review id; received "${reviewId}". ` +
221
+ 'Set prContext.reviewId or ensure the upstream addPullRequestReview returned a numeric databaseId.'
222
+ );
223
+ }
224
+ }
225
+
226
+ const template = (features && features.pending_review_comments_endpoint) || DEFAULT_ENDPOINT_TEMPLATE;
227
+ const endpoint = substituteEndpoint(template, {
228
+ owner,
229
+ repo,
230
+ pull_number: prNumber,
231
+ review_id: resolvedReviewId
232
+ });
233
+
234
+ // The PR head SHA is threaded through `prContext.headSha` from the
235
+ // submit site (see src/routes/pr.js). GitHub-compatible hosts require
236
+ // each comment to carry `commit_id`; when the SHA is absent we omit the
237
+ // field and let the host surface its own validation error.
238
+ const commitId = prContext.headSha;
239
+ const wireComments = comments.map((c) => toWireComment(c, commitId));
240
+ logger.info(
241
+ `Posting ${wireComments.length} comment(s) to host endpoint ${endpoint}`
242
+ );
243
+
244
+ let response;
245
+ try {
246
+ response = await octokit.request(`POST ${endpoint}`, {
247
+ headers: {
248
+ 'content-type': 'application/json',
249
+ accept: 'application/json'
250
+ },
251
+ data: { comments: wireComments }
252
+ });
253
+ } catch (error) {
254
+ const status = error && (error.status || error.statusCode);
255
+ const message = (error && error.message) || 'Unknown error';
256
+ logger.error(
257
+ `Host pending_review_comments request failed (${status || 'no status'}): ${message}`
258
+ );
259
+ // Normalise host request failures to the same partial-failure shape
260
+ // the GraphQL impl returns, so callers can branch uniformly on
261
+ // `batchResult.failed` without needing a try/catch. The orchestration
262
+ // in `src/github/client.js` remains defensive against throws as the
263
+ // primary safety guarantee — this is a secondary tidy-up so the
264
+ // failure surface matches across transports.
265
+ const failedDetails = comments.map((c) => {
266
+ const location = c.line ? `${c.path}:${c.line}` : `${c.path}:file-level`;
267
+ return `${location} - ${status || 'network error'}: ${message}`;
268
+ });
269
+ return {
270
+ successCount: 0,
271
+ failed: true,
272
+ failedDetails
273
+ };
274
+ }
275
+
276
+ const body = response && response.data ? response.data : {};
277
+ // Distinguish "host explicitly reported a count (including 0)" from
278
+ // "host omitted the field". An explicit `added: 0` with no `failed[]`
279
+ // must NOT be treated as "all succeeded" — it means the host accepted
280
+ // none. Only fall back to `comments.length` when the field is absent.
281
+ const hasExplicitAdded = typeof body.added === 'number';
282
+ const added = hasExplicitAdded ? body.added : 0;
283
+ const failedList = Array.isArray(body.failed) ? body.failed : [];
284
+
285
+ const failedDetails = [];
286
+ for (const entry of failedList) {
287
+ const idx = typeof entry.index === 'number' ? entry.index : null;
288
+ const errMsg = (entry && (entry.error_message || entry.message)) || 'Unknown error';
289
+ const source = idx !== null && idx >= 0 && idx < comments.length ? comments[idx] : null;
290
+ const location = source
291
+ ? `${source.path}:${source.line || 'file-level'}`
292
+ : idx !== null ? `comment[${idx}]` : 'comment[?]';
293
+ failedDetails.push(`${location} - ${errMsg}`);
294
+ logger.warn(`Host comment failed: ${location} - ${errMsg}`);
295
+ }
296
+
297
+ // Sanity check: if the host reported an explicit count, the accounted-
298
+ // for items (added + failed) should equal the total submitted. If they
299
+ // don't, the host response is internally inconsistent — log a warning
300
+ // but still trust the explicit counts.
301
+ if (hasExplicitAdded && added + failedList.length !== comments.length) {
302
+ logger.warn(
303
+ `Host pending_review_comments inconsistent counts: added=${added}, ` +
304
+ `failed=${failedList.length}, submitted=${comments.length}`
305
+ );
306
+ }
307
+
308
+ if (failedList.length > 0) {
309
+ logger.error(
310
+ `Host pending_review_comments partial failure: ${added} added, ${failedList.length} failed`
311
+ );
312
+ return {
313
+ successCount: added,
314
+ failed: true,
315
+ failedDetails
316
+ };
317
+ }
318
+
319
+ // No partial failures. Trust an explicit `added` value (including 0).
320
+ // Only fall back to `comments.length` when the host omitted the field
321
+ // entirely — some hosts that don't report counts rely on this.
322
+ const successCount = hasExplicitAdded ? added : comments.length;
323
+ logger.info(`Host pending_review_comments complete: ${successCount} comment(s) added`);
324
+ return {
325
+ successCount,
326
+ failed: false,
327
+ failedDetails: []
328
+ };
329
+ }
330
+
331
+ module.exports = {
332
+ addCommentsInBatches,
333
+ DEFAULT_ENDPOINT_TEMPLATE,
334
+ REQUIRED_PLACEHOLDERS,
335
+ // Exported for direct unit testing.
336
+ substituteEndpoint,
337
+ toWireComment
338
+ };