@in-the-loop-labs/pair-review 3.4.0 → 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.
@@ -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 };
@@ -0,0 +1,29 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Fetch from a remote without auto-following tags reachable from the fetched
5
+ * commits. Large monorepos can have very large tag namespaces, and pair-review
6
+ * only needs commits/refs for review setup.
7
+ * @param {Object} git - simple-git instance
8
+ * @param {string[]} args - Arguments after `git fetch --no-tags`
9
+ * @returns {Promise<*>}
10
+ */
11
+ async function fetchNoTags(git, args) {
12
+ return git.fetch(['--no-tags', ...args]);
13
+ }
14
+
15
+ /**
16
+ * Raw `git fetch --no-tags` wrapper for fetch forms not exposed cleanly by
17
+ * simple-git helpers.
18
+ * @param {Object} git - simple-git instance
19
+ * @param {string[]} args - Arguments after `git fetch --no-tags`
20
+ * @returns {Promise<*>}
21
+ */
22
+ async function rawFetchNoTags(git, args) {
23
+ return git.raw(['fetch', '--no-tags', ...args]);
24
+ }
25
+
26
+ module.exports = {
27
+ fetchNoTags,
28
+ rawFetchNoTags,
29
+ };
@@ -3,11 +3,11 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const logger = require('../utils/logger');
6
- const { WorktreePoolRepository, WorktreeRepository, generateWorktreeId } = require('../database');
6
+ const { WorktreePoolRepository, WorktreeRepository, RepoSettingsRepository, generateWorktreeId } = require('../database');
7
7
  const { GitWorktreeManager } = require('./worktree');
8
8
  const { WorktreePoolUsageTracker } = require('./worktree-pool-usage');
9
9
  const { normalizeRepository } = require('../utils/paths');
10
- const { getRepoPoolSize } = require('../config');
10
+ const { resolvePoolConfig } = require('../config');
11
11
 
12
12
  /**
13
13
  * Consolidates the worktree pool state machine: absorbs WorktreePoolManager
@@ -25,6 +25,7 @@ class WorktreePoolLifecycle {
25
25
  const defaults = {
26
26
  poolRepo: new WorktreePoolRepository(db),
27
27
  worktreeRepo: new WorktreeRepository(db),
28
+ repoSettingsRepo: new RepoSettingsRepository(db),
28
29
  usageTracker: new WorktreePoolUsageTracker(),
29
30
  fs: fs,
30
31
  simpleGit: require('simple-git'),
@@ -36,6 +37,7 @@ class WorktreePoolLifecycle {
36
37
  this.config = config;
37
38
  this._poolRepo = deps.poolRepo;
38
39
  this._worktreeRepo = deps.worktreeRepo;
40
+ this._repoSettingsRepo = deps.repoSettingsRepo;
39
41
  this._usageTracker = deps.usageTracker;
40
42
  this._fs = deps.fs;
41
43
  this._simpleGit = deps.simpleGit;
@@ -628,11 +630,20 @@ class WorktreePoolLifecycle {
628
630
  * @private
629
631
  */
630
632
  async _adoptExistingWorktrees() {
631
- const repos = this.config.repos || {};
633
+ const config = this.config || {};
634
+ const repoSettingsRows = await this._repoSettingsRepo.findPoolConfiguredRepoSettings();
635
+ const settingsByRepo = new Map(
636
+ repoSettingsRows.map(row => [String(row.repository).toLowerCase(), row])
637
+ );
638
+ const repoNames = new Set(Object.keys(config.repos || {}));
639
+ for (const row of repoSettingsRows) {
640
+ repoNames.add(String(row.repository).toLowerCase());
641
+ }
632
642
  const adoptedInUse = [];
633
643
 
634
- for (const repoName of Object.keys(repos)) {
635
- const poolSize = getRepoPoolSize(this.config, repoName);
644
+ for (const repoName of repoNames) {
645
+ const repoSettings = settingsByRepo.get(String(repoName).toLowerCase()) || null;
646
+ const { poolSize } = resolvePoolConfig(config, repoName, repoSettings);
636
647
  if (!poolSize) continue;
637
648
 
638
649
  // Count existing pool entries for this repo
@@ -8,6 +8,7 @@ const { WorktreeRepository, generateWorktreeId } = require('../database');
8
8
  const { getGeneratedFilePatterns } = require('./gitattributes');
9
9
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('../utils/paths');
10
10
  const { GIT_DIFF_FLAGS_ARRAY, GIT_DIFF_SUMMARY_FLAGS_ARRAY } = require('./diff-flags');
11
+ const { fetchNoTags, rawFetchNoTags } = require('./fetch-helpers');
11
12
  const { spawn, execSync } = require('child_process');
12
13
 
13
14
  const MISSING_COMMIT_ERROR_CODE = 'PAIR_REVIEW_MISSING_COMMIT';
@@ -250,7 +251,7 @@ class GitWorktreeManager {
250
251
 
251
252
  let fetchError = null;
252
253
  try {
253
- await git.raw(['fetch', remote, sha]);
254
+ await rawFetchNoTags(git, [remote, sha]);
254
255
  } catch (error) {
255
256
  fetchError = error;
256
257
  }
@@ -370,7 +371,7 @@ class GitWorktreeManager {
370
371
  const prTrackingRef = `refs/remotes/${baseRemote}/pr-${prNumber}`;
371
372
 
372
373
  try {
373
- await git.fetch([baseRemote, `+refs/pull/${prNumber}/head:${prTrackingRef}`]);
374
+ await fetchNoTags(git, [baseRemote, `+refs/pull/${prNumber}/head:${prTrackingRef}`]);
374
375
  return {
375
376
  remote: baseRemote,
376
377
  trackingRef: prTrackingRef,
@@ -387,7 +388,7 @@ class GitWorktreeManager {
387
388
  throw prRefError;
388
389
  }
389
390
 
390
- await git.raw(['fetch', baseRemote, headSha]);
391
+ await rawFetchNoTags(git, [baseRemote, headSha]);
391
392
  return {
392
393
  remote: baseRemote,
393
394
  trackingRef: null,
@@ -591,13 +592,13 @@ class GitWorktreeManager {
591
592
  // Fetch only the specific base branch we need, with error handling for ref conflicts
592
593
  console.log(`Fetching base branch ${prData.base_branch} from ${remote}...`);
593
594
  try {
594
- await git.fetch([remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
595
+ await fetchNoTags(git, [remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
595
596
  } catch (fetchError) {
596
597
  // If fetch fails due to ref conflicts, try alternative approaches
597
598
  console.log(`Standard fetch failed, trying alternative: ${fetchError.message}`);
598
599
  try {
599
600
  // Try fetching with force flag to overwrite conflicting refs
600
- await git.raw(['fetch', remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`, '--force']);
601
+ await rawFetchNoTags(git, ['--force', remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
601
602
  } catch (altFetchError) {
602
603
  console.warn(`Could not fetch base branch ${prData.base_branch}, will try to use existing ref`);
603
604
  // Continue anyway - the branch might already be available locally
@@ -658,7 +659,7 @@ class GitWorktreeManager {
658
659
  if (headBranch) {
659
660
  try {
660
661
  console.log(`Fetching head branch ${headBranch}...`);
661
- await worktreeGit.fetch([remote, `+refs/heads/${headBranch}:refs/remotes/${remote}/${headBranch}`]);
662
+ await fetchNoTags(worktreeGit, [remote, `+refs/heads/${headBranch}:refs/remotes/${remote}/${headBranch}`]);
662
663
  // Create/update a local branch pointing to the fetched ref so tooling can reference it by name
663
664
  await worktreeGit.branch(['-f', headBranch, `${remote}/${headBranch}`]);
664
665
  } catch (branchFetchError) {
@@ -769,14 +770,14 @@ class GitWorktreeManager {
769
770
  // This mirrors the targeted fetch used in createWorktreeForPR.
770
771
  if (prData?.base_branch) {
771
772
  try {
772
- await worktreeGit.fetch([remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
773
+ await fetchNoTags(worktreeGit, [remote, `+refs/heads/${prData.base_branch}:refs/remotes/${remote}/${prData.base_branch}`]);
773
774
  } catch (fetchError) {
774
775
  console.warn(`Targeted base-branch fetch failed, will rely on existing refs: ${fetchError.message}`);
775
776
  }
776
777
  }
777
778
  } else {
778
779
  console.log(`Fetching latest changes from ${remote}...`);
779
- await worktreeGit.fetch([remote, '--prune']);
780
+ await fetchNoTags(worktreeGit, ['--prune', remote]);
780
781
  }
781
782
 
782
783
  await this.ensureBaseShaAvailable(worktreeGit, prData, remote);
@@ -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);
@@ -6,6 +6,7 @@ const path = require('path');
6
6
  const fs = require('fs').promises;
7
7
  const { loadConfig, showWelcomeMessage, resolveDbName, getGitHubToken } = require('./config');
8
8
  const logger = require('./utils/logger');
9
+ const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
9
10
  const { fireHooks, hasHooks } = require('./hooks/hook-runner');
10
11
  const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('./hooks/payloads');
11
12
 
@@ -699,6 +700,8 @@ async function handleLocalReview(targetPath, flags = {}) {
699
700
  let db = null;
700
701
 
701
702
  try {
703
+ rejectUrlLikeLocalReviewPath(targetPath);
704
+
702
705
  // Resolve target path
703
706
  const resolvedPath = path.resolve(targetPath || process.cwd());
704
707
 
package/src/main.js CHANGED
@@ -5,6 +5,7 @@ const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, Work
5
5
  const { PRArgumentParser } = require('./github/parser');
6
6
  const { GitHubClient } = require('./github/client');
7
7
  const { GitWorktreeManager } = require('./git/worktree');
8
+ const { fetchNoTags } = require('./git/fetch-helpers');
8
9
  const { WorktreePoolLifecycle } = require('./git/worktree-pool-lifecycle');
9
10
  const { startServer } = require('./server');
10
11
  const Analyzer = require('./ai/analyzer');
@@ -13,6 +14,7 @@ const { handleLocalReview, findMainGitRoot } = require('./local-review');
13
14
  const { storePRData, registerRepositoryLocation, findRepositoryPath } = require('./setup/pr-setup');
14
15
  const { fireReviewStartedHook } = require('./hooks/payloads');
15
16
  const { normalizeRepository, resolveRenamedFile, resolveRenamedFileOld } = require('./utils/paths');
17
+ const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
16
18
  const logger = require('./utils/logger');
17
19
  const simpleGit = require('simple-git');
18
20
  const { getGeneratedFilePatterns } = require('./git/gitattributes');
@@ -288,6 +290,7 @@ function parseArgs(args) {
288
290
  flags.local = true;
289
291
  // Next argument is optional path (if not starting with -)
290
292
  if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
293
+ rejectUrlLikeLocalReviewPath(args[i + 1]);
291
294
  flags.localPath = args[i + 1];
292
295
  i++; // Skip next argument since we consumed it
293
296
  }
@@ -723,7 +726,7 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
723
726
 
724
727
  // Ensure we have the base SHA available (fetch if needed)
725
728
  try {
726
- await git.fetch(['origin', prData.base_sha]);
729
+ await fetchNoTags(git, ['origin', prData.base_sha]);
727
730
  } catch (fetchError) {
728
731
  // Fetch by SHA may fail (not all servers support it); verify SHA is available locally
729
732
  try {
@@ -1225,7 +1228,7 @@ function startPoolBackgroundFetches(db, config) {
1225
1228
  const git = simpleGit(entry.path, { timeout: { block: 300000 } });
1226
1229
  const remotes = await git.getRemotes();
1227
1230
  const remote = remotes.find(r => r.name === 'origin') || remotes[0];
1228
- if (remote) await git.fetch([remote.name, '--prune']);
1231
+ if (remote) await fetchNoTags(git, ['--prune', remote.name]);
1229
1232
  await poolRepo.updateLastFetched(entry.id);
1230
1233
  // Refresh the lease so the stale guard only needs to outlive
1231
1234
  // a single stalled fetch, not the entire serial loop.
@@ -1294,4 +1297,4 @@ if (require.main === module) {
1294
1297
  main();
1295
1298
  }
1296
1299
 
1297
- module.exports = { main, parseArgs, detectPRFromGitHubEnvironment };
1300
+ module.exports = { main, parseArgs, detectPRFromGitHubEnvironment };
@@ -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