@in-the-loop-labs/pair-review 3.6.0 → 3.7.1
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 +17 -1737
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +89 -44
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ChatPanel.js +11 -1
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/SuggestionNavigator.js +55 -10
- 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 +45 -5
- package/public/js/pr.js +703 -171
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/scroll-into-view.js +164 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +10 -0
- package/public/pr.html +10 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/analyzer.js +125 -18
- package/src/ai/claude-provider.js +31 -3
- 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 +136 -32
- 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
package/src/github/parser.js
CHANGED
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
const simpleGit = require('simple-git');
|
|
3
3
|
const path = require('path');
|
|
4
|
+
const { matchRepoByUrl } = require('../config');
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Parse command line arguments to extract PR information
|
|
7
8
|
*/
|
|
8
9
|
class PRArgumentParser {
|
|
9
|
-
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} [config] - Optional pair-review config. When provided,
|
|
12
|
+
* per-repo `url_pattern` regexes are tried before the built-in GitHub
|
|
13
|
+
* and Graphite URL parsers, allowing pasted URLs from alternate hosts
|
|
14
|
+
* to be resolved to the correct repo entry. The canonical
|
|
15
|
+
* `owner/repo` from the config key (or named capture groups) takes
|
|
16
|
+
* precedence over any host-specific parsing.
|
|
17
|
+
*/
|
|
18
|
+
constructor(config = null) {
|
|
10
19
|
this.git = simpleGit();
|
|
20
|
+
this.config = config;
|
|
11
21
|
}
|
|
12
22
|
|
|
13
23
|
/**
|
|
14
|
-
* Parse PR arguments from command line
|
|
24
|
+
* Parse PR arguments from command line.
|
|
25
|
+
*
|
|
26
|
+
* Returns at minimum `{ owner, repo, number }`. When the input was
|
|
27
|
+
* matched against a per-repo `url_pattern`, the returned object also
|
|
28
|
+
* includes `bindingRepository` — the `repos[...]` config key that was
|
|
29
|
+
* matched. Callers performing a host-binding lookup should prefer
|
|
30
|
+
* `bindingRepository` over `${owner}/${repo}` so monorepo-style configs
|
|
31
|
+
* where the URL pattern matches multiple sub-repos resolve to the
|
|
32
|
+
* correct entry. When `bindingRepository` is absent, callers should
|
|
33
|
+
* fall back to `${owner}/${repo}`.
|
|
34
|
+
*
|
|
15
35
|
* @param {Array<string>} args - Command line arguments
|
|
16
|
-
* @returns {Promise<
|
|
36
|
+
* @returns {Promise<{owner: string, repo: string, number: number, bindingRepository?: string}>}
|
|
17
37
|
*/
|
|
18
38
|
async parsePRArguments(args) {
|
|
19
39
|
if (args.length === 0) {
|
|
@@ -38,9 +58,54 @@ class PRArgumentParser {
|
|
|
38
58
|
return { owner, repo, number: prNumber };
|
|
39
59
|
}
|
|
40
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Match a URL against any configured `url_pattern` regex in the repos
|
|
63
|
+
* config. Returns `{ owner, repo, number, bindingRepository }` when a
|
|
64
|
+
* match yields a complete triple (owner+repo+number), otherwise null.
|
|
65
|
+
* Used to resolve URLs pasted from alternate Git hosts before falling
|
|
66
|
+
* back to the built-in GitHub/Graphite parsers.
|
|
67
|
+
*
|
|
68
|
+
* `bindingRepository` is the matched `repos[...]` config key — use it
|
|
69
|
+
* to look up the host binding (token, api_host, features) when the
|
|
70
|
+
* captured owner/repo differ from the config key.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} url - The URL to match
|
|
73
|
+
* @returns {Object|null} `{ owner, repo, number, bindingRepository }` or null
|
|
74
|
+
* @private
|
|
75
|
+
*/
|
|
76
|
+
_matchUrlPatternFromConfig(url) {
|
|
77
|
+
if (!this.config) return null;
|
|
78
|
+
const match = matchRepoByUrl(url, this.config);
|
|
79
|
+
if (!match) return null;
|
|
80
|
+
|
|
81
|
+
// Derive owner/repo: prefer named capture groups, fall back to the
|
|
82
|
+
// canonical "owner/repo" repository key from config.
|
|
83
|
+
let { owner, repo, number } = match;
|
|
84
|
+
if ((!owner || !repo) && match.repository && match.repository.includes('/')) {
|
|
85
|
+
const [keyOwner, keyRepo] = match.repository.split('/');
|
|
86
|
+
if (!owner) owner = keyOwner;
|
|
87
|
+
if (!repo) repo = keyRepo;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!owner || !repo || typeof number !== 'number' || isNaN(number) || number <= 0) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return {
|
|
94
|
+
owner,
|
|
95
|
+
repo,
|
|
96
|
+
number,
|
|
97
|
+
bindingRepository: match.bindingRepository
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
41
101
|
/**
|
|
42
102
|
* Parse a PR URL string and extract owner, repo, and PR number
|
|
43
|
-
* Handles both GitHub and Graphite URLs, with or without protocol
|
|
103
|
+
* Handles both GitHub and Graphite URLs, with or without protocol.
|
|
104
|
+
*
|
|
105
|
+
* When the parser was constructed with a config, per-repo `url_pattern`
|
|
106
|
+
* regexes are tried first so that alternate-host URLs resolve to the
|
|
107
|
+
* canonical owner/repo from config.
|
|
108
|
+
*
|
|
44
109
|
* @param {string} url - The PR URL to parse
|
|
45
110
|
* @returns {Object|null} { owner, repo, number } or null if not a valid PR URL
|
|
46
111
|
*/
|
|
@@ -49,6 +114,14 @@ class PRArgumentParser {
|
|
|
49
114
|
return null;
|
|
50
115
|
}
|
|
51
116
|
|
|
117
|
+
// Try config-driven URL pattern matching first. This handles
|
|
118
|
+
// alternate-host URLs and lets host-specific repos override the
|
|
119
|
+
// built-in github.com path if they choose.
|
|
120
|
+
const configMatch = this._matchUrlPatternFromConfig(url.trim());
|
|
121
|
+
if (configMatch) {
|
|
122
|
+
return configMatch;
|
|
123
|
+
}
|
|
124
|
+
|
|
52
125
|
// Clean up the URL - trim whitespace
|
|
53
126
|
let normalizedUrl = url.trim();
|
|
54
127
|
|
|
@@ -219,9 +292,162 @@ class PRArgumentParser {
|
|
|
219
292
|
return { owner: match[1], repo: match[2] };
|
|
220
293
|
}
|
|
221
294
|
|
|
295
|
+
// Fall through to config-driven alt-host matching. Only consulted
|
|
296
|
+
// when the built-in github.com patterns don't match, so common-case
|
|
297
|
+
// github.com behaviour is unchanged. Requires the parser to have
|
|
298
|
+
// been constructed with a config; otherwise short-circuits and
|
|
299
|
+
// throws.
|
|
300
|
+
const altHostMatch = this._parseAltHostRepositoryFromURL(url);
|
|
301
|
+
if (altHostMatch) {
|
|
302
|
+
return altHostMatch;
|
|
303
|
+
}
|
|
304
|
+
|
|
222
305
|
throw new Error('Current directory is not a git repository or has no GitHub remote origin');
|
|
223
306
|
}
|
|
224
307
|
|
|
308
|
+
/**
|
|
309
|
+
* Try to resolve a non-github.com git remote URL to a configured repo
|
|
310
|
+
* entry. Walks `this.config.repos` looking for entries that declare an
|
|
311
|
+
* `api_host` (alt-host repos) and matches the URL against patterns
|
|
312
|
+
* derived from that host + the canonical "owner/repo" config key.
|
|
313
|
+
*
|
|
314
|
+
* Match order per repo entry:
|
|
315
|
+
* 1. The optional `git_remote_pattern` escape hatch (a regex string).
|
|
316
|
+
* When present, it is tried FIRST so users with non-standard
|
|
317
|
+
* remote URL layouts can opt in. If the regex matches anywhere
|
|
318
|
+
* in the remote URL, the canonical config key is returned.
|
|
319
|
+
* 2. Patterns derived from `api_host` + canonical "owner/repo" key:
|
|
320
|
+
* - `https://<host>/<owner>/<repo>(.git)?`
|
|
321
|
+
* - `http://<host>/<owner>/<repo>(.git)?` (for self-hosted dev)
|
|
322
|
+
* - `git@<host>:<owner>/<repo>(.git)?`
|
|
323
|
+
* The host portion of `api_host` is used as-is for HTTP(S)
|
|
324
|
+
* patterns (it may already include a scheme/port — we strip the
|
|
325
|
+
* scheme to derive the bare host for the SSH form).
|
|
326
|
+
*
|
|
327
|
+
* First match wins; the canonical "owner/repo" from the config KEY is
|
|
328
|
+
* returned (named groups in `git_remote_pattern` are not consulted —
|
|
329
|
+
* the contract is "if the regex matches the URL, the repo entry
|
|
330
|
+
* applies").
|
|
331
|
+
*
|
|
332
|
+
* Returns null when no entry matches (or when no config is available),
|
|
333
|
+
* letting the caller fall through to its existing error path.
|
|
334
|
+
*
|
|
335
|
+
* @param {string} url - Git remote URL
|
|
336
|
+
* @returns {Object|null} { owner, repo } or null
|
|
337
|
+
* @private
|
|
338
|
+
*/
|
|
339
|
+
_parseAltHostRepositoryFromURL(url) {
|
|
340
|
+
if (!url || typeof url !== 'string') return null;
|
|
341
|
+
if (!this.config || !this.config.repos || typeof this.config.repos !== 'object') {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
for (const [repoKey, repoEntry] of Object.entries(this.config.repos)) {
|
|
346
|
+
if (!repoEntry || typeof repoEntry !== 'object') continue;
|
|
347
|
+
const apiHost = (typeof repoEntry.api_host === 'string' && repoEntry.api_host)
|
|
348
|
+
? repoEntry.api_host
|
|
349
|
+
: null;
|
|
350
|
+
// Only alt-host repos participate here. github.com repos take the
|
|
351
|
+
// built-in fast path above.
|
|
352
|
+
if (!apiHost) continue;
|
|
353
|
+
|
|
354
|
+
// 1. Escape hatch: per-repo git_remote_pattern. We treat it as a
|
|
355
|
+
// regex (consistent with the existing url_pattern field) and
|
|
356
|
+
// use RegExp#test so callers can omit `^` if they want a
|
|
357
|
+
// substring-style match. validateRepoConfig() rejects invalid
|
|
358
|
+
// regexes at startup; the try/catch here is purely defensive.
|
|
359
|
+
const remotePattern = repoEntry.git_remote_pattern;
|
|
360
|
+
if (typeof remotePattern === 'string' && remotePattern) {
|
|
361
|
+
try {
|
|
362
|
+
if (new RegExp(remotePattern).test(url)) {
|
|
363
|
+
const parts = this._splitRepoKey(repoKey);
|
|
364
|
+
if (parts) return parts;
|
|
365
|
+
}
|
|
366
|
+
} catch {
|
|
367
|
+
// Invalid regex — would have been caught at startup; skip.
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const parts = this._splitRepoKey(repoKey);
|
|
372
|
+
if (!parts) continue;
|
|
373
|
+
|
|
374
|
+
// 2. Derive HTTPS/HTTP/SSH patterns from api_host. api_host may
|
|
375
|
+
// already include a scheme (and possibly a port + path like
|
|
376
|
+
// "https://althost.example/api/v3"). Strip the scheme to get
|
|
377
|
+
// the bare host[:port] that appears in git remote URLs.
|
|
378
|
+
const bareHost = this._bareHostFromApiHost(apiHost);
|
|
379
|
+
if (!bareHost) continue;
|
|
380
|
+
|
|
381
|
+
const escapedHost = this._escapeRegex(bareHost);
|
|
382
|
+
const escapedOwner = this._escapeRegex(parts.owner);
|
|
383
|
+
const escapedRepo = this._escapeRegex(parts.repo);
|
|
384
|
+
|
|
385
|
+
// Allow either https:// or http:// scheme (self-hosted dev
|
|
386
|
+
// instances sometimes use plain HTTP). Tolerate optional .git
|
|
387
|
+
// suffix. Anchored to start/end so we don't accidentally match
|
|
388
|
+
// a substring inside a different host's URL.
|
|
389
|
+
const httpRegex = new RegExp(
|
|
390
|
+
`^https?:\\/\\/${escapedHost}\\/${escapedOwner}\\/${escapedRepo}(?:\\.git)?$`,
|
|
391
|
+
'i'
|
|
392
|
+
);
|
|
393
|
+
if (httpRegex.test(url)) return parts;
|
|
394
|
+
|
|
395
|
+
const sshRegex = new RegExp(
|
|
396
|
+
`^git@${escapedHost}:${escapedOwner}\\/${escapedRepo}(?:\\.git)?$`,
|
|
397
|
+
'i'
|
|
398
|
+
);
|
|
399
|
+
if (sshRegex.test(url)) return parts;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Strip a scheme (and any trailing path) off an `api_host` config
|
|
407
|
+
* value to derive the bare host[:port] string that appears in a git
|
|
408
|
+
* remote URL. `api_host` is conventionally something like
|
|
409
|
+
* `https://althost.example/api/v3`, but bare `althost.example` is
|
|
410
|
+
* also accepted.
|
|
411
|
+
*
|
|
412
|
+
* @param {string} apiHost
|
|
413
|
+
* @returns {string|null} - Host[:port] or null when the value is unusable
|
|
414
|
+
* @private
|
|
415
|
+
*/
|
|
416
|
+
_bareHostFromApiHost(apiHost) {
|
|
417
|
+
if (typeof apiHost !== 'string' || !apiHost) return null;
|
|
418
|
+
// Strip scheme if present.
|
|
419
|
+
let host = apiHost.replace(/^https?:\/\//i, '');
|
|
420
|
+
// Strip everything from the first slash onward (path component).
|
|
421
|
+
const slashIdx = host.indexOf('/');
|
|
422
|
+
if (slashIdx >= 0) host = host.slice(0, slashIdx);
|
|
423
|
+
return host || null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Split a canonical "owner/repo" config key into { owner, repo }.
|
|
428
|
+
* Returns null when the key is malformed.
|
|
429
|
+
*
|
|
430
|
+
* @param {string} repoKey
|
|
431
|
+
* @returns {{owner: string, repo: string}|null}
|
|
432
|
+
* @private
|
|
433
|
+
*/
|
|
434
|
+
_splitRepoKey(repoKey) {
|
|
435
|
+
if (typeof repoKey !== 'string') return null;
|
|
436
|
+
const idx = repoKey.indexOf('/');
|
|
437
|
+
if (idx <= 0 || idx === repoKey.length - 1) return null;
|
|
438
|
+
return { owner: repoKey.slice(0, idx), repo: repoKey.slice(idx + 1) };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Escape a string for safe embedding inside a regex literal.
|
|
443
|
+
* @param {string} s
|
|
444
|
+
* @returns {string}
|
|
445
|
+
* @private
|
|
446
|
+
*/
|
|
447
|
+
_escapeRegex(s) {
|
|
448
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
449
|
+
}
|
|
450
|
+
|
|
225
451
|
/**
|
|
226
452
|
* Validate PR arguments
|
|
227
453
|
* @param {Object} prInfo - PR information { owner, repo, number }
|
|
@@ -1,196 +1,21 @@
|
|
|
1
1
|
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
-
const logger = require('../utils/logger');
|
|
3
|
-
|
|
4
|
-
const DEFAULT_TRUNK_BRANCHES = ['main', 'master', 'develop'];
|
|
5
|
-
const MAX_WALK_DEPTH = 20;
|
|
6
|
-
|
|
7
|
-
const FETCH_PR_QUERY = `
|
|
8
|
-
query($owner: String!, $repo: String!, $number: Int!) {
|
|
9
|
-
repository(owner: $owner, name: $repo) {
|
|
10
|
-
pullRequest(number: $number) {
|
|
11
|
-
number title baseRefName headRefName headRefOid state url
|
|
12
|
-
}
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
`;
|
|
16
|
-
|
|
17
|
-
const FIND_PRS_BY_HEAD_QUERY = `
|
|
18
|
-
query($owner: String!, $repo: String!, $branch: String!) {
|
|
19
|
-
repository(owner: $owner, name: $repo) {
|
|
20
|
-
pullRequests(headRefName: $branch, states: [OPEN, MERGED], first: 5, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
21
|
-
nodes { number title baseRefName headRefName headRefOid state url }
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
`;
|
|
26
|
-
|
|
27
|
-
const FIND_PRS_BY_BASE_QUERY = `
|
|
28
|
-
query($owner: String!, $repo: String!, $branch: String!) {
|
|
29
|
-
repository(owner: $owner, name: $repo) {
|
|
30
|
-
pullRequests(baseRefName: $branch, states: [OPEN], first: 5, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
31
|
-
nodes { number title baseRefName headRefName headRefOid state url }
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
`;
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Select the best PR from a list of candidates for the same branch.
|
|
39
|
-
* Prefers OPEN over MERGED.
|
|
40
|
-
*
|
|
41
|
-
* @param {Array} prs - Array of PR nodes from GraphQL
|
|
42
|
-
* @returns {Object|null} The best candidate or null
|
|
43
|
-
*/
|
|
44
|
-
function pickBestPR(prs) {
|
|
45
|
-
if (!prs || prs.length === 0) return null;
|
|
46
|
-
const open = prs.find(pr => pr.state === 'OPEN');
|
|
47
|
-
if (open) return open;
|
|
48
|
-
return prs[0];
|
|
49
|
-
}
|
|
50
2
|
|
|
51
3
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
4
|
+
* Backward-compatibility shim for the legacy `walkPRStack(client, ...)`
|
|
5
|
+
* signature. The real implementation now lives in
|
|
6
|
+
* `src/github/operations/stack-walker.js` (the dispatcher) and
|
|
7
|
+
* `src/github/impl/graphql/stack-walker.js` (the GraphQL implementation).
|
|
56
8
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* @param {string[]} [_deps.defaultBranches] - Branch names considered trunk
|
|
63
|
-
* @returns {Promise<Array>} Ordered stack from trunk to tip
|
|
9
|
+
* Direct importers of this module (`src/routes/pr.js` and tests) pass a
|
|
10
|
+
* GitHubClient-like object as the first argument. The dispatcher accepts
|
|
11
|
+
* that shape and routes to the correct transport via
|
|
12
|
+
* `client.binding.features.stack_walker` when present, defaulting to
|
|
13
|
+
* `"graphql"` otherwise — preserving pre-refactor behaviour exactly.
|
|
64
14
|
*/
|
|
65
|
-
async function walkPRStack(client, owner, repo, prNumber, _deps) {
|
|
66
|
-
const deps = { defaultBranches: DEFAULT_TRUNK_BRANCHES, ..._deps };
|
|
67
|
-
const graphql = client.octokit.graphql.bind(client.octokit);
|
|
68
|
-
const visited = new Set();
|
|
69
|
-
|
|
70
|
-
// Step 1: Fetch the starting PR
|
|
71
|
-
const startResult = await graphql(FETCH_PR_QUERY, { owner, repo, number: prNumber });
|
|
72
|
-
const startPR = startResult.repository?.pullRequest;
|
|
73
|
-
if (!startPR) {
|
|
74
|
-
throw new Error(`PR #${prNumber} not found in ${owner}/${repo}`);
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
logger.debug(`Stack walker: starting from PR #${startPR.number} (${startPR.headRefName} -> ${startPR.baseRefName})`);
|
|
78
|
-
visited.add(startPR.headRefName);
|
|
79
|
-
|
|
80
|
-
// Step 2: Walk UP toward trunk
|
|
81
|
-
const parents = []; // will be reversed at the end
|
|
82
|
-
let currentBase = startPR.baseRefName;
|
|
83
|
-
let walkUpDepth = 0;
|
|
84
|
-
|
|
85
|
-
while (walkUpDepth < MAX_WALK_DEPTH) {
|
|
86
|
-
if (deps.defaultBranches.includes(currentBase)) {
|
|
87
|
-
// Reached trunk
|
|
88
|
-
break;
|
|
89
|
-
}
|
|
90
|
-
if (visited.has(currentBase)) {
|
|
91
|
-
logger.warn(`Stack walker: cycle detected at branch "${currentBase}", stopping upward walk`);
|
|
92
|
-
break;
|
|
93
|
-
}
|
|
94
|
-
visited.add(currentBase);
|
|
95
|
-
|
|
96
|
-
let parentPR;
|
|
97
|
-
try {
|
|
98
|
-
const result = await graphql(FIND_PRS_BY_HEAD_QUERY, { owner, repo, branch: currentBase });
|
|
99
|
-
const candidates = result.repository?.pullRequests?.nodes || [];
|
|
100
|
-
parentPR = pickBestPR(candidates);
|
|
101
|
-
} catch (err) {
|
|
102
|
-
logger.warn(`Stack walker: GraphQL error walking up at branch "${currentBase}": ${err.message}`);
|
|
103
|
-
break;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
if (!parentPR) {
|
|
107
|
-
// No parent PR found — currentBase is effectively trunk for this stack
|
|
108
|
-
break;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
parents.push({
|
|
112
|
-
branch: parentPR.headRefName,
|
|
113
|
-
isTrunk: false,
|
|
114
|
-
prNumber: parentPR.number,
|
|
115
|
-
title: parentPR.title,
|
|
116
|
-
state: parentPR.state,
|
|
117
|
-
url: parentPR.url,
|
|
118
|
-
headSha: parentPR.headRefOid,
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
currentBase = parentPR.baseRefName;
|
|
122
|
-
walkUpDepth++;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (walkUpDepth >= MAX_WALK_DEPTH) {
|
|
126
|
-
logger.warn(`Stack walker: upward walk reached max depth of ${MAX_WALK_DEPTH}`);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// The trunk entry is whatever branch the topmost PR targets
|
|
130
|
-
const trunkBranch = currentBase;
|
|
131
|
-
|
|
132
|
-
// Step 3: Walk DOWN toward tip
|
|
133
|
-
const children = [];
|
|
134
|
-
let currentHead = startPR.headRefName;
|
|
135
|
-
let walkDownDepth = 0;
|
|
136
|
-
|
|
137
|
-
while (walkDownDepth < MAX_WALK_DEPTH) {
|
|
138
|
-
let childPR;
|
|
139
|
-
try {
|
|
140
|
-
const result = await graphql(FIND_PRS_BY_BASE_QUERY, { owner, repo, branch: currentHead });
|
|
141
|
-
const candidates = result.repository?.pullRequests?.nodes || [];
|
|
142
|
-
childPR = pickBestPR(candidates);
|
|
143
|
-
} catch (err) {
|
|
144
|
-
logger.warn(`Stack walker: GraphQL error walking down at branch "${currentHead}": ${err.message}`);
|
|
145
|
-
break;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
if (!childPR) {
|
|
149
|
-
break;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
if (visited.has(childPR.headRefName)) {
|
|
153
|
-
logger.warn(`Stack walker: cycle detected at branch "${childPR.headRefName}", stopping downward walk`);
|
|
154
|
-
break;
|
|
155
|
-
}
|
|
156
|
-
visited.add(childPR.headRefName);
|
|
157
|
-
|
|
158
|
-
children.push({
|
|
159
|
-
branch: childPR.headRefName,
|
|
160
|
-
isTrunk: false,
|
|
161
|
-
prNumber: childPR.number,
|
|
162
|
-
title: childPR.title,
|
|
163
|
-
state: childPR.state,
|
|
164
|
-
url: childPR.url,
|
|
165
|
-
headSha: childPR.headRefOid,
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
currentHead = childPR.headRefName;
|
|
169
|
-
walkDownDepth++;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
if (walkDownDepth >= MAX_WALK_DEPTH) {
|
|
173
|
-
logger.warn(`Stack walker: downward walk reached max depth of ${MAX_WALK_DEPTH}`);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// Step 4: Assemble the ordered stack (trunk -> ... -> parents -> start -> children -> ... -> tip)
|
|
177
|
-
const stack = [
|
|
178
|
-
{ branch: trunkBranch, isTrunk: true },
|
|
179
|
-
...parents.reverse(),
|
|
180
|
-
{
|
|
181
|
-
branch: startPR.headRefName,
|
|
182
|
-
isTrunk: false,
|
|
183
|
-
prNumber: startPR.number,
|
|
184
|
-
title: startPR.title,
|
|
185
|
-
state: startPR.state,
|
|
186
|
-
url: startPR.url,
|
|
187
|
-
headSha: startPR.headRefOid,
|
|
188
|
-
},
|
|
189
|
-
...children,
|
|
190
|
-
];
|
|
191
15
|
|
|
192
|
-
|
|
193
|
-
return stack;
|
|
194
|
-
}
|
|
16
|
+
const operations = require('./operations/stack-walker');
|
|
195
17
|
|
|
196
|
-
module.exports = {
|
|
18
|
+
module.exports = {
|
|
19
|
+
walkPRStack: operations.walkPRStack,
|
|
20
|
+
DEFAULT_TRUNK_BRANCHES: operations.DEFAULT_TRUNK_BRANCHES
|
|
21
|
+
};
|