@in-the-loop-labs/pair-review 3.6.0 → 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 (63) 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 +0 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +39 -23
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ReviewModal.js +135 -13
  13. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  14. package/public/js/index.js +175 -16
  15. package/public/js/local.js +58 -8
  16. package/public/js/modules/suggestion-manager.js +25 -1
  17. package/public/js/modules/tour-renderer.js +33 -3
  18. package/public/js/pr.js +653 -157
  19. package/public/js/repo-links.js +328 -0
  20. package/public/js/utils/provider-model.js +88 -0
  21. package/public/js/utils/storage-keys.js +50 -0
  22. package/public/local.html +7 -0
  23. package/public/pr.html +7 -0
  24. package/public/repo-settings.html +1 -0
  25. package/public/setup.html +2 -0
  26. package/src/ai/analyzer.js +125 -18
  27. package/src/config.js +664 -10
  28. package/src/external/github-adapter.js +114 -25
  29. package/src/git/base-branch.js +11 -4
  30. package/src/github/client.js +482 -588
  31. package/src/github/errors.js +55 -0
  32. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  33. package/src/github/impl/graphql/pending-review.js +153 -0
  34. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  35. package/src/github/impl/graphql/stack-walker.js +210 -0
  36. package/src/github/impl/host/pending-review-comments.js +338 -0
  37. package/src/github/impl/rest/pending-review.js +251 -0
  38. package/src/github/impl/rest/review-lifecycle.js +226 -0
  39. package/src/github/impl/rest/stack-walker.js +309 -0
  40. package/src/github/operations/pending-review-comments.js +79 -0
  41. package/src/github/operations/pending-review.js +89 -0
  42. package/src/github/operations/review-lifecycle.js +126 -0
  43. package/src/github/operations/stack-walker.js +87 -0
  44. package/src/github/parser.js +230 -4
  45. package/src/github/stack-walker.js +14 -189
  46. package/src/links/repo-links.js +230 -0
  47. package/src/local-review.js +13 -4
  48. package/src/main.js +133 -30
  49. package/src/routes/analyses.js +30 -7
  50. package/src/routes/bulk-analysis-configs.js +295 -0
  51. package/src/routes/config.js +102 -2
  52. package/src/routes/external-comments.js +20 -10
  53. package/src/routes/github-collections.js +3 -1
  54. package/src/routes/local.js +101 -11
  55. package/src/routes/mcp.js +47 -4
  56. package/src/routes/pr.js +298 -68
  57. package/src/routes/setup.js +8 -3
  58. package/src/routes/stack-analysis.js +33 -9
  59. package/src/routes/worktrees.js +3 -2
  60. package/src/server.js +2 -0
  61. package/src/setup/pr-setup.js +37 -11
  62. package/src/setup/stack-setup.js +13 -3
  63. package/src/single-port.js +6 -3
@@ -0,0 +1,55 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Custom error class for GitHub API errors that preserves the HTTP status code.
5
+ * Route handlers can check `error.status` or use `instanceof GitHubApiError`
6
+ * instead of fragile string matching on error messages.
7
+ */
8
+ class GitHubApiError extends Error {
9
+ /**
10
+ * @param {string} message - Human-readable error message
11
+ * @param {number} status - HTTP status code (e.g. 401, 403, 404, 429)
12
+ */
13
+ constructor(message, status) {
14
+ super(message);
15
+ this.name = 'GitHubApiError';
16
+ this.status = status;
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Detect whether a GraphQL error is a complexity/cost limit error from GitHub.
22
+ * These errors mean the mutation was too large and can be retried with fewer items.
23
+ *
24
+ * @param {Error} error - The error thrown by octokit.graphql
25
+ * @returns {boolean} True if the error is a complexity/cost limit error
26
+ */
27
+ function isComplexityError(error) {
28
+ const patterns = [
29
+ /complexity/i,
30
+ /MAX_NODE_LIMIT/,
31
+ /cost exceeds/i,
32
+ /too large/i,
33
+ /query size exceeds/i,
34
+ ];
35
+
36
+ if (error.message) {
37
+ for (const pattern of patterns) {
38
+ if (pattern.test(error.message)) return true;
39
+ }
40
+ }
41
+
42
+ if (error.errors && Array.isArray(error.errors)) {
43
+ for (const err of error.errors) {
44
+ if (err.message) {
45
+ for (const pattern of patterns) {
46
+ if (pattern.test(err.message)) return true;
47
+ }
48
+ }
49
+ }
50
+ }
51
+
52
+ return false;
53
+ }
54
+
55
+ module.exports = { GitHubApiError, isComplexityError };
@@ -0,0 +1,230 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ const logger = require('../../../utils/logger');
3
+ const { isComplexityError } = require('../../errors');
4
+
5
+ /**
6
+ * GraphQL implementation of the pending-review-comments area.
7
+ *
8
+ * Adds inline comments (line-level, range-level, and file-level) to an
9
+ * already-pending review. Uses the `addPullRequestReviewThread` mutation
10
+ * batched together for throughput, with adaptive batch sizing that halves
11
+ * on GitHub complexity errors.
12
+ *
13
+ * The return shape (success count, failed flag, failedDetails) is the
14
+ * same shape `GitHubClient.addCommentsInBatches` historically returned;
15
+ * callers depend on that exact shape for cleanup and error reporting.
16
+ */
17
+
18
+ const MIN_BATCH_SIZE = 1;
19
+ const DEFAULT_BATCH_SIZE = 10;
20
+
21
+ /**
22
+ * Build the GraphQL mutation text for a batch of comments. Aliases each
23
+ * inner mutation as `comment0`, `comment1`, ... so partial-failure paths
24
+ * can map errors back to individual comments via `error.path[0]`.
25
+ *
26
+ * @param {Array} batch - Slice of comments to include in this mutation
27
+ * @returns {string} The full mutation text
28
+ */
29
+ function buildBatchMutation(batch) {
30
+ const commentMutations = batch.map((comment, index) => {
31
+ const isFileLevel = comment.isFileLevel || !comment.line;
32
+
33
+ if (isFileLevel) {
34
+ return `
35
+ comment${index}: addPullRequestReviewThread(input: {
36
+ pullRequestId: $prId
37
+ pullRequestReviewId: $reviewId
38
+ path: ${JSON.stringify(comment.path)}
39
+ subjectType: FILE
40
+ body: ${JSON.stringify(comment.body)}
41
+ }) {
42
+ thread { id }
43
+ }
44
+ `;
45
+ }
46
+
47
+ const side = comment.side || 'RIGHT';
48
+ const startLineField = comment.start_line ? `startLine: ${comment.start_line}\n ` : '';
49
+ return `
50
+ comment${index}: addPullRequestReviewThread(input: {
51
+ pullRequestId: $prId
52
+ pullRequestReviewId: $reviewId
53
+ path: ${JSON.stringify(comment.path)}
54
+ ${startLineField}line: ${comment.line}
55
+ side: ${side}
56
+ body: ${JSON.stringify(comment.body)}
57
+ }) {
58
+ thread { id }
59
+ }
60
+ `;
61
+ }).join('\n');
62
+
63
+ return `
64
+ mutation AddReviewComments($prId: ID!, $reviewId: ID!) {
65
+ ${commentMutations}
66
+ }
67
+ `;
68
+ }
69
+
70
+ /**
71
+ * Add comments to a pending review in batches, with adaptive batch sizing
72
+ * on GitHub complexity errors. Sequential, with one retry per batch.
73
+ *
74
+ * @param {Object} octokit - Octokit instance (must expose .graphql)
75
+ * @param {string} prNodeId - GraphQL node ID for the PR
76
+ * @param {string} reviewId - GraphQL node ID for the pending review
77
+ * @param {Array} comments - Array of comments with path, line (optional), side, body, isFileLevel
78
+ * @param {number} [batchSize=10] - Initial batch size
79
+ * @returns {Promise<{successCount: number, failed: boolean, failedDetails: string[]}>}
80
+ */
81
+ async function addCommentsInBatches(octokit, prNodeId, reviewId, comments, batchSize = DEFAULT_BATCH_SIZE) {
82
+ if (comments.length === 0) {
83
+ return { successCount: 0, failed: false, failedDetails: [] };
84
+ }
85
+
86
+ let currentBatchSize = batchSize;
87
+ let remaining = comments.slice();
88
+ let totalSuccessful = 0;
89
+ const failedDetails = [];
90
+ let batchNumber = 0;
91
+
92
+ logger.info(`Adding ${comments.length} comments in batches of up to ${currentBatchSize}`);
93
+
94
+ while (remaining.length > 0) {
95
+ batchNumber++;
96
+ const batch = remaining.slice(0, currentBatchSize);
97
+ logger.info(`Adding comments batch ${batchNumber} (${batch.length} comments, ${remaining.length} remaining)...`);
98
+
99
+ const batchMutation = buildBatchMutation(batch);
100
+
101
+ // Try the batch, with one retry on failure
102
+ let batchResult = null;
103
+ let batchError = null;
104
+ let retryAttempt = 0;
105
+ const maxRetries = 1;
106
+ let reducedBatchSize = false;
107
+
108
+ while (retryAttempt <= maxRetries) {
109
+ try {
110
+ batchResult = await octokit.graphql(batchMutation, {
111
+ prId: prNodeId,
112
+ reviewId
113
+ });
114
+ batchError = null;
115
+ break;
116
+ } catch (error) {
117
+ batchError = error;
118
+
119
+ // Complexity/cost limit: halve batch size and re-attempt
120
+ if (isComplexityError(error)) {
121
+ const newSize = Math.max(MIN_BATCH_SIZE, Math.floor(currentBatchSize / 2));
122
+ if (newSize < currentBatchSize) {
123
+ logger.warn(
124
+ `Batch ${batchNumber} hit complexity limit (size ${currentBatchSize}), ` +
125
+ `reducing batch size to ${newSize}`
126
+ );
127
+ currentBatchSize = newSize;
128
+ reducedBatchSize = true;
129
+ break;
130
+ }
131
+ // Already at the minimum - fall through to normal retry logic
132
+ }
133
+
134
+ if (retryAttempt < maxRetries) {
135
+ logger.warn(`Batch ${batchNumber} failed, retrying... (${error.message})`);
136
+ retryAttempt++;
137
+ // Fixed 1-second delay before a single retry. Backoff has no benefit
138
+ // with maxRetries=1; either it works on retry or we clean up.
139
+ await new Promise(resolve => setTimeout(resolve, 1000));
140
+ } else {
141
+ logger.error(`Batch ${batchNumber} failed after retry: ${error.message}`);
142
+ break;
143
+ }
144
+ }
145
+ }
146
+
147
+ if (reducedBatchSize) {
148
+ // Re-attempt the same remaining slice with the smaller batch size.
149
+ continue;
150
+ }
151
+
152
+ if (batchError) {
153
+ // Build a map of per-comment errors from the GraphQL errors array.
154
+ // Each GraphQL error has a `path` like ["comment0"] that maps to the
155
+ // mutation alias, letting us match errors to specific comments.
156
+ const perCommentErrors = {};
157
+ if (batchError.errors && Array.isArray(batchError.errors)) {
158
+ for (const err of batchError.errors) {
159
+ if (err.path && err.path.length > 0) {
160
+ const alias = err.path[0];
161
+ perCommentErrors[alias] = err.message || 'Unknown error';
162
+ }
163
+ }
164
+ }
165
+
166
+ if (batchError.data) {
167
+ logger.warn(`GraphQL returned partial results with errors: ${JSON.stringify(batchError.errors || batchError.message)}`);
168
+ let batchSuccessful = 0;
169
+ for (let i = 0; i < batch.length; i++) {
170
+ const commentResult = batchError.data[`comment${i}`];
171
+ if (commentResult && commentResult.thread && commentResult.thread.id) {
172
+ batchSuccessful++;
173
+ } else {
174
+ const ghError = perCommentErrors[`comment${i}`] || 'No error details available';
175
+ const location = `${batch[i].path}:${batch[i].line || 'file-level'}`;
176
+ logger.warn(`Comment ${i} in batch ${batchNumber} failed to add: ${location} - ${ghError}`);
177
+ failedDetails.push(`${location} - ${ghError}`);
178
+ }
179
+ }
180
+ if (batchSuccessful < batch.length) {
181
+ logger.error(`CRITICAL: Batch ${batchNumber} had ${batch.length - batchSuccessful} failures`);
182
+ return { successCount: totalSuccessful + batchSuccessful, failed: true, failedDetails };
183
+ }
184
+ logger.info(`Batch ${batchNumber} complete (recovered from partial error): ${batchSuccessful} comments added`);
185
+ totalSuccessful += batchSuccessful;
186
+ } else {
187
+ const totalError = batchError.message || 'Unknown error';
188
+ logger.error(`CRITICAL: Batch ${batchNumber} failed completely: ${totalError}`);
189
+ for (let i = 0; i < batch.length; i++) {
190
+ const ghError = perCommentErrors[`comment${i}`] || totalError;
191
+ const location = `${batch[i].path}:${batch[i].line || 'file-level'}`;
192
+ failedDetails.push(`${location} - ${ghError}`);
193
+ }
194
+ return { successCount: totalSuccessful, failed: true, failedDetails };
195
+ }
196
+ } else if (batchResult) {
197
+ let batchSuccessful = 0;
198
+ for (let i = 0; i < batch.length; i++) {
199
+ const commentResult = batchResult[`comment${i}`];
200
+ if (commentResult && commentResult.thread && commentResult.thread.id) {
201
+ batchSuccessful++;
202
+ } else {
203
+ const location = `${batch[i].path}:${batch[i].line || 'file-level'}`;
204
+ logger.warn(`Comment ${i} in batch ${batchNumber} failed to add: ${location} - No error details available`);
205
+ failedDetails.push(`${location} - No error details available`);
206
+ }
207
+ }
208
+
209
+ if (batchSuccessful < batch.length) {
210
+ logger.error(`CRITICAL: Batch ${batchNumber} had ${batch.length - batchSuccessful} failures`);
211
+ return { successCount: totalSuccessful + batchSuccessful, failed: true, failedDetails };
212
+ }
213
+
214
+ totalSuccessful += batchSuccessful;
215
+ logger.info(`Batch ${batchNumber} complete: ${batchSuccessful} comments added`);
216
+ }
217
+
218
+ remaining = remaining.slice(batch.length);
219
+ }
220
+
221
+ logger.info(`All batches complete: ${totalSuccessful} total comments added`);
222
+ return { successCount: totalSuccessful, failed: false, failedDetails };
223
+ }
224
+
225
+ module.exports = {
226
+ addCommentsInBatches,
227
+ buildBatchMutation,
228
+ MIN_BATCH_SIZE,
229
+ DEFAULT_BATCH_SIZE
230
+ };
@@ -0,0 +1,153 @@
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
+ * GraphQL implementation of the pending-review-check area.
7
+ *
8
+ * Provides:
9
+ * - getPendingReviewForUser(owner, repo, prNumber)
10
+ * - getReviewById(nodeId)
11
+ *
12
+ * Each function takes an Octokit-like client as its first parameter and
13
+ * returns the same shape `GitHubClient` historically returned.
14
+ */
15
+
16
+ const PENDING_REVIEW_QUERY = `
17
+ query($owner: String!, $repo: String!, $prNumber: Int!) {
18
+ repository(owner: $owner, name: $repo) {
19
+ pullRequest(number: $prNumber) {
20
+ reviews(states: PENDING, first: 1) {
21
+ nodes {
22
+ id
23
+ databaseId
24
+ body
25
+ url
26
+ state
27
+ createdAt
28
+ viewerDidAuthor
29
+ comments {
30
+ totalCount
31
+ }
32
+ }
33
+ }
34
+ }
35
+ }
36
+ }
37
+ `;
38
+
39
+ const REVIEW_BY_ID_QUERY = `
40
+ query($nodeId: ID!) {
41
+ node(id: $nodeId) {
42
+ ... on PullRequestReview {
43
+ id
44
+ state
45
+ submittedAt
46
+ url
47
+ }
48
+ }
49
+ }
50
+ `;
51
+
52
+ /**
53
+ * GitHub allows only ONE pending review per user per PR, so this returns
54
+ * either the single pending review or null if none exists.
55
+ *
56
+ * @param {Object} octokit - Octokit instance (must expose .graphql)
57
+ * @param {string} owner - Repository owner
58
+ * @param {string} repo - Repository name
59
+ * @param {number} prNumber - Pull request number
60
+ * @returns {Promise<Object|null>} The pending review object or null
61
+ */
62
+ async function getPendingReviewForUser(octokit, owner, repo, prNumber) {
63
+ try {
64
+ logger.debug(`Checking for pending review on PR #${prNumber} in ${owner}/${repo}`);
65
+
66
+ const result = await octokit.graphql(PENDING_REVIEW_QUERY, {
67
+ owner,
68
+ repo,
69
+ prNumber
70
+ });
71
+
72
+ const reviews = result.repository?.pullRequest?.reviews?.nodes || [];
73
+ const userPendingReview = reviews.find(review => review.viewerDidAuthor);
74
+
75
+ if (userPendingReview) {
76
+ logger.debug(`Found pending review for user: ${userPendingReview.id} with ${userPendingReview.comments.totalCount} comments`);
77
+ return {
78
+ id: userPendingReview.id,
79
+ databaseId: userPendingReview.databaseId,
80
+ body: userPendingReview.body,
81
+ url: userPendingReview.url,
82
+ state: userPendingReview.state,
83
+ createdAt: userPendingReview.createdAt,
84
+ comments: {
85
+ totalCount: userPendingReview.comments.totalCount
86
+ }
87
+ };
88
+ }
89
+
90
+ logger.debug('No pending review found for user');
91
+ return null;
92
+ } catch (error) {
93
+ logger.error(`Error checking for pending review: ${error.message}`);
94
+
95
+ if (error.status === 401) {
96
+ throw new GitHubApiError('GitHub authentication failed. Check your token in ~/.pair-review/config.json', 401);
97
+ }
98
+
99
+ if (error.status === 404 || error.errors?.some(e => e.type === 'NOT_FOUND')) {
100
+ throw new GitHubApiError(`Pull request #${prNumber} not found in repository ${owner}/${repo}`, 404);
101
+ }
102
+
103
+ if (error.errors) {
104
+ const messages = error.errors.map(e => e.message).join(', ');
105
+ throw new Error(`GitHub GraphQL error: ${messages}`);
106
+ }
107
+
108
+ throw new Error(`Failed to check for pending review: ${error.message}`);
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Fetch a review by its GraphQL node ID.
114
+ *
115
+ * @param {Object} octokit - Octokit instance (must expose .graphql)
116
+ * @param {string} nodeId - GraphQL node ID for the review
117
+ * @returns {Promise<Object|null>} Review data or null if not found
118
+ */
119
+ async function getReviewById(octokit, nodeId) {
120
+ try {
121
+ logger.debug(`Fetching review by node ID: ${nodeId}`);
122
+
123
+ const result = await octokit.graphql(REVIEW_BY_ID_QUERY, { nodeId });
124
+
125
+ if (!result.node || !result.node.id) {
126
+ logger.debug(`Review not found for node ID: ${nodeId}`);
127
+ return null;
128
+ }
129
+
130
+ const review = result.node;
131
+ logger.debug(`Found review ${nodeId}: state=${review.state}, submittedAt=${review.submittedAt}`);
132
+
133
+ return {
134
+ id: review.id,
135
+ state: review.state,
136
+ submittedAt: review.submittedAt,
137
+ url: review.url
138
+ };
139
+ } catch (error) {
140
+ if (error.errors?.some(e => e.type === 'NOT_FOUND' || e.message?.includes('not found'))) {
141
+ logger.debug(`Review not found for node ID: ${nodeId}`);
142
+ return null;
143
+ }
144
+
145
+ logger.warn(`Error fetching review by node ID ${nodeId}: ${error.message}`);
146
+ return null;
147
+ }
148
+ }
149
+
150
+ module.exports = {
151
+ getPendingReviewForUser,
152
+ getReviewById
153
+ };
@@ -0,0 +1,161 @@
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 review-lifecycle area:
6
+ * - addPullRequestReview (with or without body)
7
+ * - submitPullRequestReview
8
+ * - deletePullRequestReview
9
+ *
10
+ * Each function takes an Octokit-like client as its first parameter.
11
+ * Return shapes match what `GitHubClient` historically returned for these
12
+ * primitives so callers (notably the orchestration in `createReviewGraphQL`
13
+ * and `createDraftReviewGraphQL`) remain byte-identical.
14
+ */
15
+
16
+ const ADD_REVIEW_MUTATION = `
17
+ mutation AddPendingReview($prId: ID!) {
18
+ addPullRequestReview(input: {
19
+ pullRequestId: $prId
20
+ }) {
21
+ pullRequestReview {
22
+ id
23
+ databaseId
24
+ }
25
+ }
26
+ }
27
+ `;
28
+
29
+ const ADD_REVIEW_WITH_BODY_MUTATION = `
30
+ mutation AddPendingReview($prId: ID!, $body: String) {
31
+ addPullRequestReview(input: {
32
+ pullRequestId: $prId
33
+ body: $body
34
+ }) {
35
+ pullRequestReview {
36
+ id
37
+ databaseId
38
+ url
39
+ }
40
+ }
41
+ }
42
+ `;
43
+
44
+ const SUBMIT_REVIEW_MUTATION = `
45
+ mutation SubmitReview($reviewId: ID!, $event: PullRequestReviewEvent!, $body: String) {
46
+ submitPullRequestReview(input: {
47
+ pullRequestReviewId: $reviewId
48
+ event: $event
49
+ body: $body
50
+ }) {
51
+ pullRequestReview {
52
+ id
53
+ databaseId
54
+ url
55
+ state
56
+ }
57
+ }
58
+ }
59
+ `;
60
+
61
+ const DELETE_REVIEW_MUTATION = `
62
+ mutation DeleteReview($reviewId: ID!) {
63
+ deletePullRequestReview(input: { pullRequestReviewId: $reviewId }) {
64
+ pullRequestReview { id }
65
+ }
66
+ }
67
+ `;
68
+
69
+ /**
70
+ * Create a pending review (no body). Used by the submit-review flow that
71
+ * passes the body later via `submitPullRequestReview`.
72
+ *
73
+ * Returns both the GraphQL node id (`id`) and the numeric database id
74
+ * (`databaseId`). The numeric id is required by the orchestration in
75
+ * `client.js` to address the same review via REST/host endpoints (e.g.
76
+ * the host `addCommentsInBatches` extension, which is path-shaped).
77
+ *
78
+ * @param {Object} octokit
79
+ * @param {string} prNodeId
80
+ * @returns {Promise<{id: string, databaseId: number|null}>}
81
+ */
82
+ async function addPullRequestReview(octokit, prNodeId) {
83
+ const result = await octokit.graphql(ADD_REVIEW_MUTATION, { prId: prNodeId });
84
+ const review = result.addPullRequestReview.pullRequestReview;
85
+ return {
86
+ id: review.id,
87
+ databaseId: typeof review.databaseId === 'number' ? review.databaseId : null
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Create a pending review with a body. Used by the draft-review flow which
93
+ * persists the summary on the pending review itself.
94
+ *
95
+ * @param {Object} octokit
96
+ * @param {string} prNodeId
97
+ * @param {string|null} body
98
+ * @returns {Promise<{id: string, databaseId: number|null, url: string}>}
99
+ */
100
+ async function addPullRequestReviewWithBody(octokit, prNodeId, body) {
101
+ const result = await octokit.graphql(ADD_REVIEW_WITH_BODY_MUTATION, {
102
+ prId: prNodeId,
103
+ body: body || null
104
+ });
105
+ const review = result.addPullRequestReview.pullRequestReview;
106
+ return {
107
+ id: review.id,
108
+ databaseId: review.databaseId,
109
+ url: review.url
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Submit a pending review with the chosen event (APPROVE/REQUEST_CHANGES/COMMENT).
115
+ *
116
+ * @param {Object} octokit
117
+ * @param {string} reviewId - GraphQL node ID of the pending review
118
+ * @param {string} event - APPROVE | REQUEST_CHANGES | COMMENT
119
+ * @param {string|null} body
120
+ * @returns {Promise<{id: string, databaseId: number|null, url: string, state: string}>}
121
+ */
122
+ async function submitPullRequestReview(octokit, reviewId, event, body) {
123
+ const result = await octokit.graphql(SUBMIT_REVIEW_MUTATION, {
124
+ reviewId,
125
+ event,
126
+ body: body || null
127
+ });
128
+ const review = result.submitPullRequestReview.pullRequestReview;
129
+ return {
130
+ id: review.id,
131
+ databaseId: review.databaseId,
132
+ url: review.url,
133
+ state: review.state
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Delete a pending review (cleanup on failure). Never throws; logs and
139
+ * returns false on failure.
140
+ *
141
+ * @param {Object} octokit
142
+ * @param {string} reviewId
143
+ * @returns {Promise<boolean>}
144
+ */
145
+ async function deletePullRequestReview(octokit, reviewId) {
146
+ try {
147
+ await octokit.graphql(DELETE_REVIEW_MUTATION, { reviewId });
148
+ logger.info('Cleaned up pending review after failure');
149
+ return true;
150
+ } catch (cleanupError) {
151
+ logger.warn(`Failed to clean up pending review: ${cleanupError.message}`);
152
+ return false;
153
+ }
154
+ }
155
+
156
+ module.exports = {
157
+ addPullRequestReview,
158
+ addPullRequestReviewWithBody,
159
+ submitPullRequestReview,
160
+ deletePullRequestReview
161
+ };