@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.
- package/README.md +4 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +1029 -2169
- 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/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/TourBar.js +248 -0
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +64 -8
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +755 -0
- package/public/js/pr.js +1826 -56
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/modal-detection.js +77 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +24 -0
- package/public/pr.html +24 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/analyzer.js +125 -18
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +778 -10
- package/src/database.js +282 -1
- 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 +201 -172
- 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 +118 -3
- package/src/routes/context-files.js +2 -29
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +410 -13
- package/src/routes/mcp.js +47 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +556 -71
- package/src/routes/reviews.js +145 -29
- 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
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
package/src/github/client.js
CHANGED
|
@@ -2,89 +2,235 @@
|
|
|
2
2
|
const { Octokit } = require('@octokit/rest');
|
|
3
3
|
const logger = require('../utils/logger');
|
|
4
4
|
const { DEFAULT_SHA_ABBREV_LENGTH } = require('../git/sha-abbrev');
|
|
5
|
+
const { GitHubApiError, isComplexityError } = require('./errors');
|
|
6
|
+
const pendingReviewOps = require('./operations/pending-review');
|
|
7
|
+
const reviewLifecycleOps = require('./operations/review-lifecycle');
|
|
8
|
+
const pendingReviewCommentsOps = require('./operations/pending-review-comments');
|
|
9
|
+
|
|
10
|
+
// Defaults used when `GitHubClient` is constructed from a bare token
|
|
11
|
+
// string (i.e. without a resolved binding). These mirror the
|
|
12
|
+
// config-resolved defaults for github.com: every area listed in
|
|
13
|
+
// `GRAPHQL_DEFAULT_AREAS` in `src/config.js` defaults to "graphql".
|
|
14
|
+
// If you add a new area to `FEATURE_AREAS` in `src/config.js`, mirror it
|
|
15
|
+
// here with the appropriate default.
|
|
16
|
+
const DEFAULT_FEATURES = Object.freeze({
|
|
17
|
+
pending_review_check: 'graphql',
|
|
18
|
+
stack_walker: 'graphql',
|
|
19
|
+
review_lifecycle: 'graphql',
|
|
20
|
+
pending_review_comments: 'graphql'
|
|
21
|
+
});
|
|
5
22
|
|
|
6
23
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
24
|
+
* Build the `Authorization` header value for a token, mirroring
|
|
25
|
+
* `@octokit/auth-token`'s scheme selection: JWTs (three dot-delimited
|
|
26
|
+
* segments) use the `bearer` scheme; everything else (classic/fine-grained
|
|
27
|
+
* PATs, installation tokens, alt-host token-command output) uses `token`.
|
|
28
|
+
*
|
|
29
|
+
* We stamp this header ourselves via an Octokit `before` hook instead of
|
|
30
|
+
* passing `auth` to the constructor, so a token refreshed mid-flight reaches
|
|
31
|
+
* every request without rebuilding the client. Replicating the prefix logic
|
|
32
|
+
* here keeps behaviour identical to the previous `auth: token` path.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} token
|
|
35
|
+
* @returns {string}
|
|
10
36
|
*/
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
* @param {string} message - Human-readable error message
|
|
14
|
-
* @param {number} status - HTTP status code (e.g. 401, 403, 404, 429)
|
|
15
|
-
*/
|
|
16
|
-
constructor(message, status) {
|
|
17
|
-
super(message);
|
|
18
|
-
this.name = 'GitHubApiError';
|
|
19
|
-
this.status = status;
|
|
20
|
-
}
|
|
37
|
+
function withAuthorizationPrefix(token) {
|
|
38
|
+
return token.split('.').length === 3 ? `bearer ${token}` : `token ${token}`;
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
42
|
+
* Normalise the constructor argument into a binding object. Accepts the
|
|
43
|
+
* legacy "bare token string" shape and the new
|
|
44
|
+
* `{ token, apiHost, features }` shape so existing callers and tests do
|
|
45
|
+
* not need to be updated.
|
|
46
|
+
*
|
|
47
|
+
* The optional `refresh` capability from an object binding is preserved so
|
|
48
|
+
* the client can re-run a token command and retry on a 401 (see
|
|
49
|
+
* `resolveHostBinding` in `src/config.js`). The legacy bare-token path keeps
|
|
50
|
+
* `refresh: null`, so a github.com PAT client behaves exactly as before
|
|
51
|
+
* (no hook-driven retry).
|
|
26
52
|
*
|
|
27
|
-
* @param {
|
|
28
|
-
* @returns {
|
|
53
|
+
* @param {string|Object} arg - Token string or binding object
|
|
54
|
+
* @returns {{ token: string, apiHost: string|null, features: Object, refresh: (function(): (string|Promise<string>))|null }}
|
|
29
55
|
*/
|
|
30
|
-
function
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// Check the top-level error message
|
|
40
|
-
if (error.message) {
|
|
41
|
-
for (const pattern of patterns) {
|
|
42
|
-
if (pattern.test(error.message)) return true;
|
|
43
|
-
}
|
|
56
|
+
function normaliseBinding(arg) {
|
|
57
|
+
if (typeof arg === 'string') {
|
|
58
|
+
return {
|
|
59
|
+
token: arg,
|
|
60
|
+
apiHost: null,
|
|
61
|
+
features: { ...DEFAULT_FEATURES },
|
|
62
|
+
refresh: null
|
|
63
|
+
};
|
|
44
64
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
}
|
|
54
|
-
}
|
|
65
|
+
if (arg && typeof arg === 'object') {
|
|
66
|
+
const token = typeof arg.token === 'string' ? arg.token : '';
|
|
67
|
+
const apiHost = (typeof arg.apiHost === 'string' && arg.apiHost) ? arg.apiHost : null;
|
|
68
|
+
const features = (arg.features && typeof arg.features === 'object')
|
|
69
|
+
? { ...DEFAULT_FEATURES, ...arg.features }
|
|
70
|
+
: { ...DEFAULT_FEATURES };
|
|
71
|
+
const refresh = typeof arg.refresh === 'function' ? arg.refresh : null;
|
|
72
|
+
return { token, apiHost, features, refresh };
|
|
55
73
|
}
|
|
56
|
-
|
|
57
|
-
return false;
|
|
74
|
+
return { token: '', apiHost: null, features: { ...DEFAULT_FEATURES }, refresh: null };
|
|
58
75
|
}
|
|
59
76
|
|
|
60
|
-
const MIN_BATCH_SIZE = 1;
|
|
61
|
-
|
|
62
77
|
/**
|
|
63
|
-
* GitHub API client wrapper with error handling and rate limiting
|
|
78
|
+
* GitHub API client wrapper with error handling and rate limiting.
|
|
79
|
+
*
|
|
80
|
+
* Constructor accepts either a bare token string (legacy) or a binding
|
|
81
|
+
* object `{ token, apiHost, features }` returned by
|
|
82
|
+
* `resolveHostBinding()`. When a binding is provided, `apiHost` is passed
|
|
83
|
+
* to Octokit as `baseUrl` (defaults to `api.github.com` when null) and
|
|
84
|
+
* `features` controls per-area dispatch into the operations layer.
|
|
85
|
+
*
|
|
86
|
+
* The public method signatures remain identical to the pre-refactor
|
|
87
|
+
* shape — all GraphQL operations are now thin delegations to the
|
|
88
|
+
* per-area dispatchers in `src/github/operations/`.
|
|
64
89
|
*/
|
|
65
90
|
class GitHubClient {
|
|
66
|
-
constructor(
|
|
67
|
-
|
|
91
|
+
constructor(tokenOrBinding) {
|
|
92
|
+
const binding = normaliseBinding(tokenOrBinding);
|
|
93
|
+
if (!binding.token) {
|
|
68
94
|
throw new Error('GitHub token is required');
|
|
69
95
|
}
|
|
70
|
-
|
|
71
|
-
this.
|
|
72
|
-
|
|
96
|
+
|
|
97
|
+
this.binding = binding;
|
|
98
|
+
this.features = binding.features;
|
|
99
|
+
this.apiHost = binding.apiHost;
|
|
100
|
+
this.token = binding.token;
|
|
101
|
+
// Capability to obtain a fresh token (e.g. re-run a token_command).
|
|
102
|
+
// Only present for refreshable bindings; null for bare-token / literal
|
|
103
|
+
// / env sources, which therefore get NO 401 refresh-and-retry behaviour.
|
|
104
|
+
this.refresh = binding.refresh;
|
|
105
|
+
|
|
106
|
+
// In-flight token refresh, shared across all concurrent/in-flight
|
|
107
|
+
// requests so a burst of 401s triggers exactly one `refresh()`. Reset to
|
|
108
|
+
// null once that refresh settles. See `_buildOctokit`.
|
|
109
|
+
this._refreshing = null;
|
|
110
|
+
|
|
111
|
+
this.octokit = this._buildOctokit();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build the single, long-lived Octokit instance and register its request
|
|
116
|
+
* hooks. There is exactly ONE instance for the lifetime of the client — a
|
|
117
|
+
* mid-flight token refresh updates `this.token` in place rather than
|
|
118
|
+
* swapping the instance, so in-flight work (e.g. later pages of an
|
|
119
|
+
* `octokit.paginate` loop, concurrent requests) observes the new token
|
|
120
|
+
* instead of staying bound to a stale instance.
|
|
121
|
+
*
|
|
122
|
+
* Two hooks are registered, and ORDER MATTERS — `before` is registered
|
|
123
|
+
* first so it sits innermost and re-runs when the `wrap` hook re-issues a
|
|
124
|
+
* request:
|
|
125
|
+
*
|
|
126
|
+
* 1. `before('request')` stamps the CURRENT `this.token` onto the
|
|
127
|
+
* `Authorization` header of every outgoing request at dispatch time
|
|
128
|
+
* (REST via `octokit.rest.*`/`octokit.paginate` AND GraphQL via
|
|
129
|
+
* `octokit.graphql` — both flow through this pipeline). We do this
|
|
130
|
+
* instead of passing `auth` to the constructor precisely so the token
|
|
131
|
+
* is read late, per-request.
|
|
132
|
+
* 2. `wrap('request')` implements refresh-on-401: on a 401, if a `refresh`
|
|
133
|
+
* capability exists and the request has not already been retried, it
|
|
134
|
+
* obtains a fresh token (coalescing concurrent refreshes onto one shared
|
|
135
|
+
* promise) and re-issues the request exactly once.
|
|
136
|
+
*
|
|
137
|
+
* @returns {Octokit}
|
|
138
|
+
*/
|
|
139
|
+
_buildOctokit() {
|
|
140
|
+
const octokit = new Octokit({
|
|
141
|
+
baseUrl: this.apiHost || undefined,
|
|
73
142
|
userAgent: 'pair-review v1.0.0'
|
|
74
143
|
});
|
|
144
|
+
|
|
145
|
+
// (1) Stamp the live token onto every request. Reads `this.token` at
|
|
146
|
+
// dispatch time, so requests issued after a refresh — including
|
|
147
|
+
// pagination follow-ups and the retry below — always carry the latest
|
|
148
|
+
// token. Registered BEFORE the wrap hook so it re-runs on the retry.
|
|
149
|
+
octokit.hook.before('request', (options) => {
|
|
150
|
+
options.headers = {
|
|
151
|
+
...options.headers,
|
|
152
|
+
authorization: withAuthorizationPrefix(this.token)
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// (2) Refresh-on-401, with concurrency-safe coalescing.
|
|
157
|
+
octokit.hook.wrap('request', async (request, options) => {
|
|
158
|
+
try {
|
|
159
|
+
return await request(options);
|
|
160
|
+
} catch (error) {
|
|
161
|
+
// Only token-expiry (401) is recoverable by re-running the token
|
|
162
|
+
// command. 403 (rate-limit/permissions), 404, 422, and 5xx are NOT
|
|
163
|
+
// auth-expiry and must not trigger a refresh.
|
|
164
|
+
if (error.status !== 401) {
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
// No refresh capability (bare-token / literal / env source), or this
|
|
168
|
+
// request was already retried once → propagate without looping.
|
|
169
|
+
if (typeof this.refresh !== 'function' || options.request?._pairReviewRetried) {
|
|
170
|
+
throw error;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Coalesce concurrent/in-flight 401s onto a SINGLE refresh. Without
|
|
174
|
+
// this, every straggler bound to the now-stale token (the next page
|
|
175
|
+
// of a `paginate` loop, sibling concurrent requests) would call
|
|
176
|
+
// `refresh()` independently. The first 401 to arrive starts the
|
|
177
|
+
// refresh; the rest await the same promise. The promise resolves to
|
|
178
|
+
// whether the token actually changed — an empty/unchanged result (or
|
|
179
|
+
// a thrown refresh) means retrying cannot help, so we re-throw the
|
|
180
|
+
// original 401 rather than burn a pointless attempt.
|
|
181
|
+
if (!this._refreshing) {
|
|
182
|
+
const previousToken = this.token;
|
|
183
|
+
this._refreshing = (async () => {
|
|
184
|
+
try {
|
|
185
|
+
const fresh = await this.refresh();
|
|
186
|
+
if (fresh && fresh !== this.token) {
|
|
187
|
+
this.token = fresh;
|
|
188
|
+
}
|
|
189
|
+
} catch (refreshError) {
|
|
190
|
+
logger.warn(`Token refresh after 401 failed: ${refreshError.message}`);
|
|
191
|
+
} finally {
|
|
192
|
+
this._refreshing = null;
|
|
193
|
+
}
|
|
194
|
+
return this.token !== previousToken;
|
|
195
|
+
})();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const tokenChanged = await this._refreshing;
|
|
199
|
+
if (!tokenChanged) {
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const host = this.apiHost || 'api.github.com';
|
|
204
|
+
logger.info(`401 from ${host}; refreshed token and retrying request once`);
|
|
205
|
+
|
|
206
|
+
// Re-issue once through the full pipeline. Mark `_pairReviewRetried`
|
|
207
|
+
// so a still-failing fresh token throws instead of looping. Strip the
|
|
208
|
+
// stale `authorization` header and the inherited `hook` binding so the
|
|
209
|
+
// `before` hook re-stamps the fresh token cleanly on the retry.
|
|
210
|
+
const { hook: _staleHook, ...staleRequest } = options.request || {};
|
|
211
|
+
const { authorization: _staleAuth, ...staleHeaders } = options.headers || {};
|
|
212
|
+
return await this.octokit.request({
|
|
213
|
+
...options,
|
|
214
|
+
headers: staleHeaders,
|
|
215
|
+
request: { ...staleRequest, _pairReviewRetried: true }
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return octokit;
|
|
75
221
|
}
|
|
76
222
|
|
|
77
223
|
/**
|
|
78
224
|
* Fetch pull request data from GitHub API
|
|
79
225
|
* @param {string} owner - Repository owner
|
|
80
|
-
* @param {string} repo - Repository name
|
|
226
|
+
* @param {string} repo - Repository name
|
|
81
227
|
* @param {number} pullNumber - Pull request number
|
|
82
228
|
* @returns {Promise<Object>} Pull request data
|
|
83
229
|
*/
|
|
84
230
|
async fetchPullRequest(owner, repo, pullNumber) {
|
|
85
231
|
try {
|
|
86
232
|
console.log(`Fetching pull request #${pullNumber} from ${owner}/${repo}`);
|
|
87
|
-
|
|
233
|
+
|
|
88
234
|
const { data } = await this.octokit.rest.pulls.get({
|
|
89
235
|
owner,
|
|
90
236
|
repo,
|
|
@@ -98,7 +244,7 @@ class GitHubClient {
|
|
|
98
244
|
body: data.body || '',
|
|
99
245
|
author: data.user.login,
|
|
100
246
|
state: data.state,
|
|
101
|
-
merged: data.merged || false,
|
|
247
|
+
merged: data.merged || false,
|
|
102
248
|
base_branch: data.base.ref,
|
|
103
249
|
head_branch: data.head.ref,
|
|
104
250
|
base_sha: data.base.sha,
|
|
@@ -246,7 +392,6 @@ class GitHubClient {
|
|
|
246
392
|
* @throws {Error} Reformatted error with user-friendly message
|
|
247
393
|
*/
|
|
248
394
|
async handleApiError(error, owner, repo, pullNumber) {
|
|
249
|
-
// Only log detailed errors for debugging if verbose mode is enabled
|
|
250
395
|
if (process.env.VERBOSE || logger.isDebugEnabled()) {
|
|
251
396
|
console.error('GitHub API error:', error);
|
|
252
397
|
}
|
|
@@ -300,17 +445,14 @@ class GitHubClient {
|
|
|
300
445
|
throw new GitHubApiError('GitHub authentication failed. Check your token in ~/.pair-review/config.json', 401);
|
|
301
446
|
}
|
|
302
447
|
|
|
303
|
-
// Handle not found errors
|
|
304
448
|
if (error.status === 404) {
|
|
305
449
|
throw new GitHubApiError(`Pull request #${pullNumber} not found in repository ${owner}/${repo}`, 404);
|
|
306
450
|
}
|
|
307
451
|
|
|
308
|
-
// Handle network errors
|
|
309
452
|
if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
|
|
310
453
|
throw new GitHubApiError(`Network error: ${error.message}. Please check your internet connection.`, 503);
|
|
311
454
|
}
|
|
312
455
|
|
|
313
|
-
// Generic error
|
|
314
456
|
throw new Error(`GitHub API error: ${error.message}`);
|
|
315
457
|
}
|
|
316
458
|
|
|
@@ -331,44 +473,35 @@ class GitHubClient {
|
|
|
331
473
|
const reviewType = event === 'DRAFT' ? 'draft review' : 'review';
|
|
332
474
|
console.log(`Creating ${reviewType} for PR #${pullNumber} in ${owner}/${repo}`);
|
|
333
475
|
|
|
334
|
-
// Validate event type
|
|
335
476
|
const validEvents = ['APPROVE', 'REQUEST_CHANGES', 'COMMENT', 'DRAFT'];
|
|
336
477
|
if (!validEvents.includes(event)) {
|
|
337
478
|
throw new Error(`Invalid review event: ${event}. Must be one of: ${validEvents.join(', ')}`);
|
|
338
479
|
}
|
|
339
480
|
|
|
340
|
-
// Convert comments to GitHub API format with position calculation
|
|
341
481
|
const formattedComments = [];
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
'.pdf', '.zip', '.tar', '.gz', '.exe', '.dll', '.so',
|
|
482
|
+
|
|
483
|
+
const binaryExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.svg',
|
|
484
|
+
'.pdf', '.zip', '.tar', '.gz', '.exe', '.dll', '.so',
|
|
346
485
|
'.dylib', '.bin', '.dat', '.db', '.sqlite'];
|
|
347
|
-
|
|
486
|
+
|
|
348
487
|
for (const comment of comments) {
|
|
349
488
|
if (!comment.path || !comment.body) {
|
|
350
489
|
throw new Error('Each comment must have a path and body');
|
|
351
490
|
}
|
|
352
491
|
|
|
353
|
-
// Skip binary files - GitHub doesn't allow comments on them
|
|
354
492
|
const isBinary = binaryExtensions.some(ext => comment.path.toLowerCase().endsWith(ext));
|
|
355
493
|
if (isBinary) {
|
|
356
494
|
console.warn(`Skipping comment on binary file: ${comment.path} (GitHub doesn't support comments on binary files)`);
|
|
357
495
|
continue;
|
|
358
496
|
}
|
|
359
497
|
|
|
360
|
-
|
|
361
|
-
// This is more stable than position-based comments and works for lines
|
|
362
|
-
// outside the diff context (e.g., expanded context lines)
|
|
363
|
-
const side = comment.side || 'RIGHT'; // LEFT for deleted lines, RIGHT for added/context
|
|
498
|
+
const side = comment.side || 'RIGHT';
|
|
364
499
|
const commitId = comment.commit_id;
|
|
365
500
|
|
|
366
501
|
if (!commitId) {
|
|
367
502
|
console.error(`Missing commit_id for comment on ${comment.path}:${comment.line} - comment will likely fail`);
|
|
368
503
|
}
|
|
369
504
|
|
|
370
|
-
// Always use line/side approach (GitHub's modern API)
|
|
371
|
-
// Note: commit_id is set at the review level, not per-comment
|
|
372
505
|
const isRange = comment.start_line && comment.start_line !== comment.line;
|
|
373
506
|
if (isRange) {
|
|
374
507
|
console.log(`Formatting range comment for ${comment.path}:${comment.start_line}-${comment.line} (side: ${side})`);
|
|
@@ -383,7 +516,6 @@ class GitHubClient {
|
|
|
383
516
|
body: comment.body
|
|
384
517
|
};
|
|
385
518
|
|
|
386
|
-
// For multi-line comments, add start_line and start_side
|
|
387
519
|
if (isRange) {
|
|
388
520
|
formatted.start_line = comment.start_line;
|
|
389
521
|
formatted.start_side = comment.start_side || side;
|
|
@@ -393,20 +525,17 @@ class GitHubClient {
|
|
|
393
525
|
}
|
|
394
526
|
|
|
395
527
|
console.log(`Formatted ${formattedComments.length} comments for ${reviewType}`);
|
|
396
|
-
|
|
397
|
-
// Check if we have any comments after filtering
|
|
528
|
+
|
|
398
529
|
if (comments.length > 0 && formattedComments.length === 0) {
|
|
399
530
|
console.warn('All comments were on binary files and were skipped');
|
|
400
|
-
// Allow review to proceed without inline comments if there's a review body
|
|
401
531
|
if (!body || body.trim() === '') {
|
|
402
|
-
const errorMessage = event === 'DRAFT' ?
|
|
532
|
+
const errorMessage = event === 'DRAFT' ?
|
|
403
533
|
'Cannot create draft review: all comments are on binary files (GitHub does not support comments on binary files) and no review summary was provided' :
|
|
404
534
|
'Cannot submit review: all comments are on binary files (GitHub does not support comments on binary files) and no review summary was provided';
|
|
405
535
|
throw new Error(errorMessage);
|
|
406
536
|
}
|
|
407
537
|
}
|
|
408
538
|
|
|
409
|
-
// Extract commit_id from first comment (all comments should have the same one)
|
|
410
539
|
const commitId = comments.length > 0 ? comments[0].commit_id : null;
|
|
411
540
|
if (commitId) {
|
|
412
541
|
console.log(`Using commit_id for review: ${commitId.substring(0, DEFAULT_SHA_ABBREV_LENGTH)}`);
|
|
@@ -414,7 +543,6 @@ class GitHubClient {
|
|
|
414
543
|
console.warn('No commit_id available - review may fail for lines outside diff');
|
|
415
544
|
}
|
|
416
545
|
|
|
417
|
-
// Build GitHub API payload
|
|
418
546
|
const payload = {
|
|
419
547
|
owner,
|
|
420
548
|
repo,
|
|
@@ -423,12 +551,10 @@ class GitHubClient {
|
|
|
423
551
|
comments: formattedComments
|
|
424
552
|
};
|
|
425
553
|
|
|
426
|
-
// Add commit_id at review level (required for line/side comments)
|
|
427
554
|
if (commitId) {
|
|
428
555
|
payload.commit_id = commitId;
|
|
429
556
|
}
|
|
430
557
|
|
|
431
|
-
// Only include event field for non-DRAFT reviews
|
|
432
558
|
if (event !== 'DRAFT') {
|
|
433
559
|
payload.event = event;
|
|
434
560
|
}
|
|
@@ -438,10 +564,9 @@ class GitHubClient {
|
|
|
438
564
|
comments: payload.comments.length + ' comments'
|
|
439
565
|
}, null, 2));
|
|
440
566
|
|
|
441
|
-
// Submit review to GitHub
|
|
442
567
|
const { data } = await this.octokit.rest.pulls.createReview(payload);
|
|
443
568
|
|
|
444
|
-
const successMessage = event === 'DRAFT' ?
|
|
569
|
+
const successMessage = event === 'DRAFT' ?
|
|
445
570
|
`Draft review created successfully: ${data.html_url} (Review ID: ${data.id})` :
|
|
446
571
|
`Review submitted successfully: ${data.html_url}`;
|
|
447
572
|
console.log(successMessage);
|
|
@@ -460,378 +585,84 @@ class GitHubClient {
|
|
|
460
585
|
}
|
|
461
586
|
|
|
462
587
|
/**
|
|
463
|
-
* Add comments to a pending review in batches
|
|
464
|
-
*
|
|
465
|
-
*
|
|
466
|
-
*
|
|
467
|
-
*
|
|
588
|
+
* Add comments to a pending review in batches.
|
|
589
|
+
*
|
|
590
|
+
* Thin delegation to the `pending_review_comments` operation dispatcher
|
|
591
|
+
* — see `src/github/operations/pending-review-comments.js`. Returns
|
|
592
|
+
* the same shape as before: `{ successCount, failed, failedDetails }`.
|
|
468
593
|
*
|
|
469
|
-
*
|
|
470
|
-
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
473
|
-
*
|
|
594
|
+
* `prContext` ({ owner, repo, prNumber }) is required for the `"host"`
|
|
595
|
+
* dispatch path, which uses a path-shaped REST endpoint. It is ignored
|
|
596
|
+
* on the `"graphql"` path. Callers that may run against alt-hosts must
|
|
597
|
+
* pass it; callers known to only run against github.com may omit it.
|
|
598
|
+
*
|
|
599
|
+
* @param {string} prNodeId - GraphQL node ID for the PR
|
|
600
|
+
* @param {string} reviewId - Review identifier (GraphQL node ID on the
|
|
601
|
+
* graphql path; host REST id on the host path)
|
|
602
|
+
* @param {Array} comments
|
|
603
|
+
* @param {number} [batchSize=10]
|
|
604
|
+
* @param {Object} [prContext] - `{ owner, repo, prNumber }`. Required
|
|
605
|
+
* when `features.pending_review_comments === "host"`.
|
|
606
|
+
* @returns {Promise<{successCount: number, failed: boolean, failedDetails: string[]}>}
|
|
474
607
|
*/
|
|
475
|
-
async addCommentsInBatches(prNodeId, reviewId, comments, batchSize = 10) {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
console.log(`Adding ${comments.length} comments in batches of up to ${currentBatchSize}`);
|
|
487
|
-
|
|
488
|
-
while (remaining.length > 0) {
|
|
489
|
-
batchNumber++;
|
|
490
|
-
const batch = remaining.slice(0, currentBatchSize);
|
|
491
|
-
console.log(`Adding comments batch ${batchNumber} (${batch.length} comments, ${remaining.length} remaining)...`);
|
|
492
|
-
|
|
493
|
-
// Build mutation for this batch
|
|
494
|
-
const commentMutations = batch.map((comment, index) => {
|
|
495
|
-
const isFileLevel = comment.isFileLevel || !comment.line;
|
|
496
|
-
|
|
497
|
-
if (isFileLevel) {
|
|
498
|
-
return `
|
|
499
|
-
comment${index}: addPullRequestReviewThread(input: {
|
|
500
|
-
pullRequestId: $prId
|
|
501
|
-
pullRequestReviewId: $reviewId
|
|
502
|
-
path: "${comment.path}"
|
|
503
|
-
subjectType: FILE
|
|
504
|
-
body: ${JSON.stringify(comment.body)}
|
|
505
|
-
}) {
|
|
506
|
-
thread { id }
|
|
507
|
-
}
|
|
508
|
-
`;
|
|
509
|
-
} else {
|
|
510
|
-
const side = comment.side || 'RIGHT';
|
|
511
|
-
const startLineField = comment.start_line ? `startLine: ${comment.start_line}\n ` : '';
|
|
512
|
-
return `
|
|
513
|
-
comment${index}: addPullRequestReviewThread(input: {
|
|
514
|
-
pullRequestId: $prId
|
|
515
|
-
pullRequestReviewId: $reviewId
|
|
516
|
-
path: "${comment.path}"
|
|
517
|
-
${startLineField}line: ${comment.line}
|
|
518
|
-
side: ${side}
|
|
519
|
-
body: ${JSON.stringify(comment.body)}
|
|
520
|
-
}) {
|
|
521
|
-
thread { id }
|
|
522
|
-
}
|
|
523
|
-
`;
|
|
524
|
-
}
|
|
525
|
-
}).join('\n');
|
|
526
|
-
|
|
527
|
-
const batchMutation = `
|
|
528
|
-
mutation AddReviewComments($prId: ID!, $reviewId: ID!) {
|
|
529
|
-
${commentMutations}
|
|
530
|
-
}
|
|
531
|
-
`;
|
|
532
|
-
|
|
533
|
-
// Try the batch, with one retry on failure
|
|
534
|
-
let batchResult = null;
|
|
535
|
-
let batchError = null;
|
|
536
|
-
let retryAttempt = 0;
|
|
537
|
-
const maxRetries = 1;
|
|
538
|
-
let reducedBatchSize = false;
|
|
539
|
-
|
|
540
|
-
while (retryAttempt <= maxRetries) {
|
|
541
|
-
try {
|
|
542
|
-
batchResult = await this.octokit.graphql(batchMutation, {
|
|
543
|
-
prId: prNodeId,
|
|
544
|
-
reviewId: reviewId
|
|
545
|
-
});
|
|
546
|
-
batchError = null;
|
|
547
|
-
break; // Success, exit retry loop
|
|
548
|
-
} catch (error) {
|
|
549
|
-
batchError = error;
|
|
550
|
-
|
|
551
|
-
// Check for complexity/cost limit errors — reduce batch size instead of retrying
|
|
552
|
-
if (isComplexityError(error)) {
|
|
553
|
-
const newSize = Math.max(MIN_BATCH_SIZE, Math.floor(currentBatchSize / 2));
|
|
554
|
-
if (newSize < currentBatchSize) {
|
|
555
|
-
console.warn(
|
|
556
|
-
`Batch ${batchNumber} hit complexity limit (size ${currentBatchSize}), ` +
|
|
557
|
-
`reducing batch size to ${newSize}`
|
|
558
|
-
);
|
|
559
|
-
currentBatchSize = newSize;
|
|
560
|
-
reducedBatchSize = true;
|
|
561
|
-
break; // Exit retry loop — will re-attempt with smaller batch
|
|
562
|
-
}
|
|
563
|
-
// Already at minimum batch size — fall through to normal retry logic
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
if (retryAttempt < maxRetries) {
|
|
567
|
-
console.warn(`Batch ${batchNumber} failed, retrying... (${error.message})`);
|
|
568
|
-
retryAttempt++;
|
|
569
|
-
// Simple 1-second delay before retry. We use a fixed delay rather than
|
|
570
|
-
// exponential backoff because we only retry once before aborting for atomic
|
|
571
|
-
// behavior—either the batch succeeds quickly or we clean up the pending
|
|
572
|
-
// review. Backoff provides no benefit with a single retry attempt.
|
|
573
|
-
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
574
|
-
} else {
|
|
575
|
-
console.error(`Batch ${batchNumber} failed after retry: ${error.message}`);
|
|
576
|
-
break; // Exit retry loop - all retries exhausted
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// If we reduced batch size due to complexity, retry from top of loop
|
|
582
|
-
// with the same remaining comments but a smaller batch
|
|
583
|
-
if (reducedBatchSize) {
|
|
584
|
-
continue;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// Check if batch succeeded
|
|
588
|
-
if (batchError) {
|
|
589
|
-
// Build a map of per-comment errors from the GraphQL errors array.
|
|
590
|
-
// Each GraphQL error has a `path` like ["comment0"] that maps to the
|
|
591
|
-
// mutation alias, letting us match errors to specific comments.
|
|
592
|
-
const perCommentErrors = {};
|
|
593
|
-
if (batchError.errors && Array.isArray(batchError.errors)) {
|
|
594
|
-
for (const err of batchError.errors) {
|
|
595
|
-
if (err.path && err.path.length > 0) {
|
|
596
|
-
const alias = err.path[0]; // e.g. "comment0"
|
|
597
|
-
perCommentErrors[alias] = err.message || 'Unknown error';
|
|
598
|
-
}
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
|
|
602
|
-
// Check if it's a partial success (error.data contains some results)
|
|
603
|
-
if (batchError.data) {
|
|
604
|
-
console.warn('GraphQL returned partial results with errors:', batchError.errors || batchError.message);
|
|
605
|
-
let batchSuccessful = 0;
|
|
606
|
-
for (let i = 0; i < batch.length; i++) {
|
|
607
|
-
const commentResult = batchError.data[`comment${i}`];
|
|
608
|
-
if (commentResult && commentResult.thread && commentResult.thread.id) {
|
|
609
|
-
batchSuccessful++;
|
|
610
|
-
} else {
|
|
611
|
-
const ghError = perCommentErrors[`comment${i}`] || 'No error details available';
|
|
612
|
-
const location = `${batch[i].path}:${batch[i].line || 'file-level'}`;
|
|
613
|
-
console.warn(`Comment ${i} in batch ${batchNumber} failed to add: ${location} - ${ghError}`);
|
|
614
|
-
failedDetails.push(`${location} - ${ghError}`);
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
// If not all comments in batch succeeded, it's a failure
|
|
618
|
-
if (batchSuccessful < batch.length) {
|
|
619
|
-
console.error(`CRITICAL: Batch ${batchNumber} had ${batch.length - batchSuccessful} failures`);
|
|
620
|
-
return { successCount: totalSuccessful + batchSuccessful, failed: true, failedDetails };
|
|
621
|
-
}
|
|
622
|
-
// All comments succeeded despite the error being thrown (recovered from partial error)
|
|
623
|
-
console.log(`Batch ${batchNumber} complete (recovered from partial error): ${batchSuccessful} comments added`);
|
|
624
|
-
totalSuccessful += batchSuccessful;
|
|
625
|
-
} else {
|
|
626
|
-
// Total failure of the batch
|
|
627
|
-
const totalError = batchError.message || 'Unknown error';
|
|
628
|
-
console.error(`CRITICAL: Batch ${batchNumber} failed completely: ${totalError}`);
|
|
629
|
-
// Add an entry for each comment in this batch so callers see what was lost
|
|
630
|
-
for (let i = 0; i < batch.length; i++) {
|
|
631
|
-
const ghError = perCommentErrors[`comment${i}`] || totalError;
|
|
632
|
-
const location = `${batch[i].path}:${batch[i].line || 'file-level'}`;
|
|
633
|
-
failedDetails.push(`${location} - ${ghError}`);
|
|
634
|
-
}
|
|
635
|
-
return { successCount: totalSuccessful, failed: true, failedDetails };
|
|
636
|
-
}
|
|
637
|
-
} else if (batchResult) {
|
|
638
|
-
// Verify each comment was successfully added
|
|
639
|
-
let batchSuccessful = 0;
|
|
640
|
-
for (let i = 0; i < batch.length; i++) {
|
|
641
|
-
const commentResult = batchResult[`comment${i}`];
|
|
642
|
-
if (commentResult && commentResult.thread && commentResult.thread.id) {
|
|
643
|
-
batchSuccessful++;
|
|
644
|
-
} else {
|
|
645
|
-
const location = `${batch[i].path}:${batch[i].line || 'file-level'}`;
|
|
646
|
-
console.warn(`Comment ${i} in batch ${batchNumber} failed to add: ${location} - No error details available`);
|
|
647
|
-
failedDetails.push(`${location} - No error details available`);
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
if (batchSuccessful < batch.length) {
|
|
652
|
-
console.error(`CRITICAL: Batch ${batchNumber} had ${batch.length - batchSuccessful} failures`);
|
|
653
|
-
return { successCount: totalSuccessful + batchSuccessful, failed: true, failedDetails };
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
totalSuccessful += batchSuccessful;
|
|
657
|
-
console.log(`Batch ${batchNumber} complete: ${batchSuccessful} comments added`);
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// Advance past the successfully processed batch
|
|
661
|
-
remaining = remaining.slice(batch.length);
|
|
662
|
-
}
|
|
663
|
-
|
|
664
|
-
console.log(`All batches complete: ${totalSuccessful} total comments added`);
|
|
665
|
-
return { successCount: totalSuccessful, failed: false, failedDetails };
|
|
608
|
+
async addCommentsInBatches(prNodeId, reviewId, comments, batchSize = 10, prContext = null) {
|
|
609
|
+
return pendingReviewCommentsOps.addCommentsInBatches(
|
|
610
|
+
this.octokit,
|
|
611
|
+
this.features,
|
|
612
|
+
prNodeId,
|
|
613
|
+
reviewId,
|
|
614
|
+
comments,
|
|
615
|
+
batchSize,
|
|
616
|
+
prContext
|
|
617
|
+
);
|
|
666
618
|
}
|
|
667
619
|
|
|
668
620
|
/**
|
|
669
|
-
* Get the pending (draft) review for the authenticated user on a PR
|
|
670
|
-
* GitHub allows only ONE pending review per user per PR, so this returns
|
|
671
|
-
* either the single pending review or null if none exists.
|
|
621
|
+
* Get the pending (draft) review for the authenticated user on a PR.
|
|
672
622
|
*
|
|
673
|
-
*
|
|
674
|
-
* @param {string} repo - Repository name
|
|
675
|
-
* @param {number} prNumber - Pull request number
|
|
676
|
-
* @returns {Promise<Object|null>} The pending review object or null if none exists
|
|
677
|
-
* Returns: { id, databaseId, body, url, state, createdAt, comments: { totalCount } }
|
|
623
|
+
* Thin delegation to the `pending_review_check` dispatcher.
|
|
678
624
|
*/
|
|
679
625
|
async getPendingReviewForUser(owner, repo, prNumber) {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
reviews(states: PENDING, first: 1) {
|
|
688
|
-
nodes {
|
|
689
|
-
id
|
|
690
|
-
databaseId
|
|
691
|
-
body
|
|
692
|
-
url
|
|
693
|
-
state
|
|
694
|
-
createdAt
|
|
695
|
-
viewerDidAuthor
|
|
696
|
-
comments {
|
|
697
|
-
totalCount
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
`, {
|
|
705
|
-
owner,
|
|
706
|
-
repo,
|
|
707
|
-
prNumber
|
|
708
|
-
});
|
|
709
|
-
|
|
710
|
-
const reviews = result.repository?.pullRequest?.reviews?.nodes || [];
|
|
711
|
-
|
|
712
|
-
// Find the review authored by the authenticated user
|
|
713
|
-
const userPendingReview = reviews.find(review => review.viewerDidAuthor);
|
|
714
|
-
|
|
715
|
-
if (userPendingReview) {
|
|
716
|
-
logger.debug(`Found pending review for user: ${userPendingReview.id} with ${userPendingReview.comments.totalCount} comments`);
|
|
717
|
-
return {
|
|
718
|
-
id: userPendingReview.id,
|
|
719
|
-
databaseId: userPendingReview.databaseId,
|
|
720
|
-
body: userPendingReview.body,
|
|
721
|
-
url: userPendingReview.url,
|
|
722
|
-
state: userPendingReview.state,
|
|
723
|
-
createdAt: userPendingReview.createdAt,
|
|
724
|
-
comments: {
|
|
725
|
-
totalCount: userPendingReview.comments.totalCount
|
|
726
|
-
}
|
|
727
|
-
};
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
logger.debug('No pending review found for user');
|
|
731
|
-
return null;
|
|
732
|
-
|
|
733
|
-
} catch (error) {
|
|
734
|
-
logger.error(`Error checking for pending review: ${error.message}`);
|
|
735
|
-
|
|
736
|
-
// Handle authentication errors
|
|
737
|
-
if (error.status === 401) {
|
|
738
|
-
throw new GitHubApiError('GitHub authentication failed. Check your token in ~/.pair-review/config.json', 401);
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
// Handle not found errors
|
|
742
|
-
if (error.status === 404 || error.errors?.some(e => e.type === 'NOT_FOUND')) {
|
|
743
|
-
throw new GitHubApiError(`Pull request #${prNumber} not found in repository ${owner}/${repo}`, 404);
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
// Parse GraphQL errors
|
|
747
|
-
if (error.errors) {
|
|
748
|
-
const messages = error.errors.map(e => e.message).join(', ');
|
|
749
|
-
throw new Error(`GitHub GraphQL error: ${messages}`);
|
|
750
|
-
}
|
|
751
|
-
|
|
752
|
-
throw new Error(`Failed to check for pending review: ${error.message}`);
|
|
753
|
-
}
|
|
626
|
+
return pendingReviewOps.getPendingReviewForUser(
|
|
627
|
+
this.octokit,
|
|
628
|
+
this.features,
|
|
629
|
+
owner,
|
|
630
|
+
repo,
|
|
631
|
+
prNumber
|
|
632
|
+
);
|
|
754
633
|
}
|
|
755
634
|
|
|
756
635
|
/**
|
|
757
|
-
* Get a review by its GraphQL node ID
|
|
758
|
-
* Used to determine the actual state of a review that may have been
|
|
759
|
-
* submitted or dismissed outside of pair-review.
|
|
636
|
+
* Get a review by its GraphQL node ID.
|
|
760
637
|
*
|
|
761
|
-
*
|
|
762
|
-
*
|
|
763
|
-
*
|
|
764
|
-
*
|
|
765
|
-
*
|
|
638
|
+
* Thin delegation to the `pending_review_check` dispatcher. When the
|
|
639
|
+
* dispatcher is in REST mode, `prContext` is REQUIRED — the REST
|
|
640
|
+
* endpoint identifies a review by (owner, repo, pull_number,
|
|
641
|
+
* review_id) rather than by node id. The GraphQL path ignores it.
|
|
642
|
+
*
|
|
643
|
+
* @param {string} nodeId - GraphQL node id
|
|
644
|
+
* @param {Object} [prContext] - `{ owner, repo, prNumber, reviewId? }`
|
|
766
645
|
*/
|
|
767
|
-
async getReviewById(nodeId) {
|
|
768
|
-
|
|
769
|
-
logger.debug(`Fetching review by node ID: ${nodeId}`);
|
|
770
|
-
|
|
771
|
-
const result = await this.octokit.graphql(`
|
|
772
|
-
query($nodeId: ID!) {
|
|
773
|
-
node(id: $nodeId) {
|
|
774
|
-
... on PullRequestReview {
|
|
775
|
-
id
|
|
776
|
-
state
|
|
777
|
-
submittedAt
|
|
778
|
-
url
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
`, {
|
|
783
|
-
nodeId
|
|
784
|
-
});
|
|
785
|
-
|
|
786
|
-
// Check if we got a valid result
|
|
787
|
-
if (!result.node || !result.node.id) {
|
|
788
|
-
logger.debug(`Review not found for node ID: ${nodeId}`);
|
|
789
|
-
return null;
|
|
790
|
-
}
|
|
791
|
-
|
|
792
|
-
const review = result.node;
|
|
793
|
-
logger.debug(`Found review ${nodeId}: state=${review.state}, submittedAt=${review.submittedAt}`);
|
|
794
|
-
|
|
795
|
-
return {
|
|
796
|
-
id: review.id,
|
|
797
|
-
state: review.state,
|
|
798
|
-
submittedAt: review.submittedAt,
|
|
799
|
-
url: review.url
|
|
800
|
-
};
|
|
801
|
-
|
|
802
|
-
} catch (error) {
|
|
803
|
-
// Handle not found errors gracefully
|
|
804
|
-
if (error.errors?.some(e => e.type === 'NOT_FOUND' || e.message?.includes('not found'))) {
|
|
805
|
-
logger.debug(`Review not found for node ID: ${nodeId}`);
|
|
806
|
-
return null;
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
logger.warn(`Error fetching review by node ID ${nodeId}: ${error.message}`);
|
|
810
|
-
// Don't throw - return null to treat as "not found" for sync purposes
|
|
811
|
-
return null;
|
|
812
|
-
}
|
|
646
|
+
async getReviewById(nodeId, prContext) {
|
|
647
|
+
return pendingReviewOps.getReviewById(this.octokit, this.features, nodeId, prContext);
|
|
813
648
|
}
|
|
814
649
|
|
|
815
650
|
/**
|
|
816
|
-
* Delete a pending review (used for cleanup on failure)
|
|
651
|
+
* Delete a pending review (used for cleanup on failure).
|
|
652
|
+
*
|
|
653
|
+
* Thin delegation to the `review_lifecycle` dispatcher.
|
|
654
|
+
*
|
|
817
655
|
* @param {string} reviewId - GraphQL node ID for the review
|
|
818
|
-
* @
|
|
656
|
+
* @param {Object} [prContext] - `{ owner, repo, prNumber, reviewId? }`, required for REST mode
|
|
657
|
+
* @returns {Promise<boolean>}
|
|
819
658
|
*/
|
|
820
|
-
async deletePendingReview(reviewId) {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
}
|
|
828
|
-
`, { reviewId });
|
|
829
|
-
console.log('Cleaned up pending review after failure');
|
|
830
|
-
return true;
|
|
831
|
-
} catch (cleanupError) {
|
|
832
|
-
console.warn('Failed to clean up pending review:', cleanupError.message);
|
|
833
|
-
return false;
|
|
834
|
-
}
|
|
659
|
+
async deletePendingReview(reviewId, prContext) {
|
|
660
|
+
return reviewLifecycleOps.deletePullRequestReview(
|
|
661
|
+
this.octokit,
|
|
662
|
+
this.features,
|
|
663
|
+
reviewId,
|
|
664
|
+
prContext
|
|
665
|
+
);
|
|
835
666
|
}
|
|
836
667
|
|
|
837
668
|
/**
|
|
@@ -839,63 +670,112 @@ class GitHubClient {
|
|
|
839
670
|
* This supports both line-level comments (within diff hunks) and file-level comments
|
|
840
671
|
* (for expanded context lines outside diff hunks).
|
|
841
672
|
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
846
|
-
*
|
|
847
|
-
* @
|
|
673
|
+
* Orchestration is unchanged from the pre-refactor implementation; the
|
|
674
|
+
* three GraphQL primitives (create pending review, add comments,
|
|
675
|
+
* submit) are now dispatched through `review_lifecycle` and
|
|
676
|
+
* `pending_review_comments` operations.
|
|
677
|
+
*
|
|
678
|
+
* @param {string} prNodeId - GraphQL node ID for the PR
|
|
679
|
+
* @param {string} event - APPROVE, REQUEST_CHANGES, COMMENT
|
|
680
|
+
* @param {string} body
|
|
681
|
+
* @param {Array} comments
|
|
682
|
+
* @param {string|null} [existingReviewId=null]
|
|
683
|
+
* @param {Object|null} [prContext=null] - `{ owner, repo, prNumber }`,
|
|
684
|
+
* required when `features.pending_review_comments === "host"`.
|
|
685
|
+
* @returns {Promise<Object>}
|
|
848
686
|
*/
|
|
849
|
-
async createReviewGraphQL(prNodeId, event, body, comments = [], existingReviewId = null) {
|
|
687
|
+
async createReviewGraphQL(prNodeId, event, body, comments = [], existingReviewId = null, prContext = null) {
|
|
688
|
+
// Transport label for user-facing log/error strings. This method name
|
|
689
|
+
// is historical (callers depend on it), but the actual transport may be
|
|
690
|
+
// the alt-host REST/extension path rather than GraphQL. Keep the
|
|
691
|
+
// messages accurate to what really ran.
|
|
692
|
+
const transport = this.apiHost ? 'alt-host' : 'GraphQL';
|
|
850
693
|
try {
|
|
851
|
-
console.log(`Creating
|
|
694
|
+
console.log(`Creating review (${transport}) for PR ${prNodeId} with ${comments.length} comments`);
|
|
852
695
|
|
|
853
|
-
// Validate event type
|
|
854
696
|
const validEvents = ['APPROVE', 'REQUEST_CHANGES', 'COMMENT'];
|
|
855
697
|
if (!validEvents.includes(event)) {
|
|
856
698
|
throw new Error(`Invalid review event: ${event}. Must be one of: ${validEvents.join(', ')}`);
|
|
857
699
|
}
|
|
858
700
|
|
|
859
|
-
//
|
|
701
|
+
// When running on the REST review-lifecycle path, callers can
|
|
702
|
+
// pass only a numeric review id via `prContext.reviewId` (no node
|
|
703
|
+
// id is available without an extra round-trip). Treat that as an
|
|
704
|
+
// existing-review signal so we don't accidentally create a second
|
|
705
|
+
// pending review on top of the user's existing draft.
|
|
706
|
+
const existingRestReviewId = this.features.review_lifecycle === 'rest' ? prContext?.reviewId : null;
|
|
707
|
+
const effectiveExistingReviewId = existingReviewId ?? existingRestReviewId;
|
|
708
|
+
const usedExistingReview = effectiveExistingReviewId !== undefined && effectiveExistingReviewId !== null;
|
|
709
|
+
|
|
860
710
|
let reviewId;
|
|
861
|
-
|
|
862
|
-
if (
|
|
863
|
-
console.log(`Step 1: Using existing pending review: ${
|
|
864
|
-
reviewId =
|
|
711
|
+
let reviewDatabaseId = null;
|
|
712
|
+
if (usedExistingReview) {
|
|
713
|
+
console.log(`Step 1: Using existing pending review: ${effectiveExistingReviewId}`);
|
|
714
|
+
reviewId = effectiveExistingReviewId;
|
|
715
|
+
// Caller is expected to pass the numeric id on `prContext.reviewId`
|
|
716
|
+
// (e.g. `existingDraft.databaseId`). Capture it so we can propagate
|
|
717
|
+
// it explicitly to subsequent calls in this orchestration.
|
|
718
|
+
if (prContext && (typeof prContext.reviewId === 'number' || typeof prContext.reviewId === 'string')) {
|
|
719
|
+
const numeric = Number(prContext.reviewId);
|
|
720
|
+
if (Number.isFinite(numeric)) reviewDatabaseId = numeric;
|
|
721
|
+
}
|
|
865
722
|
} else {
|
|
866
723
|
console.log('Step 1: Creating pending review...');
|
|
867
|
-
const
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
}
|
|
877
|
-
`, {
|
|
878
|
-
prId: prNodeId
|
|
879
|
-
});
|
|
880
|
-
|
|
881
|
-
reviewId = createReviewResult.addPullRequestReview.pullRequestReview.id;
|
|
882
|
-
console.log(`Created pending review: ${reviewId}`);
|
|
724
|
+
const created = await reviewLifecycleOps.addPullRequestReview(
|
|
725
|
+
this.octokit,
|
|
726
|
+
this.features,
|
|
727
|
+
prNodeId,
|
|
728
|
+
prContext
|
|
729
|
+
);
|
|
730
|
+
reviewId = created.id;
|
|
731
|
+
reviewDatabaseId = (typeof created.databaseId === 'number') ? created.databaseId : null;
|
|
732
|
+
console.log(`Created pending review: ${reviewId}${reviewDatabaseId !== null ? ` (databaseId=${reviewDatabaseId})` : ''}`);
|
|
883
733
|
}
|
|
884
734
|
|
|
885
|
-
//
|
|
735
|
+
// Build a downstream prContext that carries the numeric review id
|
|
736
|
+
// so REST/host paths (which need a numeric REST id) can resolve it
|
|
737
|
+
// without needing the caller to remember to pass it.
|
|
738
|
+
const downstreamPrContext = prContext
|
|
739
|
+
? { ...prContext, reviewId: reviewDatabaseId !== null ? reviewDatabaseId : prContext.reviewId }
|
|
740
|
+
: (reviewDatabaseId !== null ? { reviewId: reviewDatabaseId } : null);
|
|
741
|
+
|
|
886
742
|
let successfulComments = 0;
|
|
887
743
|
if (comments.length > 0) {
|
|
888
744
|
console.log(`Step 2: Adding ${comments.length} comments in batches...`);
|
|
889
|
-
|
|
745
|
+
let batchResult;
|
|
746
|
+
try {
|
|
747
|
+
batchResult = await this.addCommentsInBatches(prNodeId, reviewId, comments, 10, downstreamPrContext);
|
|
748
|
+
} catch (commentError) {
|
|
749
|
+
// The comment-batch path threw before returning a result shape.
|
|
750
|
+
// This happens on the host pending-review-comments path (which
|
|
751
|
+
// throws on request failure) and on unsupported-mode rejection
|
|
752
|
+
// in the dispatcher. The original code only cleaned up when
|
|
753
|
+
// `batchResult.failed` was returned, leaving a pending review
|
|
754
|
+
// behind on these throws when we created it ourselves.
|
|
755
|
+
console.error(`CRITICAL: comment batch threw before completion: ${commentError.message}`);
|
|
756
|
+
if (!usedExistingReview) {
|
|
757
|
+
const cleaned = await this.deletePendingReview(reviewId, downstreamPrContext);
|
|
758
|
+
if (!cleaned) {
|
|
759
|
+
console.warn('Warning: Failed to clean up pending review - manual cleanup may be required');
|
|
760
|
+
}
|
|
761
|
+
} else {
|
|
762
|
+
console.warn('Skipping cleanup of pre-existing pending review - comments may be partially added');
|
|
763
|
+
}
|
|
764
|
+
const wrapped = new Error(
|
|
765
|
+
`Failed to add comments to GitHub: comment batch threw before completion: ${commentError.message}`
|
|
766
|
+
);
|
|
767
|
+
wrapped.cause = commentError;
|
|
768
|
+
if (commentError.stack) wrapped.stack = commentError.stack;
|
|
769
|
+
throw wrapped;
|
|
770
|
+
}
|
|
890
771
|
successfulComments = batchResult.successCount;
|
|
891
772
|
|
|
892
773
|
if (batchResult.failed) {
|
|
893
774
|
const failedCount = comments.length - successfulComments;
|
|
894
775
|
const details = batchResult.failedDetails || [];
|
|
895
776
|
console.error(`CRITICAL: ${failedCount} of ${comments.length} comments failed to add to GitHub`);
|
|
896
|
-
// Only clean up the pending review if we created it (not if it was pre-existing)
|
|
897
777
|
if (!usedExistingReview) {
|
|
898
|
-
const cleaned = await this.deletePendingReview(reviewId);
|
|
778
|
+
const cleaned = await this.deletePendingReview(reviewId, downstreamPrContext);
|
|
899
779
|
if (!cleaned) {
|
|
900
780
|
console.warn('Warning: Failed to clean up pending review - manual cleanup may be required');
|
|
901
781
|
}
|
|
@@ -907,30 +787,16 @@ class GitHubClient {
|
|
|
907
787
|
}
|
|
908
788
|
}
|
|
909
789
|
|
|
910
|
-
// Step 3: Submit the review
|
|
911
790
|
console.log(`Step 3: Submitting review with event ${event}...`);
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
id
|
|
921
|
-
databaseId
|
|
922
|
-
url
|
|
923
|
-
state
|
|
924
|
-
}
|
|
925
|
-
}
|
|
926
|
-
}
|
|
927
|
-
`, {
|
|
928
|
-
reviewId: reviewId,
|
|
929
|
-
event: event,
|
|
930
|
-
body: body || null
|
|
931
|
-
});
|
|
791
|
+
const result = await reviewLifecycleOps.submitPullRequestReview(
|
|
792
|
+
this.octokit,
|
|
793
|
+
this.features,
|
|
794
|
+
reviewId,
|
|
795
|
+
event,
|
|
796
|
+
body,
|
|
797
|
+
downstreamPrContext
|
|
798
|
+
);
|
|
932
799
|
|
|
933
|
-
const result = submitResult.submitPullRequestReview.pullRequestReview;
|
|
934
800
|
console.log(`Review submitted successfully: ${result.url}`);
|
|
935
801
|
|
|
936
802
|
return {
|
|
@@ -942,15 +808,17 @@ class GitHubClient {
|
|
|
942
808
|
};
|
|
943
809
|
|
|
944
810
|
} catch (error) {
|
|
945
|
-
console.error(
|
|
811
|
+
console.error(`Review error (${transport}):`, error);
|
|
946
812
|
|
|
947
|
-
//
|
|
813
|
+
// The `error.errors` branch is a GraphQL-shaped error envelope. It is
|
|
814
|
+
// harmless on the REST/host path (which won't populate it); we still
|
|
815
|
+
// label the message with the actual transport for accuracy.
|
|
948
816
|
if (error.errors) {
|
|
949
817
|
const messages = error.errors.map(e => e.message).join(', ');
|
|
950
|
-
throw new Error(`GitHub
|
|
818
|
+
throw new Error(`GitHub ${transport} error: ${messages}`);
|
|
951
819
|
}
|
|
952
820
|
|
|
953
|
-
throw new Error(`Failed to submit review
|
|
821
|
+
throw new Error(`Failed to submit review (${transport}): ${error.message}`);
|
|
954
822
|
}
|
|
955
823
|
}
|
|
956
824
|
|
|
@@ -959,61 +827,97 @@ class GitHubClient {
|
|
|
959
827
|
* This creates a review and adds comments but does NOT submit it.
|
|
960
828
|
* The review remains as PENDING on GitHub for later submission.
|
|
961
829
|
*
|
|
962
|
-
* @param {string} prNodeId
|
|
963
|
-
* @param {string} body
|
|
964
|
-
* @param {Array} comments
|
|
965
|
-
* @param {string|null} [existingReviewId=null]
|
|
966
|
-
* @
|
|
830
|
+
* @param {string} prNodeId
|
|
831
|
+
* @param {string} body
|
|
832
|
+
* @param {Array} comments
|
|
833
|
+
* @param {string|null} [existingReviewId=null]
|
|
834
|
+
* @param {Object|null} [prContext=null] - `{ owner, repo, prNumber }`,
|
|
835
|
+
* required when `features.pending_review_comments === "host"`.
|
|
836
|
+
* @returns {Promise<Object>}
|
|
967
837
|
*/
|
|
968
|
-
async createDraftReviewGraphQL(prNodeId, body, comments = [], existingReviewId = null) {
|
|
838
|
+
async createDraftReviewGraphQL(prNodeId, body, comments = [], existingReviewId = null, prContext = null) {
|
|
839
|
+
// Transport label for user-facing log/error strings. The method name is
|
|
840
|
+
// historical; the real transport may be the alt-host REST/extension
|
|
841
|
+
// path rather than GraphQL. See createReviewGraphQL for the rationale.
|
|
842
|
+
const transport = this.apiHost ? 'alt-host' : 'GraphQL';
|
|
969
843
|
try {
|
|
970
|
-
console.log(`Creating
|
|
844
|
+
console.log(`Creating draft review (${transport}) for PR ${prNodeId} with ${comments.length} comments`);
|
|
845
|
+
|
|
846
|
+
// See createReviewGraphQL for the rationale: on the REST
|
|
847
|
+
// review-lifecycle path the caller may have only a numeric review
|
|
848
|
+
// id and no node id, so treat `prContext.reviewId` as an
|
|
849
|
+
// existing-review signal in that mode.
|
|
850
|
+
const existingRestReviewId = this.features.review_lifecycle === 'rest' ? prContext?.reviewId : null;
|
|
851
|
+
const effectiveExistingReviewId = existingReviewId ?? existingRestReviewId;
|
|
852
|
+
const usedExistingReview = effectiveExistingReviewId !== undefined && effectiveExistingReviewId !== null;
|
|
971
853
|
|
|
972
|
-
// Step 1: Use existing pending review or create a new one
|
|
973
854
|
let reviewId;
|
|
974
855
|
let reviewDatabaseId = null;
|
|
975
856
|
let reviewUrl;
|
|
976
|
-
const usedExistingReview = !!existingReviewId;
|
|
977
857
|
// Note: the body parameter is not updated for existing pending reviews because
|
|
978
858
|
// GitHub only uses the body at submission time (via submitPullRequestReview),
|
|
979
859
|
// not during the pending/draft phase.
|
|
980
|
-
if (
|
|
981
|
-
console.log(`Step 1: Using existing pending review: ${
|
|
982
|
-
reviewId =
|
|
983
|
-
// URL and databaseId not available from existing review ID alone
|
|
860
|
+
if (usedExistingReview) {
|
|
861
|
+
console.log(`Step 1: Using existing pending review: ${effectiveExistingReviewId}`);
|
|
862
|
+
reviewId = effectiveExistingReviewId;
|
|
863
|
+
// URL and databaseId not available from existing review ID alone.
|
|
864
|
+
// Caller is expected to pass the numeric id on `prContext.reviewId`
|
|
865
|
+
// (e.g. `existingDraft.databaseId`). Capture it for propagation.
|
|
866
|
+
if (prContext && (typeof prContext.reviewId === 'number' || typeof prContext.reviewId === 'string')) {
|
|
867
|
+
const numeric = Number(prContext.reviewId);
|
|
868
|
+
if (Number.isFinite(numeric)) reviewDatabaseId = numeric;
|
|
869
|
+
}
|
|
984
870
|
reviewUrl = null;
|
|
985
871
|
} else {
|
|
986
872
|
console.log('Step 1: Creating pending review...');
|
|
987
|
-
const
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
}
|
|
999
|
-
}
|
|
1000
|
-
`, {
|
|
1001
|
-
prId: prNodeId,
|
|
1002
|
-
body: body || null
|
|
1003
|
-
});
|
|
1004
|
-
|
|
1005
|
-
const review = createReviewResult.addPullRequestReview.pullRequestReview;
|
|
1006
|
-
reviewId = review.id;
|
|
1007
|
-
reviewDatabaseId = review.databaseId;
|
|
1008
|
-
reviewUrl = review.url;
|
|
1009
|
-
console.log(`Created pending review: ${reviewId}`);
|
|
873
|
+
const created = await reviewLifecycleOps.addPullRequestReviewWithBody(
|
|
874
|
+
this.octokit,
|
|
875
|
+
this.features,
|
|
876
|
+
prNodeId,
|
|
877
|
+
body,
|
|
878
|
+
prContext
|
|
879
|
+
);
|
|
880
|
+
reviewId = created.id;
|
|
881
|
+
reviewDatabaseId = (typeof created.databaseId === 'number') ? created.databaseId : null;
|
|
882
|
+
reviewUrl = created.url;
|
|
883
|
+
console.log(`Created pending review: ${reviewId}${reviewDatabaseId !== null ? ` (databaseId=${reviewDatabaseId})` : ''}`);
|
|
1010
884
|
}
|
|
1011
885
|
|
|
1012
|
-
//
|
|
886
|
+
// Build a downstream prContext carrying the numeric review id so
|
|
887
|
+
// REST/host paths can address the review without re-resolving it.
|
|
888
|
+
const downstreamPrContext = prContext
|
|
889
|
+
? { ...prContext, reviewId: reviewDatabaseId !== null ? reviewDatabaseId : prContext.reviewId }
|
|
890
|
+
: (reviewDatabaseId !== null ? { reviewId: reviewDatabaseId } : null);
|
|
891
|
+
|
|
1013
892
|
let successfulComments = 0;
|
|
1014
893
|
if (comments.length > 0) {
|
|
1015
894
|
console.log(`Step 2: Adding ${comments.length} comments in batches...`);
|
|
1016
|
-
|
|
895
|
+
let batchResult;
|
|
896
|
+
try {
|
|
897
|
+
batchResult = await this.addCommentsInBatches(prNodeId, reviewId, comments, 10, downstreamPrContext);
|
|
898
|
+
} catch (commentError) {
|
|
899
|
+
// The comment-batch path threw before returning a result shape.
|
|
900
|
+
// This happens on the host pending-review-comments path (which
|
|
901
|
+
// throws on request failure) and on unsupported-mode rejection
|
|
902
|
+
// in the dispatcher. The original code only cleaned up when
|
|
903
|
+
// `batchResult.failed` was returned, leaving a pending review
|
|
904
|
+
// behind on these throws when we created it ourselves.
|
|
905
|
+
console.error(`CRITICAL: comment batch threw before completion: ${commentError.message}`);
|
|
906
|
+
if (!usedExistingReview) {
|
|
907
|
+
const cleaned = await this.deletePendingReview(reviewId, downstreamPrContext);
|
|
908
|
+
if (!cleaned) {
|
|
909
|
+
console.warn('Warning: Failed to clean up pending review - manual cleanup may be required');
|
|
910
|
+
}
|
|
911
|
+
} else {
|
|
912
|
+
console.warn('Skipping cleanup of pre-existing pending review - comments may be partially added');
|
|
913
|
+
}
|
|
914
|
+
const wrapped = new Error(
|
|
915
|
+
`Failed to add comments to draft review: comment batch threw before completion: ${commentError.message}`
|
|
916
|
+
);
|
|
917
|
+
wrapped.cause = commentError;
|
|
918
|
+
if (commentError.stack) wrapped.stack = commentError.stack;
|
|
919
|
+
throw wrapped;
|
|
920
|
+
}
|
|
1017
921
|
successfulComments = batchResult.successCount;
|
|
1018
922
|
|
|
1019
923
|
if (batchResult.failed) {
|
|
@@ -1021,9 +925,8 @@ class GitHubClient {
|
|
|
1021
925
|
const details = batchResult.failedDetails || [];
|
|
1022
926
|
const detailSuffix = details.length > 0 ? ` Failures:\n${details.join('\n')}` : '';
|
|
1023
927
|
console.error(`CRITICAL: ${failedCount} of ${comments.length} comments failed to add to draft review`);
|
|
1024
|
-
// Only clean up the pending review if we created it (not if it was pre-existing)
|
|
1025
928
|
if (!usedExistingReview) {
|
|
1026
|
-
const cleaned = await this.deletePendingReview(reviewId);
|
|
929
|
+
const cleaned = await this.deletePendingReview(reviewId, downstreamPrContext);
|
|
1027
930
|
if (!cleaned) {
|
|
1028
931
|
console.warn('Warning: Failed to clean up pending review - manual cleanup may be required');
|
|
1029
932
|
}
|
|
@@ -1050,14 +953,16 @@ class GitHubClient {
|
|
|
1050
953
|
};
|
|
1051
954
|
|
|
1052
955
|
} catch (error) {
|
|
1053
|
-
console.error(
|
|
956
|
+
console.error(`Draft review error (${transport}):`, error);
|
|
1054
957
|
|
|
958
|
+
// The `error.errors` branch is a GraphQL-shaped error envelope,
|
|
959
|
+
// harmless on the REST/host path. Label with the actual transport.
|
|
1055
960
|
if (error.errors) {
|
|
1056
961
|
const messages = error.errors.map(e => e.message).join(', ');
|
|
1057
|
-
throw new Error(`GitHub
|
|
962
|
+
throw new Error(`GitHub ${transport} error: ${messages}`);
|
|
1058
963
|
}
|
|
1059
964
|
|
|
1060
|
-
throw new Error(`Failed to create draft review
|
|
965
|
+
throw new Error(`Failed to create draft review (${transport}): ${error.message}`);
|
|
1061
966
|
}
|
|
1062
967
|
}
|
|
1063
968
|
|
|
@@ -1071,10 +976,10 @@ class GitHubClient {
|
|
|
1071
976
|
*/
|
|
1072
977
|
calculateDiffPosition(diffContent, filePath, lineNumber) {
|
|
1073
978
|
if (!diffContent || !filePath || lineNumber === undefined) {
|
|
1074
|
-
console.warn('calculateDiffPosition: Missing required parameters', {
|
|
1075
|
-
filePath,
|
|
1076
|
-
lineNumber,
|
|
1077
|
-
hasDiffContent: !!diffContent
|
|
979
|
+
console.warn('calculateDiffPosition: Missing required parameters', {
|
|
980
|
+
filePath,
|
|
981
|
+
lineNumber,
|
|
982
|
+
hasDiffContent: !!diffContent
|
|
1078
983
|
});
|
|
1079
984
|
return -1;
|
|
1080
985
|
}
|
|
@@ -1089,11 +994,10 @@ class GitHubClient {
|
|
|
1089
994
|
for (let i = 0; i < lines.length; i++) {
|
|
1090
995
|
const line = lines[i];
|
|
1091
996
|
|
|
1092
|
-
// Check for file header (diff --git a/path b/path)
|
|
1093
997
|
if (line.startsWith('diff --git')) {
|
|
1094
998
|
const match = line.match(/diff --git a\/(.+) b\/(.+)/);
|
|
1095
999
|
if (match) {
|
|
1096
|
-
currentFile = match[2];
|
|
1000
|
+
currentFile = match[2];
|
|
1097
1001
|
inFile = currentFile === filePath;
|
|
1098
1002
|
position = 0;
|
|
1099
1003
|
newLineNumber = 0;
|
|
@@ -1102,61 +1006,52 @@ class GitHubClient {
|
|
|
1102
1006
|
continue;
|
|
1103
1007
|
}
|
|
1104
1008
|
|
|
1105
|
-
// Skip if not in the target file
|
|
1106
1009
|
if (!inFile) continue;
|
|
1107
1010
|
|
|
1108
|
-
// Check for hunk header (@@ -oldstart,oldcount +newstart,newcount @@)
|
|
1109
1011
|
if (line.startsWith('@@')) {
|
|
1110
1012
|
const match = line.match(/@@ -\d+,?\d* \+(\d+),?\d* @@/);
|
|
1111
1013
|
if (match) {
|
|
1112
|
-
newLineNumber = parseInt(match[1]) - 1;
|
|
1113
|
-
|
|
1014
|
+
newLineNumber = parseInt(match[1]) - 1;
|
|
1015
|
+
|
|
1114
1016
|
if (!foundHunk) {
|
|
1115
|
-
// First hunk header - NOT counted as a position (per GitHub spec)
|
|
1116
1017
|
position = 0;
|
|
1117
1018
|
foundHunk = true;
|
|
1118
1019
|
} else {
|
|
1119
|
-
// Subsequent hunk headers ARE counted as positions (per GitHub spec)
|
|
1120
1020
|
position++;
|
|
1121
1021
|
}
|
|
1122
1022
|
}
|
|
1123
1023
|
continue;
|
|
1124
1024
|
}
|
|
1125
1025
|
|
|
1126
|
-
// Only process lines after we've found a hunk in our target file
|
|
1127
1026
|
if (!foundHunk) continue;
|
|
1128
1027
|
|
|
1129
|
-
// Check if this is a diff content line (addition, deletion, context, or empty context)
|
|
1130
1028
|
const isDiffContentLine = line.startsWith('+') || line.startsWith('-') || line.startsWith(' ') || (line === '' && foundHunk);
|
|
1131
|
-
|
|
1029
|
+
|
|
1132
1030
|
if (!isDiffContentLine) continue;
|
|
1133
|
-
|
|
1134
|
-
// Count position for all diff lines (context, additions, deletions, empty context)
|
|
1031
|
+
|
|
1135
1032
|
position++;
|
|
1136
1033
|
|
|
1137
|
-
// Track line numbers for additions, context lines, and empty context lines
|
|
1138
1034
|
if (line.startsWith('+')) {
|
|
1139
1035
|
newLineNumber++;
|
|
1140
1036
|
if (newLineNumber === lineNumber) {
|
|
1141
1037
|
return position;
|
|
1142
1038
|
}
|
|
1143
|
-
} else if (line.startsWith(' ') || (line === '' && foundHunk)) {
|
|
1039
|
+
} else if (line.startsWith(' ') || (line === '' && foundHunk)) {
|
|
1144
1040
|
newLineNumber++;
|
|
1145
1041
|
if (newLineNumber === lineNumber) {
|
|
1146
1042
|
return position;
|
|
1147
1043
|
}
|
|
1148
1044
|
}
|
|
1149
|
-
// Deletion lines don't increment newLineNumber but do increment position
|
|
1150
1045
|
}
|
|
1151
1046
|
|
|
1152
|
-
console.warn('calculateDiffPosition: Position not found', {
|
|
1153
|
-
filePath,
|
|
1154
|
-
lineNumber,
|
|
1155
|
-
inFile,
|
|
1156
|
-
foundHunk,
|
|
1157
|
-
finalNewLineNumber: newLineNumber
|
|
1047
|
+
console.warn('calculateDiffPosition: Position not found', {
|
|
1048
|
+
filePath,
|
|
1049
|
+
lineNumber,
|
|
1050
|
+
inFile,
|
|
1051
|
+
foundHunk,
|
|
1052
|
+
finalNewLineNumber: newLineNumber
|
|
1158
1053
|
});
|
|
1159
|
-
return -1;
|
|
1054
|
+
return -1;
|
|
1160
1055
|
}
|
|
1161
1056
|
|
|
1162
1057
|
/**
|
|
@@ -1170,55 +1065,46 @@ class GitHubClient {
|
|
|
1170
1065
|
async handleReviewError(error, owner, repo, pullNumber) {
|
|
1171
1066
|
console.error('GitHub review submission error:', error);
|
|
1172
1067
|
|
|
1173
|
-
// Handle authentication errors
|
|
1174
1068
|
if (error.status === 401) {
|
|
1175
1069
|
throw new GitHubApiError('GitHub authentication failed. Your token may be invalid or expired. Check ~/.pair-review/config.json', 401);
|
|
1176
1070
|
}
|
|
1177
1071
|
|
|
1178
|
-
// Handle forbidden errors (insufficient permissions)
|
|
1179
1072
|
if (error.status === 403) {
|
|
1180
1073
|
throw new GitHubApiError(`Insufficient permissions to review PR #${pullNumber} in ${owner}/${repo}. Your GitHub token may need additional scopes.`, 403);
|
|
1181
1074
|
}
|
|
1182
1075
|
|
|
1183
|
-
// Handle not found errors
|
|
1184
1076
|
if (error.status === 404) {
|
|
1185
1077
|
throw new GitHubApiError(`Pull request #${pullNumber} not found in repository ${owner}/${repo}`, 404);
|
|
1186
1078
|
}
|
|
1187
1079
|
|
|
1188
|
-
// Handle validation errors
|
|
1189
1080
|
if (error.status === 422) {
|
|
1190
1081
|
console.error('GitHub 422 validation error response:', JSON.stringify(error.response?.data, null, 2));
|
|
1191
1082
|
const message = error.response?.data?.message || 'Validation error';
|
|
1192
1083
|
const errors = error.response?.data?.errors;
|
|
1193
|
-
|
|
1194
|
-
// Check for pending review error specifically
|
|
1084
|
+
|
|
1195
1085
|
if (errors && Array.isArray(errors)) {
|
|
1196
1086
|
const errorMessages = errors.map(e => e.message || e.code || e);
|
|
1197
1087
|
const errorDetails = errorMessages.join(', ');
|
|
1198
|
-
|
|
1199
|
-
// Special handling for pending review error
|
|
1088
|
+
|
|
1200
1089
|
if (errorMessages.some(msg => msg.includes('pending review'))) {
|
|
1201
1090
|
throw new Error(`You already have a pending (draft) review on this PR. Please submit or dismiss it on GitHub before creating a new draft review.`);
|
|
1202
1091
|
}
|
|
1203
|
-
|
|
1092
|
+
|
|
1204
1093
|
throw new Error(`GitHub API validation error: ${message}. Details: ${errorDetails}`);
|
|
1205
1094
|
}
|
|
1206
1095
|
throw new Error(`GitHub API validation error: ${message}`);
|
|
1207
1096
|
}
|
|
1208
1097
|
|
|
1209
|
-
// Handle network errors
|
|
1210
1098
|
if (error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
|
|
1211
1099
|
throw new GitHubApiError(`Network error during review submission: ${error.message}. Please check your internet connection.`, 503);
|
|
1212
1100
|
}
|
|
1213
1101
|
|
|
1214
|
-
// Handle rate limiting
|
|
1215
1102
|
if (error.status === 403 && error.response?.headers?.['x-ratelimit-remaining'] === '0') {
|
|
1216
1103
|
const resetTime = parseInt(error.response.headers['x-ratelimit-reset']) * 1000;
|
|
1217
1104
|
const waitTime = Math.max(resetTime - Date.now(), 1000);
|
|
1218
1105
|
throw new Error(`GitHub API rate limit exceeded. Review submission failed. Please wait ${Math.ceil(waitTime / 1000)} seconds and try again.`);
|
|
1219
1106
|
}
|
|
1220
1107
|
|
|
1221
|
-
// Generic error
|
|
1222
1108
|
throw new Error(`Failed to submit review: ${error.message}`);
|
|
1223
1109
|
}
|
|
1224
1110
|
|
|
@@ -1234,7 +1120,6 @@ class GitHubClient {
|
|
|
1234
1120
|
);
|
|
1235
1121
|
|
|
1236
1122
|
return items.map(item => {
|
|
1237
|
-
// repository_url format: https://api.github.com/repos/OWNER/REPO
|
|
1238
1123
|
const parts = item.repository_url.split('/');
|
|
1239
1124
|
const repo = parts.pop();
|
|
1240
1125
|
const owner = parts.pop();
|
|
@@ -1274,19 +1159,17 @@ class GitHubClient {
|
|
|
1274
1159
|
*/
|
|
1275
1160
|
async retryWithBackoff(apiCall, maxRetries = 3, baseDelay = 1000) {
|
|
1276
1161
|
let lastError;
|
|
1277
|
-
|
|
1162
|
+
|
|
1278
1163
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1279
1164
|
try {
|
|
1280
1165
|
return await apiCall();
|
|
1281
1166
|
} catch (error) {
|
|
1282
1167
|
lastError = error;
|
|
1283
|
-
|
|
1284
|
-
// Don't retry on authentication or not found errors
|
|
1168
|
+
|
|
1285
1169
|
if (error.status === 401 || error.status === 404) {
|
|
1286
1170
|
throw error;
|
|
1287
1171
|
}
|
|
1288
|
-
|
|
1289
|
-
// Only retry on rate limiting or network errors
|
|
1172
|
+
|
|
1290
1173
|
if (error.status === 403 || error.code === 'ENOTFOUND' || error.code === 'ECONNREFUSED') {
|
|
1291
1174
|
if (attempt < maxRetries) {
|
|
1292
1175
|
const delay = baseDelay * Math.pow(2, attempt);
|
|
@@ -1295,11 +1178,11 @@ class GitHubClient {
|
|
|
1295
1178
|
continue;
|
|
1296
1179
|
}
|
|
1297
1180
|
}
|
|
1298
|
-
|
|
1181
|
+
|
|
1299
1182
|
throw error;
|
|
1300
1183
|
}
|
|
1301
1184
|
}
|
|
1302
|
-
|
|
1185
|
+
|
|
1303
1186
|
throw lastError;
|
|
1304
1187
|
}
|
|
1305
1188
|
|
|
@@ -1334,4 +1217,15 @@ class GitHubClient {
|
|
|
1334
1217
|
}
|
|
1335
1218
|
}
|
|
1336
1219
|
|
|
1337
|
-
module.exports = {
|
|
1220
|
+
module.exports = {
|
|
1221
|
+
GitHubClient,
|
|
1222
|
+
GitHubApiError,
|
|
1223
|
+
isComplexityError,
|
|
1224
|
+
// Exported so tests can assert that the bare-token defaults stay in
|
|
1225
|
+
// sync with the canonical `GRAPHQL_DEFAULT_AREAS` set in `src/config.js`.
|
|
1226
|
+
DEFAULT_FEATURES,
|
|
1227
|
+
// Exported for unit tests asserting binding normalisation (e.g. that the
|
|
1228
|
+
// `refresh` capability is preserved for object bindings and null for the
|
|
1229
|
+
// legacy bare-token path).
|
|
1230
|
+
normaliseBinding
|
|
1231
|
+
};
|