@in-the-loop-labs/pair-review 3.5.2 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/analysis-config.css +1807 -0
  6. package/public/css/pr.css +1029 -2169
  7. package/public/index.html +11 -0
  8. package/public/js/components/AIPanel.js +39 -23
  9. package/public/js/components/AdvancedConfigTab.js +56 -4
  10. package/public/js/components/AnalysisConfigModal.js +41 -25
  11. package/public/js/components/ChatPanel.js +163 -3
  12. package/public/js/components/KeyboardShortcuts.js +10 -26
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/TourBar.js +248 -0
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +64 -8
  18. package/public/js/modules/cancel-background-job.js +183 -0
  19. package/public/js/modules/hunk-summary-renderer.js +116 -0
  20. package/public/js/modules/storage-cleanup.js +16 -0
  21. package/public/js/modules/suggestion-manager.js +25 -1
  22. package/public/js/modules/tour-renderer.js +755 -0
  23. package/public/js/pr.js +1826 -56
  24. package/public/js/repo-links.js +328 -0
  25. package/public/js/utils/modal-detection.js +77 -0
  26. package/public/js/utils/provider-model.js +88 -0
  27. package/public/js/utils/storage-keys.js +50 -0
  28. package/public/local.html +24 -0
  29. package/public/pr.html +24 -0
  30. package/public/repo-settings.html +1 -0
  31. package/public/setup.html +2 -0
  32. package/src/ai/abort-signal-wiring.js +130 -0
  33. package/src/ai/analyzer.js +125 -18
  34. package/src/ai/background-queue.js +290 -0
  35. package/src/ai/claude-cli.js +1 -1
  36. package/src/ai/claude-provider.js +50 -7
  37. package/src/ai/codex-provider.js +28 -5
  38. package/src/ai/copilot-provider.js +22 -3
  39. package/src/ai/cursor-agent-provider.js +22 -6
  40. package/src/ai/executable-provider.js +4 -19
  41. package/src/ai/gemini-provider.js +22 -5
  42. package/src/ai/hunk-hashing.js +161 -0
  43. package/src/ai/index.js +2 -0
  44. package/src/ai/opencode-provider.js +21 -5
  45. package/src/ai/pi-provider.js +21 -5
  46. package/src/ai/prompts/hunk-summary.js +199 -0
  47. package/src/ai/prompts/tour.js +232 -0
  48. package/src/ai/provider.js +21 -1
  49. package/src/ai/summary-generator.js +469 -0
  50. package/src/ai/tour-generator.js +568 -0
  51. package/src/config.js +778 -10
  52. package/src/database.js +282 -1
  53. package/src/external/github-adapter.js +114 -25
  54. package/src/git/base-branch.js +11 -4
  55. package/src/github/client.js +482 -588
  56. package/src/github/errors.js +55 -0
  57. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  58. package/src/github/impl/graphql/pending-review.js +153 -0
  59. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  60. package/src/github/impl/graphql/stack-walker.js +210 -0
  61. package/src/github/impl/host/pending-review-comments.js +338 -0
  62. package/src/github/impl/rest/pending-review.js +251 -0
  63. package/src/github/impl/rest/review-lifecycle.js +226 -0
  64. package/src/github/impl/rest/stack-walker.js +309 -0
  65. package/src/github/operations/pending-review-comments.js +79 -0
  66. package/src/github/operations/pending-review.js +89 -0
  67. package/src/github/operations/review-lifecycle.js +126 -0
  68. package/src/github/operations/stack-walker.js +87 -0
  69. package/src/github/parser.js +230 -4
  70. package/src/github/stack-walker.js +14 -189
  71. package/src/links/repo-links.js +230 -0
  72. package/src/local-review.js +201 -172
  73. package/src/main.js +133 -30
  74. package/src/routes/analyses.js +30 -7
  75. package/src/routes/bulk-analysis-configs.js +295 -0
  76. package/src/routes/config.js +118 -3
  77. package/src/routes/context-files.js +2 -29
  78. package/src/routes/external-comments.js +20 -10
  79. package/src/routes/github-collections.js +3 -1
  80. package/src/routes/local.js +410 -13
  81. package/src/routes/mcp.js +47 -4
  82. package/src/routes/middleware/validate-review-id.js +53 -0
  83. package/src/routes/pr.js +556 -71
  84. package/src/routes/reviews.js +145 -29
  85. package/src/routes/setup.js +8 -3
  86. package/src/routes/stack-analysis.js +33 -9
  87. package/src/routes/worktrees.js +3 -2
  88. package/src/server.js +2 -0
  89. package/src/setup/pr-setup.js +37 -11
  90. package/src/setup/stack-setup.js +13 -3
  91. package/src/single-port.js +6 -3
  92. package/src/utils/diff-hunks.js +65 -0
  93. package/src/utils/json-extractor.js +5 -2
@@ -0,0 +1,251 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ const logger = require('../../../utils/logger');
3
+ const { GitHubApiError } = require('../../errors');
4
+
5
+ /**
6
+ * Derive an identity for a REST review payload. Falls back to the
7
+ * stringified numeric id when `node_id` is absent so callers always
8
+ * have a non-null id to work with. Returns null only when the payload
9
+ * has neither field.
10
+ */
11
+ function deriveRestReviewId(data) {
12
+ if (!data || typeof data !== 'object') return null;
13
+ if (data.node_id) return data.node_id;
14
+ if (data.id !== undefined && data.id !== null) return String(data.id);
15
+ return null;
16
+ }
17
+
18
+ /**
19
+ * REST implementation of the pending-review-check area.
20
+ *
21
+ * Provides REST-backed equivalents of the GraphQL implementations in
22
+ * `impl/graphql/pending-review.js`. Each function returns the same shape
23
+ * that the GraphQL implementation does so the dispatcher's callers do
24
+ * not see any difference in behaviour.
25
+ *
26
+ * - getPendingReviewForUser(octokit, owner, repo, prNumber)
27
+ * - getReviewById(octokit, nodeId, prContext)
28
+ *
29
+ * Note: `getReviewById` requires a `prContext = { owner, repo, prNumber }`
30
+ * because the GitHub REST API's `pulls.getReview` endpoint identifies a
31
+ * review by `(owner, repo, pull_number, review_id)` — the node ID alone
32
+ * is not enough. The GraphQL form accepts only the node ID, so the
33
+ * dispatcher signature accepts an optional `prContext` and the REST path
34
+ * requires it.
35
+ */
36
+
37
+ /**
38
+ * Cache of authenticated-user lookups, keyed by Octokit instance. The
39
+ * cache survives across calls within a single Octokit instance, which
40
+ * matches how `GitHubClient` uses a single Octokit per host.
41
+ *
42
+ * Using a WeakMap so disposed Octokit instances don't keep entries
43
+ * alive. The cache value is a Promise so concurrent callers share one
44
+ * request.
45
+ *
46
+ * @type {WeakMap<Object, Promise<{id: number, login: string}>>}
47
+ */
48
+ const authenticatedUserCache = new WeakMap();
49
+
50
+ /**
51
+ * Resolve the authenticated user for the given Octokit instance,
52
+ * caching the result on the instance via a WeakMap so subsequent
53
+ * lookups in the same process don't re-call the API.
54
+ *
55
+ * @param {Object} octokit
56
+ * @returns {Promise<{id: number, login: string}>}
57
+ */
58
+ async function getAuthenticatedUserCached(octokit) {
59
+ const cached = authenticatedUserCache.get(octokit);
60
+ if (cached) return cached;
61
+
62
+ const pending = (async () => {
63
+ const { data } = await octokit.rest.users.getAuthenticated();
64
+ return { id: data.id, login: data.login };
65
+ })();
66
+
67
+ authenticatedUserCache.set(octokit, pending);
68
+ try {
69
+ return await pending;
70
+ } catch (err) {
71
+ // On failure, drop the cache entry so the next caller retries.
72
+ authenticatedUserCache.delete(octokit);
73
+ throw err;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Fetch the pending review (if any) authored by the authenticated user
79
+ * via the REST API.
80
+ *
81
+ * GitHub allows only ONE pending review per user per PR. We list all
82
+ * reviews, filter to `state === 'PENDING'` and the authenticated user's
83
+ * id, then shape the result to match the GraphQL implementation:
84
+ *
85
+ * { id (node_id), databaseId (numeric id), body, url (html_url),
86
+ * state, createdAt (submitted_at || created), comments: { totalCount } }
87
+ *
88
+ * The `comments.totalCount` field is computed by a follow-up call to
89
+ * `pulls.listCommentsForReview({ review_id })` so the caller can rely
90
+ * on the same field being populated regardless of transport.
91
+ *
92
+ * @param {Object} octokit - Octokit instance bound to the host's baseUrl
93
+ * @param {string} owner
94
+ * @param {string} repo
95
+ * @param {number} prNumber
96
+ * @returns {Promise<Object|null>} Pending review or null
97
+ */
98
+ async function getPendingReviewForUser(octokit, owner, repo, prNumber) {
99
+ try {
100
+ logger.debug(`Checking for pending review on PR #${prNumber} in ${owner}/${repo} (REST)`);
101
+
102
+ const user = await getAuthenticatedUserCached(octokit);
103
+
104
+ const reviews = await octokit.paginate(octokit.rest.pulls.listReviews, {
105
+ owner,
106
+ repo,
107
+ pull_number: prNumber,
108
+ per_page: 100
109
+ });
110
+
111
+ const pending = reviews.find(r =>
112
+ r.state === 'PENDING' && r.user && r.user.id === user.id
113
+ );
114
+
115
+ if (!pending) {
116
+ logger.debug('No pending review found for user (REST)');
117
+ return null;
118
+ }
119
+
120
+ // GraphQL exposes the review's createdAt directly. REST does not
121
+ // expose a created-at timestamp on the review payload at all —
122
+ // `submitted_at` is always null for a PENDING review by definition.
123
+ // Return null explicitly so callers do not mistake a missing-submit
124
+ // timestamp for a missing-created timestamp; the GraphQL shape is
125
+ // nullable for never-submitted reviews so this is consistent.
126
+ const createdAt = null;
127
+
128
+ // Count comments attached to the pending review. We use
129
+ // `pulls.listCommentsForReview` (GET
130
+ // /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments)
131
+ // which returns comments scoped to a single review — no filtering
132
+ // required and far less wasteful than listing every comment on the
133
+ // PR and discarding non-matching ones. Alt-hosts that do not
134
+ // surface `pull_request_review_id` consistently also benefit from
135
+ // this scoped endpoint. Paginated to match GraphQL's totalCount
136
+ // semantics.
137
+ let totalCount = 0;
138
+ try {
139
+ const reviewComments = await octokit.paginate(octokit.rest.pulls.listCommentsForReview, {
140
+ owner,
141
+ repo,
142
+ pull_number: prNumber,
143
+ review_id: pending.id,
144
+ per_page: 100
145
+ });
146
+ totalCount = reviewComments.length;
147
+ } catch (commentErr) {
148
+ logger.warn(`Could not count comments on pending review ${pending.id}: ${commentErr.message}`);
149
+ }
150
+
151
+ logger.debug(`Found pending review for user: ${pending.node_id} with ${totalCount} comments`);
152
+
153
+ return {
154
+ id: deriveRestReviewId(pending),
155
+ databaseId: pending.id,
156
+ body: pending.body || '',
157
+ url: pending.html_url,
158
+ state: pending.state,
159
+ createdAt,
160
+ comments: { totalCount }
161
+ };
162
+ } catch (error) {
163
+ logger.error(`Error checking for pending review (REST): ${error.message}`);
164
+
165
+ if (error.status === 401) {
166
+ throw new GitHubApiError('GitHub authentication failed. Check your token in ~/.pair-review/config.json', 401);
167
+ }
168
+
169
+ if (error.status === 404) {
170
+ throw new GitHubApiError(`Pull request #${prNumber} not found in repository ${owner}/${repo}`, 404);
171
+ }
172
+
173
+ throw new Error(`Failed to check for pending review: ${error.message}`);
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Fetch a review by id via REST.
179
+ *
180
+ * Unlike GraphQL, the REST endpoint requires `(owner, repo, pull_number,
181
+ * review_id)`. Callers must therefore supply a `prContext` argument when
182
+ * the dispatcher is in REST mode. The `nodeId` parameter is accepted for
183
+ * signature parity with the GraphQL impl but is not used by REST — the
184
+ * `prContext.reviewId` (numeric) field is consulted first, then we fall
185
+ * back to the `nodeId` argument when it is a numeric string.
186
+ *
187
+ * @param {Object} octokit
188
+ * @param {string|number} nodeId - GraphQL node id OR numeric review id
189
+ * @param {Object} prContext - { owner, repo, prNumber, reviewId? }
190
+ * @returns {Promise<Object|null>}
191
+ */
192
+ async function getReviewById(octokit, nodeId, prContext) {
193
+ if (!prContext || !prContext.owner || !prContext.repo || !prContext.prNumber) {
194
+ throw new Error(
195
+ 'REST getReviewById requires prContext={owner, repo, prNumber}. ' +
196
+ 'The REST API identifies a review by (owner, repo, pull_number, review_id); ' +
197
+ 'a node id alone is not sufficient.'
198
+ );
199
+ }
200
+
201
+ // Prefer an explicit numeric review id when present; otherwise expect
202
+ // `nodeId` to be a numeric REST id (callers that have only a GraphQL
203
+ // node id should resolve it upstream).
204
+ let reviewId = prContext.reviewId;
205
+ if (reviewId === undefined || reviewId === null) {
206
+ if (typeof nodeId === 'number') {
207
+ reviewId = nodeId;
208
+ } else if (typeof nodeId === 'string' && /^\d+$/.test(nodeId)) {
209
+ reviewId = Number(nodeId);
210
+ } else {
211
+ // No usable numeric id — surface a clear error rather than calling
212
+ // the REST API with a GraphQL node id (which would 404).
213
+ logger.warn(`REST getReviewById called with non-numeric id "${nodeId}" and no prContext.reviewId`);
214
+ return null;
215
+ }
216
+ }
217
+
218
+ try {
219
+ logger.debug(`Fetching review ${reviewId} on PR #${prContext.prNumber} (REST)`);
220
+
221
+ const { data } = await octokit.rest.pulls.getReview({
222
+ owner: prContext.owner,
223
+ repo: prContext.repo,
224
+ pull_number: prContext.prNumber,
225
+ review_id: reviewId
226
+ });
227
+
228
+ return {
229
+ id: deriveRestReviewId(data),
230
+ state: data.state,
231
+ submittedAt: data.submitted_at || null,
232
+ url: data.html_url
233
+ };
234
+ } catch (error) {
235
+ if (error.status === 404) {
236
+ logger.debug(`Review ${reviewId} not found via REST`);
237
+ return null;
238
+ }
239
+ logger.warn(`Error fetching review ${reviewId} via REST: ${error.message}`);
240
+ return null;
241
+ }
242
+ }
243
+
244
+ module.exports = {
245
+ getPendingReviewForUser,
246
+ getReviewById,
247
+ // Exported for tests so they can reset the per-instance cache.
248
+ _resetAuthenticatedUserCache(octokit) {
249
+ if (octokit) authenticatedUserCache.delete(octokit);
250
+ }
251
+ };
@@ -0,0 +1,226 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ const logger = require('../../../utils/logger');
3
+
4
+ /**
5
+ * Derive an identity for a REST review payload. GraphQL identifies
6
+ * reviews by their `node_id` string; on alt-hosts that don't surface
7
+ * `node_id` we fall back to the numeric `id` stringified, so callers
8
+ * always receive a non-null id when the review was created. Returns
9
+ * null only when neither field is present.
10
+ *
11
+ * @param {Object|null|undefined} data - The REST API review payload
12
+ * @returns {string|null}
13
+ */
14
+ function deriveRestReviewId(data) {
15
+ if (!data || typeof data !== 'object') return null;
16
+ if (data.node_id) return data.node_id;
17
+ if (data.id !== undefined && data.id !== null) return String(data.id);
18
+ return null;
19
+ }
20
+
21
+ /**
22
+ * REST implementation of the review-lifecycle area:
23
+ * - addPullRequestReview -> `pulls.createReview({ body: '' })` (no event, empty body)
24
+ * - addPullRequestReviewWithBody -> `pulls.createReview({ body })`
25
+ * - submitPullRequestReview -> `pulls.submitReview({ event, body })`
26
+ * - deletePullRequestReview -> `pulls.deletePendingReview()`
27
+ *
28
+ * Each function takes an Octokit-like client as its first parameter and
29
+ * a `prContext = { owner, repo, prNumber }` since REST endpoints
30
+ * identify the review by `(owner, repo, pull_number, review_id)` rather
31
+ * than by a GraphQL node id.
32
+ *
33
+ * Return shapes are byte-identical to the GraphQL impl in
34
+ * `impl/graphql/review-lifecycle.js` so the orchestration in
35
+ * `client.js` does not have to branch on transport.
36
+ */
37
+
38
+ /**
39
+ * Ensure prContext has the fields REST endpoints require. Throws a
40
+ * descriptive error pointing at the dispatcher/client wiring when
41
+ * called without enough context.
42
+ *
43
+ * @param {Object} prContext
44
+ * @param {string} fnName - Function name for the error message
45
+ */
46
+ function requirePRContext(prContext, fnName) {
47
+ if (!prContext || !prContext.owner || !prContext.repo || !prContext.prNumber) {
48
+ throw new Error(
49
+ `REST ${fnName} requires prContext={owner, repo, prNumber}. ` +
50
+ 'The REST API needs the PR coordinates to identify the review; ' +
51
+ 'pass them via the dispatcher / GitHubClient.'
52
+ );
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Create a pending review with no body. Used by the submit-review flow
58
+ * that supplies the body at submission time.
59
+ *
60
+ * Returns both the GraphQL node id (`id`) and the numeric REST database
61
+ * id (`databaseId`). Downstream callers (e.g.
62
+ * `submitPullRequestReview`, `deletePullRequestReview`, the host
63
+ * `addCommentsInBatches` extension) require the numeric id to address
64
+ * the review via REST/host endpoints — they read it from
65
+ * `prContext.reviewId` populated by the orchestration in `client.js`.
66
+ *
67
+ * @param {Object} octokit
68
+ * @param {string} prNodeId - Accepted for signature parity; REST does not need it
69
+ * @param {Object} prContext - { owner, repo, prNumber }
70
+ * @returns {Promise<{id: string, databaseId: number|null}>}
71
+ */
72
+ async function addPullRequestReview(octokit, prNodeId, prContext) {
73
+ requirePRContext(prContext, 'addPullRequestReview');
74
+
75
+ const { data } = await octokit.rest.pulls.createReview({
76
+ owner: prContext.owner,
77
+ repo: prContext.repo,
78
+ pull_number: prContext.prNumber,
79
+ // No `event` -> review is created in PENDING state.
80
+ //
81
+ // The explicit empty `body` is REQUIRED, not cosmetic. Without any
82
+ // body params Octokit serializes a POST with an empty HTTP body.
83
+ // github.com tolerates that and creates an empty pending review, but
84
+ // strict GitHub-compatible alt-hosts (those with an `api_host`)
85
+ // reject it with HTTP 400 `{ message: "request body is empty" }`.
86
+ // Sending `body: ''` makes the serialized body non-empty (`{"body":""}`)
87
+ // while keeping the review PENDING. This is observationally identical
88
+ // on github.com to an empty-summary pending review and mirrors the
89
+ // sibling `addPullRequestReviewWithBody` (`body: body || ''`).
90
+ // Do NOT "simplify" this away.
91
+ body: ''
92
+ });
93
+
94
+ return {
95
+ id: deriveRestReviewId(data),
96
+ databaseId: typeof data.id === 'number' ? data.id : null
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Create a pending review with a body. Used by the draft-review flow
102
+ * which persists the summary on the pending review itself.
103
+ *
104
+ * @param {Object} octokit
105
+ * @param {string} prNodeId
106
+ * @param {string|null} body
107
+ * @param {Object} prContext
108
+ * @returns {Promise<{id: string, databaseId: number|null, url: string}>}
109
+ */
110
+ async function addPullRequestReviewWithBody(octokit, prNodeId, body, prContext) {
111
+ requirePRContext(prContext, 'addPullRequestReviewWithBody');
112
+
113
+ const { data } = await octokit.rest.pulls.createReview({
114
+ owner: prContext.owner,
115
+ repo: prContext.repo,
116
+ pull_number: prContext.prNumber,
117
+ body: body || ''
118
+ // No `event` -> PENDING.
119
+ });
120
+
121
+ return {
122
+ id: deriveRestReviewId(data),
123
+ databaseId: typeof data.id === 'number' ? data.id : null,
124
+ url: data.html_url
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Submit a pending review. Maps the GraphQL `event` enum values
130
+ * (APPROVE/REQUEST_CHANGES/COMMENT) directly to REST's `event` field,
131
+ * which accepts the same uppercase values.
132
+ *
133
+ * @param {Object} octokit
134
+ * @param {string} reviewId - GraphQL node id OR numeric review id
135
+ * @param {string} event - APPROVE | REQUEST_CHANGES | COMMENT
136
+ * @param {string|null} body
137
+ * @param {Object} prContext - { owner, repo, prNumber, reviewId? } - numeric id required for REST
138
+ * @returns {Promise<{id: string, databaseId: number|null, url: string, state: string}>}
139
+ */
140
+ async function submitPullRequestReview(octokit, reviewId, event, body, prContext) {
141
+ requirePRContext(prContext, 'submitPullRequestReview');
142
+
143
+ const numericId = resolveNumericReviewId(reviewId, prContext);
144
+ if (numericId === null) {
145
+ throw new Error(
146
+ 'REST submitPullRequestReview needs a numeric review id. Pass prContext.reviewId ' +
147
+ 'when the reviewId argument is a GraphQL node id.'
148
+ );
149
+ }
150
+
151
+ const { data } = await octokit.rest.pulls.submitReview({
152
+ owner: prContext.owner,
153
+ repo: prContext.repo,
154
+ pull_number: prContext.prNumber,
155
+ review_id: numericId,
156
+ event,
157
+ body: body || ''
158
+ });
159
+
160
+ return {
161
+ id: deriveRestReviewId(data),
162
+ databaseId: typeof data.id === 'number' ? data.id : null,
163
+ url: data.html_url,
164
+ state: data.state
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Delete a pending review (cleanup on failure). Never throws — mirrors
170
+ * the GraphQL impl, which logs and returns false on failure.
171
+ *
172
+ * @param {Object} octokit
173
+ * @param {string} reviewId
174
+ * @param {Object} prContext
175
+ * @returns {Promise<boolean>}
176
+ */
177
+ async function deletePullRequestReview(octokit, reviewId, prContext) {
178
+ try {
179
+ requirePRContext(prContext, 'deletePullRequestReview');
180
+
181
+ const numericId = resolveNumericReviewId(reviewId, prContext);
182
+ if (numericId === null) {
183
+ logger.warn('REST deletePullRequestReview called without a numeric review id; skipping');
184
+ return false;
185
+ }
186
+
187
+ await octokit.rest.pulls.deletePendingReview({
188
+ owner: prContext.owner,
189
+ repo: prContext.repo,
190
+ pull_number: prContext.prNumber,
191
+ review_id: numericId
192
+ });
193
+ logger.info('Cleaned up pending review after failure (REST)');
194
+ return true;
195
+ } catch (cleanupError) {
196
+ logger.warn(`Failed to clean up pending review (REST): ${cleanupError.message}`);
197
+ return false;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Resolve a numeric REST review id from the dispatcher-supplied
203
+ * `reviewId` (which may be either a numeric REST id or a GraphQL node
204
+ * id) plus an optional override on `prContext.reviewId`.
205
+ *
206
+ * @param {string|number} reviewId
207
+ * @param {Object} prContext
208
+ * @returns {number|null}
209
+ */
210
+ function resolveNumericReviewId(reviewId, prContext) {
211
+ if (prContext && (typeof prContext.reviewId === 'number' || typeof prContext.reviewId === 'string')) {
212
+ const fromCtx = Number(prContext.reviewId);
213
+ if (Number.isFinite(fromCtx)) return fromCtx;
214
+ }
215
+ if (typeof reviewId === 'number') return reviewId;
216
+ if (typeof reviewId === 'string' && /^\d+$/.test(reviewId)) return Number(reviewId);
217
+ return null;
218
+ }
219
+
220
+ module.exports = {
221
+ addPullRequestReview,
222
+ addPullRequestReviewWithBody,
223
+ submitPullRequestReview,
224
+ deletePullRequestReview,
225
+ _internals: { resolveNumericReviewId, requirePRContext }
226
+ };