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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/analysis-config.css +1807 -0
  6. package/public/css/pr.css +1029 -2169
  7. package/public/index.html +11 -0
  8. package/public/js/components/AIPanel.js +39 -23
  9. package/public/js/components/AdvancedConfigTab.js +56 -4
  10. package/public/js/components/AnalysisConfigModal.js +41 -25
  11. package/public/js/components/ChatPanel.js +163 -3
  12. package/public/js/components/KeyboardShortcuts.js +10 -26
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/TourBar.js +248 -0
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +64 -8
  18. package/public/js/modules/cancel-background-job.js +183 -0
  19. package/public/js/modules/hunk-summary-renderer.js +116 -0
  20. package/public/js/modules/storage-cleanup.js +16 -0
  21. package/public/js/modules/suggestion-manager.js +25 -1
  22. package/public/js/modules/tour-renderer.js +755 -0
  23. package/public/js/pr.js +1826 -56
  24. package/public/js/repo-links.js +328 -0
  25. package/public/js/utils/modal-detection.js +77 -0
  26. package/public/js/utils/provider-model.js +88 -0
  27. package/public/js/utils/storage-keys.js +50 -0
  28. package/public/local.html +24 -0
  29. package/public/pr.html +24 -0
  30. package/public/repo-settings.html +1 -0
  31. package/public/setup.html +2 -0
  32. package/src/ai/abort-signal-wiring.js +130 -0
  33. package/src/ai/analyzer.js +125 -18
  34. package/src/ai/background-queue.js +290 -0
  35. package/src/ai/claude-cli.js +1 -1
  36. package/src/ai/claude-provider.js +50 -7
  37. package/src/ai/codex-provider.js +28 -5
  38. package/src/ai/copilot-provider.js +22 -3
  39. package/src/ai/cursor-agent-provider.js +22 -6
  40. package/src/ai/executable-provider.js +4 -19
  41. package/src/ai/gemini-provider.js +22 -5
  42. package/src/ai/hunk-hashing.js +161 -0
  43. package/src/ai/index.js +2 -0
  44. package/src/ai/opencode-provider.js +21 -5
  45. package/src/ai/pi-provider.js +21 -5
  46. package/src/ai/prompts/hunk-summary.js +199 -0
  47. package/src/ai/prompts/tour.js +232 -0
  48. package/src/ai/provider.js +21 -1
  49. package/src/ai/summary-generator.js +469 -0
  50. package/src/ai/tour-generator.js +568 -0
  51. package/src/config.js +778 -10
  52. package/src/database.js +282 -1
  53. package/src/external/github-adapter.js +114 -25
  54. package/src/git/base-branch.js +11 -4
  55. package/src/github/client.js +482 -588
  56. package/src/github/errors.js +55 -0
  57. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  58. package/src/github/impl/graphql/pending-review.js +153 -0
  59. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  60. package/src/github/impl/graphql/stack-walker.js +210 -0
  61. package/src/github/impl/host/pending-review-comments.js +338 -0
  62. package/src/github/impl/rest/pending-review.js +251 -0
  63. package/src/github/impl/rest/review-lifecycle.js +226 -0
  64. package/src/github/impl/rest/stack-walker.js +309 -0
  65. package/src/github/operations/pending-review-comments.js +79 -0
  66. package/src/github/operations/pending-review.js +89 -0
  67. package/src/github/operations/review-lifecycle.js +126 -0
  68. package/src/github/operations/stack-walker.js +87 -0
  69. package/src/github/parser.js +230 -4
  70. package/src/github/stack-walker.js +14 -189
  71. package/src/links/repo-links.js +230 -0
  72. package/src/local-review.js +201 -172
  73. package/src/main.js +133 -30
  74. package/src/routes/analyses.js +30 -7
  75. package/src/routes/bulk-analysis-configs.js +295 -0
  76. package/src/routes/config.js +118 -3
  77. package/src/routes/context-files.js +2 -29
  78. package/src/routes/external-comments.js +20 -10
  79. package/src/routes/github-collections.js +3 -1
  80. package/src/routes/local.js +410 -13
  81. package/src/routes/mcp.js +47 -4
  82. package/src/routes/middleware/validate-review-id.js +53 -0
  83. package/src/routes/pr.js +556 -71
  84. package/src/routes/reviews.js +145 -29
  85. package/src/routes/setup.js +8 -3
  86. package/src/routes/stack-analysis.js +33 -9
  87. package/src/routes/worktrees.js +3 -2
  88. package/src/server.js +2 -0
  89. package/src/setup/pr-setup.js +37 -11
  90. package/src/setup/stack-setup.js +13 -3
  91. package/src/single-port.js +6 -3
  92. package/src/utils/diff-hunks.js +65 -0
  93. package/src/utils/json-extractor.js +5 -2
@@ -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
- * Custom error class for GitHub API errors that preserves the HTTP status code.
8
- * Route handlers can check `error.status` or use `instanceof GitHubApiError`
9
- * instead of fragile string matching on error messages.
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
- class GitHubApiError extends Error {
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
- * Detect whether a GraphQL error is a complexity/cost limit error from GitHub.
25
- * These errors mean the mutation was too large and can be retried with fewer items.
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 {Error} error - The error thrown by octokit.graphql
28
- * @returns {boolean} True if the error is a complexity/cost limit error
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 isComplexityError(error) {
31
- const patterns = [
32
- /complexity/i,
33
- /MAX_NODE_LIMIT/,
34
- /cost exceeds/i,
35
- /too large/i,
36
- /query size exceeds/i,
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
- // Check individual GraphQL error messages in the errors array
47
- if (error.errors && Array.isArray(error.errors)) {
48
- for (const err of error.errors) {
49
- if (err.message) {
50
- for (const pattern of patterns) {
51
- if (pattern.test(err.message)) return true;
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(token) {
67
- if (!token) {
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.octokit = new Octokit({
72
- auth: token,
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, // Boolean indicating if PR was merged
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
- // Binary file extensions that GitHub doesn't allow comments on
344
- const binaryExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.svg',
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
- // Use the new line/side/commit_id approach for ALL comments
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
- * This helper splits comments into batches to avoid GitHub API limits
465
- * on large mutations. Each batch is executed sequentially with retry logic.
466
- * If a batch fails due to GitHub GraphQL complexity/cost limits, the batch
467
- * size is automatically halved and the failed batch is retried.
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
- * @param {string} prNodeId - GraphQL node ID for the PR (e.g., "PR_kwDOM...")
470
- * @param {string} reviewId - GraphQL node ID for the pending review
471
- * @param {Array} comments - Array of comments with path, line (optional), side, body, isFileLevel
472
- * @param {number} batchSize - Number of comments per batch (default: 10)
473
- * @returns {Promise<Object>} Result with successCount, failed flag, and failedDetails array of error strings
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
- if (comments.length === 0) {
477
- return { successCount: 0, failed: false, failedDetails: [] };
478
- }
479
-
480
- let currentBatchSize = batchSize;
481
- let remaining = comments.slice();
482
- let totalSuccessful = 0;
483
- const failedDetails = [];
484
- let batchNumber = 0;
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
- * @param {string} owner - Repository owner
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
- try {
681
- logger.debug(`Checking for pending review on PR #${prNumber} in ${owner}/${repo}`);
682
-
683
- const result = await this.octokit.graphql(`
684
- query($owner: String!, $repo: String!, $prNumber: Int!) {
685
- repository(owner: $owner, name: $repo) {
686
- pullRequest(number: $prNumber) {
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
- * @param {string} nodeId - GraphQL node ID for the review (e.g., "PRR_kwDOM...")
762
- * @returns {Promise<Object|null>} Review data or null if not found
763
- * Returns: { id, state, submittedAt, url } where state is GitHub's review state
764
- * (PENDING, APPROVED, CHANGES_REQUESTED, COMMENTED, or DISMISSED).
765
- * Note: APPROVED, COMMENTED, CHANGES_REQUESTED all indicate a submitted review.
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
- try {
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
- * @returns {Promise<boolean>} True if deleted successfully
656
+ * @param {Object} [prContext] - `{ owner, repo, prNumber, reviewId? }`, required for REST mode
657
+ * @returns {Promise<boolean>}
819
658
  */
820
- async deletePendingReview(reviewId) {
821
- try {
822
- await this.octokit.graphql(`
823
- mutation DeleteReview($reviewId: ID!) {
824
- deletePullRequestReview(input: { pullRequestReviewId: $reviewId }) {
825
- pullRequestReview { id }
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
- * @param {string} prNodeId - GraphQL node ID for the PR (e.g., "PR_kwDOM...")
843
- * @param {string} event - Review event (APPROVE, REQUEST_CHANGES, COMMENT)
844
- * @param {string} body - Overall review body/summary
845
- * @param {Array} comments - Array of comments with path, line (optional), side, body, isFileLevel
846
- * @param {string|null} [existingReviewId=null] - GraphQL node ID of an existing pending review to reuse instead of creating a new one. When provided, skips creating a new pending review and won't delete the review on comment batch failure.
847
- * @returns {Promise<Object>} Review submission result
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 GraphQL review for PR ${prNodeId} with ${comments.length} comments`);
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
- // Step 1: Use existing pending review or create a new one
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
- const usedExistingReview = !!existingReviewId;
862
- if (existingReviewId) {
863
- console.log(`Step 1: Using existing pending review: ${existingReviewId}`);
864
- reviewId = existingReviewId;
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 createReviewResult = await this.octokit.graphql(`
868
- mutation AddPendingReview($prId: ID!) {
869
- addPullRequestReview(input: {
870
- pullRequestId: $prId
871
- }) {
872
- pullRequestReview {
873
- id
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
- // Step 2: Add comments in batches
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
- const batchResult = await this.addCommentsInBatches(prNodeId, reviewId, comments);
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 submitResult = await this.octokit.graphql(`
913
- mutation SubmitReview($reviewId: ID!, $event: PullRequestReviewEvent!, $body: String) {
914
- submitPullRequestReview(input: {
915
- pullRequestReviewId: $reviewId
916
- event: $event
917
- body: $body
918
- }) {
919
- pullRequestReview {
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('GraphQL review error:', error);
811
+ console.error(`Review error (${transport}):`, error);
946
812
 
947
- // Parse GraphQL errors
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 GraphQL error: ${messages}`);
818
+ throw new Error(`GitHub ${transport} error: ${messages}`);
951
819
  }
952
820
 
953
- throw new Error(`Failed to submit review via GraphQL: ${error.message}`);
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 - GraphQL node ID for the PR (e.g., "PR_kwDOM...")
963
- * @param {string} body - Overall review body/summary
964
- * @param {Array} comments - Array of comments with path, line (optional), side, body, isFileLevel
965
- * @param {string|null} [existingReviewId=null] - GraphQL node ID of an existing pending review to add comments to instead of creating a new one. When provided, skips creating a new pending review and won't delete the review on comment batch failure.
966
- * @returns {Promise<Object>} Draft review result
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 GraphQL draft review for PR ${prNodeId} with ${comments.length} comments`);
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 (existingReviewId) {
981
- console.log(`Step 1: Using existing pending review: ${existingReviewId}`);
982
- reviewId = existingReviewId;
983
- // URL and databaseId not available from existing review ID alone; callers use existingDraft fields as fallbacks
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 createReviewResult = await this.octokit.graphql(`
988
- mutation AddPendingReview($prId: ID!, $body: String) {
989
- addPullRequestReview(input: {
990
- pullRequestId: $prId
991
- body: $body
992
- }) {
993
- pullRequestReview {
994
- id
995
- databaseId
996
- url
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
- // Step 2: Add comments in batches
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
- const batchResult = await this.addCommentsInBatches(prNodeId, reviewId, comments);
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('GraphQL draft review error:', 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 GraphQL error: ${messages}`);
962
+ throw new Error(`GitHub ${transport} error: ${messages}`);
1058
963
  }
1059
964
 
1060
- throw new Error(`Failed to create draft review via GraphQL: ${error.message}`);
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]; // Use the "b/" path (new file)
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; // Start counting from the line before
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)) { // Context line (including empty context)
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; // Position not found
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 = { GitHubClient, GitHubApiError, isComplexityError };
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
+ };