@in-the-loop-labs/pair-review 4.0.0 → 4.1.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 +82 -7
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +3 -4
- package/plugin-pair-loop/.claude-plugin/plugin.json +19 -0
- package/plugin-pair-loop/skills/loop/SKILL.md +233 -0
- package/public/js/index.js +87 -10
- package/public/js/local.js +19 -9
- package/public/js/pr.js +64 -36
- package/public/js/repo-links.js +11 -3
- package/public/js/utils/analyze-params.js +69 -0
- package/public/js/utils/provider-model.js +66 -1
- package/public/js/vendor/pierre-diffs-worker.js +16158 -0
- package/public/js/vendor/pierre-diffs.js +1880 -0
- package/public/local.html +1 -0
- package/public/pr.html +1 -0
- package/public/setup.html +35 -16
- package/src/config.js +150 -28
- package/src/database.js +127 -3
- package/src/external/github-adapter.js +18 -3
- package/src/github/client.js +37 -0
- package/src/github/parser.js +41 -7
- package/src/interactive-analysis-config.js +2 -2
- package/src/links/repo-links.js +66 -28
- package/src/local-review.js +134 -5
- package/src/local-scope.js +38 -0
- package/src/main.js +199 -33
- package/src/routes/config.js +47 -12
- package/src/routes/external-comments.js +13 -1
- package/src/routes/github-collections.js +175 -13
- package/src/routes/local.js +11 -20
- package/src/routes/pr.js +63 -36
- package/src/routes/setup.js +63 -8
- package/src/routes/shared.js +85 -0
- package/src/routes/stack-analysis.js +39 -3
- package/src/server.js +74 -3
- package/src/setup/local-setup.js +23 -7
- package/src/setup/pr-setup.js +237 -39
- package/src/setup/stack-setup.js +7 -2
- package/src/single-port.js +73 -18
- package/src/utils/host-resolution.js +157 -0
- package/plugin-code-critic/skills/loop/SKILL.md +0 -373
package/src/github/client.js
CHANGED
|
@@ -1137,6 +1137,43 @@ class GitHubClient {
|
|
|
1137
1137
|
});
|
|
1138
1138
|
}
|
|
1139
1139
|
|
|
1140
|
+
/**
|
|
1141
|
+
* List open pull requests for a single repository via the REST API.
|
|
1142
|
+
*
|
|
1143
|
+
* Used by the dashboard collections sweep against alt-hosts, which speak a
|
|
1144
|
+
* REST subset and generally have no Search API. The returned rows carry the
|
|
1145
|
+
* classification fields (`author`, `requested_reviewers`, `requested_teams`)
|
|
1146
|
+
* so the caller can bucket each PR into a collection locally, plus the same
|
|
1147
|
+
* display fields `searchPullRequests` returns so both paths cache uniformly.
|
|
1148
|
+
*
|
|
1149
|
+
* @param {string} owner - Repository owner
|
|
1150
|
+
* @param {string} repo - Repository name
|
|
1151
|
+
* @returns {Promise<Array<{owner: string, repo: string, number: number, title: string, author: string|null, updated_at: string, html_url: string, state: string, requested_reviewers: string[], requested_teams: string[]}>>}
|
|
1152
|
+
*/
|
|
1153
|
+
async listOpenPullRequests(owner, repo) {
|
|
1154
|
+
const pulls = await this.octokit.paginate(
|
|
1155
|
+
this.octokit.rest.pulls.list,
|
|
1156
|
+
{ owner, repo, state: 'open', per_page: 100 }
|
|
1157
|
+
);
|
|
1158
|
+
|
|
1159
|
+
return pulls.map(pr => ({
|
|
1160
|
+
owner,
|
|
1161
|
+
repo,
|
|
1162
|
+
number: pr.number,
|
|
1163
|
+
title: pr.title,
|
|
1164
|
+
author: pr.user?.login || null,
|
|
1165
|
+
updated_at: pr.updated_at,
|
|
1166
|
+
html_url: pr.html_url,
|
|
1167
|
+
state: pr.state,
|
|
1168
|
+
requested_reviewers: Array.isArray(pr.requested_reviewers)
|
|
1169
|
+
? pr.requested_reviewers.map(r => r && r.login).filter(Boolean)
|
|
1170
|
+
: [],
|
|
1171
|
+
requested_teams: Array.isArray(pr.requested_teams)
|
|
1172
|
+
? pr.requested_teams.map(t => t && t.slug).filter(Boolean)
|
|
1173
|
+
: []
|
|
1174
|
+
}));
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1140
1177
|
/**
|
|
1141
1178
|
* Get the authenticated user's information.
|
|
1142
1179
|
* @returns {Promise<{login: string, name: string, avatar_url: string}>}
|
package/src/github/parser.js
CHANGED
|
@@ -90,11 +90,20 @@ class PRArgumentParser {
|
|
|
90
90
|
if (!owner || !repo || typeof number !== 'number' || isNaN(number) || number <= 0) {
|
|
91
91
|
return null;
|
|
92
92
|
}
|
|
93
|
+
// Carry the matched repo's `api_host` so downstream setup binds the PR to
|
|
94
|
+
// the alternate host without probing. A `url_pattern` match on a repo with
|
|
95
|
+
// no `api_host` (a plain github.com entry) resolves to `host: null`.
|
|
96
|
+
const matchedApiHost = (match.repoConfig
|
|
97
|
+
&& typeof match.repoConfig.api_host === 'string'
|
|
98
|
+
&& match.repoConfig.api_host)
|
|
99
|
+
? match.repoConfig.api_host
|
|
100
|
+
: null;
|
|
93
101
|
return {
|
|
94
102
|
owner,
|
|
95
103
|
repo,
|
|
96
104
|
number,
|
|
97
|
-
bindingRepository: match.bindingRepository
|
|
105
|
+
bindingRepository: match.bindingRepository,
|
|
106
|
+
host: matchedApiHost
|
|
98
107
|
};
|
|
99
108
|
}
|
|
100
109
|
|
|
@@ -114,16 +123,30 @@ class PRArgumentParser {
|
|
|
114
123
|
return null;
|
|
115
124
|
}
|
|
116
125
|
|
|
126
|
+
// Clean up the URL - trim whitespace
|
|
127
|
+
const trimmedUrl = url.trim();
|
|
128
|
+
|
|
117
129
|
// Try config-driven URL pattern matching first. This handles
|
|
118
130
|
// alternate-host URLs and lets host-specific repos override the
|
|
119
131
|
// built-in github.com path if they choose.
|
|
120
|
-
const configMatch = this._matchUrlPatternFromConfig(
|
|
132
|
+
const configMatch = this._matchUrlPatternFromConfig(trimmedUrl);
|
|
121
133
|
if (configMatch) {
|
|
122
|
-
|
|
134
|
+
// INVARIANT: an `api_host`-bearing `url_pattern` must NEVER bind a
|
|
135
|
+
// canonical github.com / Graphite URL. An over-broad or unanchored pattern
|
|
136
|
+
// can match one and pre-pin it to the alt host (an explicit host bypasses
|
|
137
|
+
// the setup probe → a silent, durable wrong binding). When the config
|
|
138
|
+
// match carries an alt host but the URL is a canonical github/graphite URL,
|
|
139
|
+
// discard the match and fall through to the built-in parsers (host: null).
|
|
140
|
+
const sansProtocol = trimmedUrl.replace(/^https?:\/\//i, '');
|
|
141
|
+
const isCanonicalHostUrl = sansProtocol.startsWith('github.com/')
|
|
142
|
+
|| sansProtocol.startsWith('app.graphite.dev/')
|
|
143
|
+
|| sansProtocol.startsWith('app.graphite.com/');
|
|
144
|
+
if (!(configMatch.host && isCanonicalHostUrl)) {
|
|
145
|
+
return configMatch;
|
|
146
|
+
}
|
|
123
147
|
}
|
|
124
148
|
|
|
125
|
-
|
|
126
|
-
let normalizedUrl = url.trim();
|
|
149
|
+
let normalizedUrl = trimmedUrl;
|
|
127
150
|
|
|
128
151
|
// Add https:// if no protocol is present
|
|
129
152
|
if (normalizedUrl.startsWith('github.com')) {
|
|
@@ -222,7 +245,9 @@ class PRArgumentParser {
|
|
|
222
245
|
* @param {string} repo - Repository name
|
|
223
246
|
* @param {string} numberStr - PR number as string
|
|
224
247
|
* @param {string} source - Source name for error messages ('GitHub' or 'Graphite')
|
|
225
|
-
* @returns {Object} Validated PR info { owner, repo, number }
|
|
248
|
+
* @returns {Object} Validated PR info `{ owner, repo, number, host? }`. `host`
|
|
249
|
+
* is `null` for github.com/Graphite URLs (definitively github); it is OMITTED
|
|
250
|
+
* (undefined) for the pair-review:// scheme, which carries no host in its path.
|
|
226
251
|
* @private
|
|
227
252
|
*/
|
|
228
253
|
_createPRInfo(owner, repo, numberStr, source) {
|
|
@@ -237,7 +262,16 @@ class PRArgumentParser {
|
|
|
237
262
|
throw new Error(`Invalid ${source} URL format. Expected: ${exampleUrl}`);
|
|
238
263
|
}
|
|
239
264
|
|
|
240
|
-
|
|
265
|
+
// A github.com or Graphite URL unambiguously identifies a github.com PR, so
|
|
266
|
+
// the host is explicitly null (not "unknown"). The pair-review:// scheme
|
|
267
|
+
// carries NO host in its path and is not github-only, so leave `host`
|
|
268
|
+
// undefined — it flows into the probe/derive path like a bare number,
|
|
269
|
+
// instead of self-healing (dual repo) or throwing (exclusive alt) on github.
|
|
270
|
+
const result = { owner, repo, number };
|
|
271
|
+
if (source !== 'pair-review://') {
|
|
272
|
+
result.host = null;
|
|
273
|
+
}
|
|
274
|
+
return result;
|
|
241
275
|
}
|
|
242
276
|
|
|
243
277
|
/**
|
|
@@ -93,7 +93,7 @@ async function resolveCliInstructions(flags) {
|
|
|
93
93
|
* @param {Object} params
|
|
94
94
|
* @param {Object} params.db - Database instance
|
|
95
95
|
* @param {Object} params.config - Loaded global config
|
|
96
|
-
* @param {Object} params.flags - Parsed CLI flags (council/model/instructions)
|
|
96
|
+
* @param {Object} params.flags - Parsed CLI flags (council/provider/model/instructions)
|
|
97
97
|
* @param {string} params.repository - owner/repo (for repo-default resolution)
|
|
98
98
|
* @returns {Promise<Object|null>} The analysisConfig object, or null when no instructions.
|
|
99
99
|
*/
|
|
@@ -104,7 +104,7 @@ async function buildInteractiveAnalysisConfig({ db, config, flags, repository })
|
|
|
104
104
|
const reviewConfig = await resolveReviewConfig(
|
|
105
105
|
db,
|
|
106
106
|
repository,
|
|
107
|
-
{ council: flags.council, model: flags.model },
|
|
107
|
+
{ council: flags.council, provider: flags.provider, model: flags.model },
|
|
108
108
|
config
|
|
109
109
|
);
|
|
110
110
|
|
package/src/links/repo-links.js
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
*/
|
|
32
32
|
|
|
33
33
|
const { getRepoConfig } = require('../config');
|
|
34
|
+
const { isDualHostRepoConfig } = require('../utils/host-resolution');
|
|
34
35
|
const logger = require('../utils/logger');
|
|
35
36
|
|
|
36
37
|
// Whitelist of allowed `{placeholder}` names. Anything else in the template
|
|
@@ -158,13 +159,28 @@ function sanitizeSvgIcon(svg) {
|
|
|
158
159
|
* malformed), a warning is logged and `icon` becomes `null` — the link
|
|
159
160
|
* is still rendered, just without a custom icon.
|
|
160
161
|
*
|
|
162
|
+
* **Per-PR host awareness (dual-host repos only).** `host` is the PR's
|
|
163
|
+
* resolved host: `null` = github.com, an `api_host` URL string = the alt
|
|
164
|
+
* host, `undefined` = unknown / not applicable. It only affects a
|
|
165
|
+
* *dual-host* repo (`api_host` + `exclusive: false`):
|
|
166
|
+
* - `host === null` (github-hosted PR) → keep the GitHub/Graphite links
|
|
167
|
+
* (subject to any explicit `links.github/graphite: false`) and hide the
|
|
168
|
+
* alt-host external link.
|
|
169
|
+
* - `host === '<url>'` (alt-hosted PR) → keep the external link and hide
|
|
170
|
+
* the GitHub/Graphite links.
|
|
171
|
+
* Exclusive alt-host repos and plain github repos ignore `host` entirely, so
|
|
172
|
+
* existing two-arg callers (and `host === undefined`) render byte-identically
|
|
173
|
+
* to before this parameter existed.
|
|
174
|
+
*
|
|
161
175
|
* @param {Object} config
|
|
162
176
|
* @param {string} repository Canonical `owner/repo` identifier
|
|
177
|
+
* @param {string|null} [host] PR's resolved host: null=github, url=alt,
|
|
178
|
+
* undefined=unknown/not applicable
|
|
163
179
|
* @returns {{ external: { label: string, url_template: string, icon: string|null }|null,
|
|
164
180
|
* github: boolean,
|
|
165
181
|
* graphite: boolean }}
|
|
166
182
|
*/
|
|
167
|
-
function resolveRepoLinks(config, repository) {
|
|
183
|
+
function resolveRepoLinks(config, repository, host = undefined) {
|
|
168
184
|
const result = { external: null, github: true, graphite: true };
|
|
169
185
|
if (!config || !repository) return result;
|
|
170
186
|
|
|
@@ -172,33 +188,47 @@ function resolveRepoLinks(config, repository) {
|
|
|
172
188
|
if (!repoConfig || typeof repoConfig !== 'object') return result;
|
|
173
189
|
|
|
174
190
|
const links = repoConfig.links;
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
191
|
+
if (links && typeof links === 'object') {
|
|
192
|
+
if (links.github === false) result.github = false;
|
|
193
|
+
if (links.graphite === false) result.graphite = false;
|
|
194
|
+
|
|
195
|
+
const ext = links.external;
|
|
196
|
+
if (ext && typeof ext === 'object'
|
|
197
|
+
&& typeof ext.label === 'string' && ext.label
|
|
198
|
+
&& typeof ext.url_template === 'string'
|
|
199
|
+
&& ext.url_template.startsWith('https://')) {
|
|
200
|
+
let icon = null;
|
|
201
|
+
if (ext.icon !== undefined && ext.icon !== null && ext.icon !== '') {
|
|
202
|
+
icon = sanitizeSvgIcon(ext.icon);
|
|
203
|
+
if (icon === null) {
|
|
204
|
+
logger.warn(
|
|
205
|
+
`Dropping links.external.icon for "${repository}" — failed sanitisation.`
|
|
206
|
+
);
|
|
207
|
+
}
|
|
192
208
|
}
|
|
209
|
+
result.external = {
|
|
210
|
+
// Optional host display name (e.g. "Meteorite"). When absent, the
|
|
211
|
+
// field is null and consumers fall back to "GitHub" via resolveHostName.
|
|
212
|
+
name: (typeof ext.name === 'string' && ext.name) ? ext.name : null,
|
|
213
|
+
label: ext.label,
|
|
214
|
+
url_template: ext.url_template,
|
|
215
|
+
icon
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Per-PR host awareness for dual-host repos. A `host` of `undefined`
|
|
221
|
+
// (omitted arg, unknown host) leaves the repo-level result untouched, so
|
|
222
|
+
// non-dual repos and legacy two-arg callers are unaffected.
|
|
223
|
+
if (host !== undefined && isDualHostRepoConfig(repoConfig)) {
|
|
224
|
+
if (host === null) {
|
|
225
|
+
// github-hosted PR: the alt-host external link does not apply.
|
|
226
|
+
result.external = null;
|
|
227
|
+
} else {
|
|
228
|
+
// alt-hosted PR: hide the GitHub/Graphite links, keep the external one.
|
|
229
|
+
result.github = false;
|
|
230
|
+
result.graphite = false;
|
|
193
231
|
}
|
|
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
232
|
}
|
|
203
233
|
|
|
204
234
|
return result;
|
|
@@ -212,12 +242,20 @@ function resolveRepoLinks(config, repository) {
|
|
|
212
242
|
* `"GitHub"`. This is the server-side counterpart to the frontend
|
|
213
243
|
* `window.RepoLinks.hostName()` accessor.
|
|
214
244
|
*
|
|
245
|
+
* Host-aware for dual-host repos: a github-hosted PR (`host === null`)
|
|
246
|
+
* reports "GitHub" even when an external name is configured, because
|
|
247
|
+
* `resolveRepoLinks` clears the external link for that host. See
|
|
248
|
+
* `resolveRepoLinks` for the `host` semantics. Non-dual repos and two-arg
|
|
249
|
+
* callers behave exactly as before.
|
|
250
|
+
*
|
|
215
251
|
* @param {Object} config
|
|
216
252
|
* @param {string} repository Canonical `owner/repo` identifier
|
|
253
|
+
* @param {string|null} [host] PR's resolved host: null=github, url=alt,
|
|
254
|
+
* undefined=unknown/not applicable
|
|
217
255
|
* @returns {string} The configured host name, or "GitHub" by default
|
|
218
256
|
*/
|
|
219
|
-
function resolveHostName(config, repository) {
|
|
220
|
-
const links = resolveRepoLinks(config, repository);
|
|
257
|
+
function resolveHostName(config, repository, host = undefined) {
|
|
258
|
+
const links = resolveRepoLinks(config, repository, host);
|
|
221
259
|
return (links.external && links.external.name) ? links.external.name : 'GitHub';
|
|
222
260
|
}
|
|
223
261
|
|
package/src/local-review.js
CHANGED
|
@@ -11,7 +11,7 @@ const { fireHooks, hasHooks } = require('./hooks/hook-runner');
|
|
|
11
11
|
const { buildReviewStartedPayload, buildReviewLoadedPayload, getCachedUser } = require('./hooks/payloads');
|
|
12
12
|
|
|
13
13
|
const execAsync = promisify(exec);
|
|
14
|
-
const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel, reviewScope } = require('./local-scope');
|
|
14
|
+
const { STOPS, scopeIncludes, includesBranch, DEFAULT_SCOPE, scopeLabel, reviewScope, parseScopeArg } = require('./local-scope');
|
|
15
15
|
const { initializeDatabase, ReviewRepository, RepoSettingsRepository } = require('./database');
|
|
16
16
|
const { resolveCouncilHandle } = require('./councils/resolve-council');
|
|
17
17
|
const { prepareInteractiveAnalysisConfig } = require('./interactive-analysis-config');
|
|
@@ -21,6 +21,10 @@ const summaryGenerator = require('./ai/summary-generator');
|
|
|
21
21
|
const tourGenerator = require('./ai/tour-generator');
|
|
22
22
|
const { getShaAbbrevLength } = require('./git/sha-abbrev');
|
|
23
23
|
const { GIT_DIFF_FLAGS, GIT_DIFF_FLAGS_ARRAY } = require('./git/diff-flags');
|
|
24
|
+
// Namespace import (not destructured) so detectBaseBranch is resolved off the
|
|
25
|
+
// module object at call time — this keeps it interceptable by vi.spyOn on the
|
|
26
|
+
// base-branch module in tests, unlike a destructured binding captured at load.
|
|
27
|
+
const baseBranchModule = require('./git/base-branch');
|
|
24
28
|
const open = (...args) => process.env.PAIR_REVIEW_NO_OPEN ? Promise.resolve() : import('open').then(({ default: open }) => open(...args));
|
|
25
29
|
|
|
26
30
|
// Design note: This module uses execSync for git commands despite async function signatures.
|
|
@@ -694,6 +698,112 @@ async function generateLocalDiff(repoPath, options = {}) {
|
|
|
694
698
|
};
|
|
695
699
|
}
|
|
696
700
|
|
|
701
|
+
/**
|
|
702
|
+
* Resolve the effective scope and base branch for a local review session,
|
|
703
|
+
* applying an explicit `--scope`/`--base` (or delegated query) override on top
|
|
704
|
+
* of the persisted/default scope.
|
|
705
|
+
*
|
|
706
|
+
* Shared by the CLI seam (setupLocalReviewSession) and the web setup seam
|
|
707
|
+
* (src/setup/local-setup.js) so a scoped launch behaves identically whether it
|
|
708
|
+
* cold-starts a server or is delegated to a running one. Callers MUST have
|
|
709
|
+
* validated flags.scope/flags.base first (parseScopeArg + branch-name regex +
|
|
710
|
+
* "base requires branch scope"); an invalid flags.scope is treated as "no
|
|
711
|
+
* override" here rather than throwing.
|
|
712
|
+
*
|
|
713
|
+
* Precedence — scope: explicit override > persisted review scope > DEFAULT_SCOPE.
|
|
714
|
+
* Precedence — base: explicit --base > persisted local_base_branch >
|
|
715
|
+
* detectBaseBranch. The base is nulled for any non-branch scope so a stale value
|
|
716
|
+
* from a previously branch-scoped session never leaks into the diff/persist/return.
|
|
717
|
+
*
|
|
718
|
+
* @param {Object} params
|
|
719
|
+
* @param {Object|null} params.existingReview - Persisted review row, or null for a fresh session
|
|
720
|
+
* @param {Object} [params.flags] - { scope?, base? }
|
|
721
|
+
* @param {string} params.repoPath
|
|
722
|
+
* @param {string} params.branch - Current branch name
|
|
723
|
+
* @param {string} params.repository - owner/repo (for host binding / PR base lookup)
|
|
724
|
+
* @param {Object} params.config
|
|
725
|
+
* @returns {Promise<{scopeStart: string, scopeEnd: string, baseBranch: string|null, scopeOverridden: boolean}>}
|
|
726
|
+
*/
|
|
727
|
+
async function resolveScopeAndBase({ existingReview, flags = {}, repoPath, branch, repository, config }) {
|
|
728
|
+
const scopeOverride = flags.scope ? parseScopeArg(flags.scope) : null;
|
|
729
|
+
let scopeStart, scopeEnd;
|
|
730
|
+
if (scopeOverride) {
|
|
731
|
+
({ start: scopeStart, end: scopeEnd } = scopeOverride);
|
|
732
|
+
} else if (existingReview) {
|
|
733
|
+
({ start: scopeStart, end: scopeEnd } = reviewScope(existingReview));
|
|
734
|
+
} else {
|
|
735
|
+
({ start: scopeStart, end: scopeEnd } = DEFAULT_SCOPE);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
let baseBranch = existingReview?.local_base_branch || null;
|
|
739
|
+
if (scopeOverride && includesBranch(scopeStart)) {
|
|
740
|
+
if (flags.base) {
|
|
741
|
+
baseBranch = flags.base;
|
|
742
|
+
} else if (!baseBranch) {
|
|
743
|
+
const branchBinding = repository ? resolveHostBinding(repository, config) : null;
|
|
744
|
+
const token = branchBinding?.token || getGitHubToken(config);
|
|
745
|
+
const detection = await baseBranchModule.detectBaseBranch(repoPath, branch, {
|
|
746
|
+
repository,
|
|
747
|
+
enableGraphite: config?.enable_graphite === true,
|
|
748
|
+
_deps: (token || branchBinding) ? {
|
|
749
|
+
getGitHubToken: () => token || '',
|
|
750
|
+
getHostBinding: () => branchBinding || null
|
|
751
|
+
} : undefined
|
|
752
|
+
});
|
|
753
|
+
if (!detection) {
|
|
754
|
+
throw new Error(
|
|
755
|
+
`Could not detect a base branch for scope '${scopeStart}..${scopeEnd}'. ` +
|
|
756
|
+
'Pass --base <branch> to specify one explicitly.'
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
baseBranch = detection.baseBranch;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// A non-branch scope has no base branch. Drop any value seeded from a
|
|
764
|
+
// previously branch-scoped session so the stale base never reaches the diff,
|
|
765
|
+
// the persisted row, or the returned session — matching the web set-scope
|
|
766
|
+
// route, which persists null when leaving branch scope.
|
|
767
|
+
if (!includesBranch(scopeStart)) {
|
|
768
|
+
baseBranch = null;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
return { scopeStart, scopeEnd, baseBranch, scopeOverridden: !!scopeOverride };
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Persist an explicitly-overridden scope (and its resolved base) and auto-name
|
|
776
|
+
* the review when a branch scope is newly applied to an unnamed review. Call
|
|
777
|
+
* ONLY after generateScopedDiff succeeds, so a scope whose diff fails (e.g. a
|
|
778
|
+
* bad --base) never sticks. Shared by the CLI and web setup seams. Caller gates
|
|
779
|
+
* this on `scopeOverridden` — it always writes when invoked.
|
|
780
|
+
*
|
|
781
|
+
* @param {Object} params
|
|
782
|
+
* @param {Object} params.reviewRepo - ReviewRepository instance
|
|
783
|
+
* @param {number} params.sessionId
|
|
784
|
+
* @param {Object|null} params.existingReview
|
|
785
|
+
* @param {string} params.scopeStart
|
|
786
|
+
* @param {string} params.scopeEnd
|
|
787
|
+
* @param {string|null} params.baseBranch
|
|
788
|
+
* @param {string} params.branch - Current branch (stored as head branch for branch scopes)
|
|
789
|
+
* @param {string} params.repoPath
|
|
790
|
+
*/
|
|
791
|
+
async function persistScopeSelection({ reviewRepo, sessionId, existingReview, scopeStart, scopeEnd, baseBranch, branch, repoPath }) {
|
|
792
|
+
await reviewRepo.updateLocalScope(sessionId, scopeStart, scopeEnd, baseBranch, branch);
|
|
793
|
+
|
|
794
|
+
// Keep CLI and web set-scope in sync: when a branch scope is newly applied to
|
|
795
|
+
// an as-yet-unnamed review, auto-name it from the first commit subject.
|
|
796
|
+
// Mirrors routes/local.js set-scope. A fresh session has no existing review,
|
|
797
|
+
// which counts as both unnamed and previously non-branch.
|
|
798
|
+
const oldScopeStart = existingReview ? reviewScope(existingReview).start : DEFAULT_SCOPE.start;
|
|
799
|
+
if (!existingReview?.name && includesBranch(scopeStart) && !includesBranch(oldScopeStart) && baseBranch) {
|
|
800
|
+
const firstSubject = await module.exports.getFirstCommitSubject(repoPath, baseBranch);
|
|
801
|
+
if (firstSubject) {
|
|
802
|
+
await reviewRepo.updateReview(sessionId, { name: firstSubject.slice(0, 200) });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
697
807
|
/**
|
|
698
808
|
* Set up a local review session: resolve git state, persist the diff,
|
|
699
809
|
* and enqueue the background summary job. Caller is responsible for
|
|
@@ -777,7 +887,11 @@ async function setupLocalReviewSession({ db, config, repoPath, flags = {}, start
|
|
|
777
887
|
console.log(`Created new review session (ID: ${sessionId})`);
|
|
778
888
|
}
|
|
779
889
|
|
|
780
|
-
|
|
890
|
+
// Resolve scope + base (explicit --scope/--base override > persisted > default)
|
|
891
|
+
// via the shared helper so the CLI and delegated web-setup seam stay identical.
|
|
892
|
+
const { scopeStart, scopeEnd, baseBranch, scopeOverridden } = await resolveScopeAndBase({
|
|
893
|
+
existingReview, flags, repoPath, branch, repository, config
|
|
894
|
+
});
|
|
781
895
|
|
|
782
896
|
const hookEvent = existingReview ? 'review.loaded' : 'review.started';
|
|
783
897
|
if (hasHooks(hookEvent, config)) {
|
|
@@ -790,11 +904,20 @@ async function setupLocalReviewSession({ db, config, repoPath, flags = {}, start
|
|
|
790
904
|
fireHooks(hookEvent, payload, config);
|
|
791
905
|
}).catch(err => { logger.warn(`Review hook failed: ${err.message}`); });
|
|
792
906
|
}
|
|
793
|
-
const baseBranch = existingReview?.local_base_branch || null;
|
|
794
907
|
|
|
795
908
|
console.log(`Generating diff for scope: ${scopeLabel(scopeStart, scopeEnd)}...`);
|
|
796
909
|
const { diff, stats } = await module.exports.generateScopedDiff(repoPath, scopeStart, scopeEnd, baseBranch);
|
|
797
910
|
|
|
911
|
+
// Persist an explicitly-requested scope (and its resolved base) so the web UI
|
|
912
|
+
// later opens with the same scope. Only after generateScopedDiff succeeds, so a
|
|
913
|
+
// scope whose diff fails (e.g. a bad --base) never sticks. Never touch persisted
|
|
914
|
+
// scope when no override was supplied.
|
|
915
|
+
if (scopeOverridden) {
|
|
916
|
+
await persistScopeSelection({
|
|
917
|
+
reviewRepo, sessionId, existingReview, scopeStart, scopeEnd, baseBranch, branch, repoPath
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
|
|
798
921
|
let branchInfo = null;
|
|
799
922
|
if (!includesBranch(scopeStart)) {
|
|
800
923
|
const untrackedFiles = await getUntrackedFiles(repoPath);
|
|
@@ -932,6 +1055,11 @@ async function handleLocalReview(targetPath, flags = {}) {
|
|
|
932
1055
|
if (flags.model && !flags.council) {
|
|
933
1056
|
process.env.PAIR_REVIEW_MODEL = flags.model;
|
|
934
1057
|
}
|
|
1058
|
+
// Mirror --provider too so the local web/UI routes (which read
|
|
1059
|
+
// PAIR_REVIEW_PROVIDER via getProvider(req)) honor it, matching --model above.
|
|
1060
|
+
if (flags.provider) {
|
|
1061
|
+
process.env.PAIR_REVIEW_PROVIDER = flags.provider;
|
|
1062
|
+
}
|
|
935
1063
|
|
|
936
1064
|
console.log('Starting server...');
|
|
937
1065
|
const port = await startServer(db);
|
|
@@ -1085,14 +1213,13 @@ async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
|
|
|
1085
1213
|
if (untrackedFiles && untrackedFiles.length > 0) return null;
|
|
1086
1214
|
|
|
1087
1215
|
try {
|
|
1088
|
-
const { detectBaseBranch } = require('./git/base-branch');
|
|
1089
1216
|
const depsOverride = githubToken || hostBinding
|
|
1090
1217
|
? {
|
|
1091
1218
|
getGitHubToken: () => githubToken || '',
|
|
1092
1219
|
getHostBinding: () => hostBinding || null
|
|
1093
1220
|
}
|
|
1094
1221
|
: undefined;
|
|
1095
|
-
const detection = await detectBaseBranch(repoPath, branch, {
|
|
1222
|
+
const detection = await baseBranchModule.detectBaseBranch(repoPath, branch, {
|
|
1096
1223
|
repository,
|
|
1097
1224
|
enableGraphite,
|
|
1098
1225
|
_deps: depsOverride
|
|
@@ -1117,6 +1244,8 @@ async function detectAndBuildBranchInfo(repoPath, branch, options = {}) {
|
|
|
1117
1244
|
module.exports = {
|
|
1118
1245
|
handleLocalReview,
|
|
1119
1246
|
setupLocalReviewSession,
|
|
1247
|
+
resolveScopeAndBase,
|
|
1248
|
+
persistScopeSelection,
|
|
1120
1249
|
findGitRoot,
|
|
1121
1250
|
findMainGitRoot,
|
|
1122
1251
|
getHeadSha,
|
package/src/local-scope.js
CHANGED
|
@@ -15,6 +15,42 @@ function isValidScope(start, end) {
|
|
|
15
15
|
return si !== -1 && ei !== -1 && si <= ei && si <= UNSTAGED_INDEX && ei >= UNSTAGED_INDEX;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The full set of valid scope ranges, formatted as `start..end` strings.
|
|
20
|
+
* Computed from STOPS + isValidScope so it stays the single source of truth.
|
|
21
|
+
* Ordered by STOPS position (branch-first).
|
|
22
|
+
* @type {string[]}
|
|
23
|
+
*/
|
|
24
|
+
const VALID_SCOPE_RANGES = (() => {
|
|
25
|
+
const ranges = [];
|
|
26
|
+
for (const start of STOPS) {
|
|
27
|
+
for (const end of STOPS) {
|
|
28
|
+
if (isValidScope(start, end)) ranges.push(`${start}..${end}`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return ranges;
|
|
32
|
+
})();
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a `--scope` CLI argument of the form `<start>..<end>` into a scope
|
|
36
|
+
* object. Splits on `..`, trims each side, and delegates validation to
|
|
37
|
+
* isValidScope (the single source of truth). Single tokens, missing `..`,
|
|
38
|
+
* unknown stops, non-contiguous ranges, ranges excluding 'unstaged', and
|
|
39
|
+
* reversed ranges all return null.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} value - Raw CLI value (e.g. 'branch..untracked')
|
|
42
|
+
* @returns {{ start: string, end: string }|null} Parsed scope, or null if invalid
|
|
43
|
+
*/
|
|
44
|
+
function parseScopeArg(value) {
|
|
45
|
+
if (typeof value !== 'string') return null;
|
|
46
|
+
const parts = value.split('..');
|
|
47
|
+
if (parts.length !== 2) return null;
|
|
48
|
+
const start = parts[0].trim();
|
|
49
|
+
const end = parts[1].trim();
|
|
50
|
+
if (!isValidScope(start, end)) return null;
|
|
51
|
+
return { start, end };
|
|
52
|
+
}
|
|
53
|
+
|
|
18
54
|
function normalizeScope(start, end) {
|
|
19
55
|
if (isValidScope(start, end)) return { start, end };
|
|
20
56
|
const si = STOPS.indexOf(start);
|
|
@@ -130,7 +166,9 @@ function scopeGitHints(start, end, baseBranch) {
|
|
|
130
166
|
const LocalScope = {
|
|
131
167
|
STOPS,
|
|
132
168
|
DEFAULT_SCOPE,
|
|
169
|
+
VALID_SCOPE_RANGES,
|
|
133
170
|
isValidScope,
|
|
171
|
+
parseScopeArg,
|
|
134
172
|
normalizeScope,
|
|
135
173
|
reviewScope,
|
|
136
174
|
scopeIncludes,
|