@in-the-loop-labs/pair-review 3.5.2 → 3.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +1029 -2169
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/TourBar.js +248 -0
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +64 -8
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +755 -0
- package/public/js/pr.js +1826 -56
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/modal-detection.js +77 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +24 -0
- package/public/pr.html +24 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/analyzer.js +125 -18
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +778 -10
- package/src/database.js +282 -1
- package/src/external/github-adapter.js +114 -25
- package/src/git/base-branch.js +11 -4
- package/src/github/client.js +482 -588
- package/src/github/errors.js +55 -0
- package/src/github/impl/graphql/pending-review-comments.js +230 -0
- package/src/github/impl/graphql/pending-review.js +153 -0
- package/src/github/impl/graphql/review-lifecycle.js +161 -0
- package/src/github/impl/graphql/stack-walker.js +210 -0
- package/src/github/impl/host/pending-review-comments.js +338 -0
- package/src/github/impl/rest/pending-review.js +251 -0
- package/src/github/impl/rest/review-lifecycle.js +226 -0
- package/src/github/impl/rest/stack-walker.js +309 -0
- package/src/github/operations/pending-review-comments.js +79 -0
- package/src/github/operations/pending-review.js +89 -0
- package/src/github/operations/review-lifecycle.js +126 -0
- package/src/github/operations/stack-walker.js +87 -0
- package/src/github/parser.js +230 -4
- package/src/github/stack-walker.js +14 -189
- package/src/links/repo-links.js +230 -0
- package/src/local-review.js +201 -172
- package/src/main.js +133 -30
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +118 -3
- package/src/routes/context-files.js +2 -29
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +410 -13
- package/src/routes/mcp.js +47 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +556 -71
- package/src/routes/reviews.js +145 -29
- package/src/routes/setup.js +8 -3
- package/src/routes/stack-analysis.js +33 -9
- package/src/routes/worktrees.js +3 -2
- package/src/server.js +2 -0
- package/src/setup/pr-setup.js +37 -11
- package/src/setup/stack-setup.js +13 -3
- package/src/single-port.js +6 -3
- package/src/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
package/src/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,9 @@ 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');
|
|
31
|
+
const { backgroundQueue } = require('../ai/background-queue');
|
|
29
32
|
const logger = require('../utils/logger');
|
|
30
33
|
const { buildDiffLineSet } = require('../utils/diff-annotator');
|
|
31
34
|
const { broadcastReviewEvent } = require('../events/review-events');
|
|
@@ -34,6 +37,8 @@ const { buildReviewStartedPayload, buildReviewLoadedPayload, buildAnalysisStarte
|
|
|
34
37
|
const simpleGit = require('simple-git');
|
|
35
38
|
const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('../git/diff-flags');
|
|
36
39
|
const { walkPRStack, DEFAULT_TRUNK_BRANCHES } = require('../github/stack-walker');
|
|
40
|
+
const summaryGenerator = require('../ai/summary-generator');
|
|
41
|
+
const tourGenerator = require('../ai/tour-generator');
|
|
37
42
|
const {
|
|
38
43
|
activeAnalyses,
|
|
39
44
|
reviewToAnalysisId,
|
|
@@ -45,7 +50,9 @@ const {
|
|
|
45
50
|
registerProcess: registerProcessForCancellation
|
|
46
51
|
} = require('./shared');
|
|
47
52
|
const { safeParseJson } = require('../utils/safe-parse-json');
|
|
48
|
-
const { mergeChangedFilesWithDiff } = require('../utils/diff-file-list');
|
|
53
|
+
const { mergeChangedFilesWithDiff, parseUnifiedDiffPatches } = require('../utils/diff-file-list');
|
|
54
|
+
const { parseHunks } = require('../utils/diff-hunks');
|
|
55
|
+
const { hashHunk } = require('../ai/hunk-hashing');
|
|
49
56
|
const { resolveOriginalFileContentSpecs } = require('../utils/diff-file-content');
|
|
50
57
|
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
51
58
|
const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
|
|
@@ -56,6 +63,108 @@ const analysesRouter = require('./analyses');
|
|
|
56
63
|
const { worktreeLock } = require('../git/worktree-lock');
|
|
57
64
|
const router = express.Router();
|
|
58
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Compute per-file hunk hashes from a canonical (unfiltered) unified diff.
|
|
68
|
+
* Returns a Map<filePath, string[]> where the array is parallel to the order
|
|
69
|
+
* `parseHunks(filePatch)` returns hunks. The frontend's `parseDiffIntoBlocks`
|
|
70
|
+
* walks hunks in the same order, so `hunk_hashes[i]` matches `block[i]`.
|
|
71
|
+
*
|
|
72
|
+
* This is computed from the canonical (non-whitespace-filtered) diff so that
|
|
73
|
+
* the resulting hashes always match the keys persisted in `hunk_summaries`.
|
|
74
|
+
* Even when the rendered patch is `?w=1` (whitespace-filtered), the hashes
|
|
75
|
+
* stay aligned to the canonical hunks.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} canonicalDiff - Full unified diff (NOT whitespace-filtered)
|
|
78
|
+
* @returns {Map<string, string[]>}
|
|
79
|
+
*/
|
|
80
|
+
function computeHunkHashesFromDiff(canonicalDiff) {
|
|
81
|
+
const result = new Map();
|
|
82
|
+
if (!canonicalDiff) return result;
|
|
83
|
+
const filePatchMap = parseUnifiedDiffPatches(canonicalDiff);
|
|
84
|
+
for (const [filePath, filePatch] of filePatchMap.entries()) {
|
|
85
|
+
const hunks = parseHunks(filePatch);
|
|
86
|
+
const hashes = hunks.map((h) => hashHunk(filePath, `${h.header}\n${h.lines.join('\n')}`));
|
|
87
|
+
result.set(filePath, hashes);
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Decorate a `changed_files` array with a parallel `hunk_hashes` array per
|
|
94
|
+
* file. Hashes come from the canonical diff so they remain stable across
|
|
95
|
+
* whitespace-filtered renders.
|
|
96
|
+
*
|
|
97
|
+
* @param {Array<object>} changedFiles
|
|
98
|
+
* @param {string} canonicalDiff
|
|
99
|
+
* @returns {Array<object>} New array; inputs are not mutated.
|
|
100
|
+
*/
|
|
101
|
+
function attachHunkHashes(changedFiles, canonicalDiff) {
|
|
102
|
+
if (!Array.isArray(changedFiles) || changedFiles.length === 0) return changedFiles;
|
|
103
|
+
const hashes = computeHunkHashesFromDiff(canonicalDiff);
|
|
104
|
+
return changedFiles.map((file) => {
|
|
105
|
+
if (typeof file === 'string') return file;
|
|
106
|
+
const filePath = file?.file;
|
|
107
|
+
if (!filePath) return file;
|
|
108
|
+
const fileHashes = hashes.get(filePath);
|
|
109
|
+
if (!fileHashes) return file;
|
|
110
|
+
return { ...file, hunk_hashes: fileHashes };
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
module.exports._computeHunkHashesFromDiff = computeHunkHashesFromDiff;
|
|
115
|
+
module.exports._attachHunkHashes = attachHunkHashes;
|
|
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
|
+
|
|
59
168
|
/**
|
|
60
169
|
* Sync pending draft review from GitHub with local database
|
|
61
170
|
*
|
|
@@ -71,15 +180,24 @@ const router = express.Router();
|
|
|
71
180
|
* @param {number} reviewId - The local review ID
|
|
72
181
|
* @param {Object} githubPendingReview - The pending review data from GitHub GraphQL API
|
|
73
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`
|
|
74
184
|
* @returns {Promise<Object>} The synced pending draft record with comments_count
|
|
75
185
|
*/
|
|
76
|
-
async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPendingReview, githubClient = null) {
|
|
186
|
+
async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPendingReview, githubClient = null, prContext = null) {
|
|
77
187
|
// Find all our pending records for this review
|
|
78
188
|
const existingPendingRecords = await githubReviewRepo.findPendingByReviewId(reviewId);
|
|
79
189
|
|
|
80
|
-
// Check if this GitHub draft matches any of our records
|
|
81
|
-
|
|
82
|
-
|
|
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)
|
|
83
201
|
);
|
|
84
202
|
|
|
85
203
|
let pendingDraft;
|
|
@@ -99,9 +217,20 @@ async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPend
|
|
|
99
217
|
let actualState = 'dismissed'; // Default if we can't determine
|
|
100
218
|
let githubReviewData = null;
|
|
101
219
|
|
|
102
|
-
|
|
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)) {
|
|
103
226
|
try {
|
|
104
|
-
|
|
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);
|
|
105
234
|
|
|
106
235
|
if (githubReviewData) {
|
|
107
236
|
// Map GitHub state to our local state
|
|
@@ -116,15 +245,15 @@ async function syncPendingDraftFromGitHub(githubReviewRepo, reviewId, githubPend
|
|
|
116
245
|
// APPROVED, CHANGES_REQUESTED, COMMENTED all mean it was submitted
|
|
117
246
|
actualState = 'submitted';
|
|
118
247
|
}
|
|
119
|
-
logger.debug(`Old review ${
|
|
248
|
+
logger.debug(`Old review ${oldLookupId} actual state from GitHub: ${githubReviewData.state} -> ${actualState}`);
|
|
120
249
|
} else {
|
|
121
250
|
// Review not found on GitHub - treat as dismissed
|
|
122
|
-
logger.debug(`Old review ${
|
|
251
|
+
logger.debug(`Old review ${oldLookupId} not found on GitHub, marking as dismissed`);
|
|
123
252
|
actualState = 'dismissed';
|
|
124
253
|
}
|
|
125
254
|
} catch (error) {
|
|
126
255
|
// On error, default to dismissed (most likely scenario)
|
|
127
|
-
logger.warn(`Error querying GitHub for old review ${
|
|
256
|
+
logger.warn(`Error querying GitHub for old review ${oldLookupId}: ${error.message}, marking as dismissed`);
|
|
128
257
|
actualState = 'dismissed';
|
|
129
258
|
}
|
|
130
259
|
}
|
|
@@ -211,18 +340,30 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
211
340
|
// Check for pending GitHub draft
|
|
212
341
|
let pendingDraft = null;
|
|
213
342
|
{
|
|
214
|
-
|
|
215
|
-
|
|
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
|
+
}
|
|
216
351
|
|
|
217
|
-
if (
|
|
352
|
+
if (resolved) {
|
|
218
353
|
try {
|
|
219
|
-
const githubClient = new GitHubClient(
|
|
354
|
+
const githubClient = new GitHubClient(resolved.binding);
|
|
220
355
|
const githubReviewRepo = new GitHubReviewRepository(db);
|
|
221
356
|
|
|
222
357
|
const githubPendingReview = await githubClient.getPendingReviewForUser(repoOwner, repoName, prNumber);
|
|
223
358
|
|
|
224
359
|
if (githubPendingReview) {
|
|
225
|
-
pendingDraft = await syncPendingDraftFromGitHub(
|
|
360
|
+
pendingDraft = await syncPendingDraftFromGitHub(
|
|
361
|
+
githubReviewRepo,
|
|
362
|
+
review.id,
|
|
363
|
+
githubPendingReview,
|
|
364
|
+
githubClient,
|
|
365
|
+
{ owner: repoOwner, repo: repoName, prNumber }
|
|
366
|
+
);
|
|
226
367
|
}
|
|
227
368
|
} catch (githubError) {
|
|
228
369
|
// Log the error but don't fail the request - draft info is supplementary
|
|
@@ -239,11 +380,15 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
239
380
|
// Detect PR stack via GitHub GraphQL chain-walking
|
|
240
381
|
let stackData = null;
|
|
241
382
|
{
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
383
|
+
let resolved = null;
|
|
384
|
+
try {
|
|
385
|
+
resolved = resolveBindingForRequest(req, repository);
|
|
386
|
+
} catch (configErr) {
|
|
387
|
+
logger.warn(configErr.message);
|
|
388
|
+
}
|
|
389
|
+
if (resolved) {
|
|
245
390
|
try {
|
|
246
|
-
const ghClient = new GitHubClient(
|
|
391
|
+
const ghClient = new GitHubClient(resolved.binding);
|
|
247
392
|
const defaultBranch = extendedData.repository?.default_branch;
|
|
248
393
|
stackData = await walkPRStack(ghClient, repoOwner, repoName, prNumber, {
|
|
249
394
|
defaultBranches: [defaultBranch, ...DEFAULT_TRUNK_BRANCHES].filter(Boolean)
|
|
@@ -254,8 +399,13 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
254
399
|
}
|
|
255
400
|
}
|
|
256
401
|
|
|
257
|
-
// Prepare response
|
|
258
|
-
|
|
402
|
+
// Prepare response. Hunk hashes are computed from the canonical diff so
|
|
403
|
+
// they remain stable across whitespace-filtered renders (the rendered
|
|
404
|
+
// patch may be filtered, but the persisted hash keys are not).
|
|
405
|
+
const changedFiles = attachHunkHashes(
|
|
406
|
+
mergeChangedFilesWithDiff(extendedData.changed_files || [], extendedData.diff || ''),
|
|
407
|
+
extendedData.diff || ''
|
|
408
|
+
);
|
|
259
409
|
|
|
260
410
|
// Use review.id instead of prMetadata.id to avoid ID collision with local mode
|
|
261
411
|
const response = {
|
|
@@ -314,6 +464,40 @@ router.get('/api/pr/:owner/:repo/:number', async (req, res) => {
|
|
|
314
464
|
}).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
|
|
315
465
|
}
|
|
316
466
|
|
|
467
|
+
(async () => {
|
|
468
|
+
const reviewContext = {
|
|
469
|
+
prTitle: prMetadata.title,
|
|
470
|
+
prDescription: prMetadata.description,
|
|
471
|
+
changedFiles: changedFiles.map((f) => (typeof f === 'string' ? f : (f.filename || f.file || f.path))).filter(Boolean)
|
|
472
|
+
};
|
|
473
|
+
const results = await Promise.allSettled([
|
|
474
|
+
summaryGenerator.kickOffSummaryJob({
|
|
475
|
+
db,
|
|
476
|
+
config,
|
|
477
|
+
reviewId: review.id,
|
|
478
|
+
diffText: extendedData.diff,
|
|
479
|
+
worktreePath: extendedData.worktree_path,
|
|
480
|
+
reviewContext,
|
|
481
|
+
trigger: 'auto'
|
|
482
|
+
}),
|
|
483
|
+
tourGenerator.kickOffTourJob({
|
|
484
|
+
db,
|
|
485
|
+
config,
|
|
486
|
+
reviewId: review.id,
|
|
487
|
+
diffText: extendedData.diff,
|
|
488
|
+
worktreePath: extendedData.worktree_path,
|
|
489
|
+
reviewContext,
|
|
490
|
+
trigger: 'auto'
|
|
491
|
+
})
|
|
492
|
+
]);
|
|
493
|
+
const labels = ['Hunk summary', 'Tour'];
|
|
494
|
+
results.forEach((r, i) => {
|
|
495
|
+
if (r.status === 'rejected') {
|
|
496
|
+
logger.warn(`${labels[i]} kickoff failed for review ${review.id}: ${r.reason?.message || r.reason}`);
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
})().catch((err) => logger.warn(`Background AI kickoff failed for review ${review.id}: ${err.message}`));
|
|
500
|
+
|
|
317
501
|
} catch (error) {
|
|
318
502
|
console.error('Error fetching PR data:', error);
|
|
319
503
|
res.status(500).json({
|
|
@@ -354,10 +538,16 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
354
538
|
});
|
|
355
539
|
}
|
|
356
540
|
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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 });
|
|
361
551
|
}
|
|
362
552
|
|
|
363
553
|
// Check if worktree is locked before modifying it
|
|
@@ -373,7 +563,7 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
373
563
|
}
|
|
374
564
|
}
|
|
375
565
|
|
|
376
|
-
const githubClient = new GitHubClient(
|
|
566
|
+
const githubClient = new GitHubClient(binding);
|
|
377
567
|
const prData = await githubClient.fetchPullRequest(owner, repo, prNumber);
|
|
378
568
|
|
|
379
569
|
// Update worktree with latest changes
|
|
@@ -460,10 +650,15 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
460
650
|
// Refresh stack data via GitHub GraphQL
|
|
461
651
|
let stackData = null;
|
|
462
652
|
{
|
|
463
|
-
|
|
464
|
-
|
|
653
|
+
let resolved = null;
|
|
654
|
+
try {
|
|
655
|
+
resolved = resolveBindingForRequest(req, repository);
|
|
656
|
+
} catch (configErr) {
|
|
657
|
+
logger.warn(configErr.message);
|
|
658
|
+
}
|
|
659
|
+
if (resolved) {
|
|
465
660
|
try {
|
|
466
|
-
const ghClient = new GitHubClient(
|
|
661
|
+
const ghClient = new GitHubClient(resolved.binding);
|
|
467
662
|
const defaultBranch = prData.repository?.default_branch;
|
|
468
663
|
stackData = await walkPRStack(ghClient, ...repository.split('/'), prNumber, {
|
|
469
664
|
defaultBranches: [defaultBranch, ...DEFAULT_TRUNK_BRANCHES].filter(Boolean)
|
|
@@ -506,6 +701,35 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
506
701
|
|
|
507
702
|
res.json(response);
|
|
508
703
|
|
|
704
|
+
// Re-kick the summary and tour jobs against the freshly-refreshed diff.
|
|
705
|
+
// The frontend's refreshPR() calls this POST then GETs /diff (which is a
|
|
706
|
+
// read-only endpoint and does NOT enqueue), so without an explicit
|
|
707
|
+
// kickoff here the in-flight stale job would keep burning tokens until
|
|
708
|
+
// it completes. Each kickoff is dedup'd by diff digest/hash; when the
|
|
709
|
+
// diff actually changed (new PR HEAD), the kickoffs auto-cancel the
|
|
710
|
+
// stale in-flight job before enqueueing the fresh one.
|
|
711
|
+
(async () => {
|
|
712
|
+
const reviewContext = {
|
|
713
|
+
prTitle: prMetadata.title,
|
|
714
|
+
prDescription: prMetadata.description,
|
|
715
|
+
changedFiles: (changedFiles || []).map((f) => (typeof f === 'string' ? f : (f.filename || f.file || f.path))).filter(Boolean)
|
|
716
|
+
};
|
|
717
|
+
const results = await Promise.allSettled([
|
|
718
|
+
summaryGenerator.kickOffSummaryJob({
|
|
719
|
+
db, config, reviewId: review.id, diffText: extendedData.diff, worktreePath: extendedData.worktree_path, reviewContext, trigger: 'auto'
|
|
720
|
+
}),
|
|
721
|
+
tourGenerator.kickOffTourJob({
|
|
722
|
+
db, config, reviewId: review.id, diffText: extendedData.diff, worktreePath: extendedData.worktree_path, reviewContext, trigger: 'auto'
|
|
723
|
+
})
|
|
724
|
+
]);
|
|
725
|
+
const labels = ['Hunk summary', 'Tour'];
|
|
726
|
+
results.forEach((r, i) => {
|
|
727
|
+
if (r.status === 'rejected') {
|
|
728
|
+
logger.warn(`${labels[i]} kickoff failed for review ${review.id} on refresh: ${r.reason?.message || r.reason}`);
|
|
729
|
+
}
|
|
730
|
+
});
|
|
731
|
+
})().catch((err) => logger.warn(`Background AI kickoff failed for review ${review.id} on refresh: ${err.message}`));
|
|
732
|
+
|
|
509
733
|
} catch (error) {
|
|
510
734
|
logger.error('Error refreshing PR:', error);
|
|
511
735
|
res.status(500).json({
|
|
@@ -514,6 +738,127 @@ router.post('/api/pr/:owner/:repo/:number/refresh', async (req, res) => {
|
|
|
514
738
|
}
|
|
515
739
|
});
|
|
516
740
|
|
|
741
|
+
/**
|
|
742
|
+
* POST /api/pr/:owner/:repo/:number/jobs/:jobKey/start
|
|
743
|
+
*
|
|
744
|
+
* Manually trigger a summary or tour generation job for this PR. Used by the
|
|
745
|
+
* frontend when `auto_generate` is off and the user clicks the toolbar button.
|
|
746
|
+
*
|
|
747
|
+
* Mirrors the server-side kickoff that runs on PR load, but passes
|
|
748
|
+
* `trigger: 'manual'` so it bypasses the `auto_generate` gate (the `enabled`
|
|
749
|
+
* gate still applies — disabled features return 409).
|
|
750
|
+
*
|
|
751
|
+
* Request:
|
|
752
|
+
* - `jobKey` path param: `summary` or `tour`
|
|
753
|
+
*
|
|
754
|
+
* Responses:
|
|
755
|
+
* - 200 `{ started: true, alreadyRunning: false }` — enqueued
|
|
756
|
+
* - 200 `{ started: false, alreadyRunning: true }` — feature on but a job
|
|
757
|
+
* is already in flight
|
|
758
|
+
* (idempotent no-op)
|
|
759
|
+
* - 200 `{ started: false, reason: 'no-diff' }` — diff is empty
|
|
760
|
+
* - 400 `{ error: 'Invalid jobKey' }` — unknown jobKey
|
|
761
|
+
* - 404 `{ error: '...' }` — PR not found
|
|
762
|
+
* - 409 `{ error: '... disabled' }` — feature disabled in config
|
|
763
|
+
*/
|
|
764
|
+
const MANUAL_START_JOB_KEYS = new Set(['summary', 'tour']);
|
|
765
|
+
|
|
766
|
+
router.post('/api/pr/:owner/:repo/:number/jobs/:jobKey/start', async (req, res) => {
|
|
767
|
+
try {
|
|
768
|
+
const { owner, repo, number, jobKey } = req.params;
|
|
769
|
+
const prNumber = parseInt(number, 10);
|
|
770
|
+
|
|
771
|
+
if (!Number.isInteger(prNumber) || prNumber <= 0) {
|
|
772
|
+
return res.status(400).json({ error: 'Invalid pull request number' });
|
|
773
|
+
}
|
|
774
|
+
if (!MANUAL_START_JOB_KEYS.has(jobKey)) {
|
|
775
|
+
return res.status(400).json({ error: `Invalid jobKey "${jobKey}" (expected "summary" or "tour")` });
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const repository = normalizeRepository(owner, repo);
|
|
779
|
+
const db = req.app.get('db');
|
|
780
|
+
const config = req.app.get('config') || {};
|
|
781
|
+
|
|
782
|
+
// Enforce the feature-enabled gate at the HTTP boundary so the frontend
|
|
783
|
+
// gets a clean 409 instead of a silent no-op from the generator.
|
|
784
|
+
if (jobKey === 'summary' && !getSummaryEnabled(config)) {
|
|
785
|
+
return res.status(409).json({ error: 'Summaries feature is disabled in config' });
|
|
786
|
+
}
|
|
787
|
+
if (jobKey === 'tour' && !getTourEnabled(config)) {
|
|
788
|
+
return res.status(409).json({ error: 'Tours feature is disabled in config' });
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const prMetadata = await queryOne(db, `
|
|
792
|
+
SELECT id, pr_number, repository, title, description, pr_data
|
|
793
|
+
FROM pr_metadata
|
|
794
|
+
WHERE pr_number = ? AND repository = ? COLLATE NOCASE
|
|
795
|
+
`, [prNumber, repository]);
|
|
796
|
+
|
|
797
|
+
if (!prMetadata) {
|
|
798
|
+
return res.status(404).json({
|
|
799
|
+
error: `Pull request #${prNumber} not found in repository ${repository}`
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
const reviewRepo = new ReviewRepository(db);
|
|
804
|
+
const { review } = await reviewRepo.getOrCreate({ prNumber, repository });
|
|
805
|
+
|
|
806
|
+
let extendedData = {};
|
|
807
|
+
try {
|
|
808
|
+
extendedData = prMetadata.pr_data ? JSON.parse(prMetadata.pr_data) : {};
|
|
809
|
+
} catch (parseError) {
|
|
810
|
+
logger.warn(`Could not parse pr_data for PR #${prNumber}: ${parseError.message}`);
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
const diffText = extendedData.diff || '';
|
|
814
|
+
const worktreePath = extendedData.worktree_path || null;
|
|
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).
|
|
821
|
+
if (!diffText || !worktreePath) {
|
|
822
|
+
return res.json({ started: false, reason: 'no-diff' });
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Idempotency: if a job is already in flight for this review/job-type,
|
|
826
|
+
// don't double-start. The frontend already has the in-flight event stream.
|
|
827
|
+
const activeJobType = typeof backgroundQueue.findActiveJobType === 'function'
|
|
828
|
+
? backgroundQueue.findActiveJobType(review.id, jobKey === 'summary' ? 'summaries' : 'tour')
|
|
829
|
+
: null;
|
|
830
|
+
if (activeJobType) {
|
|
831
|
+
return res.json({ started: false, alreadyRunning: true });
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
const reviewContext = {
|
|
835
|
+
prTitle: prMetadata.title,
|
|
836
|
+
prDescription: prMetadata.description,
|
|
837
|
+
changedFiles: (extendedData.changed_files || [])
|
|
838
|
+
.map((f) => (typeof f === 'string' ? f : (f.filename || f.file || f.path)))
|
|
839
|
+
.filter(Boolean)
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
// Kick off in the background — return the start status immediately so
|
|
843
|
+
// the frontend can switch the button to its generating state. Errors
|
|
844
|
+
// are logged but the HTTP response is already sent.
|
|
845
|
+
if (jobKey === 'summary') {
|
|
846
|
+
Promise.resolve(summaryGenerator.kickOffSummaryJob({
|
|
847
|
+
db, config, reviewId: review.id, diffText, worktreePath, reviewContext, trigger: 'manual'
|
|
848
|
+
})).catch((err) => logger.warn(`Manual hunk summary kickoff failed for review ${review.id}: ${err.message}`));
|
|
849
|
+
} else {
|
|
850
|
+
Promise.resolve(tourGenerator.kickOffTourJob({
|
|
851
|
+
db, config, reviewId: review.id, diffText, worktreePath, reviewContext, trigger: 'manual'
|
|
852
|
+
})).catch((err) => logger.warn(`Manual tour kickoff failed for review ${review.id}: ${err.message}`));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return res.json({ started: true, alreadyRunning: false });
|
|
856
|
+
} catch (error) {
|
|
857
|
+
logger.error('Error starting manual job:', error);
|
|
858
|
+
res.status(500).json({ error: 'Failed to start job: ' + error.message });
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
|
|
517
862
|
/**
|
|
518
863
|
* Check if PR data is stale (remote has newer commits)
|
|
519
864
|
*/
|
|
@@ -567,11 +912,17 @@ router.get('/api/pr/:owner/:repo/:number/check-stale', async (req, res) => {
|
|
|
567
912
|
}
|
|
568
913
|
|
|
569
914
|
// Fetch current PR from GitHub
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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 });
|
|
573
924
|
}
|
|
574
|
-
const githubClient = new GitHubClient(
|
|
925
|
+
const githubClient = new GitHubClient(binding);
|
|
575
926
|
const remotePrData = await githubClient.fetchPullRequest(owner, repo, prNumber);
|
|
576
927
|
|
|
577
928
|
const remoteHeadSha = remotePrData.head_sha;
|
|
@@ -633,14 +984,20 @@ router.get('/api/pr/:owner/:repo/:number/github-drafts', async (req, res) => {
|
|
|
633
984
|
}
|
|
634
985
|
|
|
635
986
|
// Initialize GitHub client and check for pending drafts on GitHub
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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 });
|
|
641
998
|
}
|
|
642
999
|
|
|
643
|
-
const githubClient = new GitHubClient(
|
|
1000
|
+
const githubClient = new GitHubClient(binding);
|
|
644
1001
|
const githubReviewRepo = new GitHubReviewRepository(db);
|
|
645
1002
|
|
|
646
1003
|
// Fetch pending review from GitHub
|
|
@@ -649,7 +1006,13 @@ router.get('/api/pr/:owner/:repo/:number/github-drafts', async (req, res) => {
|
|
|
649
1006
|
const githubPendingReview = await githubClient.getPendingReviewForUser(owner, repo, prNumber);
|
|
650
1007
|
|
|
651
1008
|
if (githubPendingReview) {
|
|
652
|
-
pendingDraft = await syncPendingDraftFromGitHub(
|
|
1009
|
+
pendingDraft = await syncPendingDraftFromGitHub(
|
|
1010
|
+
githubReviewRepo,
|
|
1011
|
+
review.id,
|
|
1012
|
+
githubPendingReview,
|
|
1013
|
+
githubClient,
|
|
1014
|
+
{ owner, repo, prNumber }
|
|
1015
|
+
);
|
|
653
1016
|
}
|
|
654
1017
|
} catch (githubError) {
|
|
655
1018
|
// Log the error but don't fail the request - return local data only
|
|
@@ -826,6 +1189,21 @@ router.get('/api/pr/:owner/:repo/:number/diff', async (req, res) => {
|
|
|
826
1189
|
}));
|
|
827
1190
|
}
|
|
828
1191
|
|
|
1192
|
+
// Hunk hashes MUST come from the canonical (unfiltered) diff. When
|
|
1193
|
+
// hideWhitespace is on the rendered `diffContent` is the filtered diff,
|
|
1194
|
+
// which would produce hashes that diverge from the keys persisted in
|
|
1195
|
+
// `hunk_summaries`. We deliberately do NOT fall back to `diffContent`:
|
|
1196
|
+
// if `prData.diff` is missing, fail closed and emit no hashes so the
|
|
1197
|
+
// frontend skips anchoring rather than anchoring to misaligned hunks.
|
|
1198
|
+
if (prData.diff) {
|
|
1199
|
+
changedFiles = attachHunkHashes(changedFiles, prData.diff);
|
|
1200
|
+
} else {
|
|
1201
|
+
logger.warn(
|
|
1202
|
+
`[hunk-hash] PR #${prNumber} ${repository}: no canonical prData.diff; ` +
|
|
1203
|
+
'omitting hunk_hashes (summaries will not anchor for this response).'
|
|
1204
|
+
);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
829
1207
|
// When diff was regenerated (whitespace), compute aggregate stats from
|
|
830
1208
|
// the regenerated changedFiles instead of using stale cached values from prData.
|
|
831
1209
|
const additions = hideWhitespace
|
|
@@ -1085,16 +1463,22 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
|
|
|
1085
1463
|
const repository = normalizeRepository(owner, repo);
|
|
1086
1464
|
const db = req.app.get('db');
|
|
1087
1465
|
|
|
1088
|
-
//
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
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 });
|
|
1094
1478
|
}
|
|
1095
1479
|
|
|
1096
1480
|
// Initialize GitHub client
|
|
1097
|
-
const githubClient = new GitHubClient(
|
|
1481
|
+
const githubClient = new GitHubClient(binding);
|
|
1098
1482
|
|
|
1099
1483
|
// Get PR metadata and worktree path
|
|
1100
1484
|
const prMetadata = await queryOne(db, `
|
|
@@ -1150,13 +1534,6 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
|
|
|
1150
1534
|
//
|
|
1151
1535
|
// We check whether the comment's target line actually appears in a diff hunk
|
|
1152
1536
|
// rather than relying on diff_position (which may not be set by all sources).
|
|
1153
|
-
const prNodeId = prData.node_id;
|
|
1154
|
-
if (!prNodeId) {
|
|
1155
|
-
return res.status(400).json({
|
|
1156
|
-
error: 'PR node_id not available. Please refresh the PR data and try again.'
|
|
1157
|
-
});
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
1537
|
const diffLineSet = buildDiffLineSet(diffContent);
|
|
1161
1538
|
|
|
1162
1539
|
const graphqlComments = comments.map(comment => {
|
|
@@ -1226,10 +1603,63 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
|
|
|
1226
1603
|
// GitHub only allows one pending review per user per PR
|
|
1227
1604
|
const existingDraft = await githubClient.getPendingReviewForUser(owner, repo, prNumber);
|
|
1228
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
|
+
|
|
1229
1659
|
if (event === 'DRAFT') {
|
|
1230
1660
|
// Delegate to createDraftReviewGraphQL (handles both new and existing drafts)
|
|
1231
1661
|
githubReview = await githubClient.createDraftReviewGraphQL(
|
|
1232
|
-
prNodeId, body || '', graphqlComments, existingDraft?.id
|
|
1662
|
+
prNodeId, body || '', graphqlComments, existingDraft?.id, submitPrContext
|
|
1233
1663
|
);
|
|
1234
1664
|
// When adding to an existing draft, use the existing URL and include prior comments in total count
|
|
1235
1665
|
if (existingDraft) {
|
|
@@ -1238,7 +1668,9 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
|
|
|
1238
1668
|
}
|
|
1239
1669
|
} else {
|
|
1240
1670
|
// For non-drafts, create/use review, add comments, and submit
|
|
1241
|
-
githubReview = await githubClient.createReviewGraphQL(
|
|
1671
|
+
githubReview = await githubClient.createReviewGraphQL(
|
|
1672
|
+
prNodeId, event, body || '', graphqlComments, existingDraft?.id, submitPrContext
|
|
1673
|
+
);
|
|
1242
1674
|
}
|
|
1243
1675
|
|
|
1244
1676
|
// ID storage strategy:
|
|
@@ -1311,10 +1743,15 @@ router.post('/api/pr/:owner/:repo/:number/submit-review', async (req, res) => {
|
|
|
1311
1743
|
// Commit transaction
|
|
1312
1744
|
await run(db, 'COMMIT');
|
|
1313
1745
|
|
|
1314
|
-
// 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));
|
|
1315
1752
|
res.json({
|
|
1316
1753
|
success: true,
|
|
1317
|
-
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}`,
|
|
1318
1755
|
github_url: githubReview.html_url,
|
|
1319
1756
|
comments_submitted: githubReview.comments_count,
|
|
1320
1757
|
event: event,
|
|
@@ -1489,11 +1926,12 @@ router.get('/api/pr/health', (req, res) => {
|
|
|
1489
1926
|
|
|
1490
1927
|
/**
|
|
1491
1928
|
* Parse a PR URL and extract owner, repo, and PR number
|
|
1492
|
-
* 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.
|
|
1493
1931
|
*/
|
|
1494
1932
|
router.post('/api/parse-pr-url', (req, res) => {
|
|
1495
|
-
const
|
|
1496
|
-
const parser = new PRArgumentParser();
|
|
1933
|
+
const config = req.app.get('config') || null;
|
|
1934
|
+
const parser = new PRArgumentParser(config);
|
|
1497
1935
|
|
|
1498
1936
|
const { url } = req.body;
|
|
1499
1937
|
|
|
@@ -1666,6 +2104,24 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
|
|
|
1666
2104
|
const appConfig = req.app.get('config') || {};
|
|
1667
2105
|
const globalInstructions = appConfig.globalInstructions || null;
|
|
1668
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
|
+
|
|
1669
2125
|
const { provider, model, repoInstructions, combinedInstructions, repoSettings: fetchedRepoSettings } = await withTransaction(db, async () => {
|
|
1670
2126
|
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
1671
2127
|
const fetchedRepoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
@@ -1821,7 +2277,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses', async (req, res) => {
|
|
|
1821
2277
|
|
|
1822
2278
|
const progressCallback = createProgressCallback(analysisId);
|
|
1823
2279
|
|
|
1824
|
-
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 });
|
|
1825
2281
|
} catch (setupError) {
|
|
1826
2282
|
// Synchronous setup failure — clean up the analysis hold immediately
|
|
1827
2283
|
reviewToAnalysisId.delete(review.id);
|
|
@@ -2067,6 +2523,19 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
|
|
|
2067
2523
|
const { providerOverrides: councilProviderOverrides, providerOverridesMap: councilProviderOverridesMap } =
|
|
2068
2524
|
buildCouncilProviderOverrides(prCouncilConfig, repository, repoSettings);
|
|
2069
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
|
+
|
|
2070
2539
|
const { analysisId, runId } = await analysesRouter.launchCouncilAnalysis(
|
|
2071
2540
|
db,
|
|
2072
2541
|
{
|
|
@@ -2081,6 +2550,7 @@ router.post('/api/pr/:owner/:repo/:number/analyses/council', async (req, res) =>
|
|
|
2081
2550
|
config: prCouncilConfig,
|
|
2082
2551
|
excludePrevious,
|
|
2083
2552
|
serverPort: req.socket.localPort,
|
|
2553
|
+
githubClient: councilGithubClient,
|
|
2084
2554
|
poolLifecycle: req.app.get('poolLifecycle'),
|
|
2085
2555
|
providerOverrides: councilProviderOverrides,
|
|
2086
2556
|
providerOverridesMap: councilProviderOverridesMap,
|
|
@@ -2172,13 +2642,14 @@ router.get('/api/pr/:owner/:repo/:number/share', async (req, res) => {
|
|
|
2172
2642
|
deletions: f.deletions ?? 0
|
|
2173
2643
|
}));
|
|
2174
2644
|
|
|
2175
|
-
// 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.
|
|
2176
2648
|
let sharedBy = null;
|
|
2177
2649
|
try {
|
|
2178
|
-
const
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
const githubClient = new GitHubClient(githubToken);
|
|
2650
|
+
const resolved = resolveBindingForRequest(req, repository);
|
|
2651
|
+
if (resolved) {
|
|
2652
|
+
const githubClient = new GitHubClient(resolved.binding);
|
|
2182
2653
|
const user = await githubClient.getAuthenticatedUser();
|
|
2183
2654
|
sharedBy = user.login;
|
|
2184
2655
|
}
|
|
@@ -2308,8 +2779,15 @@ router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
|
|
|
2308
2779
|
const db = req.app.get('db');
|
|
2309
2780
|
const config = req.app.get('config') || {};
|
|
2310
2781
|
|
|
2311
|
-
|
|
2312
|
-
|
|
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);
|
|
2313
2791
|
return res.json({ stack: [] });
|
|
2314
2792
|
}
|
|
2315
2793
|
|
|
@@ -2321,7 +2799,7 @@ router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
|
|
|
2321
2799
|
const parsedPrData = prMetadataRow?.pr_data ? JSON.parse(prMetadataRow.pr_data) : {};
|
|
2322
2800
|
const defaultBranch = parsedPrData.repository?.default_branch;
|
|
2323
2801
|
|
|
2324
|
-
const ghClient = new GitHubClient(
|
|
2802
|
+
const ghClient = new GitHubClient(binding);
|
|
2325
2803
|
let stack;
|
|
2326
2804
|
try {
|
|
2327
2805
|
stack = await walkPRStack(ghClient, ...repository.split('/'), prNumber, {
|
|
@@ -2381,3 +2859,10 @@ router.get('/api/pr/:owner/:repo/:number/stack-info', async (req, res) => {
|
|
|
2381
2859
|
});
|
|
2382
2860
|
|
|
2383
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
|
+
};
|