@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.
- package/README.md +4 -0
- package/package.json +20 -15
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +0 -1737
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +58 -8
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +33 -3
- package/public/js/pr.js +653 -157
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +7 -0
- package/public/pr.html +7 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/analyzer.js +125 -18
- package/src/config.js +664 -10
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +13 -4
- package/src/main.js +133 -30
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +102 -2
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +101 -11
- package/src/routes/mcp.js +47 -4
- package/src/routes/pr.js +298 -68
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- 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
|
+
};
|