@in-the-loop-labs/pair-review 3.6.0 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +20 -15
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +0 -1737
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +58 -8
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +33 -3
- package/public/js/pr.js +653 -157
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +7 -0
- package/public/pr.html +7 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/analyzer.js +125 -18
- package/src/config.js +664 -10
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +13 -4
- package/src/main.js +133 -30
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +102 -2
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +101 -11
- package/src/routes/mcp.js +47 -4
- package/src/routes/pr.js +298 -68
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- package/src/single-port.js +6 -3
package/src/routes/pr.js
CHANGED
|
@@ -17,6 +17,7 @@ const express = require('express');
|
|
|
17
17
|
const { query, queryOne, run, withTransaction, WorktreeRepository, ReviewRepository, GitHubReviewRepository, RepoSettingsRepository, AnalysisRunRepository, PRMetadataRepository, CouncilRepository } = require('../database');
|
|
18
18
|
const { GitWorktreeManager } = require('../git/worktree');
|
|
19
19
|
const { GitHubClient } = require('../github/client');
|
|
20
|
+
const { PRArgumentParser } = require('../github/parser');
|
|
20
21
|
const { getGeneratedFilePatterns } = require('../git/gitattributes');
|
|
21
22
|
const { getShaAbbrevLength, DEFAULT_SHA_ABBREV_LENGTH } = require('../git/sha-abbrev');
|
|
22
23
|
const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('../utils/paths');
|
|
@@ -25,7 +26,8 @@ const Analyzer = require('../ai/analyzer');
|
|
|
25
26
|
const { v4: uuidv4 } = require('uuid');
|
|
26
27
|
const fs = require('fs').promises;
|
|
27
28
|
const path = require('path');
|
|
28
|
-
const {
|
|
29
|
+
const { resolveHostBinding, resolveBindingRepositoryFromPR, getWorktreeDisplayName, resolveLoadSkills, buildCouncilProviderOverrides, getRepoSkipBulkFetch, getSummaryEnabled, getTourEnabled } = require('../config');
|
|
30
|
+
const { resolveHostName } = require('../links/repo-links');
|
|
29
31
|
const { backgroundQueue } = require('../ai/background-queue');
|
|
30
32
|
const logger = require('../utils/logger');
|
|
31
33
|
const { buildDiffLineSet } = require('../utils/diff-annotator');
|
|
@@ -112,6 +114,57 @@ function attachHunkHashes(changedFiles, canonicalDiff) {
|
|
|
112
114
|
module.exports._computeHunkHashesFromDiff = computeHunkHashesFromDiff;
|
|
113
115
|
module.exports._attachHunkHashes = attachHunkHashes;
|
|
114
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Resolve the host binding for a PR route, with alt-host safety checks.
|
|
119
|
+
*
|
|
120
|
+
* For an alt-host-configured repo we MUST NOT silently fall back to the
|
|
121
|
+
* server-startup github.com token cached at `req.app.get('githubToken')` —
|
|
122
|
+
* pointing that token at the alt-host would either auth-fail or, worse,
|
|
123
|
+
* leak it to a third party. When the binding resolves no token for an
|
|
124
|
+
* alt-host repo, throw a clear configuration error.
|
|
125
|
+
*
|
|
126
|
+
* For github.com repos (no `apiHost`) we preserve the pre-existing
|
|
127
|
+
* fallback to `req.app.get('githubToken')` so the GET endpoints that
|
|
128
|
+
* never touched per-repo config keep working.
|
|
129
|
+
*
|
|
130
|
+
* @param {import('express').Request} req
|
|
131
|
+
* @param {string} repository - "owner/repo" identifier
|
|
132
|
+
* @returns {{ binding: {apiHost: string|null, token: string, features: Object, source: string}, token: string }|null}
|
|
133
|
+
* `null` when no token can be resolved AND the repo is github.com (so
|
|
134
|
+
* callers can fall through to optional behaviour). Throws for alt-host
|
|
135
|
+
* misconfiguration.
|
|
136
|
+
*/
|
|
137
|
+
function resolveBindingForRequest(req, repository) {
|
|
138
|
+
const config = req.app.get('config') || {};
|
|
139
|
+
// `repository` here is the PR-identity `${owner}/${repo}`. For
|
|
140
|
+
// monorepo-style configs the binding-key in `config.repos` can differ;
|
|
141
|
+
// resolve it via `resolveBindingRepositoryFromPR` before looking up
|
|
142
|
+
// the host binding so per-repo tokens/api_host/features apply.
|
|
143
|
+
const [owner, repo] = String(repository).split('/');
|
|
144
|
+
const bindingRepository = resolveBindingRepositoryFromPR(owner, repo, config);
|
|
145
|
+
const binding = resolveHostBinding(bindingRepository, config);
|
|
146
|
+
if (binding.token) {
|
|
147
|
+
return { binding, token: binding.token, bindingRepository };
|
|
148
|
+
}
|
|
149
|
+
// No token from repo or top-level config — for github.com fall back to
|
|
150
|
+
// the server-startup cached token (legacy behaviour); for alt-host we
|
|
151
|
+
// refuse to use that token because it's for github.com.
|
|
152
|
+
if (binding.apiHost) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`No GitHub token configured for alt-host repo ${repository} (${binding.apiHost}). Configure repos["${bindingRepository}"].token or token_command.`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
const fallback = req.app.get('githubToken');
|
|
158
|
+
if (fallback) {
|
|
159
|
+
return {
|
|
160
|
+
binding: { ...binding, token: fallback, source: 'app:githubToken' },
|
|
161
|
+
token: fallback,
|
|
162
|
+
bindingRepository
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
|
|
115
168
|
/**
|
|
116
169
|
* Sync pending draft review from GitHub with local database
|
|
117
170
|
*
|
|
@@ -127,15 +180,24 @@ module.exports._attachHunkHashes = attachHunkHashes;
|
|
|
127
180
|
* @param {number} reviewId - The local review ID
|
|
128
181
|
* @param {Object} githubPendingReview - The pending review data from GitHub GraphQL API
|
|
129
182
|
* @param {GitHubClient} [githubClient] - Optional GitHub client for querying old review states
|
|
183
|
+
* @param {Object} [prContext] - `{ owner, repo, prNumber }` — required for REST mode of `getReviewById`
|
|
130
184
|
* @returns {Promise<Object>} The synced pending draft record with comments_count
|
|
131
185
|
*/
|
|
132
|
-
async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPendingReview, githubClient = null) {
|
|
186
|
+
async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPendingReview, githubClient = null, prContext = null) {
|
|
133
187
|
// Find all our pending records for this review
|
|
134
188
|
const existingPendingRecords = await githubReviewRepo.findPendingByReviewId(reviewId);
|
|
135
189
|
|
|
136
|
-
// Check if this GitHub draft matches any of our records
|
|
137
|
-
|
|
138
|
-
|
|
190
|
+
// Check if this GitHub draft matches any of our records. Match on
|
|
191
|
+
// either the GraphQL node id OR the stringified numeric databaseId —
|
|
192
|
+
// alt-host REST responses may not surface a node_id consistently, so
|
|
193
|
+
// a numeric-id-only record is the only identifier we have to anchor
|
|
194
|
+
// a draft against an existing local record.
|
|
195
|
+
const githubDbIdStr = (githubPendingReview.databaseId !== undefined && githubPendingReview.databaseId !== null)
|
|
196
|
+
? String(githubPendingReview.databaseId)
|
|
197
|
+
: null;
|
|
198
|
+
const matchingRecord = existingPendingRecords.find(r =>
|
|
199
|
+
(r.github_node_id && r.github_node_id === githubPendingReview.id) ||
|
|
200
|
+
(githubDbIdStr !== null && r.github_review_id === githubDbIdStr)
|
|
139
201
|
);
|
|
140
202
|
|
|
141
203
|
let pendingDraft;
|
|
@@ -155,9 +217,20 @@ async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPend
|
|
|
155
217
|
let actualState = 'dismissed'; // Default if we can't determine
|
|
156
218
|
let githubReviewData = null;
|
|
157
219
|
|
|
158
|
-
|
|
220
|
+
// On the GraphQL path the node id is the canonical identifier; on
|
|
221
|
+
// the REST path the numeric id (`github_review_id`) is the only
|
|
222
|
+
// value we may have. Run the lookup whenever we have either —
|
|
223
|
+
// otherwise mark the record as dismissed without querying.
|
|
224
|
+
const oldLookupId = oldRecord.github_node_id || oldRecord.github_review_id;
|
|
225
|
+
if (githubClient && (oldRecord.github_node_id || oldRecord.github_review_id)) {
|
|
159
226
|
try {
|
|
160
|
-
|
|
227
|
+
// prContext carries the REST review id when available. The
|
|
228
|
+
// GraphQL path ignores it. The github_review_id column holds
|
|
229
|
+
// the numeric REST id we received when the draft was created.
|
|
230
|
+
const reviewPrContext = prContext
|
|
231
|
+
? { ...prContext, reviewId: oldRecord.github_review_id }
|
|
232
|
+
: null;
|
|
233
|
+
githubReviewData = await githubClient.getReviewById(oldLookupId, reviewPrContext);
|
|
161
234
|
|
|
162
235
|
if (githubReviewData) {
|
|
163
236
|
// Map GitHub state to our local state
|
|
@@ -172,15 +245,15 @@ async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPend
|
|
|
172
245
|
// APPROVED, CHANGES_REQUESTED, COMMENTED all mean it was submitted
|
|
173
246
|
actualState = 'submitted';
|
|
174
247
|
}
|
|
175
|
-
logger.debug(`Old review ${
|
|
248
|
+
logger.debug(`Old review ${oldLookupId} actual state from GitHub: ${githubReviewData.state} -> ${actualState}`);
|
|
176
249
|
} else {
|
|
177
250
|
// Review not found on GitHub - treat as dismissed
|
|
178
|
-
logger.debug(`Old review ${
|
|
251
|
+
logger.debug(`Old review ${oldLookupId} not found on GitHub, marking as dismissed`);
|
|
179
252
|
actualState = 'dismissed';
|
|
180
253
|
}
|
|
181
254
|
} catch (error) {
|
|
182
255
|
// On error, default to dismissed (most likely scenario)
|
|
183
|
-
logger.warn(`Error querying GitHub for old review ${
|
|
256
|
+
logger.warn(`Error querying GitHub for old review ${oldLookupId}: ${error.message}, marking as dismissed`);
|
|
184
257
|
actualState = 'dismissed';
|
|
185
258
|
}
|
|
186
259
|
}
|
|
@@ -267,18 +340,30 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
267
340
|
// Check for pending GitHub draft
|
|
268
341
|
let pendingDraft = null;
|
|
269
342
|
{
|
|
270
|
-
|
|
271
|
-
|
|
343
|
+
let resolved = null;
|
|
344
|
+
try {
|
|
345
|
+
resolved = resolveBindingForRequest(req, repository);
|
|
346
|
+
} catch (configErr) {
|
|
347
|
+
// Alt-host repo with no token configured — surface the message
|
|
348
|
+
// but don't fail the GET (draft info is supplementary).
|
|
349
|
+
logger.warn(configErr.message);
|
|
350
|
+
}
|
|
272
351
|
|
|
273
|
-
if (
|
|
352
|
+
if (resolved) {
|
|
274
353
|
try {
|
|
275
|
-
const githubClient = new GitHubClient(
|
|
354
|
+
const githubClient = new GitHubClient(resolved.binding);
|
|
276
355
|
const githubReviewRepo = new GitHubReviewRepository(db);
|
|
277
356
|
|
|
278
357
|
const githubPendingReview = await githubClient.getPendingReviewForUser(repoOwner, repoName, prNumber);
|
|
279
358
|
|
|
280
359
|
if (githubPendingReview) {
|
|
281
|
-
pendingDraft = await syncPendingDraftFromGitHub(
|
|
360
|
+
pendingDraft = await syncPendingDraftFromGitHub(
|
|
361
|
+
githubReviewRepo,
|
|
362
|
+
review.id,
|
|
363
|
+
githubPendingReview,
|
|
364
|
+
githubClient,
|
|
365
|
+
{ owner: repoOwner, repo: repoName, prNumber }
|
|
366
|
+
);
|
|
282
367
|
}
|
|
283
368
|
} catch (githubError) {
|
|
284
369
|
// Log the error but don't fail the request - draft info is supplementary
|
|
@@ -295,11 +380,15 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
295
380
|
// Detect PR stack via GitHub GraphQL chain-walking
|
|
296
381
|
let stackData = null;
|
|
297
382
|
{
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
383
|
+
let resolved = null;
|
|
384
|
+
try {
|
|
385
|
+
resolved = resolveBindingForRequest(req, repository);
|
|
386
|
+
} catch (configErr) {
|
|
387
|
+
logger.warn(configErr.message);
|
|
388
|
+
}
|
|
389
|
+
if (resolved) {
|
|
301
390
|
try {
|
|
302
|
-
const ghClient = new GitHubClient(
|
|
391
|
+
const ghClient = new GitHubClient(resolved.binding);
|
|
303
392
|
const defaultBranch = extendedData.repository?.default_branch;
|
|
304
393
|
stackData = await walkPRStack(ghClient, repoOwner, repoName, prNumber, {
|
|
305
394
|
defaultBranches: [defaultBranch, ...DEFAULT_TRUNK_BRANCHES].filter(Boolean)
|
|
@@ -449,10 +538,16 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
449
538
|
});
|
|
450
539
|
}
|
|
451
540
|
|
|
452
|
-
//
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
541
|
+
// Resolve host binding for this repo (validates alt-host token presence).
|
|
542
|
+
let binding;
|
|
543
|
+
try {
|
|
544
|
+
const resolved = resolveBindingForRequest(req, repository);
|
|
545
|
+
if (!resolved) {
|
|
546
|
+
return res.status(401).json({ error: 'GitHub token not configured' });
|
|
547
|
+
}
|
|
548
|
+
binding = resolved.binding;
|
|
549
|
+
} catch (configErr) {
|
|
550
|
+
return res.status(500).json({ error: configErr.message });
|
|
456
551
|
}
|
|
457
552
|
|
|
458
553
|
// Check if worktree is locked before modifying it
|
|
@@ -468,7 +563,7 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
468
563
|
}
|
|
469
564
|
}
|
|
470
565
|
|
|
471
|
-
const githubClient = new GitHubClient(
|
|
566
|
+
const githubClient = new GitHubClient(binding);
|
|
472
567
|
const prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
|
|
473
568
|
|
|
474
569
|
// Update worktree with latest changes
|
|
@@ -555,10 +650,15 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
555
650
|
// Refresh stack data via GitHub GraphQL
|
|
556
651
|
let stackData = null;
|
|
557
652
|
{
|
|
558
|
-
|
|
559
|
-
|
|
653
|
+
let resolved = null;
|
|
654
|
+
try {
|
|
655
|
+
resolved = resolveBindingForRequest(req, repository);
|
|
656
|
+
} catch (configErr) {
|
|
657
|
+
logger.warn(configErr.message);
|
|
658
|
+
}
|
|
659
|
+
if (resolved) {
|
|
560
660
|
try {
|
|
561
|
-
const ghClient = new GitHubClient(
|
|
661
|
+
const ghClient = new GitHubClient(resolved.binding);
|
|
562
662
|
const defaultBranch = prData.repository?.default_branch;
|
|
563
663
|
stackData = await walkPRStack(ghClient, ...repository.split('/'), prNumber, {
|
|
564
664
|
defaultBranches: [defaultBranch, ...DEFAULT_TRUNK_BRANCHES].filter(Boolean)
|
|
@@ -713,6 +813,11 @@ router.post('/api/pr/:owner/:repo/:number/jobs/:jobKey/start', async (req, res)
|
|
|
713
813
|
const diffText = extendedData.diff || '';
|
|
714
814
|
const worktreePath = extendedData.worktree_path || null;
|
|
715
815
|
|
|
816
|
+
// Unlike local mode, a PR's diff is always persisted in `pr_data` at PR-load
|
|
817
|
+
// time — there is no in-memory cache or working-tree regeneration to fall
|
|
818
|
+
// back on. So an empty diff here genuinely means `pr_data` has no diff, and
|
|
819
|
+
// this `no-diff` cannot be a false negative (no parity fix needed; see the
|
|
820
|
+
// local manual-start handler in local.js for the self-healing variant).
|
|
716
821
|
if (!diffText || !worktreePath) {
|
|
717
822
|
return res.json({ started: false, reason: 'no-diff' });
|
|
718
823
|
}
|
|
@@ -807,11 +912,17 @@ router.get('/api/pr/:owner/:repo/:number/check-stale', async (req, res) => {
|
|
|
807
912
|
}
|
|
808
913
|
|
|
809
914
|
// Fetch current PR from GitHub
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
915
|
+
let binding;
|
|
916
|
+
try {
|
|
917
|
+
const resolved = resolveBindingForRequest(req, repository);
|
|
918
|
+
if (!resolved) {
|
|
919
|
+
return res.json({ isStale: null, error: 'GitHub token not configured' });
|
|
920
|
+
}
|
|
921
|
+
binding = resolved.binding;
|
|
922
|
+
} catch (configErr) {
|
|
923
|
+
return res.json({ isStale: null, error: configErr.message });
|
|
813
924
|
}
|
|
814
|
-
const githubClient = new GitHubClient(
|
|
925
|
+
const githubClient = new GitHubClient(binding);
|
|
815
926
|
const remotePrData = await githubClient.fetchPullRequest(owner, repo, prNumber);
|
|
816
927
|
|
|
817
928
|
const remoteHeadSha = remotePrData.head_sha;
|
|
@@ -873,14 +984,20 @@ router.get('/api/pr/:owner/:repo/:number/github-drafts', async (req, res) => {
|
|
|
873
984
|
}
|
|
874
985
|
|
|
875
986
|
// Initialize GitHub client and check for pending drafts on GitHub
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
987
|
+
let binding;
|
|
988
|
+
try {
|
|
989
|
+
const resolved = resolveBindingForRequest(req, repository);
|
|
990
|
+
if (!resolved) {
|
|
991
|
+
return res.status(500).json({
|
|
992
|
+
error: 'GitHub token not configured. Please check your ~/.pair-review/config.json'
|
|
993
|
+
});
|
|
994
|
+
}
|
|
995
|
+
binding = resolved.binding;
|
|
996
|
+
} catch (configErr) {
|
|
997
|
+
return res.status(500).json({ error: configErr.message });
|
|
881
998
|
}
|
|
882
999
|
|
|
883
|
-
const githubClient = new GitHubClient(
|
|
1000
|
+
const githubClient = new GitHubClient(binding);
|
|
884
1001
|
const githubReviewRepo = new GitHubReviewRepository(db);
|
|
885
1002
|
|
|
886
1003
|
// Fetch pending review from GitHub
|
|
@@ -889,7 +1006,13 @@ router.get('/api/pr/:owner/:repo/:number/github-drafts', async (req, res) => {
|
|
|
889
1006
|
const githubPendingReview = await githubClient.getPendingReviewForUser(owner, repo, prNumber);
|
|
890
1007
|
|
|
891
1008
|
if (githubPendingReview) {
|
|
892
|
-
pendingDraft = await syncPendingDraftFromGitHub(
|
|
1009
|
+
pendingDraft = await syncPendingDraftFromGitHub(
|
|
1010
|
+
githubReviewRepo,
|
|
1011
|
+
review.id,
|
|
1012
|
+
githubPendingReview,
|
|
1013
|
+
githubClient,
|
|
1014
|
+
{ owner, repo, prNumber }
|
|
1015
|
+
);
|
|
893
1016
|
}
|
|
894
1017
|
} catch (githubError) {
|
|
895
1018
|
// Log the error but don't fail the request - return local data only
|
|
@@ -1340,16 +1463,22 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
|
|
|
1340
1463
|
const repository = normalizeRepository(owner, repo);
|
|
1341
1464
|
const db = req.app.get('db');
|
|
1342
1465
|
|
|
1343
|
-
//
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1466
|
+
// Resolve host binding (repo-aware: alt-host repos require their own token).
|
|
1467
|
+
let binding;
|
|
1468
|
+
try {
|
|
1469
|
+
const resolved = resolveBindingForRequest(req, repository);
|
|
1470
|
+
if (!resolved) {
|
|
1471
|
+
return res.status(500).json({
|
|
1472
|
+
error: 'GitHub token not configured. Please check your ~/.pair-review/config.json'
|
|
1473
|
+
});
|
|
1474
|
+
}
|
|
1475
|
+
binding = resolved.binding;
|
|
1476
|
+
} catch (configErr) {
|
|
1477
|
+
return res.status(500).json({ error: configErr.message });
|
|
1349
1478
|
}
|
|
1350
1479
|
|
|
1351
1480
|
// Initialize GitHub client
|
|
1352
|
-
const githubClient = new GitHubClient(
|
|
1481
|
+
const githubClient = new GitHubClient(binding);
|
|
1353
1482
|
|
|
1354
1483
|
// Get PR metadata and worktree path
|
|
1355
1484
|
const prMetadata = await queryOne(db, `
|
|
@@ -1405,13 +1534,6 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
|
|
|
1405
1534
|
//
|
|
1406
1535
|
// We check whether the comment's target line actually appears in a diff hunk
|
|
1407
1536
|
// rather than relying on diff_position (which may not be set by all sources).
|
|
1408
|
-
const prNodeId = prData.node_id;
|
|
1409
|
-
if (!prNodeId) {
|
|
1410
|
-
return res.status(400).json({
|
|
1411
|
-
error: 'PR node_id not available. Please refresh the PR data and try again.'
|
|
1412
|
-
});
|
|
1413
|
-
}
|
|
1414
|
-
|
|
1415
1537
|
const diffLineSet = buildDiffLineSet(diffContent);
|
|
1416
1538
|
|
|
1417
1539
|
const graphqlComments = comments.map(comment => {
|
|
@@ -1481,10 +1603,63 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
|
|
|
1481
1603
|
// GitHub only allows one pending review per user per PR
|
|
1482
1604
|
const existingDraft = await githubClient.getPendingReviewForUser(owner, repo, prNumber);
|
|
1483
1605
|
|
|
1606
|
+
// GraphQL PR node id is only required when the dispatcher actually
|
|
1607
|
+
// routes to a GraphQL implementation AND we'll be creating a brand
|
|
1608
|
+
// new review (not reusing the existing draft) OR adding GraphQL
|
|
1609
|
+
// review comments. REST review-lifecycle and host pending-review
|
|
1610
|
+
// -comments address the PR by (owner, repo, prNumber) + numeric
|
|
1611
|
+
// review id and ignore prNodeId; reusing an existing GraphQL draft
|
|
1612
|
+
// also doesn't need the PR node id because the review node id is
|
|
1613
|
+
// sufficient. Compute this after fetching existingDraft so the
|
|
1614
|
+
// requirement is narrowed correctly.
|
|
1615
|
+
const willCreateNewGraphQLReview =
|
|
1616
|
+
binding.features.review_lifecycle === 'graphql' && !existingDraft;
|
|
1617
|
+
const willAddGraphQLComments =
|
|
1618
|
+
graphqlComments.length > 0 && binding.features.pending_review_comments === 'graphql';
|
|
1619
|
+
const needsGraphQLNodeId = willCreateNewGraphQLReview || willAddGraphQLComments;
|
|
1620
|
+
|
|
1621
|
+
if (needsGraphQLNodeId && !prData.node_id) {
|
|
1622
|
+
return res.status(400).json({
|
|
1623
|
+
error:
|
|
1624
|
+
`GraphQL PR node id required for ${repository}#${prNumber} ` +
|
|
1625
|
+
`(features.review_lifecycle = "${binding.features.review_lifecycle}", ` +
|
|
1626
|
+
`pending_review_comments = "${binding.features.pending_review_comments}"). ` +
|
|
1627
|
+
`PR record is missing node_id — refresh the PR data and try again.`
|
|
1628
|
+
});
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
const prNodeId = prData.node_id ?? null;
|
|
1632
|
+
|
|
1633
|
+
// The PR head SHA is required by the host pending-review-comments path
|
|
1634
|
+
// (GitHub-compatible alt-hosts validate each inline comment like
|
|
1635
|
+
// `pulls.createReviewComment`, which mandates `commit_id`). The GraphQL
|
|
1636
|
+
// path on github.com ignores it (the pending review pins the commit
|
|
1637
|
+
// implicitly), so threading it through is harmless there.
|
|
1638
|
+
// `prData.head_sha` is the canonical source (merged from the cached PR
|
|
1639
|
+
// JSON); `prMetadata.head_sha` is a defensive fallback for callers whose
|
|
1640
|
+
// record exposes it as a column. If neither is present, proceed without
|
|
1641
|
+
// it but warn loudly so the resulting 422 is diagnosable.
|
|
1642
|
+
const headSha = prData.head_sha || prMetadata.head_sha || null;
|
|
1643
|
+
if (!headSha) {
|
|
1644
|
+
logger.warn(
|
|
1645
|
+
`Submit review for ${repository}#${prNumber}: PR head SHA is missing ` +
|
|
1646
|
+
`(prData.head_sha and prMetadata.head_sha both absent). Host inline-comment ` +
|
|
1647
|
+
`posting will likely fail with a 422 missing commit_id error.`
|
|
1648
|
+
);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
const submitPrContext = {
|
|
1652
|
+
owner,
|
|
1653
|
+
repo,
|
|
1654
|
+
prNumber,
|
|
1655
|
+
reviewId: existingDraft?.databaseId,
|
|
1656
|
+
headSha
|
|
1657
|
+
};
|
|
1658
|
+
|
|
1484
1659
|
if (event === 'DRAFT') {
|
|
1485
1660
|
// Delegate to createDraftReviewGraphQL (handles both new and existing drafts)
|
|
1486
1661
|
githubReview = await githubClient.createDraftReviewGraphQL(
|
|
1487
|
-
prNodeId, body || '', graphqlComments, existingDraft?.id
|
|
1662
|
+
prNodeId, body || '', graphqlComments, existingDraft?.id, submitPrContext
|
|
1488
1663
|
);
|
|
1489
1664
|
// When adding to an existing draft, use the existing URL and include prior comments in total count
|
|
1490
1665
|
if (existingDraft) {
|
|
@@ -1493,7 +1668,9 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
|
|
|
1493
1668
|
}
|
|
1494
1669
|
} else {
|
|
1495
1670
|
// For non-drafts, create/use review, add comments, and submit
|
|
1496
|
-
githubReview = await githubClient.createReviewGraphQL(
|
|
1671
|
+
githubReview = await githubClient.createReviewGraphQL(
|
|
1672
|
+
prNodeId, event, body || '', graphqlComments, existingDraft?.id, submitPrContext
|
|
1673
|
+
);
|
|
1497
1674
|
}
|
|
1498
1675
|
|
|
1499
1676
|
// ID storage strategy:
|
|
@@ -1566,10 +1743,15 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
|
|
|
1566
1743
|
// Commit transaction
|
|
1567
1744
|
await run(db, 'COMMIT');
|
|
1568
1745
|
|
|
1569
|
-
// Send success response after all database operations complete
|
|
1746
|
+
// Send success response after all database operations complete.
|
|
1747
|
+
// Use the configured remote-host display name (e.g. "Meteorite")
|
|
1748
|
+
// instead of the literal "GitHub". Resolve via the binding repository
|
|
1749
|
+
// so monorepo url_pattern configs map to the right repos[...] entry.
|
|
1750
|
+
const cfg = req.app.get('config') || {};
|
|
1751
|
+
const hostName = resolveHostName(cfg, resolveBindingRepositoryFromPR(owner, repo, cfg));
|
|
1570
1752
|
res.json({
|
|
1571
1753
|
success: true,
|
|
1572
|
-
message: `${event === 'DRAFT' ? 'Draft review created' : 'Review submitted'} successfully ${event === 'DRAFT' ? 'on' : 'to'}
|
|
1754
|
+
message: `${event === 'DRAFT' ? 'Draft review created' : 'Review submitted'} successfully ${event === 'DRAFT' ? 'on' : 'to'} ${hostName}`,
|
|
1573
1755
|
github_url: githubReview.html_url,
|
|
1574
1756
|
comments_submitted: githubReview.comments_count,
|
|
1575
1757
|
event: event,
|
|
@@ -1744,11 +1926,12 @@ router.get('/api/pr/health', (req, res) => {
|
|
|
1744
1926
|
|
|
1745
1927
|
/**
|
|
1746
1928
|
* Parse a PR URL and extract owner, repo, and PR number
|
|
1747
|
-
* Supports GitHub and Graphite URLs (with or without protocol)
|
|
1929
|
+
* Supports GitHub and Graphite URLs (with or without protocol), plus
|
|
1930
|
+
* any per-repo `url_pattern` regexes configured in pair-review config.
|
|
1748
1931
|
*/
|
|
1749
1932
|
router.post('/api/parse-pr-url', (req, res) => {
|
|
1750
|
-
const
|
|
1751
|
-
const parser = new PRArgumentParser();
|
|
1933
|
+
const config = req.app.get('config') || null;
|
|
1934
|
+
const parser = new PRArgumentParser(config);
|
|
1752
1935
|
|
|
1753
1936
|
const { url } = req.body;
|
|
1754
1937
|
|
|
@@ -1921,6 +2104,24 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
|
|
|
1921
2104
|
const appConfig = req.app.get('config') || {};
|
|
1922
2105
|
const globalInstructions = appConfig.globalInstructions || null;
|
|
1923
2106
|
|
|
2107
|
+
// Build a GitHubClient so the analyzer can pre-fetch existing PR review
|
|
2108
|
+
// comments when the request opts in via excludePrevious.github. If no token
|
|
2109
|
+
// is available we pass undefined and the analyzer silently omits the GitHub
|
|
2110
|
+
// dedup section. For alt-host repos we use the repo-bound token and host;
|
|
2111
|
+
// for github.com repos we fall back to the server-startup cached token.
|
|
2112
|
+
let analyzerGithubClient;
|
|
2113
|
+
try {
|
|
2114
|
+
const resolved = resolveBindingForRequest(req, repository);
|
|
2115
|
+
analyzerGithubClient = resolved ? new GitHubClient(resolved.binding) : undefined;
|
|
2116
|
+
} catch (configErr) {
|
|
2117
|
+
// Alt-host misconfiguration — skip dedup pre-fetch with a clear log.
|
|
2118
|
+
logger.warn(`Skipping GitHub dedup pre-fetch: ${configErr.message}`);
|
|
2119
|
+
analyzerGithubClient = undefined;
|
|
2120
|
+
}
|
|
2121
|
+
if (analyzerGithubClient) {
|
|
2122
|
+
logger.debug(`analyzer githubClient wired for ${owner}/${repo}#${prNumber}`);
|
|
2123
|
+
}
|
|
2124
|
+
|
|
1924
2125
|
const { provider, model, repoInstructions, combinedInstructions, repoSettings: fetchedRepoSettings } = await withTransaction(db, async () => {
|
|
1925
2126
|
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1926
2127
|
const fetchedRepoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
@@ -2076,7 +2277,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
|
|
|
2076
2277
|
|
|
2077
2278
|
const progressCallback = createProgressCallback(analysisId);
|
|
2078
2279
|
|
|
2079
|
-
analysisPromise = analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { globalInstructions, repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig, excludePrevious, serverPort: req.socket.localPort });
|
|
2280
|
+
analysisPromise = analyzer.analyzeLevel1(review.id, worktreePath, prMetadata, progressCallback, { globalInstructions, repoInstructions, requestInstructions }, null, { analysisId, runId, skipRunCreation: true, tier, skipLevel3: requestSkipLevel3, enabledLevels: levelsConfig, excludePrevious, serverPort: req.socket.localPort, githubClient: analyzerGithubClient });
|
|
2080
2281
|
} catch (setupError) {
|
|
2081
2282
|
// Synchronous setup failure — clean up the analysis hold immediately
|
|
2082
2283
|
reviewToAnalysisId.delete(review.id);
|
|
@@ -2322,6 +2523,19 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
|
|
|
2322
2523
|
const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
|
|
2323
2524
|
buildCouncilProviderOverrides(prCouncilConfig, repository, repoSettings);
|
|
2324
2525
|
|
|
2526
|
+
// Build a GitHubClient for analyzer-side dedup pre-fetch (PR mode only).
|
|
2527
|
+
let councilGithubClient;
|
|
2528
|
+
try {
|
|
2529
|
+
const resolved = resolveBindingForRequest(req, repository);
|
|
2530
|
+
councilGithubClient = resolved ? new GitHubClient(resolved.binding) : undefined;
|
|
2531
|
+
} catch (configErr) {
|
|
2532
|
+
logger.warn(`Skipping GitHub dedup pre-fetch (council): ${configErr.message}`);
|
|
2533
|
+
councilGithubClient = undefined;
|
|
2534
|
+
}
|
|
2535
|
+
if (councilGithubClient) {
|
|
2536
|
+
logger.debug(`analyzer githubClient wired for ${owner}/${repo}#${prNumber} (council)`);
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2325
2539
|
const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
|
|
2326
2540
|
db,
|
|
2327
2541
|
{
|
|
@@ -2336,6 +2550,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
|
|
|
2336
2550
|
config: prCouncilConfig,
|
|
2337
2551
|
excludePrevious,
|
|
2338
2552
|
serverPort: req.socket.localPort,
|
|
2553
|
+
githubClient: councilGithubClient,
|
|
2339
2554
|
poolLifecycle: req.app.get('poolLifecycle'),
|
|
2340
2555
|
providerOverrides: councilProviderOverrides,
|
|
2341
2556
|
providerOverridesMap: councilProviderOverridesMap,
|
|
@@ -2427,13 +2642,14 @@ router.get('/api/pr/:owner/:repo/:number/share', async (req, res) => {
|
|
|
2427
2642
|
deletions: f.deletions ?? 0
|
|
2428
2643
|
}));
|
|
2429
2644
|
|
|
2430
|
-
// Get the authenticated user (who is sharing)
|
|
2645
|
+
// Get the authenticated user (who is sharing).
|
|
2646
|
+
// Use the repo's binding so authentication targets the right host —
|
|
2647
|
+
// an alt-host's `getAuthenticatedUser` resolves the user on that host.
|
|
2431
2648
|
let sharedBy = null;
|
|
2432
2649
|
try {
|
|
2433
|
-
const
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
const githubClient = new GitHubClient(githubToken);
|
|
2650
|
+
const resolved = resolveBindingForRequest(req, repository);
|
|
2651
|
+
if (resolved) {
|
|
2652
|
+
const githubClient = new GitHubClient(resolved.binding);
|
|
2437
2653
|
const user = await githubClient.getAuthenticatedUser();
|
|
2438
2654
|
sharedBy = user.login;
|
|
2439
2655
|
}
|
|
@@ -2563,8 +2779,15 @@ router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
|
|
|
2563
2779
|
const db = req.app.get('db');
|
|
2564
2780
|
const config = req.app.get('config') || {};
|
|
2565
2781
|
|
|
2566
|
-
|
|
2567
|
-
|
|
2782
|
+
let binding;
|
|
2783
|
+
try {
|
|
2784
|
+
const resolved = resolveBindingForRequest(req, repository);
|
|
2785
|
+
if (!resolved) {
|
|
2786
|
+
return res.json({ stack: [] });
|
|
2787
|
+
}
|
|
2788
|
+
binding = resolved.binding;
|
|
2789
|
+
} catch (configErr) {
|
|
2790
|
+
logger.warn(configErr.message);
|
|
2568
2791
|
return res.json({ stack: [] });
|
|
2569
2792
|
}
|
|
2570
2793
|
|
|
@@ -2576,7 +2799,7 @@ router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
|
|
|
2576
2799
|
const parsedPrData = prMetadataRow?.pr_data ? JSON.parse(prMetadataRow.pr_data) : {};
|
|
2577
2800
|
const defaultBranch = parsedPrData.repository?.default_branch;
|
|
2578
2801
|
|
|
2579
|
-
const ghClient = new GitHubClient(
|
|
2802
|
+
const ghClient = new GitHubClient(binding);
|
|
2580
2803
|
let stack;
|
|
2581
2804
|
try {
|
|
2582
2805
|
stack = await walkPRStack(ghClient, ...repository.split('/'), prNumber, {
|
|
@@ -2636,3 +2859,10 @@ router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
|
|
|
2636
2859
|
});
|
|
2637
2860
|
|
|
2638
2861
|
module.exports = router;
|
|
2862
|
+
// Exported for tests so behavioural changes to the GitHub pending-draft
|
|
2863
|
+
// sync logic (e.g. Fix #10: matching by numeric id when node_id is
|
|
2864
|
+
// absent) can be exercised directly without spinning up the route.
|
|
2865
|
+
module.exports._internals = {
|
|
2866
|
+
syncPendingDraftFromGitHub,
|
|
2867
|
+
resolveBindingForRequest
|
|
2868
|
+
};
|
package/src/routes/setup.js
CHANGED
|
@@ -15,7 +15,7 @@ const crypto = require('crypto');
|
|
|
15
15
|
const { activeSetups, broadcastSetupProgress } = require('./shared');
|
|
16
16
|
const { setupPRReview } = require('../setup/pr-setup');
|
|
17
17
|
const { setupLocalReview } = require('../setup/local-setup');
|
|
18
|
-
const { getGitHubToken, expandPath } = require('../config');
|
|
18
|
+
const { getGitHubToken, expandPath, resolveBindingRepositoryFromPR } = require('../config');
|
|
19
19
|
const { queryOne, ReviewRepository } = require('../database');
|
|
20
20
|
const { normalizeRepository } = require('../utils/paths');
|
|
21
21
|
const { rejectUrlLikeLocalReviewPath } = require('../utils/local-path-input');
|
|
@@ -63,8 +63,12 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
63
63
|
const db = req.app.get('db');
|
|
64
64
|
const config = req.app.get('config');
|
|
65
65
|
|
|
66
|
-
// GitHub token is required for PR setup
|
|
67
|
-
|
|
66
|
+
// GitHub token is required for PR setup. Resolve the binding key
|
|
67
|
+
// first so monorepo-style `repos[...]` entries (matched via
|
|
68
|
+
// `url_pattern` named captures) supply their per-repo token even when
|
|
69
|
+
// the captured owner/repo differs from the config key.
|
|
70
|
+
const repositoryForToken = resolveBindingRepositoryFromPR(owner, repo, config);
|
|
71
|
+
const githubToken = getGitHubToken(config, repositoryForToken);
|
|
68
72
|
if (!githubToken) {
|
|
69
73
|
return res.status(401).json({ error: 'GitHub token not configured' });
|
|
70
74
|
}
|
|
@@ -143,6 +147,7 @@ router.post('/api/setup/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
143
147
|
repo,
|
|
144
148
|
prNumber,
|
|
145
149
|
githubToken,
|
|
150
|
+
bindingRepository: repositoryForToken,
|
|
146
151
|
config,
|
|
147
152
|
poolLifecycle: req.app.get('poolLifecycle'),
|
|
148
153
|
restoreMetadata,
|