@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.
Files changed (67) hide show
  1. package/README.md +4 -0
  2. package/package.json +20 -15
  3. package/plugin/.claude-plugin/plugin.json +1 -1
  4. package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
  5. package/plugin-code-critic/skills/analyze/scripts/git-diff-lines +0 -0
  6. package/public/css/analysis-config.css +1807 -0
  7. package/public/css/pr.css +17 -1737
  8. package/public/index.html +11 -0
  9. package/public/js/components/AIPanel.js +89 -44
  10. package/public/js/components/AdvancedConfigTab.js +56 -4
  11. package/public/js/components/AnalysisConfigModal.js +41 -25
  12. package/public/js/components/ChatPanel.js +11 -1
  13. package/public/js/components/ReviewModal.js +135 -13
  14. package/public/js/components/SuggestionNavigator.js +55 -10
  15. package/public/js/components/VoiceCentricConfigTab.js +36 -0
  16. package/public/js/index.js +175 -16
  17. package/public/js/local.js +58 -8
  18. package/public/js/modules/suggestion-manager.js +25 -1
  19. package/public/js/modules/tour-renderer.js +45 -5
  20. package/public/js/pr.js +703 -171
  21. package/public/js/repo-links.js +328 -0
  22. package/public/js/utils/provider-model.js +88 -0
  23. package/public/js/utils/scroll-into-view.js +164 -0
  24. package/public/js/utils/storage-keys.js +50 -0
  25. package/public/local.html +10 -0
  26. package/public/pr.html +10 -0
  27. package/public/repo-settings.html +1 -0
  28. package/public/setup.html +2 -0
  29. package/src/ai/analyzer.js +125 -18
  30. package/src/ai/claude-provider.js +31 -3
  31. package/src/config.js +664 -10
  32. package/src/external/github-adapter.js +114 -25
  33. package/src/git/base-branch.js +11 -4
  34. package/src/github/client.js +482 -588
  35. package/src/github/errors.js +55 -0
  36. package/src/github/impl/graphql/pending-review-comments.js +230 -0
  37. package/src/github/impl/graphql/pending-review.js +153 -0
  38. package/src/github/impl/graphql/review-lifecycle.js +161 -0
  39. package/src/github/impl/graphql/stack-walker.js +210 -0
  40. package/src/github/impl/host/pending-review-comments.js +338 -0
  41. package/src/github/impl/rest/pending-review.js +251 -0
  42. package/src/github/impl/rest/review-lifecycle.js +226 -0
  43. package/src/github/impl/rest/stack-walker.js +309 -0
  44. package/src/github/operations/pending-review-comments.js +79 -0
  45. package/src/github/operations/pending-review.js +89 -0
  46. package/src/github/operations/review-lifecycle.js +126 -0
  47. package/src/github/operations/stack-walker.js +87 -0
  48. package/src/github/parser.js +230 -4
  49. package/src/github/stack-walker.js +14 -189
  50. package/src/links/repo-links.js +230 -0
  51. package/src/local-review.js +13 -4
  52. package/src/main.js +136 -32
  53. package/src/routes/analyses.js +30 -7
  54. package/src/routes/bulk-analysis-configs.js +295 -0
  55. package/src/routes/config.js +102 -2
  56. package/src/routes/external-comments.js +20 -10
  57. package/src/routes/github-collections.js +3 -1
  58. package/src/routes/local.js +101 -11
  59. package/src/routes/mcp.js +47 -4
  60. package/src/routes/pr.js +298 -68
  61. package/src/routes/setup.js +8 -3
  62. package/src/routes/stack-analysis.js +33 -9
  63. package/src/routes/worktrees.js +3 -2
  64. package/src/server.js +2 -0
  65. package/src/setup/pr-setup.js +37 -11
  66. package/src/setup/stack-setup.js +13 -3
  67. package/src/single-port.js +6 -3
@@ -0,0 +1,309 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ const logger = require('../../../utils/logger');
3
+
4
+ /**
5
+ * REST implementation of the stack-walker area.
6
+ *
7
+ * Mirrors `impl/graphql/stack-walker.js` but uses REST endpoints:
8
+ *
9
+ * - Q3 FETCH_PR_QUERY -> `pulls.get({ owner, repo, pull_number })`
10
+ * - Q4 FIND_PRS_BY_HEAD_QUERY -> `pulls.list({ owner, repo, head, state: 'all' })`
11
+ * - Q5 FIND_PRS_BY_BASE_QUERY -> `pulls.list({ owner, repo, base, state: 'open' })`
12
+ *
13
+ * Returns the ordered stack shape `walkPRStack` historically returned
14
+ * (trunk -> parents (oldest first when reversed in caller) -> starting
15
+ * PR -> children). PR entries match the GraphQL impl's normalised node
16
+ * shape: { number, title, baseRefName, headRefName, headRefOid, state,
17
+ * url }, with `state` normalised to uppercase to match GraphQL
18
+ * semantics.
19
+ *
20
+ * Discovery scope: `findPRsByHead` passes `head: "${owner}:${branch}"`
21
+ * to `pulls.list`. GitHub REST's `head` filter is strictly
22
+ * `user:branch` and only matches PRs whose head ref lives on the same
23
+ * owner as the base repo; PRs opened from contributor forks are
24
+ * silently excluded. The GraphQL impl's `headRefName` filter has no
25
+ * such restriction.
26
+ *
27
+ * This impl is intended for alt-hosts (GitHub Enterprise, etc.) where
28
+ * stacking workflows do not involve forks. On github.com, `stack_walker`
29
+ * defaults to GraphQL (see `GRAPHQL_DEFAULT_AREAS` in `src/config.js`),
30
+ * where the fork restriction does not apply.
31
+ */
32
+
33
+ const DEFAULT_TRUNK_BRANCHES = ['main', 'master', 'develop'];
34
+ const MAX_WALK_DEPTH = 20;
35
+
36
+ /**
37
+ * Normalise a REST PR object to the GraphQL-style shape used by the
38
+ * stack walker. REST exposes `state` in lowercase (`open`/`closed`) and
39
+ * separates `merged` via `merged_at != null`; GraphQL exposes
40
+ * `OPEN`/`CLOSED`/`MERGED` directly. Normalising here keeps the
41
+ * downstream walk logic transport-agnostic.
42
+ *
43
+ * @param {Object} pr - REST PR object (from `pulls.get` or `pulls.list`)
44
+ * @returns {Object} GraphQL-shaped PR node
45
+ */
46
+ function normalisePR(pr) {
47
+ let state;
48
+ if (pr.merged_at) {
49
+ state = 'MERGED';
50
+ } else if (typeof pr.state === 'string') {
51
+ state = pr.state.toUpperCase();
52
+ } else {
53
+ state = 'OPEN';
54
+ }
55
+ return {
56
+ number: pr.number,
57
+ title: pr.title,
58
+ baseRefName: pr.base && pr.base.ref,
59
+ headRefName: pr.head && pr.head.ref,
60
+ headRefOid: pr.head && pr.head.sha,
61
+ state,
62
+ url: pr.html_url
63
+ };
64
+ }
65
+
66
+ /**
67
+ * Select the best PR from a list of candidates for the same branch.
68
+ * Prefers OPEN over MERGED. Mirrors the GraphQL impl exactly.
69
+ *
70
+ * @param {Array} prs - Array of normalised PR nodes
71
+ * @returns {Object|null}
72
+ */
73
+ function pickBestPR(prs) {
74
+ if (!prs || prs.length === 0) return null;
75
+ const open = prs.find(pr => pr.state === 'OPEN');
76
+ if (open) return open;
77
+ return prs[0];
78
+ }
79
+
80
+ /**
81
+ * Fetch the starting PR by number.
82
+ *
83
+ * @param {Object} octokit
84
+ * @param {string} owner
85
+ * @param {string} repo
86
+ * @param {number} prNumber
87
+ * @returns {Promise<Object|null>}
88
+ */
89
+ async function fetchPR(octokit, owner, repo, prNumber) {
90
+ try {
91
+ const { data } = await octokit.rest.pulls.get({
92
+ owner,
93
+ repo,
94
+ pull_number: prNumber
95
+ });
96
+ return normalisePR(data);
97
+ } catch (err) {
98
+ if (err.status === 404) return null;
99
+ throw err;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Find PRs whose HEAD branch matches `branch`. Filters to OPEN + MERGED
105
+ * to match the GraphQL impl's `states: [OPEN, MERGED]`.
106
+ *
107
+ * Ordering note: GraphQL uses `orderBy: { field: UPDATED_AT, direction:
108
+ * DESC }`. The REST `pulls.list` endpoint sorts by `updated_at`
109
+ * descending by default; we pass it explicitly to stay
110
+ * observationally-identical.
111
+ *
112
+ * @param {Object} octokit
113
+ * @param {string} owner
114
+ * @param {string} repo
115
+ * @param {string} branch
116
+ * @returns {Promise<Array>}
117
+ */
118
+ async function findPRsByHead(octokit, owner, repo, branch) {
119
+ // REST `state` filter accepts only `open|closed|all`. `closed`
120
+ // includes merged. We need OPEN + MERGED but not CLOSED-without-merge,
121
+ // so fetch with `state: 'all'` and filter client-side.
122
+ const { data } = await octokit.rest.pulls.list({
123
+ owner,
124
+ repo,
125
+ head: `${owner}:${branch}`,
126
+ state: 'all',
127
+ sort: 'updated',
128
+ direction: 'desc',
129
+ per_page: 5
130
+ });
131
+ return data
132
+ .map(normalisePR)
133
+ .filter(pr => pr.state === 'OPEN' || pr.state === 'MERGED');
134
+ }
135
+
136
+ /**
137
+ * Find PRs whose BASE branch matches `branch`. Mirrors GraphQL's
138
+ * `states: [OPEN]`.
139
+ *
140
+ * @param {Object} octokit
141
+ * @param {string} owner
142
+ * @param {string} repo
143
+ * @param {string} branch
144
+ * @returns {Promise<Array>}
145
+ */
146
+ async function findPRsByBase(octokit, owner, repo, branch) {
147
+ const { data } = await octokit.rest.pulls.list({
148
+ owner,
149
+ repo,
150
+ base: branch,
151
+ state: 'open',
152
+ sort: 'updated',
153
+ direction: 'desc',
154
+ per_page: 5
155
+ });
156
+ return data.map(normalisePR);
157
+ }
158
+
159
+ /**
160
+ * Walk a PR stack using REST endpoints.
161
+ *
162
+ * Same algorithm as the GraphQL implementation; only the transport
163
+ * differs.
164
+ *
165
+ * @param {Object} octokit - Octokit instance bound to the host's baseUrl
166
+ * @param {string} owner
167
+ * @param {string} repo
168
+ * @param {number} prNumber
169
+ * @param {Object} [_deps]
170
+ * @param {string[]} [_deps.defaultBranches]
171
+ * @returns {Promise<Array>}
172
+ */
173
+ async function walkPRStack(octokit, owner, repo, prNumber, _deps) {
174
+ const deps = { defaultBranches: DEFAULT_TRUNK_BRANCHES, ..._deps };
175
+ const visited = new Set();
176
+
177
+ // Step 1: Fetch the starting PR
178
+ const startPR = await fetchPR(octokit, owner, repo, prNumber);
179
+ if (!startPR) {
180
+ throw new Error(`PR #${prNumber} not found in ${owner}/${repo}`);
181
+ }
182
+
183
+ logger.debug(`Stack walker (REST): starting from PR #${startPR.number} (${startPR.headRefName} -> ${startPR.baseRefName})`);
184
+ visited.add(startPR.headRefName);
185
+
186
+ // Step 2: Walk UP toward trunk
187
+ const parents = [];
188
+ let currentBase = startPR.baseRefName;
189
+ let walkUpDepth = 0;
190
+
191
+ while (walkUpDepth < MAX_WALK_DEPTH) {
192
+ if (deps.defaultBranches.includes(currentBase)) {
193
+ break;
194
+ }
195
+ if (visited.has(currentBase)) {
196
+ logger.warn(`Stack walker (REST): cycle detected at branch "${currentBase}", stopping upward walk`);
197
+ break;
198
+ }
199
+ visited.add(currentBase);
200
+
201
+ let parentPR;
202
+ try {
203
+ const candidates = await findPRsByHead(octokit, owner, repo, currentBase);
204
+ parentPR = pickBestPR(candidates);
205
+ } catch (err) {
206
+ logger.warn(`Stack walker (REST): error walking up at branch "${currentBase}": ${err.message}`);
207
+ break;
208
+ }
209
+
210
+ if (!parentPR) {
211
+ break;
212
+ }
213
+
214
+ parents.push({
215
+ branch: parentPR.headRefName,
216
+ isTrunk: false,
217
+ prNumber: parentPR.number,
218
+ title: parentPR.title,
219
+ state: parentPR.state,
220
+ url: parentPR.url,
221
+ headSha: parentPR.headRefOid,
222
+ });
223
+
224
+ currentBase = parentPR.baseRefName;
225
+ walkUpDepth++;
226
+ }
227
+
228
+ if (walkUpDepth >= MAX_WALK_DEPTH) {
229
+ logger.warn(`Stack walker (REST): upward walk reached max depth of ${MAX_WALK_DEPTH}`);
230
+ }
231
+
232
+ const trunkBranch = currentBase;
233
+
234
+ // Step 3: Walk DOWN toward tip
235
+ const children = [];
236
+ let currentHead = startPR.headRefName;
237
+ let walkDownDepth = 0;
238
+
239
+ while (walkDownDepth < MAX_WALK_DEPTH) {
240
+ let childPR;
241
+ try {
242
+ const candidates = await findPRsByBase(octokit, owner, repo, currentHead);
243
+ childPR = pickBestPR(candidates);
244
+ } catch (err) {
245
+ logger.warn(`Stack walker (REST): error walking down at branch "${currentHead}": ${err.message}`);
246
+ break;
247
+ }
248
+
249
+ if (!childPR) {
250
+ break;
251
+ }
252
+
253
+ if (visited.has(childPR.headRefName)) {
254
+ logger.warn(`Stack walker (REST): cycle detected at branch "${childPR.headRefName}", stopping downward walk`);
255
+ break;
256
+ }
257
+ visited.add(childPR.headRefName);
258
+
259
+ children.push({
260
+ branch: childPR.headRefName,
261
+ isTrunk: false,
262
+ prNumber: childPR.number,
263
+ title: childPR.title,
264
+ state: childPR.state,
265
+ url: childPR.url,
266
+ headSha: childPR.headRefOid,
267
+ });
268
+
269
+ currentHead = childPR.headRefName;
270
+ walkDownDepth++;
271
+ }
272
+
273
+ if (walkDownDepth >= MAX_WALK_DEPTH) {
274
+ logger.warn(`Stack walker (REST): downward walk reached max depth of ${MAX_WALK_DEPTH}`);
275
+ }
276
+
277
+ // Step 4: Assemble the ordered stack
278
+ const stack = [
279
+ { branch: trunkBranch, isTrunk: true },
280
+ ...parents.reverse(),
281
+ {
282
+ branch: startPR.headRefName,
283
+ isTrunk: false,
284
+ prNumber: startPR.number,
285
+ title: startPR.title,
286
+ state: startPR.state,
287
+ url: startPR.url,
288
+ headSha: startPR.headRefOid,
289
+ },
290
+ ...children,
291
+ ];
292
+
293
+ logger.debug(`Stack walker (REST): found ${stack.length} entries (${stack.filter(e => !e.isTrunk).length} PRs)`);
294
+ return stack;
295
+ }
296
+
297
+ module.exports = {
298
+ walkPRStack,
299
+ DEFAULT_TRUNK_BRANCHES,
300
+ MAX_WALK_DEPTH,
301
+ // Exposed for tests and parity verification.
302
+ _internals: {
303
+ normalisePR,
304
+ pickBestPR,
305
+ fetchPR,
306
+ findPRsByHead,
307
+ findPRsByBase
308
+ }
309
+ };
@@ -0,0 +1,79 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ const graphqlImpl = require('../impl/graphql/pending-review-comments');
3
+ const hostImpl = require('../impl/host/pending-review-comments');
4
+
5
+ /**
6
+ * Dispatcher for the `pending_review_comments` area.
7
+ *
8
+ * Adds inline comments (line / range / file-level) to an already-pending
9
+ * review. This area is special: GitHub provides no REST equivalent for
10
+ * attaching comments to a pending draft, so the `"rest"` value is
11
+ * explicitly rejected at runtime per the plan. Alt-hosts must declare
12
+ * `"host"` and provide an extension endpoint.
13
+ *
14
+ * Dispatch:
15
+ * - `"graphql"` (default for github.com): delegates to
16
+ * `impl/graphql/pending-review-comments.js`. Identical behaviour to
17
+ * `GitHubClient.addCommentsInBatches` prior to the Phase 3 refactor,
18
+ * including the adaptive batch-size halving on complexity errors.
19
+ * - `"rest"`: rejected with a clear error. GitHub REST cannot reliably
20
+ * attach comments to a pending draft (see plan Hazards).
21
+ * - `"host"`: delegates to `impl/host/pending-review-comments.js`,
22
+ * which posts to the host's extension endpoint. Requires `prContext`
23
+ * ({ owner, repo, prNumber }) because the host endpoint is
24
+ * path-shaped — the GraphQL node IDs alone are not sufficient.
25
+ */
26
+
27
+ const AREA = 'pending_review_comments';
28
+ // Modes actually implemented by the dispatcher below. Co-located with the
29
+ // dispatch logic so validateRepoConfig() and the dispatcher can't drift.
30
+ // REST is intentionally excluded: GitHub REST cannot attach comments to a
31
+ // pending draft review.
32
+ const IMPLEMENTED_MODES = new Set(['graphql', 'host']);
33
+
34
+ function selectFeature(features) {
35
+ return (features && features[AREA]) || 'graphql';
36
+ }
37
+
38
+ /**
39
+ * Add a list of comments to a pending review.
40
+ *
41
+ * The `prNodeId` / `reviewId` arguments are GraphQL-shaped (opaque node
42
+ * IDs) for the GraphQL path. For the `"host"` path, `prContext` supplies
43
+ * the path components and `reviewId` is interpreted as the host's review
44
+ * identifier (a REST id returned by the host's `review_lifecycle` impl).
45
+ *
46
+ * @param {Object} octokit - Octokit instance bound to the host's baseUrl
47
+ * @param {Object} features - Feature-flag object from the host binding
48
+ * @param {string} prNodeId - GraphQL node ID for the PR (graphql path)
49
+ * @param {string} reviewId - Review identifier. GraphQL node ID on the
50
+ * graphql path; the host's REST review id on the host path.
51
+ * @param {Array} comments - Comments with path, line (optional), side, body, isFileLevel
52
+ * @param {number} [batchSize=10]
53
+ * @param {Object} [prContext] - `{ owner, repo, prNumber }`. Required for
54
+ * the `"host"` path; ignored on the graphql path.
55
+ * @returns {Promise<{successCount: number, failed: boolean, failedDetails: string[]}>}
56
+ */
57
+ async function addCommentsInBatches(octokit, features, prNodeId, reviewId, comments, batchSize, prContext) {
58
+ const mode = selectFeature(features);
59
+ if (mode === 'graphql') {
60
+ return graphqlImpl.addCommentsInBatches(octokit, prNodeId, reviewId, comments, batchSize);
61
+ }
62
+ if (mode === 'rest') {
63
+ throw new Error(
64
+ 'REST implementation for pending_review_comments is not supported: ' +
65
+ 'GitHub REST cannot reliably attach comments to a pending draft review. ' +
66
+ 'Use "graphql" for github.com or "host" with a host extension for alt-hosts.'
67
+ );
68
+ }
69
+ if (mode === 'host') {
70
+ return hostImpl.addCommentsInBatches(octokit, features, prContext, reviewId, comments, batchSize);
71
+ }
72
+ throw new Error(`Unknown features.pending_review_comments value: "${mode}"`);
73
+ }
74
+
75
+ module.exports = {
76
+ addCommentsInBatches,
77
+ AREA,
78
+ IMPLEMENTED_MODES
79
+ };
@@ -0,0 +1,89 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ const graphqlImpl = require('../impl/graphql/pending-review');
3
+ const restImpl = require('../impl/rest/pending-review');
4
+
5
+ /**
6
+ * Dispatcher for the `pending_review_check` area.
7
+ *
8
+ * Each operation inspects `features.pending_review_check`:
9
+ * - `"graphql"` (default for github.com): delegates to the GraphQL impl
10
+ * in `impl/graphql/pending-review.js`. Identical behaviour to what
11
+ * `GitHubClient` did before the Phase 3 refactor.
12
+ * - `"rest"`: delegates to `impl/rest/pending-review.js`. The REST
13
+ * implementation produces the same return shape as the GraphQL impl.
14
+ * Note that `getReviewById` requires a `prContext` because the
15
+ * REST API identifies a review by (owner, repo, pull_number,
16
+ * review_id) rather than by node id alone.
17
+ * - `"host"`: not yet implemented — Phase 5 will add it (no host
18
+ * extension is currently defined for this area).
19
+ *
20
+ * The dispatch shape allows each call site to use the same function
21
+ * signature regardless of which transport actually runs underneath.
22
+ */
23
+
24
+ const AREA = 'pending_review_check';
25
+ // Modes actually implemented by the dispatcher below. Co-located with the
26
+ // dispatch logic so validateRepoConfig() and the dispatcher can't drift.
27
+ // `host` is reserved for Phase 5 and is not yet implemented.
28
+ const IMPLEMENTED_MODES = new Set(['graphql', 'rest']);
29
+
30
+ function selectFeature(features) {
31
+ return (features && features[AREA]) || 'graphql';
32
+ }
33
+
34
+ /**
35
+ * Fetch the pending review (if any) authored by the authenticated user.
36
+ *
37
+ * @param {Object} octokit - Octokit instance bound to the host's baseUrl
38
+ * @param {Object} features - Feature-flag object from the host binding
39
+ * @param {string} owner
40
+ * @param {string} repo
41
+ * @param {number} prNumber
42
+ * @returns {Promise<Object|null>}
43
+ */
44
+ async function getPendingReviewForUser(octokit, features, owner, repo, prNumber) {
45
+ const mode = selectFeature(features);
46
+ if (mode === 'graphql') {
47
+ return graphqlImpl.getPendingReviewForUser(octokit, owner, repo, prNumber);
48
+ }
49
+ if (mode === 'rest') {
50
+ return restImpl.getPendingReviewForUser(octokit, owner, repo, prNumber);
51
+ }
52
+ if (mode === 'host') {
53
+ throw new Error('Host implementation for pending_review_check not yet available (Phase 5)');
54
+ }
55
+ throw new Error(`Unknown features.pending_review_check value: "${mode}"`);
56
+ }
57
+
58
+ /**
59
+ * Fetch a review by its GraphQL/database node ID.
60
+ *
61
+ * @param {Object} octokit
62
+ * @param {Object} features
63
+ * @param {string} nodeId
64
+ * @param {Object} [prContext] - { owner, repo, prNumber, reviewId? }
65
+ * REQUIRED when `features.pending_review_check === "rest"` because the
66
+ * REST endpoint identifies a review by (owner, repo, pull_number,
67
+ * review_id) rather than by node id. Optional for the GraphQL path.
68
+ * @returns {Promise<Object|null>}
69
+ */
70
+ async function getReviewById(octokit, features, nodeId, prContext) {
71
+ const mode = selectFeature(features);
72
+ if (mode === 'graphql') {
73
+ return graphqlImpl.getReviewById(octokit, nodeId);
74
+ }
75
+ if (mode === 'rest') {
76
+ return restImpl.getReviewById(octokit, nodeId, prContext);
77
+ }
78
+ if (mode === 'host') {
79
+ throw new Error('Host implementation for pending_review_check not yet available (Phase 5)');
80
+ }
81
+ throw new Error(`Unknown features.pending_review_check value: "${mode}"`);
82
+ }
83
+
84
+ module.exports = {
85
+ getPendingReviewForUser,
86
+ getReviewById,
87
+ AREA,
88
+ IMPLEMENTED_MODES
89
+ };
@@ -0,0 +1,126 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ const graphqlImpl = require('../impl/graphql/review-lifecycle');
3
+ const restImpl = require('../impl/rest/review-lifecycle');
4
+
5
+ /**
6
+ * Dispatcher for the `review_lifecycle` area:
7
+ * - addPullRequestReview (creates a pending review)
8
+ * - addPullRequestReviewWithBody (creates a pending review with a body)
9
+ * - submitPullRequestReview (submits a pending review with an event)
10
+ * - deletePullRequestReview (deletes a pending review)
11
+ *
12
+ * Each operation inspects `features.review_lifecycle`:
13
+ * - `"graphql"` (default for github.com): delegates to the GraphQL impl
14
+ * in `impl/graphql/review-lifecycle.js`. Identical behaviour to what
15
+ * `GitHubClient` did before the Phase 3 refactor.
16
+ * - `"rest"`: delegates to `impl/rest/review-lifecycle.js`. The REST
17
+ * impl requires a `prContext = { owner, repo, prNumber, reviewId? }`
18
+ * because REST endpoints identify the review by
19
+ * (owner, repo, pull_number, review_id) rather than by node id.
20
+ * - `"host"`: not yet implemented — Phase 5 (no current host extension).
21
+ */
22
+
23
+ const AREA = 'review_lifecycle';
24
+ // Modes actually implemented by the dispatcher below. Co-located with the
25
+ // dispatch logic so validateRepoConfig() and the dispatcher can't drift.
26
+ // `host` is reserved for Phase 5 and is not yet implemented.
27
+ const IMPLEMENTED_MODES = new Set(['graphql', 'rest']);
28
+
29
+ function selectFeature(features) {
30
+ return (features && features[AREA]) || 'graphql';
31
+ }
32
+
33
+ function notYetAvailable(mode) {
34
+ if (mode === 'host') {
35
+ throw new Error('Host implementation for review_lifecycle not yet available (Phase 5)');
36
+ }
37
+ throw new Error(`Unknown features.review_lifecycle value: "${mode}"`);
38
+ }
39
+
40
+ /**
41
+ * Create a pending review (no body).
42
+ *
43
+ * @param {Object} octokit
44
+ * @param {Object} features
45
+ * @param {string} prNodeId - GraphQL node id; required for GraphQL, accepted for REST signature parity
46
+ * @param {Object} [prContext] - { owner, repo, prNumber } — REQUIRED for REST mode
47
+ */
48
+ async function addPullRequestReview(octokit, features, prNodeId, prContext) {
49
+ const mode = selectFeature(features);
50
+ if (mode === 'graphql') {
51
+ return graphqlImpl.addPullRequestReview(octokit, prNodeId);
52
+ }
53
+ if (mode === 'rest') {
54
+ return restImpl.addPullRequestReview(octokit, prNodeId, prContext);
55
+ }
56
+ notYetAvailable(mode);
57
+ }
58
+
59
+ /**
60
+ * Create a pending review with a body.
61
+ *
62
+ * @param {Object} octokit
63
+ * @param {Object} features
64
+ * @param {string} prNodeId
65
+ * @param {string|null} body
66
+ * @param {Object} [prContext] - { owner, repo, prNumber } — REQUIRED for REST mode
67
+ */
68
+ async function addPullRequestReviewWithBody(octokit, features, prNodeId, body, prContext) {
69
+ const mode = selectFeature(features);
70
+ if (mode === 'graphql') {
71
+ return graphqlImpl.addPullRequestReviewWithBody(octokit, prNodeId, body);
72
+ }
73
+ if (mode === 'rest') {
74
+ return restImpl.addPullRequestReviewWithBody(octokit, prNodeId, body, prContext);
75
+ }
76
+ notYetAvailable(mode);
77
+ }
78
+
79
+ /**
80
+ * Submit a pending review.
81
+ *
82
+ * @param {Object} octokit
83
+ * @param {Object} features
84
+ * @param {string|number} reviewId
85
+ * @param {string} event - APPROVE | REQUEST_CHANGES | COMMENT
86
+ * @param {string|null} body
87
+ * @param {Object} [prContext] - { owner, repo, prNumber, reviewId? } — REQUIRED for REST mode
88
+ */
89
+ async function submitPullRequestReview(octokit, features, reviewId, event, body, prContext) {
90
+ const mode = selectFeature(features);
91
+ if (mode === 'graphql') {
92
+ return graphqlImpl.submitPullRequestReview(octokit, reviewId, event, body);
93
+ }
94
+ if (mode === 'rest') {
95
+ return restImpl.submitPullRequestReview(octokit, reviewId, event, body, prContext);
96
+ }
97
+ notYetAvailable(mode);
98
+ }
99
+
100
+ /**
101
+ * Delete a pending review.
102
+ *
103
+ * @param {Object} octokit
104
+ * @param {Object} features
105
+ * @param {string|number} reviewId
106
+ * @param {Object} [prContext] - { owner, repo, prNumber, reviewId? } — REQUIRED for REST mode
107
+ */
108
+ async function deletePullRequestReview(octokit, features, reviewId, prContext) {
109
+ const mode = selectFeature(features);
110
+ if (mode === 'graphql') {
111
+ return graphqlImpl.deletePullRequestReview(octokit, reviewId);
112
+ }
113
+ if (mode === 'rest') {
114
+ return restImpl.deletePullRequestReview(octokit, reviewId, prContext);
115
+ }
116
+ notYetAvailable(mode);
117
+ }
118
+
119
+ module.exports = {
120
+ addPullRequestReview,
121
+ addPullRequestReviewWithBody,
122
+ submitPullRequestReview,
123
+ deletePullRequestReview,
124
+ AREA,
125
+ IMPLEMENTED_MODES
126
+ };
@@ -0,0 +1,87 @@
1
+ // Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
2
+ const graphqlImpl = require('../impl/graphql/stack-walker');
3
+ const restImpl = require('../impl/rest/stack-walker');
4
+
5
+ /**
6
+ * Dispatcher for the `stack_walker` area.
7
+ *
8
+ * `walkPRStack` historically accepted a `client` (with `.octokit.graphql`).
9
+ * To preserve backward compatibility while still routing through the
10
+ * dispatcher, this module accepts either:
11
+ * - the new signature `(octokit, features, owner, repo, prNumber, _deps)`
12
+ * - the legacy signature `(client, owner, repo, prNumber, _deps)` where
13
+ * `client` looks like `{ octokit }` and the features map is omitted.
14
+ *
15
+ * `features.stack_walker` selects the transport:
16
+ * - `"graphql"` (default for github.com): delegates to
17
+ * `impl/graphql/stack-walker.js`.
18
+ * - `"rest"`: delegates to `impl/rest/stack-walker.js`. Returns the
19
+ * same ordered stack shape; PR `state` is normalised to GraphQL's
20
+ * uppercase form so consumers don't need to branch.
21
+ * - `"host"`: not yet implemented — Phase 5.
22
+ */
23
+
24
+ const AREA = 'stack_walker';
25
+ // Modes actually implemented by the dispatcher below. Co-located with the
26
+ // dispatch logic so validateRepoConfig() and the dispatcher can't drift.
27
+ // `host` is reserved for Phase 5 and is not yet implemented.
28
+ const IMPLEMENTED_MODES = new Set(['graphql', 'rest']);
29
+
30
+ function selectFeature(features) {
31
+ return (features && features[AREA]) || 'graphql';
32
+ }
33
+
34
+ /**
35
+ * Walk a PR stack starting from a given PR.
36
+ *
37
+ * Detects whether it has been called with the new dispatcher signature
38
+ * `(octokit, features, owner, repo, prNumber, _deps)` or the legacy
39
+ * `(client, owner, repo, prNumber, _deps)` shape used by the original
40
+ * `stack-walker.js` module so the existing call sites
41
+ * (`src/routes/pr.js`, tests) keep working without modification.
42
+ */
43
+ async function walkPRStack(arg0, arg1, arg2, arg3, arg4, arg5) {
44
+ let octokit;
45
+ let features;
46
+ let owner;
47
+ let repo;
48
+ let prNumber;
49
+ let deps;
50
+
51
+ // Legacy shape: arg0 is a GitHubClient-like object with .octokit.
52
+ if (arg0 && typeof arg0 === 'object' && arg0.octokit) {
53
+ octokit = arg0.octokit;
54
+ // Legacy callers don't pass features; treat as default github.com.
55
+ features = arg0.binding?.features;
56
+ owner = arg1;
57
+ repo = arg2;
58
+ prNumber = arg3;
59
+ deps = arg4;
60
+ } else {
61
+ octokit = arg0;
62
+ features = arg1;
63
+ owner = arg2;
64
+ repo = arg3;
65
+ prNumber = arg4;
66
+ deps = arg5;
67
+ }
68
+
69
+ const mode = selectFeature(features);
70
+ if (mode === 'graphql') {
71
+ return graphqlImpl.walkPRStack(octokit, owner, repo, prNumber, deps);
72
+ }
73
+ if (mode === 'rest') {
74
+ return restImpl.walkPRStack(octokit, owner, repo, prNumber, deps);
75
+ }
76
+ if (mode === 'host') {
77
+ throw new Error('Host implementation for stack_walker not yet available (Phase 5)');
78
+ }
79
+ throw new Error(`Unknown features.stack_walker value: "${mode}"`);
80
+ }
81
+
82
+ module.exports = {
83
+ walkPRStack,
84
+ DEFAULT_TRUNK_BRANCHES: graphqlImpl.DEFAULT_TRUNK_BRANCHES,
85
+ AREA,
86
+ IMPLEMENTED_MODES
87
+ };