@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.
- package/README.md +4 -0
- package/package.json +20 -15
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +0 -1737
- 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/ReviewModal.js +135 -13
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +58 -8
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +33 -3
- package/public/js/pr.js +653 -157
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +7 -0
- package/public/pr.html +7 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/analyzer.js +125 -18
- package/src/config.js +664 -10
- 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 +13 -4
- 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 +102 -2
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +101 -11
- package/src/routes/mcp.js +47 -4
- package/src/routes/pr.js +298 -68
- 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
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Repo Links Resolver
|
|
4
|
+
*
|
|
5
|
+
* Resolves per-repo header link configuration. Configuration shape:
|
|
6
|
+
*
|
|
7
|
+
* "links": {
|
|
8
|
+
* "external": { "label": "...", "url_template": "https://...", "icon": "<svg ...>...</svg>" },
|
|
9
|
+
* "github": false, // hide default GitHub link
|
|
10
|
+
* "graphite": false // hide Graphite stack link
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* Public API:
|
|
14
|
+
*
|
|
15
|
+
* substituteUrlTemplate(template, context)
|
|
16
|
+
* — Replace whitelisted placeholders `{owner}`, `{repo}`, `{number}`,
|
|
17
|
+
* `{branch}`, `{base_branch}`, `{head_sha}` with URL-encoded values
|
|
18
|
+
* from `context`. Returns `null` if the resulting URL does not start
|
|
19
|
+
* with `https://` or if the template is malformed.
|
|
20
|
+
*
|
|
21
|
+
* sanitizeSvgIcon(svg)
|
|
22
|
+
* — Strip `<script>` tags, on* event-handler attributes, and
|
|
23
|
+
* `javascript:` URLs. Returns the sanitised SVG string, or null if
|
|
24
|
+
* the value is not a string that looks like SVG markup.
|
|
25
|
+
*
|
|
26
|
+
* resolveRepoLinks(config, repository)
|
|
27
|
+
* — Returns `{ external, github, graphite }` for the given
|
|
28
|
+
* `owner/repo`. The booleans are normalised: any non-false value (or
|
|
29
|
+
* absence) becomes `true`. `external` is `null` unless valid, with
|
|
30
|
+
* the icon already sanitised.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const { getRepoConfig } = require('../config');
|
|
34
|
+
const logger = require('../utils/logger');
|
|
35
|
+
|
|
36
|
+
// Whitelist of allowed `{placeholder}` names. Anything else in the template
|
|
37
|
+
// is left unsubstituted (so a malformed template surfaces visually rather
|
|
38
|
+
// than silently producing the wrong URL).
|
|
39
|
+
const ALLOWED_PLACEHOLDERS = new Set([
|
|
40
|
+
'owner', 'repo', 'number', 'branch', 'base_branch', 'head_sha'
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
// Matches HTML on* event-handler attributes regardless of quoting style.
|
|
44
|
+
// Used to strip dangerous attributes from user-supplied SVG icons.
|
|
45
|
+
const ON_HANDLER_RE = /\s+on[a-zA-Z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/g;
|
|
46
|
+
|
|
47
|
+
// Matches a complete `<script ...>...</script>` block (including the open
|
|
48
|
+
// and close tags) with case-insensitive, dot-matches-newline semantics so
|
|
49
|
+
// payload bodies that span lines are removed in one pass.
|
|
50
|
+
const SCRIPT_BLOCK_RE = /<script\b[^>]*>[\s\S]*?<\/script\s*>/gi;
|
|
51
|
+
|
|
52
|
+
// Matches standalone <script ...> open tags missing a closing tag — strip
|
|
53
|
+
// them as well so a half-broken payload can't slip through.
|
|
54
|
+
const SCRIPT_OPEN_RE = /<script\b[^>]*>/gi;
|
|
55
|
+
|
|
56
|
+
// Matches values that begin with `javascript:` (anchor href, attribute
|
|
57
|
+
// values). Captures the leading quote so we can preserve attribute syntax.
|
|
58
|
+
const JS_URL_RE = /(["'=])\s*javascript:[^"'\s>]*/gi;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Substitute whitelisted `{placeholder}` tokens in a URL template.
|
|
62
|
+
*
|
|
63
|
+
* Behaviour:
|
|
64
|
+
* - Only `{owner}`, `{repo}`, `{number}`, `{branch}`, `{base_branch}`,
|
|
65
|
+
* and `{head_sha}` are substituted. Other tokens are left as-is so
|
|
66
|
+
* misconfigurations are visible.
|
|
67
|
+
* - Each substituted value is run through `encodeURIComponent()` to
|
|
68
|
+
* prevent injection of additional path segments or query params.
|
|
69
|
+
* - The result is rejected (returns `null`) unless it starts with
|
|
70
|
+
* `https://`, the same invariant `validateRepoConfig` enforces at
|
|
71
|
+
* startup. This is defence-in-depth: a template like
|
|
72
|
+
* `https://{owner}.example/foo` is technically `https://` at config
|
|
73
|
+
* time but could be subverted at substitution time if `{owner}` were
|
|
74
|
+
* unescaped — `encodeURIComponent` protects against that.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} template
|
|
77
|
+
* @param {Object} context
|
|
78
|
+
* @param {string} [context.owner]
|
|
79
|
+
* @param {string} [context.repo]
|
|
80
|
+
* @param {number|string} [context.number]
|
|
81
|
+
* @param {string} [context.branch]
|
|
82
|
+
* @param {string} [context.base_branch]
|
|
83
|
+
* @param {string} [context.head_sha]
|
|
84
|
+
* @returns {string|null} Substituted URL, or null if it fails validation
|
|
85
|
+
*/
|
|
86
|
+
function substituteUrlTemplate(template, context) {
|
|
87
|
+
if (typeof template !== 'string' || !template) return null;
|
|
88
|
+
const ctx = context || {};
|
|
89
|
+
|
|
90
|
+
const substituted = template.replace(/\{([a-zA-Z_]+)\}/g, (match, name) => {
|
|
91
|
+
if (!ALLOWED_PLACEHOLDERS.has(name)) return match;
|
|
92
|
+
const value = ctx[name];
|
|
93
|
+
if (value === undefined || value === null || value === '') return match;
|
|
94
|
+
return encodeURIComponent(String(value));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
if (!substituted.startsWith('https://')) return null;
|
|
98
|
+
// If any unsubstituted whitelisted placeholders remain, the URL is
|
|
99
|
+
// incomplete — reject rather than producing a broken link.
|
|
100
|
+
for (const placeholder of ALLOWED_PLACEHOLDERS) {
|
|
101
|
+
if (substituted.includes(`{${placeholder}}`)) return null;
|
|
102
|
+
}
|
|
103
|
+
return substituted;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Strip dangerous content from user-supplied SVG markup.
|
|
108
|
+
*
|
|
109
|
+
* Removes:
|
|
110
|
+
* - `<script>...</script>` blocks (paired or unpaired)
|
|
111
|
+
* - All `on*=` event-handler attributes
|
|
112
|
+
* - Attribute values starting with `javascript:` (sets them to empty)
|
|
113
|
+
*
|
|
114
|
+
* This is the bare-minimum sanitisation called out in the plan. The
|
|
115
|
+
* frontend additionally re-parses the SVG via DOMParser before injecting
|
|
116
|
+
* it into the DOM, so any script-like content that slips through gets a
|
|
117
|
+
* second filter at insertion time.
|
|
118
|
+
*
|
|
119
|
+
* @param {string} svg
|
|
120
|
+
* @returns {string|null} Sanitised SVG, or null if the input is not a
|
|
121
|
+
* string that looks like SVG markup.
|
|
122
|
+
*/
|
|
123
|
+
function sanitizeSvgIcon(svg) {
|
|
124
|
+
if (typeof svg !== 'string' || !svg.trim()) return null;
|
|
125
|
+
// Sanity check: the value should at least look like SVG markup. If it
|
|
126
|
+
// doesn't, refuse — `<svg ...>` is the only contract.
|
|
127
|
+
if (!/<svg\b/i.test(svg)) return null;
|
|
128
|
+
|
|
129
|
+
let cleaned = svg
|
|
130
|
+
.replace(SCRIPT_BLOCK_RE, '')
|
|
131
|
+
.replace(SCRIPT_OPEN_RE, '')
|
|
132
|
+
.replace(ON_HANDLER_RE, '')
|
|
133
|
+
// Replace dangerous javascript: URLs with empty strings, preserving
|
|
134
|
+
// the surrounding quote character so the attribute remains parseable.
|
|
135
|
+
.replace(JS_URL_RE, (_match, lead) => `${lead}`);
|
|
136
|
+
|
|
137
|
+
// Final guard: if any of the dangerous patterns survived (e.g.
|
|
138
|
+
// mismatched encoding tricks), reject the whole icon.
|
|
139
|
+
if (/<script\b/i.test(cleaned)) return null;
|
|
140
|
+
if (/\son[a-zA-Z]+\s*=/i.test(cleaned)) return null;
|
|
141
|
+
if (/javascript:/i.test(cleaned)) return null;
|
|
142
|
+
|
|
143
|
+
return cleaned;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Resolve link configuration for a repo. Reads `repos[owner/repo].links`
|
|
148
|
+
* and produces a normalised object the frontend can act on without
|
|
149
|
+
* further interpretation.
|
|
150
|
+
*
|
|
151
|
+
* `links.github` and `links.graphite` are booleans that gate the default
|
|
152
|
+
* built-in links. Anything that isn't an explicit `false` (including
|
|
153
|
+
* `undefined` and `true`) leaves the link enabled — preserving current
|
|
154
|
+
* behaviour for repos that omit the `links` block entirely.
|
|
155
|
+
*
|
|
156
|
+
* For the external link, the icon is sanitised here, server-side. If
|
|
157
|
+
* sanitisation strips the icon entirely (e.g. the input was hostile or
|
|
158
|
+
* malformed), a warning is logged and `icon` becomes `null` — the link
|
|
159
|
+
* is still rendered, just without a custom icon.
|
|
160
|
+
*
|
|
161
|
+
* @param {Object} config
|
|
162
|
+
* @param {string} repository Canonical `owner/repo` identifier
|
|
163
|
+
* @returns {{ external: { label: string, url_template: string, icon: string|null }|null,
|
|
164
|
+
* github: boolean,
|
|
165
|
+
* graphite: boolean }}
|
|
166
|
+
*/
|
|
167
|
+
function resolveRepoLinks(config, repository) {
|
|
168
|
+
const result = { external: null, github: true, graphite: true };
|
|
169
|
+
if (!config || !repository) return result;
|
|
170
|
+
|
|
171
|
+
const repoConfig = getRepoConfig(config, repository);
|
|
172
|
+
if (!repoConfig || typeof repoConfig !== 'object') return result;
|
|
173
|
+
|
|
174
|
+
const links = repoConfig.links;
|
|
175
|
+
if (!links || typeof links !== 'object') return result;
|
|
176
|
+
|
|
177
|
+
if (links.github === false) result.github = false;
|
|
178
|
+
if (links.graphite === false) result.graphite = false;
|
|
179
|
+
|
|
180
|
+
const ext = links.external;
|
|
181
|
+
if (ext && typeof ext === 'object'
|
|
182
|
+
&& typeof ext.label === 'string' && ext.label
|
|
183
|
+
&& typeof ext.url_template === 'string'
|
|
184
|
+
&& ext.url_template.startsWith('https://')) {
|
|
185
|
+
let icon = null;
|
|
186
|
+
if (ext.icon !== undefined && ext.icon !== null && ext.icon !== '') {
|
|
187
|
+
icon = sanitizeSvgIcon(ext.icon);
|
|
188
|
+
if (icon === null) {
|
|
189
|
+
logger.warn(
|
|
190
|
+
`Dropping links.external.icon for "${repository}" — failed sanitisation.`
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
result.external = {
|
|
195
|
+
// Optional host display name (e.g. "Meteorite"). When absent, the
|
|
196
|
+
// field is null and consumers fall back to "GitHub" via resolveHostName.
|
|
197
|
+
name: (typeof ext.name === 'string' && ext.name) ? ext.name : null,
|
|
198
|
+
label: ext.label,
|
|
199
|
+
url_template: ext.url_template,
|
|
200
|
+
icon
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Resolve the display name of the remote code host for a repo, for use in
|
|
209
|
+
* user-facing text in place of the literal "GitHub".
|
|
210
|
+
*
|
|
211
|
+
* Returns `repos[owner/repo].links.external.name` when configured, otherwise
|
|
212
|
+
* `"GitHub"`. This is the server-side counterpart to the frontend
|
|
213
|
+
* `window.RepoLinks.hostName()` accessor.
|
|
214
|
+
*
|
|
215
|
+
* @param {Object} config
|
|
216
|
+
* @param {string} repository Canonical `owner/repo` identifier
|
|
217
|
+
* @returns {string} The configured host name, or "GitHub" by default
|
|
218
|
+
*/
|
|
219
|
+
function resolveHostName(config, repository) {
|
|
220
|
+
const links = resolveRepoLinks(config, repository);
|
|
221
|
+
return (links.external && links.external.name) ? links.external.name : 'GitHub';
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = {
|
|
225
|
+
substituteUrlTemplate,
|
|
226
|
+
sanitizeSvgIcon,
|
|
227
|
+
resolveRepoLinks,
|
|
228
|
+
resolveHostName,
|
|
229
|
+
ALLOWED_PLACEHOLDERS,
|
|
230
|
+
};
|
package/src/local-review.js
CHANGED
|
@@ -4,7 +4,7 @@ const { promisify } = require('util');
|
|
|
4
4
|
const crypto = require('crypto');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const fs = require('fs').promises;
|
|
7
|
-
const { loadConfig, showWelcomeMessage, resolveDbName, getGitHubToken } = require('./config');
|
|
7
|
+
const { loadConfig, showWelcomeMessage, resolveDbName, getGitHubToken, resolveHostBinding } = require('./config');
|
|
8
8
|
const logger = require('./utils/logger');
|
|
9
9
|
const { rejectUrlLikeLocalReviewPath } = require('./utils/local-path-input');
|
|
10
10
|
const { fireHooks, hasHooks } = require('./hooks/hook-runner');
|
|
@@ -791,11 +791,14 @@ async function setupLocalReviewSession({ db, config, repoPath, flags = {} }) {
|
|
|
791
791
|
let branchInfo = null;
|
|
792
792
|
if (!includesBranch(scopeStart)) {
|
|
793
793
|
const untrackedFiles = await getUntrackedFiles(repoPath);
|
|
794
|
+
// Resolve binding so alt-host repos look up PRs on the right host.
|
|
795
|
+
const branchBinding = repository ? resolveHostBinding(repository, config) : null;
|
|
794
796
|
branchInfo = await module.exports.detectAndBuildBranchInfo(repoPath, branch, {
|
|
795
797
|
repository,
|
|
796
798
|
diff,
|
|
797
799
|
untrackedFiles,
|
|
798
|
-
githubToken: getGitHubToken(config),
|
|
800
|
+
githubToken: branchBinding?.token || getGitHubToken(config),
|
|
801
|
+
hostBinding: branchBinding,
|
|
799
802
|
enableGraphite: config.enable_graphite === true
|
|
800
803
|
});
|
|
801
804
|
if (branchInfo) {
|
|
@@ -1038,11 +1041,12 @@ async function getFirstCommitSubject(repoPath, baseBranch) {
|
|
|
1038
1041
|
* @param {string} [options.diff] - The uncommitted diff content (empty = eligible)
|
|
1039
1042
|
* @param {Array} [options.untrackedFiles] - Untracked files array (empty = eligible)
|
|
1040
1043
|
* @param {string} [options.githubToken] - Resolved GitHub token for PR lookup
|
|
1044
|
+
* @param {Object} [options.hostBinding] - Resolved host binding (apiHost/token/features) for alt-host PR lookup
|
|
1041
1045
|
* @param {boolean} [options.enableGraphite] - When true, try Graphite CLI for parent branch
|
|
1042
1046
|
* @returns {Promise<{baseBranch: string, commitCount: number, source: string, prNumber?: number}|null>}
|
|
1043
1047
|
*/
|
|
1044
1048
|
async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
|
|
1045
|
-
const { repository, diff, untrackedFiles, githubToken, enableGraphite } = options;
|
|
1049
|
+
const { repository, diff, untrackedFiles, githubToken, hostBinding, enableGraphite } = options;
|
|
1046
1050
|
|
|
1047
1051
|
// Guard: detached HEAD, has uncommitted changes, or has untracked files
|
|
1048
1052
|
if (branch === 'HEAD') return null;
|
|
@@ -1051,7 +1055,12 @@ async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
|
|
|
1051
1055
|
|
|
1052
1056
|
try {
|
|
1053
1057
|
const { detectBaseBranch } = require('./git/base-branch');
|
|
1054
|
-
const depsOverride = githubToken
|
|
1058
|
+
const depsOverride = githubToken || hostBinding
|
|
1059
|
+
? {
|
|
1060
|
+
getGitHubToken: () => githubToken || '',
|
|
1061
|
+
getHostBinding: () => hostBinding || null
|
|
1062
|
+
}
|
|
1063
|
+
: undefined;
|
|
1055
1064
|
const detection = await detectBaseBranch(repoPath, branch, {
|
|
1056
1065
|
repository,
|
|
1057
1066
|
enableGraphite,
|
package/src/main.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
const fs = require('fs');
|
|
3
|
-
const { loadConfig, getConfigDir, getGitHubToken, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills } = require('./config');
|
|
3
|
+
const { loadConfig, getConfigDir, getGitHubToken, resolveHostBinding, showWelcomeMessage, resolveDbName, resolveRepoOptions, resolvePoolConfig, getRepoResetScript, resolveLoadSkills } = require('./config');
|
|
4
4
|
const { initializeDatabase, run, queryOne, query, migrateExistingWorktrees, WorktreeRepository, ReviewRepository, RepoSettingsRepository, GitHubReviewRepository, WorktreePoolRepository } = require('./database');
|
|
5
5
|
const { PRArgumentParser } = require('./github/parser');
|
|
6
6
|
const { GitHubClient } = require('./github/client');
|
|
@@ -26,6 +26,47 @@ const { attemptDelegation } = require('./single-port');
|
|
|
26
26
|
|
|
27
27
|
let db = null;
|
|
28
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Build a user-facing "missing token" error message tailored to the
|
|
31
|
+
* resolved host binding.
|
|
32
|
+
*
|
|
33
|
+
* For github.com bindings (`apiHost === null`), suggest the legacy
|
|
34
|
+
* options: `GITHUB_TOKEN` env var, `config.github_token`, or
|
|
35
|
+
* `config.github_token_command`. For alt-host bindings, suppress those
|
|
36
|
+
* suggestions (the github.com top-level credentials are intentionally
|
|
37
|
+
* not used for alt-hosts) and point exclusively at the per-repo
|
|
38
|
+
* `token` / `token_command` keys under the resolved bindingRepository.
|
|
39
|
+
*
|
|
40
|
+
* @param {Object} params
|
|
41
|
+
* @param {string} params.owner
|
|
42
|
+
* @param {string} params.repo
|
|
43
|
+
* @param {string} params.bindingRepository - The `repos[...]` config key
|
|
44
|
+
* that the binding lookup was performed against. May differ from
|
|
45
|
+
* `<owner>/<repo>` for monorepo-style configs (see Fix #8).
|
|
46
|
+
* @param {string|null} params.apiHost - Resolved api_host (null = github.com)
|
|
47
|
+
* @returns {string}
|
|
48
|
+
*/
|
|
49
|
+
function buildMissingTokenError({ owner, repo, bindingRepository, apiHost }) {
|
|
50
|
+
if (apiHost) {
|
|
51
|
+
return (
|
|
52
|
+
`GitHub token not found for alt-host repo ${owner}/${repo} (${apiHost}). ` +
|
|
53
|
+
`GITHUB_TOKEN and top-level github_token/github_token_command are github.com-only ` +
|
|
54
|
+
`and are not used for alt-hosts. Configure ` +
|
|
55
|
+
`\`config.repos["${bindingRepository}"].token\` or ` +
|
|
56
|
+
`\`config.repos["${bindingRepository}"].token_command\` ` +
|
|
57
|
+
`(e.g., "althost-cli auth token"). Run: npx pair-review --configure`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return (
|
|
61
|
+
`GitHub token not found for ${owner}/${repo}. ` +
|
|
62
|
+
`Set GITHUB_TOKEN env var, or configure a token at ` +
|
|
63
|
+
`\`config.repos["${bindingRepository}"].token\` or ` +
|
|
64
|
+
`\`config.repos["${bindingRepository}"].token_command\`, or set ` +
|
|
65
|
+
`top-level \`github_token\` / \`github_token_command\` ` +
|
|
66
|
+
`(e.g., "gh auth token"). Run: npx pair-review --configure`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
29
70
|
/**
|
|
30
71
|
* Detect PR information from GitHub Actions environment variables.
|
|
31
72
|
* Returns null if not in GitHub Actions or PR info cannot be determined.
|
|
@@ -573,16 +614,33 @@ AI PROVIDERS:
|
|
|
573
614
|
*/
|
|
574
615
|
async function handlePullRequest(args, config, db, flags = {}, poolLifecycle = null) {
|
|
575
616
|
try {
|
|
576
|
-
//
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
//
|
|
583
|
-
const parser = new PRArgumentParser();
|
|
617
|
+
// Parse PR arguments FIRST — pass config so url_pattern matching for
|
|
618
|
+
// alternate hosts can resolve pasted URLs to the canonical
|
|
619
|
+
// owner/repo before falling back to GitHub/Graphite parsers.
|
|
620
|
+
// We must know the target repo before resolving its token, so that
|
|
621
|
+
// repo-scoped tokens, repo-scoped token_command entries, and alt-host
|
|
622
|
+
// configurations are honored. A no-repository token preflight only
|
|
623
|
+
// sees env + top-level credentials and would reject valid configs.
|
|
624
|
+
const parser = new PRArgumentParser(config);
|
|
584
625
|
const prInfo = await parser.parsePRArguments(args);
|
|
585
626
|
|
|
627
|
+
// Resolve token via repo-aware host binding so alt-host and
|
|
628
|
+
// repo-scoped credentials are respected. When a per-repo
|
|
629
|
+
// `url_pattern` matched, prefer its `bindingRepository` — that's the
|
|
630
|
+
// matched `repos[...]` config key, which may differ from the
|
|
631
|
+
// captured `${owner}/${repo}` for monorepo-style patterns.
|
|
632
|
+
const repositoryForBinding = prInfo.bindingRepository
|
|
633
|
+
|| normalizeRepository(prInfo.owner, prInfo.repo);
|
|
634
|
+
const binding = resolveHostBinding(repositoryForBinding, config);
|
|
635
|
+
if (!binding.token) {
|
|
636
|
+
throw new Error(buildMissingTokenError({
|
|
637
|
+
owner: prInfo.owner,
|
|
638
|
+
repo: prInfo.repo,
|
|
639
|
+
bindingRepository: repositoryForBinding,
|
|
640
|
+
apiHost: binding.apiHost
|
|
641
|
+
}));
|
|
642
|
+
}
|
|
643
|
+
|
|
586
644
|
// Register cwd as known repo path if it matches the target repo
|
|
587
645
|
const currentDir = parser.getCurrentDirectory();
|
|
588
646
|
const isMatchingRepo = await parser.isMatchingRepository(currentDir, prInfo.owner, prInfo.repo);
|
|
@@ -677,20 +735,33 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
677
735
|
let poolLifecycle = null;
|
|
678
736
|
|
|
679
737
|
try {
|
|
680
|
-
//
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
// Parse PR arguments
|
|
687
|
-
const parser = new PRArgumentParser();
|
|
738
|
+
// Parse PR arguments — pass config so url_pattern matching for
|
|
739
|
+
// alternate hosts can resolve pasted URLs to the canonical
|
|
740
|
+
// owner/repo before falling back to GitHub/Graphite parsers.
|
|
741
|
+
const parser = new PRArgumentParser(config);
|
|
688
742
|
prInfo = await parser.parsePRArguments(args);
|
|
689
743
|
|
|
744
|
+
// Resolve host binding for the target repo (handles alt-host).
|
|
745
|
+
// Prefer `bindingRepository` (from url_pattern match) over the raw
|
|
746
|
+
// `${owner}/${repo}` since they can differ when one config entry
|
|
747
|
+
// serves many monorepo-shaped URLs.
|
|
748
|
+
const repositoryForBinding = prInfo.bindingRepository
|
|
749
|
+
|| normalizeRepository(prInfo.owner, prInfo.repo);
|
|
750
|
+
const headlessBinding = resolveHostBinding(repositoryForBinding, config);
|
|
751
|
+
if (!headlessBinding.token) {
|
|
752
|
+
throw new Error(buildMissingTokenError({
|
|
753
|
+
owner: prInfo.owner,
|
|
754
|
+
repo: prInfo.repo,
|
|
755
|
+
bindingRepository: repositoryForBinding,
|
|
756
|
+
apiHost: headlessBinding.apiHost
|
|
757
|
+
}));
|
|
758
|
+
}
|
|
759
|
+
const githubToken = headlessBinding.token;
|
|
760
|
+
|
|
690
761
|
console.log(`Processing pull request #${prInfo.number} from ${prInfo.owner}/${prInfo.repo} in ${options.modeLabel}`);
|
|
691
762
|
|
|
692
763
|
// Create GitHub client and verify repository access
|
|
693
|
-
const githubClient = new GitHubClient(
|
|
764
|
+
const githubClient = new GitHubClient(headlessBinding);
|
|
694
765
|
const repoExists = await githubClient.repositoryExists(prInfo.owner, prInfo.repo);
|
|
695
766
|
if (!repoExists) {
|
|
696
767
|
throw new Error(`Repository ${prInfo.owner}/${prInfo.repo} not found or not accessible`);
|
|
@@ -786,9 +857,10 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
786
857
|
|
|
787
858
|
// Resolve monorepo config options (checkout_script, worktree_directory, worktree_name_template)
|
|
788
859
|
// even when running from inside the target repo, so they are not silently ignored.
|
|
860
|
+
// Config lookups must use the binding key (matched `repos[...]` entry), not the PR identity.
|
|
789
861
|
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
790
862
|
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
791
|
-
const resolved = resolveRepoOptions(config,
|
|
863
|
+
const resolved = resolveRepoOptions(config, repositoryForBinding, repoSettings);
|
|
792
864
|
checkoutScript = resolved.checkoutScript;
|
|
793
865
|
checkoutTimeout = resolved.checkoutTimeout;
|
|
794
866
|
worktreeConfig = resolved.worktreeConfig;
|
|
@@ -802,8 +874,10 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
802
874
|
owner: prInfo.owner,
|
|
803
875
|
repo: prInfo.repo,
|
|
804
876
|
repository,
|
|
877
|
+
bindingRepository: repositoryForBinding,
|
|
805
878
|
prNumber: prInfo.number,
|
|
806
879
|
config,
|
|
880
|
+
cloneUrl: prData?.repository?.clone_url,
|
|
807
881
|
onProgress: (progress) => {
|
|
808
882
|
if (progress.message) {
|
|
809
883
|
console.log(progress.message);
|
|
@@ -816,11 +890,12 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
816
890
|
checkoutTimeout = result.checkoutTimeout;
|
|
817
891
|
worktreeConfig = result.worktreeConfig;
|
|
818
892
|
// findRepositoryPath doesn't return pool config; resolve from DB + file config
|
|
893
|
+
// (binding key, not PR identity).
|
|
819
894
|
const repoSettingsRepo = new RepoSettingsRepository(db);
|
|
820
895
|
const repoSettings = await repoSettingsRepo.getRepoSettings(repository);
|
|
821
|
-
const { poolSize: resolvedPoolSize, poolFetchIntervalMinutes: _resolvedFetchInterval } = resolvePoolConfig(config,
|
|
896
|
+
const { poolSize: resolvedPoolSize, poolFetchIntervalMinutes: _resolvedFetchInterval } = resolvePoolConfig(config, repositoryForBinding, repoSettings);
|
|
822
897
|
poolSize = resolvedPoolSize || 0;
|
|
823
|
-
resetScript = config ? getRepoResetScript(config,
|
|
898
|
+
resetScript = config ? getRepoResetScript(config, repositoryForBinding) : null;
|
|
824
899
|
}
|
|
825
900
|
|
|
826
901
|
const worktreeManager = new GitWorktreeManager(db, worktreeConfig || {});
|
|
@@ -906,8 +981,11 @@ async function performHeadlessReview(args, config, db, flags, options, externalP
|
|
|
906
981
|
|
|
907
982
|
let analysisSummary = null;
|
|
908
983
|
try {
|
|
909
|
-
// Pass all instruction levels to ensure they're captured in the analysis run
|
|
910
|
-
|
|
984
|
+
// Pass all instruction levels to ensure they're captured in the analysis run.
|
|
985
|
+
// githubClient is forwarded so the analyzer can pre-fetch existing PR review
|
|
986
|
+
// comments when callers opt in via excludePrevious.github.
|
|
987
|
+
logger.debug(`analyzer githubClient wired for ${prInfo.owner}/${prInfo.repo}#${prInfo.number}`);
|
|
988
|
+
const analysisResult = await analyzer.analyzeAllLevels(review.id, worktreePath, storedPRData, null, { globalInstructions, repoInstructions }, null, { githubClient });
|
|
911
989
|
analysisSummary = analysisResult.summary;
|
|
912
990
|
console.log('AI analysis completed successfully');
|
|
913
991
|
} catch (analysisError) {
|
|
@@ -992,24 +1070,49 @@ Found ${validSuggestions.length} suggestion${validSuggestions.length === 1 ? ''
|
|
|
992
1070
|
// Submit review to GitHub via GraphQL (same path as web UI)
|
|
993
1071
|
console.log(`Submitting review with ${githubComments.length} comments...`);
|
|
994
1072
|
|
|
995
|
-
const prNodeId = storedPRData.node_id;
|
|
996
|
-
if (!prNodeId) {
|
|
997
|
-
throw new Error(`PR node_id not available for ${prInfo.owner}/${prInfo.repo}#${prInfo.number}. Cannot submit review without GraphQL node ID.`);
|
|
998
|
-
}
|
|
999
|
-
|
|
1000
1073
|
// Check for existing pending draft (GitHub only allows one per user per PR)
|
|
1001
1074
|
const existingDraft = await githubClient.getPendingReviewForUser(
|
|
1002
1075
|
prInfo.owner, prInfo.repo, prInfo.number
|
|
1003
1076
|
);
|
|
1004
1077
|
|
|
1078
|
+
// GraphQL PR node id is only required when we'll be creating a NEW
|
|
1079
|
+
// GraphQL review (no existing draft to reuse) OR adding GraphQL
|
|
1080
|
+
// review comments. REST and host-impl configurations address the
|
|
1081
|
+
// PR by (owner, repo, prNumber) + numeric review id and ignore
|
|
1082
|
+
// prNodeId; reusing an existing GraphQL draft also doesn't need
|
|
1083
|
+
// the PR node id because the review node id is sufficient.
|
|
1084
|
+
const willCreateNewGraphQLReview =
|
|
1085
|
+
headlessBinding.features.review_lifecycle === 'graphql' && !existingDraft;
|
|
1086
|
+
const willAddGraphQLComments =
|
|
1087
|
+
githubComments.length > 0 && headlessBinding.features.pending_review_comments === 'graphql';
|
|
1088
|
+
const needsGraphQLNodeId = willCreateNewGraphQLReview || willAddGraphQLComments;
|
|
1089
|
+
|
|
1090
|
+
if (needsGraphQLNodeId && !storedPRData.node_id) {
|
|
1091
|
+
throw new Error(
|
|
1092
|
+
`GraphQL PR node id required for ${prInfo.owner}/${prInfo.repo}#${prInfo.number} ` +
|
|
1093
|
+
`(features.review_lifecycle = "${headlessBinding.features.review_lifecycle}", ` +
|
|
1094
|
+
`pending_review_comments = "${headlessBinding.features.pending_review_comments}"). ` +
|
|
1095
|
+
`PR record is missing node_id — refresh the PR data and try again.`
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
const prNodeId = storedPRData.node_id ?? null;
|
|
1100
|
+
|
|
1101
|
+
const submitPrContext = {
|
|
1102
|
+
owner: prInfo.owner,
|
|
1103
|
+
repo: prInfo.repo,
|
|
1104
|
+
prNumber: prInfo.number,
|
|
1105
|
+
reviewId: existingDraft?.databaseId
|
|
1106
|
+
};
|
|
1107
|
+
|
|
1005
1108
|
let githubReview;
|
|
1006
1109
|
if (options.reviewEvent === 'DRAFT') {
|
|
1007
1110
|
githubReview = await githubClient.createDraftReviewGraphQL(
|
|
1008
|
-
prNodeId, reviewBody, githubComments, existingDraft?.id
|
|
1111
|
+
prNodeId, reviewBody, githubComments, existingDraft?.id, submitPrContext
|
|
1009
1112
|
);
|
|
1010
1113
|
} else {
|
|
1011
1114
|
githubReview = await githubClient.createReviewGraphQL(
|
|
1012
|
-
prNodeId, options.reviewEvent, reviewBody, githubComments, existingDraft?.id
|
|
1115
|
+
prNodeId, options.reviewEvent, reviewBody, githubComments, existingDraft?.id, submitPrContext
|
|
1013
1116
|
);
|
|
1014
1117
|
}
|
|
1015
1118
|
|
package/src/routes/analyses.js
CHANGED
|
@@ -33,7 +33,8 @@ const {
|
|
|
33
33
|
killProcesses,
|
|
34
34
|
createProgressCallback
|
|
35
35
|
} = require('./shared');
|
|
36
|
-
const {
|
|
36
|
+
const { generateScopedDiff, computeScopedDigest, getCurrentBranch } = require('../local-review');
|
|
37
|
+
const { reviewScope } = require('../local-scope');
|
|
37
38
|
const { validateCouncilConfig, normalizeCouncilConfig } = require('./councils');
|
|
38
39
|
const { TIERS, TIER_ALIASES, VALID_TIERS, resolveTier } = require('../ai/prompts/config');
|
|
39
40
|
|
|
@@ -229,13 +230,34 @@ router.post('/api/analyses/results', async (req, res) => {
|
|
|
229
230
|
});
|
|
230
231
|
}
|
|
231
232
|
|
|
232
|
-
// Generate and store diff so the web UI can display it
|
|
233
|
+
// Generate and store diff so the web UI can display it. Persist to the
|
|
234
|
+
// `local_diffs` table as well as the in-memory cache so the diff survives
|
|
235
|
+
// a restart and the manual tour/summary buttons never falsely report
|
|
236
|
+
// "no-diff" for a review created via this analysis-push path.
|
|
237
|
+
//
|
|
238
|
+
// Use the review's recorded scope (mirroring the council path) — NOT the
|
|
239
|
+
// default-scope `generateLocalDiff` wrapper — so that re-using an existing
|
|
240
|
+
// review whose scope is `staged` or `branch` does not clobber its durable
|
|
241
|
+
// `local_diffs` row with a narrower default-scope patch. For brand-new
|
|
242
|
+
// reviews the upsert above created a default-scope row, so either lookup
|
|
243
|
+
// yields the correct scope; reviewScope() falls back to the default scope
|
|
244
|
+
// when the columns are unset.
|
|
245
|
+
const reviewForScope = existingReview || await reviewRepo.getLocalReviewById(reviewId);
|
|
246
|
+
const { start: scopeStart, end: scopeEnd } = reviewScope(reviewForScope);
|
|
233
247
|
try {
|
|
234
|
-
const diffResult = await
|
|
235
|
-
|
|
248
|
+
const diffResult = await generateScopedDiff(
|
|
249
|
+
localPath,
|
|
250
|
+
scopeStart,
|
|
251
|
+
scopeEnd,
|
|
252
|
+
reviewForScope.local_base_branch || null
|
|
253
|
+
);
|
|
254
|
+
const digest = await computeScopedDigest(localPath, scopeStart, scopeEnd);
|
|
236
255
|
localReviewDiffs.set(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
|
|
256
|
+
await reviewRepo.saveLocalDiff(reviewId, { diff: diffResult.diff, stats: diffResult.stats, digest });
|
|
237
257
|
} catch (diffError) {
|
|
238
|
-
|
|
258
|
+
// Covers both diff generation AND the durable saveLocalDiff write, so the
|
|
259
|
+
// message names both failure modes rather than only generation.
|
|
260
|
+
logger.warn(`Could not generate or persist diff for local review ${reviewId}: ${diffError.message}`);
|
|
239
261
|
}
|
|
240
262
|
} else {
|
|
241
263
|
const repoParts = repo.split('/');
|
|
@@ -495,6 +517,7 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
|
|
|
495
517
|
config: modeConfig,
|
|
496
518
|
excludePrevious,
|
|
497
519
|
serverPort,
|
|
520
|
+
githubClient,
|
|
498
521
|
providerOverrides = {},
|
|
499
522
|
providerOverridesMap = null,
|
|
500
523
|
hookContext = {},
|
|
@@ -606,8 +629,8 @@ async function launchCouncilAnalysis(db, modeContext, councilConfig, councilId,
|
|
|
606
629
|
};
|
|
607
630
|
|
|
608
631
|
analysisPromise = isVoiceCentric
|
|
609
|
-
? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort })
|
|
610
|
-
: analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort });
|
|
632
|
+
? analyzer.runReviewerCentricCouncil(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort, githubClient })
|
|
633
|
+
: analyzer.runCouncilAnalysis(reviewContext, councilConfig, { analysisId, runId, progressCallback, excludePrevious, serverPort, githubClient });
|
|
611
634
|
} catch (setupError) {
|
|
612
635
|
// Synchronous setup failure — clean up the analysis hold immediately
|
|
613
636
|
reviewToAnalysisId.delete(reviewId);
|