@in-the-loop-labs/pair-review 3.4.1 → 3.5.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 +24 -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/pr.css +762 -6
- package/public/js/components/AIPanel.js +486 -42
- package/public/js/components/ChatPanel.js +2002 -528
- package/public/js/modules/comment-minimizer.js +66 -20
- package/public/js/modules/external-comment-manager.js +870 -0
- package/public/js/pr.js +297 -20
- package/public/local.html +21 -5
- package/public/pr.html +31 -5
- package/src/chat/chat-providers.js +64 -12
- package/src/config.js +2 -1
- package/src/database.js +566 -2
- package/src/external/github-adapter.js +152 -0
- package/src/external/index.js +37 -0
- package/src/github/client.js +77 -1
- package/src/routes/config.js +27 -0
- package/src/routes/external-comments.js +394 -0
- package/src/server.js +9 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GitHub source adapter for external review comments.
|
|
5
|
+
*
|
|
6
|
+
* Two responsibilities:
|
|
7
|
+
* 1. `fetchComments` — delegate to the GitHubClient method that paginates
|
|
8
|
+
* `pulls.listReviewComments`. Adapter does NOT construct its own client;
|
|
9
|
+
* the caller injects it (dependency injection per CLAUDE.md).
|
|
10
|
+
* 2. `mapComment` — translate a raw GitHub REST API row into the column
|
|
11
|
+
* shape of the `external_comments` table (see `src/database.js`).
|
|
12
|
+
*
|
|
13
|
+
* The dispatcher in `src/external/index.js` stamps `source = 'github'` at
|
|
14
|
+
* write time, so `mapComment` does not include a `source` field — keeps
|
|
15
|
+
* adapters from needing to know their own name.
|
|
16
|
+
*
|
|
17
|
+
* `synced_at` and the resolved local `parent_id` are also set by the
|
|
18
|
+
* route/repository layer, not here.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const { GitHubClient, GitHubApiError } = require('../github/client');
|
|
22
|
+
const { getGitHubToken } = require('../config');
|
|
23
|
+
|
|
24
|
+
const name = 'github';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Adapter-owned env var name. Surfaced in credential-missing errors so the
|
|
28
|
+
* user is told which env var/config key to set for THIS source. Future
|
|
29
|
+
* adapters (GitLab, Linear) name their own variable here and the route
|
|
30
|
+
* needs no per-source branching.
|
|
31
|
+
*/
|
|
32
|
+
const credentialEnvVar = 'GITHUB_TOKEN';
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the credentials this adapter needs to call its source system.
|
|
36
|
+
* Returns an opaque `{ client }` shape; the route hands `client` straight
|
|
37
|
+
* back to `fetchComments` without knowing it's a `GitHubClient`. Throwing
|
|
38
|
+
* `GitHubApiError(status: 401)` keeps the existing 401 mapping at the
|
|
39
|
+
* route layer.
|
|
40
|
+
*
|
|
41
|
+
* @param {Object} config - Server config (see `loadConfig()`)
|
|
42
|
+
* @param {Object} [_deps] - Test overrides for { GitHubClient, getGitHubToken }
|
|
43
|
+
* @returns {{ client: Object }}
|
|
44
|
+
* @throws {GitHubApiError} with status 401 when no token is configured
|
|
45
|
+
*/
|
|
46
|
+
function resolveCredentials(config, _deps) {
|
|
47
|
+
const deps = {
|
|
48
|
+
GitHubClient,
|
|
49
|
+
getGitHubToken,
|
|
50
|
+
..._deps
|
|
51
|
+
};
|
|
52
|
+
const token = deps.getGitHubToken(config || {});
|
|
53
|
+
if (!token) {
|
|
54
|
+
throw new GitHubApiError(
|
|
55
|
+
`GitHub token not configured. Set ${credentialEnvVar} or add github_token to ~/.pair-review/config.json`,
|
|
56
|
+
401
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return { client: new deps.GitHubClient(token) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Fetch all inline review comments for a pull request from GitHub.
|
|
64
|
+
*
|
|
65
|
+
* @param {Object} params
|
|
66
|
+
* @param {Object} params.client - GitHubClient instance (injected)
|
|
67
|
+
* @param {string} params.owner
|
|
68
|
+
* @param {string} params.repo
|
|
69
|
+
* @param {number} params.pull_number
|
|
70
|
+
* @returns {Promise<Array<Object>>} Raw Octokit review-comment objects
|
|
71
|
+
*/
|
|
72
|
+
async function fetchComments({ client, owner, repo, pull_number }) {
|
|
73
|
+
return client.listReviewComments({ owner, repo, pull_number });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Map a raw GitHub review-comment API row to an `external_comments` row.
|
|
78
|
+
*
|
|
79
|
+
* Edge cases handled here (per the phase spec):
|
|
80
|
+
* - `apiRow.user` is null (deleted account): `author` and `author_url`
|
|
81
|
+
* both become null. No throw.
|
|
82
|
+
* - `apiRow.position` is null (outdated): `is_outdated = 1`, current
|
|
83
|
+
* line/position fields null. `original_*` may still be populated.
|
|
84
|
+
* - `apiRow.position` AND `apiRow.original_position` both null
|
|
85
|
+
* (force-push lost anchor): still produces a row — the sync route
|
|
86
|
+
* decides whether to count or skip. We do NOT throw here.
|
|
87
|
+
* - `apiRow.path` missing: throws — `file` is NOT NULL in the schema
|
|
88
|
+
* and a missing path means upstream gave us something malformed.
|
|
89
|
+
* Failing early in the mapper is far easier to debug than a SQL
|
|
90
|
+
* constraint violation deep in an upsert loop.
|
|
91
|
+
*
|
|
92
|
+
* @param {Object} apiRow
|
|
93
|
+
* @returns {Object} A row matching the `external_comments` column names
|
|
94
|
+
*/
|
|
95
|
+
function mapComment(apiRow) {
|
|
96
|
+
if (!apiRow || apiRow.path == null) {
|
|
97
|
+
throw new Error('GitHub adapter: comment missing required field "path"');
|
|
98
|
+
}
|
|
99
|
+
// Validate id presence — `String(undefined)` returns the literal
|
|
100
|
+
// 'undefined' which would upsert as a valid external_id and even
|
|
101
|
+
// satisfy UNIQUE(review_id, source, external_id) by colliding on that
|
|
102
|
+
// string. Fail early so the route's row-level catch logs the bad row
|
|
103
|
+
// and moves on instead of corrupting the mirror.
|
|
104
|
+
if (apiRow.id == null) {
|
|
105
|
+
throw new Error('GitHub adapter: comment missing required field "id"');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const user = apiRow.user || null;
|
|
109
|
+
const positionIsNull = apiRow.position == null;
|
|
110
|
+
|
|
111
|
+
// When position is null the comment is outdated. GitHub still populates
|
|
112
|
+
// `line` in many of these responses, but the line number does NOT
|
|
113
|
+
// correspond to a position in the current diff — using it would create
|
|
114
|
+
// two conflicting truths (line_end set AND is_outdated=1) and would
|
|
115
|
+
// make the lost-anchor filter under-count. Force the current-anchor
|
|
116
|
+
// fields to null so `original_*` is the only authoritative anchor.
|
|
117
|
+
const line_start = positionIsNull
|
|
118
|
+
? null
|
|
119
|
+
: apiRow.start_line ?? apiRow.line ?? null;
|
|
120
|
+
const line_end = positionIsNull ? null : apiRow.line ?? null;
|
|
121
|
+
const diff_position = positionIsNull ? null : apiRow.position ?? null;
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
external_id: String(apiRow.id),
|
|
125
|
+
in_reply_to_id:
|
|
126
|
+
apiRow.in_reply_to_id != null ? String(apiRow.in_reply_to_id) : null,
|
|
127
|
+
external_url: apiRow.html_url || null,
|
|
128
|
+
author: user ? user.login ?? null : null,
|
|
129
|
+
author_url: user ? user.html_url ?? null : null,
|
|
130
|
+
file: apiRow.path,
|
|
131
|
+
side: apiRow.side ?? null,
|
|
132
|
+
line_start,
|
|
133
|
+
line_end,
|
|
134
|
+
diff_position,
|
|
135
|
+
commit_sha: apiRow.commit_id ?? null,
|
|
136
|
+
is_outdated: positionIsNull ? 1 : 0,
|
|
137
|
+
original_line_start:
|
|
138
|
+
apiRow.original_start_line ?? apiRow.original_line ?? null,
|
|
139
|
+
original_line_end: apiRow.original_line ?? null,
|
|
140
|
+
original_commit_sha: apiRow.original_commit_id ?? null,
|
|
141
|
+
body: apiRow.body ?? '',
|
|
142
|
+
external_created_at: apiRow.created_at ?? null,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = {
|
|
147
|
+
name,
|
|
148
|
+
credentialEnvVar,
|
|
149
|
+
resolveCredentials,
|
|
150
|
+
fetchComments,
|
|
151
|
+
mapComment,
|
|
152
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* External comment source dispatcher.
|
|
5
|
+
*
|
|
6
|
+
* Each external source (currently GitHub; GitLab/Linear planned) ships a
|
|
7
|
+
* sibling adapter module that exports `{ name, fetchComments, mapComment }`.
|
|
8
|
+
* This file maintains the keyed registry and resolves a `source` string to
|
|
9
|
+
* the matching adapter. Adding a new source is a one-file change here plus
|
|
10
|
+
* the new adapter module — no routes or repositories need to know.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const githubAdapter = require('./github-adapter');
|
|
14
|
+
|
|
15
|
+
const adapters = {
|
|
16
|
+
[githubAdapter.name]: githubAdapter,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Look up an adapter by its `source` string.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} source - e.g. 'github'
|
|
23
|
+
* @returns {{ name: string, fetchComments: Function, mapComment: Function }}
|
|
24
|
+
* @throws {Error} when no adapter is registered for the source name
|
|
25
|
+
*/
|
|
26
|
+
function getAdapter(source) {
|
|
27
|
+
// Own-property guard: `adapters` is a plain object, so `adapters['toString']`
|
|
28
|
+
// would resolve to Object.prototype.toString (a function) and the route's
|
|
29
|
+
// unknown-source check (which depends on this function throwing) would
|
|
30
|
+
// silently pass. Use hasOwnProperty so only registered adapters resolve.
|
|
31
|
+
if (typeof source !== 'string' || !Object.prototype.hasOwnProperty.call(adapters, source)) {
|
|
32
|
+
throw new Error(`Unknown external comment source: ${source}`);
|
|
33
|
+
}
|
|
34
|
+
return adapters[source];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { getAdapter, adapters };
|
package/src/github/client.js
CHANGED
|
@@ -158,6 +158,48 @@ class GitHubClient {
|
|
|
158
158
|
}
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Fetch all inline (line-anchored) review comments on a pull request.
|
|
163
|
+
*
|
|
164
|
+
* Uses the GitHub REST API `pulls.listReviewComments` endpoint and paginates
|
|
165
|
+
* automatically via `octokit.paginate` to handle PRs with more than 100
|
|
166
|
+
* comments. Returns the raw API objects unchanged — mapping to local rows
|
|
167
|
+
* happens in the adapter / route layer (keeps this client thin and testable).
|
|
168
|
+
*
|
|
169
|
+
* Note: this endpoint returns inline review comments only. Issue-level (PR
|
|
170
|
+
* conversation tab) comments come from a different endpoint and are not
|
|
171
|
+
* included here.
|
|
172
|
+
*
|
|
173
|
+
* @param {Object} params
|
|
174
|
+
* @param {string} params.owner - Repository owner
|
|
175
|
+
* @param {string} params.repo - Repository name
|
|
176
|
+
* @param {number} params.pull_number - Pull request number
|
|
177
|
+
* @returns {Promise<Array<Object>>} Raw review-comment objects with fields
|
|
178
|
+
* such as `id`, `pull_request_review_id`, `in_reply_to_id`, `body`,
|
|
179
|
+
* `user`, `path`, `commit_id`, `original_commit_id`, `position`,
|
|
180
|
+
* `original_position`, `line`, `start_line`, `original_line`,
|
|
181
|
+
* `original_start_line`, `side`, `start_side`, `html_url`, `created_at`,
|
|
182
|
+
* `updated_at`.
|
|
183
|
+
* @throws {GitHubApiError} 404 when the PR is not found, 429 on rate limit,
|
|
184
|
+
* 503 on network failure, or a wrapped error for other API failures.
|
|
185
|
+
*/
|
|
186
|
+
async listReviewComments({ owner, repo, pull_number }) {
|
|
187
|
+
try {
|
|
188
|
+
const comments = await this.octokit.paginate(
|
|
189
|
+
this.octokit.rest.pulls.listReviewComments,
|
|
190
|
+
{
|
|
191
|
+
owner,
|
|
192
|
+
repo,
|
|
193
|
+
pull_number,
|
|
194
|
+
per_page: 100
|
|
195
|
+
}
|
|
196
|
+
);
|
|
197
|
+
return comments;
|
|
198
|
+
} catch (error) {
|
|
199
|
+
await this.handleApiError(error, owner, repo, pull_number);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
161
203
|
/**
|
|
162
204
|
* Validate GitHub token by making a test API call
|
|
163
205
|
* @returns {Promise<boolean>} Whether the token is valid
|
|
@@ -209,7 +251,8 @@ class GitHubClient {
|
|
|
209
251
|
console.error('GitHub API error:', error);
|
|
210
252
|
}
|
|
211
253
|
|
|
212
|
-
// Handle rate limiting with exponential backoff
|
|
254
|
+
// Handle rate limiting with exponential backoff (primary rate limit:
|
|
255
|
+
// `x-ratelimit-remaining: 0`).
|
|
213
256
|
if (error.status === 403 && error.response?.headers?.['x-ratelimit-remaining'] === '0') {
|
|
214
257
|
const resetTime = parseInt(error.response.headers['x-ratelimit-reset']) * 1000;
|
|
215
258
|
const waitTime = Math.max(resetTime - Date.now(), 1000);
|
|
@@ -219,6 +262,39 @@ class GitHubClient {
|
|
|
219
262
|
throw new GitHubApiError(`GitHub API rate limit exceeded. Retrying in ${Math.ceil(waitTime / 1000)} seconds...`, 429);
|
|
220
263
|
}
|
|
221
264
|
|
|
265
|
+
// Secondary rate limits ("abuse detection") return 403 WITHOUT the
|
|
266
|
+
// standard rate-limit headers. They're signaled either by a `retry-after`
|
|
267
|
+
// header or by message text mentioning "secondary rate limit", "abuse",
|
|
268
|
+
// or "rate limit". Without this branch they'd fall through to the
|
|
269
|
+
// permission-failure path and the user would be told their token is
|
|
270
|
+
// missing scopes — misleading.
|
|
271
|
+
if (error.status === 403) {
|
|
272
|
+
const retryAfterHeader = error.response?.headers?.['retry-after'];
|
|
273
|
+
const messageText = String(error.message || '');
|
|
274
|
+
const looksLikeRateLimit =
|
|
275
|
+
retryAfterHeader != null ||
|
|
276
|
+
/secondary rate limit/i.test(messageText) ||
|
|
277
|
+
/abuse/i.test(messageText) ||
|
|
278
|
+
/rate limit/i.test(messageText);
|
|
279
|
+
|
|
280
|
+
if (looksLikeRateLimit) {
|
|
281
|
+
const retryAfterSec = retryAfterHeader != null ? parseInt(retryAfterHeader, 10) : null;
|
|
282
|
+
const suffix = Number.isFinite(retryAfterSec) && retryAfterSec > 0
|
|
283
|
+
? ` Retry after ${retryAfterSec} seconds.`
|
|
284
|
+
: '';
|
|
285
|
+
throw new GitHubApiError(`GitHub API rate limit exceeded (secondary rate limit).${suffix}`, 429);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Genuine permission / scope failure. Without this branch the error
|
|
289
|
+
// would fall through to the generic plain-`Error` path and route
|
|
290
|
+
// handlers would map it to a 500 instead of a 403, hiding the real
|
|
291
|
+
// cause from the reviewer.
|
|
292
|
+
throw new GitHubApiError(
|
|
293
|
+
`Insufficient permissions for ${owner}/${repo}. Your GitHub token may be missing required scopes.`,
|
|
294
|
+
403
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
222
298
|
// Handle authentication errors
|
|
223
299
|
if (error.status === 401) {
|
|
224
300
|
throw new GitHubApiError('GitHub authentication failed. Check your token in ~/.pair-review/config.json', 401);
|
package/src/routes/config.js
CHANGED
|
@@ -35,6 +35,32 @@ const router = express.Router();
|
|
|
35
35
|
// information (the next notifier will re-populate it).
|
|
36
36
|
let pendingUpdateVersion = null;
|
|
37
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Runtime configuration script
|
|
40
|
+
*
|
|
41
|
+
* Returns a tiny JS file that sets `window.PAIR_REVIEW_RUNTIME_CONFIG`
|
|
42
|
+
* synchronously, plus an `external-comments-disabled` class on
|
|
43
|
+
* `documentElement` when the feature is off. Loaded via a `<script>` tag
|
|
44
|
+
* BEFORE the main app JS so components like AIPanel can check the flag
|
|
45
|
+
* at construction time (avoids FOUC of UI elements that should be hidden).
|
|
46
|
+
*
|
|
47
|
+
* No-store so each page load reflects current config without restart.
|
|
48
|
+
*/
|
|
49
|
+
router.get('/runtime-config.js', (req, res) => {
|
|
50
|
+
const config = req.app.get('config') || {};
|
|
51
|
+
const externalCommentsEnabled = config.external_comments !== false;
|
|
52
|
+
const runtimeConfig = { external_comments_enabled: externalCommentsEnabled };
|
|
53
|
+
const body = [
|
|
54
|
+
`window.PAIR_REVIEW_RUNTIME_CONFIG = ${JSON.stringify(runtimeConfig)};`,
|
|
55
|
+
`if (!window.PAIR_REVIEW_RUNTIME_CONFIG.external_comments_enabled) {`,
|
|
56
|
+
` document.documentElement.classList.add('external-comments-disabled');`,
|
|
57
|
+
`}`,
|
|
58
|
+
].join('\n');
|
|
59
|
+
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
60
|
+
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
61
|
+
res.send(body);
|
|
62
|
+
});
|
|
63
|
+
|
|
38
64
|
/**
|
|
39
65
|
* Get user configuration (for frontend use)
|
|
40
66
|
* Returns safe-to-expose configuration values
|
|
@@ -65,6 +91,7 @@ router.get('/api/config', (req, res) => {
|
|
|
65
91
|
pi_available: getCachedAvailability('pi')?.available || false,
|
|
66
92
|
assisted_by_url: config.assisted_by_url || 'https://github.com/in-the-loop-labs/pair-review',
|
|
67
93
|
enable_graphite: config.enable_graphite === true,
|
|
94
|
+
external_comments: config.external_comments !== false,
|
|
68
95
|
chat_spinner: config.chat_spinner || 'dots',
|
|
69
96
|
// Share configuration for external review viewers.
|
|
70
97
|
// - url: The base URL of the external share site
|