@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.
- package/README.md +4 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin-code-critic/.claude-plugin/plugin.json +1 -1
- package/public/css/analysis-config.css +1807 -0
- package/public/css/pr.css +1029 -2169
- package/public/index.html +11 -0
- package/public/js/components/AIPanel.js +39 -23
- package/public/js/components/AdvancedConfigTab.js +56 -4
- package/public/js/components/AnalysisConfigModal.js +41 -25
- package/public/js/components/ChatPanel.js +163 -3
- package/public/js/components/KeyboardShortcuts.js +10 -26
- package/public/js/components/ReviewModal.js +135 -13
- package/public/js/components/TourBar.js +248 -0
- package/public/js/components/VoiceCentricConfigTab.js +36 -0
- package/public/js/index.js +175 -16
- package/public/js/local.js +64 -8
- package/public/js/modules/cancel-background-job.js +183 -0
- package/public/js/modules/hunk-summary-renderer.js +116 -0
- package/public/js/modules/storage-cleanup.js +16 -0
- package/public/js/modules/suggestion-manager.js +25 -1
- package/public/js/modules/tour-renderer.js +755 -0
- package/public/js/pr.js +1826 -56
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/modal-detection.js +77 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +24 -0
- package/public/pr.html +24 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/abort-signal-wiring.js +130 -0
- package/src/ai/analyzer.js +125 -18
- package/src/ai/background-queue.js +290 -0
- package/src/ai/claude-cli.js +1 -1
- package/src/ai/claude-provider.js +50 -7
- package/src/ai/codex-provider.js +28 -5
- package/src/ai/copilot-provider.js +22 -3
- package/src/ai/cursor-agent-provider.js +22 -6
- package/src/ai/executable-provider.js +4 -19
- package/src/ai/gemini-provider.js +22 -5
- package/src/ai/hunk-hashing.js +161 -0
- package/src/ai/index.js +2 -0
- package/src/ai/opencode-provider.js +21 -5
- package/src/ai/pi-provider.js +21 -5
- package/src/ai/prompts/hunk-summary.js +199 -0
- package/src/ai/prompts/tour.js +232 -0
- package/src/ai/provider.js +21 -1
- package/src/ai/summary-generator.js +469 -0
- package/src/ai/tour-generator.js +568 -0
- package/src/config.js +778 -10
- package/src/database.js +282 -1
- 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 +201 -172
- package/src/main.js +133 -30
- package/src/routes/analyses.js +30 -7
- package/src/routes/bulk-analysis-configs.js +295 -0
- package/src/routes/config.js +118 -3
- package/src/routes/context-files.js +2 -29
- package/src/routes/external-comments.js +20 -10
- package/src/routes/github-collections.js +3 -1
- package/src/routes/local.js +410 -13
- package/src/routes/mcp.js +47 -4
- package/src/routes/middleware/validate-review-id.js +53 -0
- package/src/routes/pr.js +556 -71
- package/src/routes/reviews.js +145 -29
- 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/utils/diff-hunks.js +65 -0
- package/src/utils/json-extractor.js +5 -2
|
@@ -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
|
+
};
|