@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
@@ -0,0 +1,328 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Repo Links UI Renderer
4
+ *
5
+ * Fetches `/api/repos/:owner/:repo/links` and renders the resulting
6
+ * configuration into the review header:
7
+ *
8
+ * - `links.external`: insert a new anchor button into the header icon
9
+ * group with the configured label, icon, and substituted URL.
10
+ * - `links.github === false`: hide `#github-link`.
11
+ * - `links.graphite === false`: hide `#graphite-link`.
12
+ *
13
+ * Shared between PR mode (public/js/pr.js) and Local mode
14
+ * (public/js/local.js). Local mode only calls this when the review is
15
+ * associated with a `repos` entry whose `owner/repo` is known.
16
+ *
17
+ * URL templates are substituted client-side because only the frontend
18
+ * has the live PR/branch context (head_sha may change at any refresh).
19
+ */
20
+ (function () {
21
+ // Whitelist of allowed `{placeholder}` names in `url_template`. Must
22
+ // match `ALLOWED_PLACEHOLDERS` in src/links/repo-links.js — kept in
23
+ // sync because mismatch produces silent template drift.
24
+ const ALLOWED_PLACEHOLDERS = [
25
+ 'owner', 'repo', 'number', 'branch', 'base_branch', 'head_sha'
26
+ ];
27
+
28
+ // The most recently resolved links + substitution context for the
29
+ // current review. Stored so other code (ReviewModal, pr.js) can read
30
+ // the configured host name / external URL / icon instead of hardcoding
31
+ // "GitHub". `fetchAndApplyRepoLinks` refreshes these on every load.
32
+ let _currentLinks = null;
33
+ let _currentContext = null;
34
+
35
+ /**
36
+ * Substitute whitelisted placeholders in a URL template. Returns the
37
+ * substituted URL, or null if any required placeholder is missing or
38
+ * the result does not start with `https://`. Mirrors the server-side
39
+ * implementation as defence-in-depth.
40
+ *
41
+ * @param {string} template
42
+ * @param {Object} context
43
+ * @returns {string|null}
44
+ */
45
+ function substituteUrlTemplate(template, context) {
46
+ if (typeof template !== 'string' || !template) return null;
47
+ const ctx = context || {};
48
+
49
+ const substituted = template.replace(/\{([a-zA-Z_]+)\}/g, (match, name) => {
50
+ if (!ALLOWED_PLACEHOLDERS.includes(name)) return match;
51
+ const value = ctx[name];
52
+ if (value === undefined || value === null || value === '') return match;
53
+ return encodeURIComponent(String(value));
54
+ });
55
+
56
+ if (!substituted.startsWith('https://')) return null;
57
+ for (const placeholder of ALLOWED_PLACEHOLDERS) {
58
+ if (substituted.includes('{' + placeholder + '}')) return null;
59
+ }
60
+ return substituted;
61
+ }
62
+
63
+ /**
64
+ * Parse a sanitised SVG string and return its root `<svg>` element,
65
+ * or null if parsing fails. The server sanitises the SVG before
66
+ * sending it down (strips scripts, on* handlers, javascript: URLs).
67
+ * We DOMParse here as a second filter, and also so the DOM we insert
68
+ * doesn't go through `innerHTML` on the live document.
69
+ *
70
+ * @param {string} svgString
71
+ * @returns {SVGElement|null}
72
+ */
73
+ function parseSvgIcon(svgString) {
74
+ if (typeof svgString !== 'string' || !svgString) return null;
75
+ try {
76
+ const doc = new DOMParser().parseFromString(svgString, 'image/svg+xml');
77
+ // image/svg+xml mode returns a <parsererror> root on bad input.
78
+ const root = doc.documentElement;
79
+ if (!root || root.nodeName !== 'svg') return null;
80
+ // Belt-and-braces: strip dangerous attributes/elements even though
81
+ // the server already removed them.
82
+ stripDangerousAttributes(root);
83
+ return root;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Recursively strip on* event handlers and `javascript:` URLs from an
91
+ * SVG element tree, and remove any `<script>` descendants. Defensive
92
+ * second pass after server sanitisation.
93
+ */
94
+ function stripDangerousAttributes(element) {
95
+ if (!element || !element.attributes) return;
96
+ const attrs = Array.from(element.attributes);
97
+ for (const attr of attrs) {
98
+ const name = attr.name.toLowerCase();
99
+ const value = String(attr.value || '').toLowerCase();
100
+ if (name.startsWith('on') || value.includes('javascript:')) {
101
+ element.removeAttribute(attr.name);
102
+ }
103
+ }
104
+ const children = Array.from(element.children || []);
105
+ for (const child of children) {
106
+ if (child.nodeName && child.nodeName.toLowerCase() === 'script') {
107
+ child.remove();
108
+ continue;
109
+ }
110
+ stripDangerousAttributes(child);
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Build the external-link anchor element. Returns null if the URL
116
+ * substitution fails (invalid template or missing required values).
117
+ *
118
+ * @param {Object} external { label, url_template, icon }
119
+ * @param {Object} context substitution context
120
+ * @returns {HTMLAnchorElement|null}
121
+ */
122
+ function buildExternalLink(external, context) {
123
+ if (!external || typeof external !== 'object') return null;
124
+ const url = substituteUrlTemplate(external.url_template, context);
125
+ if (!url) {
126
+ console.warn(
127
+ '[repo-links] Dropping external link: url_template substitution failed',
128
+ { template: external.url_template, context }
129
+ );
130
+ return null;
131
+ }
132
+
133
+ const anchor = document.createElement('a');
134
+ anchor.className = 'btn btn-icon';
135
+ anchor.id = 'external-link';
136
+ anchor.href = url;
137
+ anchor.target = '_blank';
138
+ anchor.rel = 'noopener noreferrer';
139
+ anchor.title = external.label;
140
+ anchor.setAttribute('aria-label', external.label);
141
+
142
+ if (external.icon) {
143
+ const svg = parseSvgIcon(external.icon);
144
+ if (svg) {
145
+ // Ensure the icon scales like the existing header icons.
146
+ if (!svg.getAttribute('width')) svg.setAttribute('width', '16');
147
+ if (!svg.getAttribute('height')) svg.setAttribute('height', '16');
148
+ anchor.appendChild(svg);
149
+ } else {
150
+ appendFallbackIcon(anchor);
151
+ }
152
+ } else {
153
+ appendFallbackIcon(anchor);
154
+ }
155
+
156
+ return anchor;
157
+ }
158
+
159
+ /**
160
+ * Append a generic "external link" SVG icon (an arrow leaving a box)
161
+ * when no icon is configured or sanitisation rejected the supplied
162
+ * SVG. Keeps the button visually consistent with the others.
163
+ */
164
+ function appendFallbackIcon(anchor) {
165
+ const svgNs = 'http://www.w3.org/2000/svg';
166
+ const svg = document.createElementNS(svgNs, 'svg');
167
+ svg.setAttribute('viewBox', '0 0 16 16');
168
+ svg.setAttribute('width', '16');
169
+ svg.setAttribute('height', '16');
170
+ svg.setAttribute('fill', 'currentColor');
171
+ const path = document.createElementNS(svgNs, 'path');
172
+ // "open in new tab" icon: box with arrow leaving top-right corner.
173
+ path.setAttribute('d', 'M3.75 2A1.75 1.75 0 0 0 2 3.75v8.5C2 13.216 2.784 14 3.75 14h8.5A1.75 1.75 0 0 0 14 12.25v-3a.75.75 0 0 0-1.5 0v3a.25.25 0 0 1-.25.25h-8.5a.25.25 0 0 1-.25-.25v-8.5a.25.25 0 0 1 .25-.25h3a.75.75 0 0 0 0-1.5h-3Zm6.97-.53a.75.75 0 0 0 0 1.06l1.72 1.72-4.97 4.97a.75.75 0 1 0 1.06 1.06l4.97-4.97 1.72 1.72a.75.75 0 0 0 1.28-.53V2.75A.75.75 0 0 0 14.75 2h-3.5a.75.75 0 0 0-.53 1.28Z');
174
+ svg.appendChild(path);
175
+ anchor.appendChild(svg);
176
+ }
177
+
178
+ /**
179
+ * Apply the resolved links config to the header DOM.
180
+ *
181
+ * - Hide `#github-link` if links.github === false.
182
+ * - Hide `#graphite-link` if links.graphite === false.
183
+ * - Insert an `#external-link` anchor before `#github-link` when
184
+ * `links.external` is set and substitution succeeds.
185
+ *
186
+ * Idempotent: a pre-existing `#external-link` is replaced rather than
187
+ * duplicated. Safe to call multiple times (e.g. after a refresh).
188
+ *
189
+ * @param {Object} links resolved links config from the server
190
+ * @param {Object} context substitution context for url_template
191
+ */
192
+ function applyRepoLinks(links, context) {
193
+ if (!links || typeof links !== 'object') return;
194
+
195
+ const githubLink = document.getElementById('github-link');
196
+ if (githubLink) {
197
+ if (links.github === false) {
198
+ githubLink.style.display = 'none';
199
+ }
200
+ }
201
+
202
+ const graphiteLink = document.getElementById('graphite-link');
203
+ if (graphiteLink) {
204
+ if (links.graphite === false) {
205
+ // Permanently hide — overrides the enable_graphite toggle for
206
+ // this repo. Stored on a data attribute so pr.js can detect the
207
+ // suppression and skip the show-on-load codepath.
208
+ graphiteLink.style.display = 'none';
209
+ graphiteLink.dataset.suppressed = 'true';
210
+ }
211
+ }
212
+
213
+ // Remove any previously inserted external link before re-inserting.
214
+ const existing = document.getElementById('external-link');
215
+ if (existing) existing.remove();
216
+
217
+ if (links.external) {
218
+ const anchor = buildExternalLink(links.external, context);
219
+ if (anchor) {
220
+ // Insert just before #github-link if present; otherwise append to
221
+ // the icon group. Falls back to header-right > div if needed.
222
+ if (githubLink && githubLink.parentNode) {
223
+ githubLink.parentNode.insertBefore(anchor, githubLink);
224
+ } else {
225
+ const iconGroup = document.querySelector('.header .header-icon-group')
226
+ || document.querySelector('.header-icon-group');
227
+ if (iconGroup) iconGroup.appendChild(anchor);
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Fetch the resolved links config for an owner/repo and apply it.
235
+ *
236
+ * @param {string} owner
237
+ * @param {string} repo
238
+ * @param {Object} context substitution context (owner, repo, number,
239
+ * branch, base_branch, head_sha)
240
+ * @returns {Promise<void>}
241
+ */
242
+ async function fetchAndApplyRepoLinks(owner, repo, context) {
243
+ if (!owner || !repo) return;
244
+ // Reset so a failed fetch (or a repo with no links) falls back to the
245
+ // "GitHub" defaults rather than carrying a previous review's host name.
246
+ _currentLinks = null;
247
+ _currentContext = context || null;
248
+ try {
249
+ const response = await fetch(
250
+ '/api/repos/' + encodeURIComponent(owner) + '/' + encodeURIComponent(repo) + '/links'
251
+ );
252
+ if (!response.ok) {
253
+ console.warn('[repo-links] Failed to fetch repo links:', response.status);
254
+ return;
255
+ }
256
+ const data = await response.json();
257
+ _currentLinks = (data && data.links) || null;
258
+ applyRepoLinks(_currentLinks, context);
259
+ } catch (err) {
260
+ console.warn('[repo-links] Error fetching repo links:', err);
261
+ }
262
+ }
263
+
264
+ /**
265
+ * Display name of the remote code host for the current review, for use
266
+ * in user-facing text in place of the literal "GitHub". Returns the
267
+ * configured `links.external.name`, or "GitHub" when unset. Server-side
268
+ * counterpart: `resolveHostName` in src/links/repo-links.js.
269
+ *
270
+ * @returns {string}
271
+ */
272
+ function hostName() {
273
+ if (_currentLinks && _currentLinks.external && _currentLinks.external.name) {
274
+ return _currentLinks.external.name;
275
+ }
276
+ return 'GitHub';
277
+ }
278
+
279
+ /**
280
+ * The substituted external URL for the current review (built from
281
+ * `links.external.url_template` and the stored context), or null when no
282
+ * external link is configured or substitution fails (e.g. Local mode,
283
+ * which has no `{number}`).
284
+ *
285
+ * @returns {string|null}
286
+ */
287
+ function externalUrl() {
288
+ if (!_currentLinks || !_currentLinks.external || !_currentLinks.external.url_template) {
289
+ return null;
290
+ }
291
+ return substituteUrlTemplate(_currentLinks.external.url_template, _currentContext || {});
292
+ }
293
+
294
+ /**
295
+ * The configured (server-sanitised) external host icon SVG string for the
296
+ * current review, or null when none is configured.
297
+ *
298
+ * @returns {string|null}
299
+ */
300
+ function externalIcon() {
301
+ if (_currentLinks && _currentLinks.external && _currentLinks.external.icon) {
302
+ return _currentLinks.external.icon;
303
+ }
304
+ return null;
305
+ }
306
+
307
+ const api = {
308
+ substituteUrlTemplate,
309
+ parseSvgIcon,
310
+ buildExternalLink,
311
+ applyRepoLinks,
312
+ fetchAndApplyRepoLinks,
313
+ hostName,
314
+ externalUrl,
315
+ externalIcon,
316
+ };
317
+
318
+ // Expose on window for use by pr.js and local.js.
319
+ if (typeof window !== 'undefined') {
320
+ window.RepoLinks = api;
321
+ }
322
+
323
+ // Also export for Node.js/test environments. Tests that only need the
324
+ // pure functions (substituteUrlTemplate) don't need a DOM.
325
+ if (typeof module !== 'undefined' && module.exports) {
326
+ module.exports = api;
327
+ }
328
+ })();
@@ -0,0 +1,88 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Shared provider/model pair resolver.
4
+ *
5
+ * Provider and model defaults are sourced from several scopes (repository
6
+ * settings, then app config) and historically each half was resolved
7
+ * independently with `repo || app || hardcoded`. When a scope overrides only
8
+ * ONE half, the halves can come from different scopes and produce an invalid
9
+ * pair such as `gemini` + `opus` (an Anthropic model). The modal then shows no
10
+ * selected model, and the non-modal auto-analyze path posts the broken pair
11
+ * straight to the backend.
12
+ *
13
+ * This resolver treats each scope as a unit and never mixes a provider from one
14
+ * scope with a model from another: it picks the first scope that names a known
15
+ * provider, keeps that scope's model only if it belongs to the provider, and
16
+ * otherwise derives the model from the provider's own default.
17
+ */
18
+
19
+ /**
20
+ * @param {Object} providerInfo - One entry from /api/providers
21
+ * @param {string} modelId
22
+ * @returns {boolean} Whether modelId is one of the provider's models
23
+ */
24
+ function _modelBelongsToProvider(providerInfo, modelId) {
25
+ return !!(
26
+ providerInfo &&
27
+ Array.isArray(providerInfo.models) &&
28
+ providerInfo.models.some(m => m && m.id === modelId)
29
+ );
30
+ }
31
+
32
+ /**
33
+ * Resolve a coherent { provider, model } pair from ordered candidate scopes.
34
+ *
35
+ * @param {Array<{provider?: string, model?: string}>} scopes - Ordered by
36
+ * precedence (e.g. [repoSettings, appConfig]). Entries, and either half, may
37
+ * be null/undefined.
38
+ * @param {Array<Object>} providersInfo - The `providers` array from
39
+ * /api/providers (each `{ id, models: [{id, ...}], defaultModel }`). May be
40
+ * empty if the fetch failed.
41
+ * @returns {{provider: string, model: (string|null)}} A matched pair. `model`
42
+ * is null only when no provider metadata is available to derive one.
43
+ */
44
+ function resolveProviderModelPair(scopes, providersInfo) {
45
+ const providers = Array.isArray(providersInfo) ? providersInfo : [];
46
+ const findProvider = (id) => providers.find(p => p && p.id === id);
47
+ const providerDefaultModel = (info) =>
48
+ (info && (info.defaultModel || (Array.isArray(info.models) && info.models[0] && info.models[0].id))) || null;
49
+
50
+ for (const scope of (Array.isArray(scopes) ? scopes : [])) {
51
+ if (!scope) continue;
52
+ const provId = scope.provider || null;
53
+ const modelId = scope.model || null;
54
+
55
+ if (provId) {
56
+ const info = findProvider(provId);
57
+ if (info) {
58
+ if (modelId && _modelBelongsToProvider(info, modelId)) {
59
+ return { provider: provId, model: modelId };
60
+ }
61
+ return { provider: provId, model: providerDefaultModel(info) };
62
+ }
63
+ // Provider not in metadata (custom/unavailable): can't validate, pass
64
+ // through this scope's own halves rather than mixing in another scope's.
65
+ return { provider: provId, model: modelId };
66
+ }
67
+
68
+ if (modelId) {
69
+ // Scope names a model but no provider — attribute it to whichever
70
+ // provider owns it. If none owns it, fall through to the next scope.
71
+ const owner = providers.find(p => _modelBelongsToProvider(p, modelId));
72
+ if (owner) return { provider: owner.id, model: modelId };
73
+ }
74
+ }
75
+
76
+ // Nothing resolved: fall back to claude, deriving its default model from
77
+ // metadata when available (else null → let the backend/provider decide).
78
+ const claude = findProvider('claude');
79
+ return { provider: 'claude', model: providerDefaultModel(claude) };
80
+ }
81
+
82
+ if (typeof window !== 'undefined') {
83
+ window.resolveProviderModelPair = resolveProviderModelPair;
84
+ }
85
+
86
+ if (typeof module !== 'undefined' && module.exports) {
87
+ module.exports = { resolveProviderModelPair };
88
+ }
@@ -0,0 +1,50 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Shared localStorage-key helpers.
4
+ *
5
+ * Single source of truth for repository-scoped storage keys. These keys are
6
+ * shared ACROSS pages — the index/bulk page writes per-repo keys (e.g.
7
+ * `pair-review-tab`, `pair-review-instructions`) that the PR page later reads
8
+ * via `PRManager.getRepoStorageKey`. Any divergence in the base64 byte
9
+ * assembly would silently break remembered-tab / instructions reuse with no
10
+ * error, so the encoding lives here once rather than being copied per page.
11
+ */
12
+
13
+ /**
14
+ * Base64-encode a UTF-8 string in a byte-accurate way (btoa alone mangles
15
+ * multibyte characters).
16
+ * @param {string} value
17
+ * @returns {string} Base64 (with '=' padding)
18
+ */
19
+ function encodeBase64Utf8(value) {
20
+ const bytes = new TextEncoder().encode(value);
21
+ let binary = '';
22
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
23
+ return btoa(binary);
24
+ }
25
+
26
+ /**
27
+ * Generate a safe localStorage key for repository-specific settings.
28
+ * Uses base64 encoding to handle special characters in owner/repo names.
29
+ * @param {string} prefix - Key prefix (e.g. 'pair-review-tab')
30
+ * @param {string} owner - Repository owner
31
+ * @param {string} repo - Repository name
32
+ * @returns {string} Safe localStorage key
33
+ */
34
+ function getRepoStorageKey(prefix, owner, repo) {
35
+ try {
36
+ const repoId = encodeBase64Utf8(owner + '/' + repo).replace(/=/g, '');
37
+ return prefix + ':' + repoId;
38
+ } catch (_error) {
39
+ return prefix + ':' + encodeURIComponent(owner + '/' + repo);
40
+ }
41
+ }
42
+
43
+ if (typeof window !== 'undefined') {
44
+ window.encodeBase64Utf8 = encodeBase64Utf8;
45
+ window.getRepoStorageKey = getRepoStorageKey;
46
+ }
47
+
48
+ if (typeof module !== 'undefined' && module.exports) {
49
+ module.exports = { encodeBase64Utf8, getRepoStorageKey };
50
+ }
package/public/local.html CHANGED
@@ -595,6 +595,10 @@
595
595
  <!-- Tier icons utility -->
596
596
  <script src="/js/utils/tier-icons.js"></script>
597
597
 
598
+ <!-- Shared storage-key and provider/model helpers -->
599
+ <script src="/js/utils/storage-keys.js"></script>
600
+ <script src="/js/utils/provider-model.js"></script>
601
+
598
602
  <!-- Category emoji mapping -->
599
603
  <script src="/js/utils/category-emoji.js"></script>
600
604
 
@@ -674,6 +678,9 @@
674
678
  pr.js / AIPanel initialise. Must load BEFORE pr.js. -->
675
679
  <script src="/runtime-config.js"></script>
676
680
 
681
+ <!-- Repo Links UI (must load before pr.js / local.js) -->
682
+ <script src="/js/repo-links.js"></script>
683
+
677
684
  <!-- PR JavaScript (shared with local mode) -->
678
685
  <script src="/js/pr.js"></script>
679
686
 
package/public/pr.html CHANGED
@@ -398,6 +398,10 @@
398
398
  <!-- Tier icons utility -->
399
399
  <script src="/js/utils/tier-icons.js"></script>
400
400
 
401
+ <!-- Shared storage-key and provider/model helpers -->
402
+ <script src="/js/utils/storage-keys.js"></script>
403
+ <script src="/js/utils/provider-model.js"></script>
404
+
401
405
  <!-- Category emoji mapping -->
402
406
  <script src="/js/utils/category-emoji.js"></script>
403
407
 
@@ -475,6 +479,9 @@
475
479
  pr.js / AIPanel initialise. Must load BEFORE pr.js. -->
476
480
  <script src="/runtime-config.js"></script>
477
481
 
482
+ <!-- Repo Links UI (must load before pr.js) -->
483
+ <script src="/js/repo-links.js"></script>
484
+
478
485
  <!-- PR JavaScript -->
479
486
  <script src="/js/pr.js"></script>
480
487
  </body>
@@ -13,6 +13,7 @@
13
13
  <title>Repository Settings - Pair Review</title>
14
14
  <link rel="icon" type="image/png" href="/favicon.png">
15
15
  <link rel="stylesheet" href="/css/pr.css">
16
+ <link rel="stylesheet" href="/css/analysis-config.css">
16
17
  <link rel="stylesheet" href="/css/repo-settings.css">
17
18
  </head>
18
19
  <body>
package/public/setup.html CHANGED
@@ -769,6 +769,7 @@
769
769
  var targetUrl = new URL(data.reviewUrl, window.location.origin);
770
770
  var qs = new URLSearchParams(window.location.search);
771
771
  if (qs.get('analyze') === 'true') targetUrl.searchParams.set('analyze', qs.get('analyze'));
772
+ if (qs.get('analysisConfigId')) targetUrl.searchParams.set('analysisConfigId', qs.get('analysisConfigId'));
772
773
  window.location.href = targetUrl.toString();
773
774
  return;
774
775
  }
@@ -829,6 +830,7 @@
829
830
  var targetUrl = new URL(msg.reviewUrl, window.location.origin);
830
831
  var qs = new URLSearchParams(window.location.search);
831
832
  if (qs.get('analyze') === 'true') targetUrl.searchParams.set('analyze', qs.get('analyze'));
833
+ if (qs.get('analysisConfigId')) targetUrl.searchParams.set('analysisConfigId', qs.get('analysisConfigId'));
832
834
  window.location.href = targetUrl.toString();
833
835
  }
834
836
  }, 400);