@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.
Files changed (93) hide show
  1. package/README.md +4 -0
  2. package/package.json +1 -1
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/public/css/analysis-config.css +1807 -0
  6. package/public/css/pr.css +1029 -2169
  7. package/public/index.html +11 -0
  8. package/public/js/components/AIPanel.js +39 -23
  9. package/public/js/components/AdvancedConfigTab.js +56 -4
  10. package/public/js/components/AnalysisConfigModal.js +41 -25
  11. package/public/js/components/ChatPanel.js +163 -3
  12. package/public/js/components/KeyboardShortcuts.js +10 -26
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/TourBar.js +248 -0
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +64 -8
  18. package/public/js/modules/cancel-background-job.js +183 -0
  19. package/public/js/modules/hunk-summary-renderer.js +116 -0
  20. package/public/js/modules/storage-cleanup.js +16 -0
  21. package/public/js/modules/suggestion-manager.js +25 -1
  22. package/public/js/modules/tour-renderer.js +755 -0
  23. package/public/js/pr.js +1826 -56
  24. package/public/js/repo-links.js +328 -0
  25. package/public/js/utils/modal-detection.js +77 -0
  26. package/public/js/utils/provider-model.js +88 -0
  27. package/public/js/utils/storage-keys.js +50 -0
  28. package/public/local.html +24 -0
  29. package/public/pr.html +24 -0
  30. package/public/repo-settings.html +1 -0
  31. package/public/setup.html +2 -0
  32. package/src/ai/abort-signal-wiring.js +130 -0
  33. package/src/ai/analyzer.js +125 -18
  34. package/src/ai/background-queue.js +290 -0
  35. package/src/ai/claude-cli.js +1 -1
  36. package/src/ai/claude-provider.js +50 -7
  37. package/src/ai/codex-provider.js +28 -5
  38. package/src/ai/copilot-provider.js +22 -3
  39. package/src/ai/cursor-agent-provider.js +22 -6
  40. package/src/ai/executable-provider.js +4 -19
  41. package/src/ai/gemini-provider.js +22 -5
  42. package/src/ai/hunk-hashing.js +161 -0
  43. package/src/ai/index.js +2 -0
  44. package/src/ai/opencode-provider.js +21 -5
  45. package/src/ai/pi-provider.js +21 -5
  46. package/src/ai/prompts/hunk-summary.js +199 -0
  47. package/src/ai/prompts/tour.js +232 -0
  48. package/src/ai/provider.js +21 -1
  49. package/src/ai/summary-generator.js +469 -0
  50. package/src/ai/tour-generator.js +568 -0
  51. package/src/config.js +778 -10
  52. package/src/database.js +282 -1
  53. package/src/external/github-adapter.js +114 -25
  54. package/src/git/base-branch.js +11 -4
  55. package/src/github/client.js +482 -588
  56. package/src/github/errors.js +55 -0
  57. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  58. package/src/github/impl/graphql/pending-review.js +153 -0
  59. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  60. package/src/github/impl/graphql/stack-walker.js +210 -0
  61. package/src/github/impl/host/pending-review-comments.js +338 -0
  62. package/src/github/impl/rest/pending-review.js +251 -0
  63. package/src/github/impl/rest/review-lifecycle.js +226 -0
  64. package/src/github/impl/rest/stack-walker.js +309 -0
  65. package/src/github/operations/pending-review-comments.js +79 -0
  66. package/src/github/operations/pending-review.js +89 -0
  67. package/src/github/operations/review-lifecycle.js +126 -0
  68. package/src/github/operations/stack-walker.js +87 -0
  69. package/src/github/parser.js +230 -4
  70. package/src/github/stack-walker.js +14 -189
  71. package/src/links/repo-links.js +230 -0
  72. package/src/local-review.js +201 -172
  73. package/src/main.js +133 -30
  74. package/src/routes/analyses.js +30 -7
  75. package/src/routes/bulk-analysis-configs.js +295 -0
  76. package/src/routes/config.js +118 -3
  77. package/src/routes/context-files.js +2 -29
  78. package/src/routes/external-comments.js +20 -10
  79. package/src/routes/github-collections.js +3 -1
  80. package/src/routes/local.js +410 -13
  81. package/src/routes/mcp.js +47 -4
  82. package/src/routes/middleware/validate-review-id.js +53 -0
  83. package/src/routes/pr.js +556 -71
  84. package/src/routes/reviews.js +145 -29
  85. package/src/routes/setup.js +8 -3
  86. package/src/routes/stack-analysis.js +33 -9
  87. package/src/routes/worktrees.js +3 -2
  88. package/src/server.js +2 -0
  89. package/src/setup/pr-setup.js +37 -11
  90. package/src/setup/stack-setup.js +13 -3
  91. package/src/single-port.js +6 -3
  92. package/src/utils/diff-hunks.js +65 -0
  93. package/src/utils/json-extractor.js +5 -2
@@ -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
- constructor() {
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<Object>} Parsed PR information { owner, repo, number }
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
- * Walk a GitHub PR stack by following the branch chain via GraphQL.
53
- *
54
- * Starting from a given PR, walks up toward trunk (following baseRefName)
55
- * and down toward the tip (following headRefName) to discover the full stack.
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
- * @param {Object} client - GitHubClient instance (uses client.octokit.graphql)
58
- * @param {string} owner - Repository owner
59
- * @param {string} repo - Repository name
60
- * @param {number} prNumber - Starting PR number
61
- * @param {Object} [_deps] - Optional dependency overrides for testing
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
- logger.debug(`Stack walker: found ${stack.length} entries (${stack.filter(e => !e.isTrunk).length} PRs)`);
193
- return stack;
194
- }
16
+ const operations = require('./operations/stack-walker');
195
17
 
196
- module.exports = { walkPRStack, DEFAULT_TRUNK_BRANCHES };
18
+ module.exports = {
19
+ walkPRStack: operations.walkPRStack,
20
+ DEFAULT_TRUNK_BRANCHES: operations.DEFAULT_TRUNK_BRANCHES
21
+ };