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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +4 -0
  2. package/package.json +20 -15
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
  6. package/public/css/analysis-config.css +1807 -0
  7. package/public/css/pr.css +0 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +39 -23
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ReviewModal.js +135 -13
  13. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  14. package/public/js/index.js +175 -16
  15. package/public/js/local.js +58 -8
  16. package/public/js/modules/suggestion-manager.js +25 -1
  17. package/public/js/modules/tour-renderer.js +33 -3
  18. package/public/js/pr.js +653 -157
  19. package/public/js/repo-links.js +328 -0
  20. package/public/js/utils/provider-model.js +88 -0
  21. package/public/js/utils/storage-keys.js +50 -0
  22. package/public/local.html +7 -0
  23. package/public/pr.html +7 -0
  24. package/public/repo-settings.html +1 -0
  25. package/public/setup.html +2 -0
  26. package/src/ai/analyzer.js +125 -18
  27. package/src/config.js +664 -10
  28. package/src/external/github-adapter.js +114 -25
  29. package/src/git/base-branch.js +11 -4
  30. package/src/github/client.js +482 -588
  31. package/src/github/errors.js +55 -0
  32. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  33. package/src/github/impl/graphql/pending-review.js +153 -0
  34. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  35. package/src/github/impl/graphql/stack-walker.js +210 -0
  36. package/src/github/impl/host/pending-review-comments.js +338 -0
  37. package/src/github/impl/rest/pending-review.js +251 -0
  38. package/src/github/impl/rest/review-lifecycle.js +226 -0
  39. package/src/github/impl/rest/stack-walker.js +309 -0
  40. package/src/github/operations/pending-review-comments.js +79 -0
  41. package/src/github/operations/pending-review.js +89 -0
  42. package/src/github/operations/review-lifecycle.js +126 -0
  43. package/src/github/operations/stack-walker.js +87 -0
  44. package/src/github/parser.js +230 -4
  45. package/src/github/stack-walker.js +14 -189
  46. package/src/links/repo-links.js +230 -0
  47. package/src/local-review.js +13 -4
  48. package/src/main.js +133 -30
  49. package/src/routes/analyses.js +30 -7
  50. package/src/routes/bulk-analysis-configs.js +295 -0
  51. package/src/routes/config.js +102 -2
  52. package/src/routes/external-comments.js +20 -10
  53. package/src/routes/github-collections.js +3 -1
  54. package/src/routes/local.js +101 -11
  55. package/src/routes/mcp.js +47 -4
  56. package/src/routes/pr.js +298 -68
  57. package/src/routes/setup.js +8 -3
  58. package/src/routes/stack-analysis.js +33 -9
  59. package/src/routes/worktrees.js +3 -2
  60. package/src/server.js +2 -0
  61. package/src/setup/pr-setup.js +37 -11
  62. package/src/setup/stack-setup.js +13 -3
  63. package/src/single-port.js +6 -3
@@ -19,7 +19,11 @@
19
19
  */
20
20
 
21
21
  const { GitHubClient, GitHubApiError } = require('../github/client');
22
- const { getGitHubToken } = require('../config');
22
+ const {
23
+ getGitHubToken,
24
+ resolveHostBinding,
25
+ resolveBindingRepositoryFromPR
26
+ } = require('../config');
23
27
 
24
28
  const name = 'github';
25
29
 
@@ -38,25 +42,74 @@ const credentialEnvVar = 'GITHUB_TOKEN';
38
42
  * `GitHubApiError(status: 401)` keeps the existing 401 mapping at the
39
43
  * route layer.
40
44
  *
45
+ * Binding-aware: when `repository` (`owner/repo`) is supplied, credential
46
+ * resolution mirrors `resolveBindingForRequest` in `src/routes/pr.js` —
47
+ * the repo is resolved to its binding key via `resolveBindingRepositoryFromPR`
48
+ * and then to a host binding via `resolveHostBinding`, so per-repo
49
+ * `api_host` / `token` / `token_command` / `features` apply. The
50
+ * `GitHubClient` is constructed from the FULL binding (not a bare token),
51
+ * so an alt-host repo routes Octokit's `baseUrl` to its `api_host` instead
52
+ * of always hitting `api.github.com`.
53
+ *
54
+ * When `repository` is absent/empty the no-repo fallback is preserved
55
+ * exactly: `getGitHubToken(config)` (top-level/env token) + a bare-token
56
+ * `GitHubClient` (→ `api.github.com`). This keeps any caller without repo
57
+ * context working unchanged.
58
+ *
59
+ * The returned shape also carries `isAltHost` so the route can drive
60
+ * host-aware comment mapping. On the repo path it reflects the resolved
61
+ * binding (`Boolean(binding.apiHost)`); on the no-repo fallback it is always
62
+ * `false` (api.github.com). Alt-hosts don't implement GitHub's deprecated
63
+ * diff-relative `position` field, so `mapComment` must anchor by `line`
64
+ * instead — see its docstring.
65
+ *
41
66
  * @param {Object} config - Server config (see `loadConfig()`)
42
- * @param {Object} [_deps] - Test overrides for { GitHubClient, getGitHubToken }
43
- * @returns {{ client: Object }}
67
+ * @param {string} [repository] - "owner/repo" identifier for binding-aware resolution
68
+ * @param {Object} [_deps] - Test overrides for
69
+ * { GitHubClient, getGitHubToken, resolveHostBinding, resolveBindingRepositoryFromPR }
70
+ * @returns {{ client: Object, isAltHost: boolean }}
44
71
  * @throws {GitHubApiError} with status 401 when no token is configured
45
72
  */
46
- function resolveCredentials(config, _deps) {
73
+ function resolveCredentials(config, repository, _deps) {
47
74
  const deps = {
48
75
  GitHubClient,
49
76
  getGitHubToken,
77
+ resolveHostBinding,
78
+ resolveBindingRepositoryFromPR,
50
79
  ..._deps
51
80
  };
52
- const token = deps.getGitHubToken(config || {});
81
+ const safeConfig = config || {};
82
+
83
+ if (repository) {
84
+ // Binding-aware path. Mirrors resolveBindingForRequest in routes/pr.js:
85
+ // resolve the PR identity to a binding key, then to a host binding.
86
+ const [owner, repo] = String(repository).split('/');
87
+ const bindingRepository = deps.resolveBindingRepositoryFromPR(owner, repo, safeConfig);
88
+ const binding = deps.resolveHostBinding(bindingRepository, safeConfig);
89
+ if (!binding || !binding.token) {
90
+ throw new GitHubApiError(
91
+ `GitHub token not configured for ${repository}. Set ${credentialEnvVar}, add github_token to ~/.pair-review/config.json, or configure repos["${bindingRepository}"].token / token_command (required for alt-host repos)`,
92
+ 401
93
+ );
94
+ }
95
+ // An api_host on the binding means this repo lives on an alternate Git
96
+ // host. Surface that so the route can switch mapComment to line-based
97
+ // anchoring (alt-hosts return position:null with a valid `line`).
98
+ return { client: new deps.GitHubClient(binding), isAltHost: Boolean(binding.apiHost) };
99
+ }
100
+
101
+ // No-repo fallback — preserved exactly as before so callers without repo
102
+ // context (top-level/env token → api.github.com) continue to work.
103
+ const token = deps.getGitHubToken(safeConfig);
53
104
  if (!token) {
54
105
  throw new GitHubApiError(
55
106
  `GitHub token not configured. Set ${credentialEnvVar} or add github_token to ~/.pair-review/config.json`,
56
107
  401
57
108
  );
58
109
  }
59
- return { client: new deps.GitHubClient(token) };
110
+ // The no-repo fallback always targets api.github.com, so it is never an
111
+ // alt-host — keep the github.com position-based mapping.
112
+ return { client: new deps.GitHubClient(token), isAltHost: false };
60
113
  }
61
114
 
62
115
  /**
@@ -76,23 +129,40 @@ async function fetchComments({ client, owner, repo, pull_number }) {
76
129
  /**
77
130
  * Map a raw GitHub review-comment API row to an `external_comments` row.
78
131
  *
132
+ * Host-aware anchoring (the `options.isAltHost` switch):
133
+ * - **github.com (default, `isAltHost` falsy)**: `position` is the signal
134
+ * for "outdated". A null `position` means the comment no longer maps to
135
+ * the current diff, so the current-anchor fields are nulled and
136
+ * `original_*` becomes the only authoritative anchor. This path is
137
+ * byte-identical to the long-standing behaviour and must not change.
138
+ * - **alt-host (`isAltHost === true`)**: alternate Git hosts do NOT
139
+ * implement GitHub's DEPRECATED diff-relative `position` field — they
140
+ * return `position: null` even for perfectly current comments while
141
+ * supplying a valid modern `line`. Keying "outdated" off `position`
142
+ * there would discard a good `line` and mis-flag live comments as lost
143
+ * anchors. So we anchor uniformly by `line`: a current comment has a
144
+ * non-null `line` (`is_outdated = 0`); a genuinely outdated one has
145
+ * `line == null` (`is_outdated = 1`, anchored via `original_*`). This
146
+ * works whether or not the host also happens to return `position`.
147
+ *
79
148
  * Edge cases handled here (per the phase spec):
80
149
  * - `apiRow.user` is null (deleted account): `author` and `author_url`
81
150
  * 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.
151
+ * - both current AND original anchors null (force-push lost anchor):
152
+ * still produces a row the sync route decides whether to count or
153
+ * skip. We do NOT throw here.
87
154
  * - `apiRow.path` missing: throws — `file` is NOT NULL in the schema
88
155
  * and a missing path means upstream gave us something malformed.
89
156
  * Failing early in the mapper is far easier to debug than a SQL
90
157
  * constraint violation deep in an upsert loop.
91
158
  *
92
159
  * @param {Object} apiRow
160
+ * @param {Object} [options]
161
+ * @param {boolean} [options.isAltHost=false] - When true, use line-based
162
+ * anchoring (alt-host); when false/omitted, github.com position-based.
93
163
  * @returns {Object} A row matching the `external_comments` column names
94
164
  */
95
- function mapComment(apiRow) {
165
+ function mapComment(apiRow, options = {}) {
96
166
  if (!apiRow || apiRow.path == null) {
97
167
  throw new Error('GitHub adapter: comment missing required field "path"');
98
168
  }
@@ -106,19 +176,38 @@ function mapComment(apiRow) {
106
176
  }
107
177
 
108
178
  const user = apiRow.user || null;
109
- const positionIsNull = apiRow.position == null;
179
+ const isAltHost = options.isAltHost === true;
110
180
 
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;
181
+ let line_start;
182
+ let line_end;
183
+ let diff_position;
184
+ let is_outdated;
185
+
186
+ if (isAltHost) {
187
+ // Alt-host: `line` is the authoritative signal. `position` is unreliable
188
+ // (alt-hosts leave it null even for current comments), so we never null
189
+ // out a good `line` based on it. `diff_position` is carried through —
190
+ // legitimately null on most alt-hosts; the frontend renders by
191
+ // line_start/line_end/side, not by diff_position.
192
+ const lineIsNull = apiRow.line == null;
193
+ line_start = lineIsNull ? null : apiRow.start_line ?? apiRow.line ?? null;
194
+ line_end = lineIsNull ? null : apiRow.line ?? null;
195
+ diff_position = apiRow.position ?? null;
196
+ is_outdated = lineIsNull ? 1 : 0;
197
+ } else {
198
+ // github.com (unchanged): when position is null the comment is outdated.
199
+ // GitHub still populates `line` in many of these responses, but the line
200
+ // number does NOT correspond to a position in the current diff — using
201
+ // it would create two conflicting truths (line_end set AND is_outdated=1)
202
+ // and would make the lost-anchor filter under-count. Force the
203
+ // current-anchor fields to null so `original_*` is the only
204
+ // authoritative anchor.
205
+ const positionIsNull = apiRow.position == null;
206
+ line_start = positionIsNull ? null : apiRow.start_line ?? apiRow.line ?? null;
207
+ line_end = positionIsNull ? null : apiRow.line ?? null;
208
+ diff_position = positionIsNull ? null : apiRow.position ?? null;
209
+ is_outdated = positionIsNull ? 1 : 0;
210
+ }
122
211
 
123
212
  return {
124
213
  external_id: String(apiRow.id),
@@ -133,7 +222,7 @@ function mapComment(apiRow) {
133
222
  line_end,
134
223
  diff_position,
135
224
  commit_sha: apiRow.commit_id ?? null,
136
- is_outdated: positionIsNull ? 1 : 0,
225
+ is_outdated,
137
226
  original_line_start:
138
227
  apiRow.original_start_line ?? apiRow.original_line ?? null,
139
228
  original_line_end: apiRow.original_line ?? null,
@@ -8,9 +8,13 @@ const defaults = {
8
8
  // This default returns empty so GitHub lookup is silently skipped
9
9
  // when no token is provided — never re-resolve config internally.
10
10
  getGitHubToken: () => '',
11
- createGitHubClient: (token) => {
11
+ // Callers may optionally pass a binding via _deps.getHostBinding so
12
+ // alt-host repos route to the configured api_host. The default
13
+ // returns null which makes the client default to github.com.
14
+ getHostBinding: () => null,
15
+ createGitHubClient: (tokenOrBinding) => {
12
16
  const { GitHubClient } = require('../github/client');
13
- return new GitHubClient(token);
17
+ return new GitHubClient(tokenOrBinding);
14
18
  }
15
19
  };
16
20
 
@@ -139,11 +143,14 @@ async function tryGitHubPR(repoPath, currentBranch, repository, deps) {
139
143
  if (!repository || !repository.includes('/')) return null;
140
144
 
141
145
  try {
146
+ // Prefer the host binding (alt-host aware) when callers provide one.
147
+ // Falls back to the bare token for legacy callers.
148
+ const binding = deps.getHostBinding();
142
149
  const token = deps.getGitHubToken();
143
- if (!token) return null;
150
+ if (!binding && !token) return null;
144
151
 
145
152
  const [owner, repo] = repository.split('/');
146
- const client = deps.createGitHubClient(token);
153
+ const client = deps.createGitHubClient(binding || token);
147
154
  const result = await client.findPRByBranch(owner, repo, currentBranch);
148
155
 
149
156
  if (result) {