@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
|
@@ -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,77 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Shared modal-detection helpers.
|
|
4
|
+
*
|
|
5
|
+
* Used by KeyboardShortcuts and PRManager so keyboard handlers consistently
|
|
6
|
+
* defer to whatever modal/dialog is currently open. Single source of truth
|
|
7
|
+
* for both the selector list and the visibility check — keeping them in
|
|
8
|
+
* sync across consumers was the original motivation for extracting this.
|
|
9
|
+
*
|
|
10
|
+
* The help overlay (`#keyboard-shortcuts-help`) is intentionally excluded
|
|
11
|
+
* from the selectors so the shortcuts overlay itself doesn't suppress
|
|
12
|
+
* Escape handling for its own close button.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Selectors that identify "a modal is open". Anything matched here will be
|
|
17
|
+
* treated as a blocker for unrelated keyboard shortcuts.
|
|
18
|
+
* @type {string[]}
|
|
19
|
+
*/
|
|
20
|
+
const MODAL_SELECTORS = [
|
|
21
|
+
'.modal-overlay:not(#keyboard-shortcuts-help)',
|
|
22
|
+
'.review-modal-overlay',
|
|
23
|
+
'.preview-modal-overlay',
|
|
24
|
+
'.confirm-dialog-overlay',
|
|
25
|
+
'.analysis-config-overlay',
|
|
26
|
+
'.ai-summary-modal-overlay',
|
|
27
|
+
'[role="dialog"]:not(#keyboard-shortcuts-help)'
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Return true when `element` is visually present — i.e. not hidden via
|
|
32
|
+
* `display`, `visibility`, or zero opacity. Mirrors the legacy
|
|
33
|
+
* KeyboardShortcuts.isElementVisible behavior so existing call sites keep
|
|
34
|
+
* the same semantics.
|
|
35
|
+
*
|
|
36
|
+
* @param {Element|null|undefined} element
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function isElementVisible(element) {
|
|
40
|
+
if (!element) return false;
|
|
41
|
+
if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') {
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const style = window.getComputedStyle(element);
|
|
45
|
+
if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return true when at least one of the registered modal selectors matches
|
|
53
|
+
* a visible element in the document.
|
|
54
|
+
*
|
|
55
|
+
* Excludes the keyboard-shortcuts help overlay so it doesn't block its own
|
|
56
|
+
* close behavior.
|
|
57
|
+
*
|
|
58
|
+
* @returns {boolean}
|
|
59
|
+
*/
|
|
60
|
+
function isModalOpen() {
|
|
61
|
+
if (typeof document === 'undefined') return false;
|
|
62
|
+
for (const selector of MODAL_SELECTORS) {
|
|
63
|
+
const el = document.querySelector(selector);
|
|
64
|
+
if (el && isElementVisible(el)) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (typeof window !== 'undefined') {
|
|
72
|
+
window.ModalDetection = { isModalOpen, isElementVisible, MODAL_SELECTORS };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
76
|
+
module.exports = { isModalOpen, isElementVisible, MODAL_SELECTORS };
|
|
77
|
+
}
|
|
@@ -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
|
@@ -448,6 +448,16 @@
|
|
|
448
448
|
</svg>
|
|
449
449
|
<span class="btn-text">Analyze</span>
|
|
450
450
|
</button>
|
|
451
|
+
<button class="btn btn-sm btn-icon btn-summary-toggle" id="summary-toggle-btn" title="Hide hunk summaries" aria-label="Toggle hunk summaries" aria-pressed="true" style="display: none;">
|
|
452
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
453
|
+
<path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3.5 6.25a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1-.75-.75Zm.75 2.25h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z"/>
|
|
454
|
+
</svg>
|
|
455
|
+
</button>
|
|
456
|
+
<button class="btn btn-sm btn-icon btn-tour-toggle" id="tour-toggle-btn" title="Start guided tour" aria-label="Start guided tour" aria-pressed="false" style="display: none;">
|
|
457
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
458
|
+
<path d="M7.75 0a.75.75 0 0 1 .75.75V3h3.634c.414 0 .814.144 1.13.406l2.501 2.071a1.75 1.75 0 0 1 0 2.696l-2.5 2.07a1.75 1.75 0 0 1-1.131.407H8.5v5.6a.75.75 0 0 1-1.5 0V10.65H3.75A1.75 1.75 0 0 1 2 8.9V4.75C2 3.784 2.784 3 3.75 3H7V.75A.75.75 0 0 1 7.75 0Zm-4 4.5a.25.25 0 0 0-.25.25V8.9c0 .138.112.25.25.25h8.384a.25.25 0 0 0 .16-.058l2.5-2.07a.25.25 0 0 0 0-.386l-2.5-2.07a.25.25 0 0 0-.16-.058H3.75Z"/>
|
|
459
|
+
</svg>
|
|
460
|
+
</button>
|
|
451
461
|
<button class="btn btn-sm btn-icon" id="chat-toggle-btn" title="Toggle Chat panel">
|
|
452
462
|
<svg class="chat-icon" viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
453
463
|
<path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
|
|
@@ -585,6 +595,10 @@
|
|
|
585
595
|
<!-- Tier icons utility -->
|
|
586
596
|
<script src="/js/utils/tier-icons.js"></script>
|
|
587
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
|
+
|
|
588
602
|
<!-- Category emoji mapping -->
|
|
589
603
|
<script src="/js/utils/category-emoji.js"></script>
|
|
590
604
|
|
|
@@ -597,6 +611,9 @@
|
|
|
597
611
|
<!-- Timestamp parsing utility -->
|
|
598
612
|
<script src="/js/utils/time.js"></script>
|
|
599
613
|
|
|
614
|
+
<!-- Modal detection (shared by KeyboardShortcuts and PRManager) -->
|
|
615
|
+
<script src="/js/utils/modal-detection.js"></script>
|
|
616
|
+
|
|
600
617
|
<!-- WebSocket client -->
|
|
601
618
|
<script src="/js/ws-client.js"></script>
|
|
602
619
|
|
|
@@ -639,6 +656,10 @@
|
|
|
639
656
|
<script src="/js/modules/analysis-history.js"></script>
|
|
640
657
|
<script src="/js/modules/diff-context.js"></script>
|
|
641
658
|
<script src="/js/modules/file-list-merger.js"></script>
|
|
659
|
+
<script src="/js/modules/hunk-summary-renderer.js"></script>
|
|
660
|
+
<script src="/js/modules/tour-renderer.js"></script>
|
|
661
|
+
<script src="/js/modules/cancel-background-job.js"></script>
|
|
662
|
+
<script src="/js/components/TourBar.js"></script>
|
|
642
663
|
|
|
643
664
|
<!-- Chat Panel Component -->
|
|
644
665
|
<script src="/js/components/ChatPanel.js"></script>
|
|
@@ -657,6 +678,9 @@
|
|
|
657
678
|
pr.js / AIPanel initialise. Must load BEFORE pr.js. -->
|
|
658
679
|
<script src="/runtime-config.js"></script>
|
|
659
680
|
|
|
681
|
+
<!-- Repo Links UI (must load before pr.js / local.js) -->
|
|
682
|
+
<script src="/js/repo-links.js"></script>
|
|
683
|
+
|
|
660
684
|
<!-- PR JavaScript (shared with local mode) -->
|
|
661
685
|
<script src="/js/pr.js"></script>
|
|
662
686
|
|
package/public/pr.html
CHANGED
|
@@ -244,6 +244,16 @@
|
|
|
244
244
|
</svg>
|
|
245
245
|
<span class="btn-text">Analyze</span>
|
|
246
246
|
</button>
|
|
247
|
+
<button class="btn btn-sm btn-icon btn-summary-toggle" id="summary-toggle-btn" title="Hide hunk summaries" aria-label="Toggle hunk summaries" aria-pressed="true" style="display: none;">
|
|
248
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
249
|
+
<path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3.5 6.25a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1-.75-.75Zm.75 2.25h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z"/>
|
|
250
|
+
</svg>
|
|
251
|
+
</button>
|
|
252
|
+
<button class="btn btn-sm btn-icon btn-tour-toggle" id="tour-toggle-btn" title="Start guided tour" aria-label="Start guided tour" aria-pressed="false" style="display: none;">
|
|
253
|
+
<svg viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
254
|
+
<path d="M7.75 0a.75.75 0 0 1 .75.75V3h3.634c.414 0 .814.144 1.13.406l2.501 2.071a1.75 1.75 0 0 1 0 2.696l-2.5 2.07a1.75 1.75 0 0 1-1.131.407H8.5v5.6a.75.75 0 0 1-1.5 0V10.65H3.75A1.75 1.75 0 0 1 2 8.9V4.75C2 3.784 2.784 3 3.75 3H7V.75A.75.75 0 0 1 7.75 0Zm-4 4.5a.25.25 0 0 0-.25.25V8.9c0 .138.112.25.25.25h8.384a.25.25 0 0 0 .16-.058l2.5-2.07a.25.25 0 0 0 0-.386l-2.5-2.07a.25.25 0 0 0-.16-.058H3.75Z"/>
|
|
255
|
+
</svg>
|
|
256
|
+
</button>
|
|
247
257
|
<button class="btn btn-sm btn-icon" id="chat-toggle-btn" title="Toggle Chat panel">
|
|
248
258
|
<svg class="chat-icon" viewBox="0 0 16 16" fill="currentColor" width="14" height="14">
|
|
249
259
|
<path d="M1.75 1h8.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 10.25 10H7.061l-2.574 2.573A1.458 1.458 0 0 1 2 11.543V10h-.25A1.75 1.75 0 0 1 0 8.25v-5.5C0 1.784.784 1 1.75 1ZM1.5 2.75v5.5c0 .138.112.25.25.25h1a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h3.5a.25.25 0 0 0 .25-.25v-5.5a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13 2a.25.25 0 0 0-.25-.25h-.5a.75.75 0 0 1 0-1.5h.5c.966 0 1.75.784 1.75 1.75v5.5A1.75 1.75 0 0 1 14.25 12H14v1.543a1.458 1.458 0 0 1-2.487 1.03L9.22 12.28a.749.749 0 0 1 .326-1.275.749.749 0 0 1 .734.215l2.22 2.22v-2.19a.75.75 0 0 1 .75-.75h1a.25.25 0 0 0 .25-.25Z"/>
|
|
@@ -388,6 +398,10 @@
|
|
|
388
398
|
<!-- Tier icons utility -->
|
|
389
399
|
<script src="/js/utils/tier-icons.js"></script>
|
|
390
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
|
+
|
|
391
405
|
<!-- Category emoji mapping -->
|
|
392
406
|
<script src="/js/utils/category-emoji.js"></script>
|
|
393
407
|
|
|
@@ -400,6 +414,9 @@
|
|
|
400
414
|
<!-- Timestamp parsing utility -->
|
|
401
415
|
<script src="/js/utils/time.js"></script>
|
|
402
416
|
|
|
417
|
+
<!-- Modal detection (shared by KeyboardShortcuts and PRManager) -->
|
|
418
|
+
<script src="/js/utils/modal-detection.js"></script>
|
|
419
|
+
|
|
403
420
|
<!-- WebSocket client -->
|
|
404
421
|
<script src="/js/ws-client.js"></script>
|
|
405
422
|
|
|
@@ -443,6 +460,10 @@
|
|
|
443
460
|
<script src="/js/modules/analysis-history.js"></script>
|
|
444
461
|
<script src="/js/modules/diff-context.js"></script>
|
|
445
462
|
<script src="/js/modules/file-list-merger.js"></script>
|
|
463
|
+
<script src="/js/modules/hunk-summary-renderer.js"></script>
|
|
464
|
+
<script src="/js/modules/tour-renderer.js"></script>
|
|
465
|
+
<script src="/js/modules/cancel-background-job.js"></script>
|
|
466
|
+
<script src="/js/components/TourBar.js"></script>
|
|
446
467
|
|
|
447
468
|
<!-- Chat Panel Component -->
|
|
448
469
|
<script src="/js/components/ChatPanel.js"></script>
|
|
@@ -458,6 +479,9 @@
|
|
|
458
479
|
pr.js / AIPanel initialise. Must load BEFORE pr.js. -->
|
|
459
480
|
<script src="/runtime-config.js"></script>
|
|
460
481
|
|
|
482
|
+
<!-- Repo Links UI (must load before pr.js) -->
|
|
483
|
+
<script src="/js/repo-links.js"></script>
|
|
484
|
+
|
|
461
485
|
<!-- PR JavaScript -->
|
|
462
486
|
<script src="/js/pr.js"></script>
|
|
463
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>
|