@in-the-loop-labs/pair-review 3.6.0 → 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 +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 +0 -1737
- 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/ReviewModal.js +135 -13
- 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 +33 -3
- package/public/js/pr.js +653 -157
- package/public/js/repo-links.js +328 -0
- package/public/js/utils/provider-model.js +88 -0
- package/public/js/utils/storage-keys.js +50 -0
- package/public/local.html +7 -0
- package/public/pr.html +7 -0
- package/public/repo-settings.html +1 -0
- package/public/setup.html +2 -0
- package/src/ai/analyzer.js +125 -18
- 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 +133 -30
- 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
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
const logger = require('../../../utils/logger');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GraphQL implementation of the stack-walker area.
|
|
6
|
+
*
|
|
7
|
+
* Walks a GitHub PR stack by following the branch chain via GraphQL.
|
|
8
|
+
* Starting from a given PR, walks up toward trunk (following baseRefName)
|
|
9
|
+
* and down toward the tip (following headRefName) to discover the full
|
|
10
|
+
* stack. Returns the same ordered array (trunk -> ... -> tip) that
|
|
11
|
+
* `walkPRStack` historically returned.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TRUNK_BRANCHES = ['main', 'master', 'develop'];
|
|
15
|
+
const MAX_WALK_DEPTH = 20;
|
|
16
|
+
|
|
17
|
+
const FETCH_PR_QUERY = `
|
|
18
|
+
query($owner: String!, $repo: String!, $number: Int!) {
|
|
19
|
+
repository(owner: $owner, name: $repo) {
|
|
20
|
+
pullRequest(number: $number) {
|
|
21
|
+
number title baseRefName headRefName headRefOid state url
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
const FIND_PRS_BY_HEAD_QUERY = `
|
|
28
|
+
query($owner: String!, $repo: String!, $branch: String!) {
|
|
29
|
+
repository(owner: $owner, name: $repo) {
|
|
30
|
+
pullRequests(headRefName: $branch, states: [OPEN, MERGED], first: 5, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
31
|
+
nodes { number title baseRefName headRefName headRefOid state url }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
const FIND_PRS_BY_BASE_QUERY = `
|
|
38
|
+
query($owner: String!, $repo: String!, $branch: String!) {
|
|
39
|
+
repository(owner: $owner, name: $repo) {
|
|
40
|
+
pullRequests(baseRefName: $branch, states: [OPEN], first: 5, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
41
|
+
nodes { number title baseRefName headRefName headRefOid state url }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Select the best PR from a list of candidates for the same branch.
|
|
49
|
+
* Prefers OPEN over MERGED.
|
|
50
|
+
*
|
|
51
|
+
* @param {Array} prs - Array of PR nodes from GraphQL
|
|
52
|
+
* @returns {Object|null} The best candidate or null
|
|
53
|
+
*/
|
|
54
|
+
function pickBestPR(prs) {
|
|
55
|
+
if (!prs || prs.length === 0) return null;
|
|
56
|
+
const open = prs.find(pr => pr.state === 'OPEN');
|
|
57
|
+
if (open) return open;
|
|
58
|
+
return prs[0];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Walk a GitHub PR stack using GraphQL.
|
|
63
|
+
*
|
|
64
|
+
* @param {Object} octokit - Octokit instance (must expose .graphql)
|
|
65
|
+
* @param {string} owner - Repository owner
|
|
66
|
+
* @param {string} repo - Repository name
|
|
67
|
+
* @param {number} prNumber - Starting PR number
|
|
68
|
+
* @param {Object} [_deps] - Optional dependency overrides for testing
|
|
69
|
+
* @param {string[]} [_deps.defaultBranches] - Branch names considered trunk
|
|
70
|
+
* @returns {Promise<Array>} Ordered stack from trunk to tip
|
|
71
|
+
*/
|
|
72
|
+
async function walkPRStack(octokit, owner, repo, prNumber, _deps) {
|
|
73
|
+
const deps = { defaultBranches: DEFAULT_TRUNK_BRANCHES, ..._deps };
|
|
74
|
+
const graphql = octokit.graphql.bind(octokit);
|
|
75
|
+
const visited = new Set();
|
|
76
|
+
|
|
77
|
+
// Step 1: Fetch the starting PR
|
|
78
|
+
const startResult = await graphql(FETCH_PR_QUERY, { owner, repo, number: prNumber });
|
|
79
|
+
const startPR = startResult.repository?.pullRequest;
|
|
80
|
+
if (!startPR) {
|
|
81
|
+
throw new Error(`PR #${prNumber} not found in ${owner}/${repo}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
logger.debug(`Stack walker: starting from PR #${startPR.number} (${startPR.headRefName} -> ${startPR.baseRefName})`);
|
|
85
|
+
visited.add(startPR.headRefName);
|
|
86
|
+
|
|
87
|
+
// Step 2: Walk UP toward trunk
|
|
88
|
+
const parents = [];
|
|
89
|
+
let currentBase = startPR.baseRefName;
|
|
90
|
+
let walkUpDepth = 0;
|
|
91
|
+
|
|
92
|
+
while (walkUpDepth < MAX_WALK_DEPTH) {
|
|
93
|
+
if (deps.defaultBranches.includes(currentBase)) {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (visited.has(currentBase)) {
|
|
97
|
+
logger.warn(`Stack walker: cycle detected at branch "${currentBase}", stopping upward walk`);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
visited.add(currentBase);
|
|
101
|
+
|
|
102
|
+
let parentPR;
|
|
103
|
+
try {
|
|
104
|
+
const result = await graphql(FIND_PRS_BY_HEAD_QUERY, { owner, repo, branch: currentBase });
|
|
105
|
+
const candidates = result.repository?.pullRequests?.nodes || [];
|
|
106
|
+
parentPR = pickBestPR(candidates);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
logger.warn(`Stack walker: GraphQL error walking up at branch "${currentBase}": ${err.message}`);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!parentPR) {
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
parents.push({
|
|
117
|
+
branch: parentPR.headRefName,
|
|
118
|
+
isTrunk: false,
|
|
119
|
+
prNumber: parentPR.number,
|
|
120
|
+
title: parentPR.title,
|
|
121
|
+
state: parentPR.state,
|
|
122
|
+
url: parentPR.url,
|
|
123
|
+
headSha: parentPR.headRefOid,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
currentBase = parentPR.baseRefName;
|
|
127
|
+
walkUpDepth++;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (walkUpDepth >= MAX_WALK_DEPTH) {
|
|
131
|
+
logger.warn(`Stack walker: upward walk reached max depth of ${MAX_WALK_DEPTH}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const trunkBranch = currentBase;
|
|
135
|
+
|
|
136
|
+
// Step 3: Walk DOWN toward tip
|
|
137
|
+
const children = [];
|
|
138
|
+
let currentHead = startPR.headRefName;
|
|
139
|
+
let walkDownDepth = 0;
|
|
140
|
+
|
|
141
|
+
while (walkDownDepth < MAX_WALK_DEPTH) {
|
|
142
|
+
let childPR;
|
|
143
|
+
try {
|
|
144
|
+
const result = await graphql(FIND_PRS_BY_BASE_QUERY, { owner, repo, branch: currentHead });
|
|
145
|
+
const candidates = result.repository?.pullRequests?.nodes || [];
|
|
146
|
+
childPR = pickBestPR(candidates);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
logger.warn(`Stack walker: GraphQL error walking down at branch "${currentHead}": ${err.message}`);
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!childPR) {
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (visited.has(childPR.headRefName)) {
|
|
157
|
+
logger.warn(`Stack walker: cycle detected at branch "${childPR.headRefName}", stopping downward walk`);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
visited.add(childPR.headRefName);
|
|
161
|
+
|
|
162
|
+
children.push({
|
|
163
|
+
branch: childPR.headRefName,
|
|
164
|
+
isTrunk: false,
|
|
165
|
+
prNumber: childPR.number,
|
|
166
|
+
title: childPR.title,
|
|
167
|
+
state: childPR.state,
|
|
168
|
+
url: childPR.url,
|
|
169
|
+
headSha: childPR.headRefOid,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
currentHead = childPR.headRefName;
|
|
173
|
+
walkDownDepth++;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (walkDownDepth >= MAX_WALK_DEPTH) {
|
|
177
|
+
logger.warn(`Stack walker: downward walk reached max depth of ${MAX_WALK_DEPTH}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Step 4: Assemble the ordered stack
|
|
181
|
+
const stack = [
|
|
182
|
+
{ branch: trunkBranch, isTrunk: true },
|
|
183
|
+
...parents.reverse(),
|
|
184
|
+
{
|
|
185
|
+
branch: startPR.headRefName,
|
|
186
|
+
isTrunk: false,
|
|
187
|
+
prNumber: startPR.number,
|
|
188
|
+
title: startPR.title,
|
|
189
|
+
state: startPR.state,
|
|
190
|
+
url: startPR.url,
|
|
191
|
+
headSha: startPR.headRefOid,
|
|
192
|
+
},
|
|
193
|
+
...children,
|
|
194
|
+
];
|
|
195
|
+
|
|
196
|
+
logger.debug(`Stack walker: found ${stack.length} entries (${stack.filter(e => !e.isTrunk).length} PRs)`);
|
|
197
|
+
return stack;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
walkPRStack,
|
|
202
|
+
DEFAULT_TRUNK_BRANCHES,
|
|
203
|
+
MAX_WALK_DEPTH,
|
|
204
|
+
// Exported for tests and impl-internal use only.
|
|
205
|
+
_queries: {
|
|
206
|
+
FETCH_PR_QUERY,
|
|
207
|
+
FIND_PRS_BY_HEAD_QUERY,
|
|
208
|
+
FIND_PRS_BY_BASE_QUERY
|
|
209
|
+
}
|
|
210
|
+
};
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// Copyright 2026 Tim Perkins (tjwp) | SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
const logger = require('../../../utils/logger');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Host-extension implementation of the `pending_review_comments` area.
|
|
6
|
+
*
|
|
7
|
+
* GitHub's REST API does not support adding inline comments to a *pending*
|
|
8
|
+
* draft review (the `addPullRequestReviewThread` GraphQL mutation has no
|
|
9
|
+
* REST equivalent). Alt-hosts that advertise a compatible extension expose
|
|
10
|
+
* this via a single HTTP POST that accepts a batch of comments.
|
|
11
|
+
*
|
|
12
|
+
* Documented generic contract:
|
|
13
|
+
*
|
|
14
|
+
* POST {api_host}/repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments
|
|
15
|
+
*
|
|
16
|
+
* Request body:
|
|
17
|
+
* {
|
|
18
|
+
* "comments": [
|
|
19
|
+
* { "path": "...", "body": "...", "side": "RIGHT",
|
|
20
|
+
* "line": 42, "start_line": 40, "start_side": "RIGHT",
|
|
21
|
+
* "subject_type": "line" | "file",
|
|
22
|
+
* "commit_id": "<PR head SHA>" },
|
|
23
|
+
* ...
|
|
24
|
+
* ]
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* `commit_id` is the PR head SHA. It is required by GitHub-compatible
|
|
28
|
+
* hosts that validate each comment like `pulls.createReviewComment`
|
|
29
|
+
* (which rejects a missing `commit_id` with a 422). It is sourced from
|
|
30
|
+
* `prContext.headSha` and omitted entirely when that value is absent.
|
|
31
|
+
*
|
|
32
|
+
* Response (HTTP 200, partial-success body):
|
|
33
|
+
* {
|
|
34
|
+
* "added": <number>,
|
|
35
|
+
* "failed": [ { "index": <number>, "error_message": "..." }, ... ]
|
|
36
|
+
* }
|
|
37
|
+
*
|
|
38
|
+
* Authorization: standard `Authorization: Bearer <token>` (attached by
|
|
39
|
+
* Octokit from the binding token).
|
|
40
|
+
*
|
|
41
|
+
* The host returns HTTP 200 with a partial-success body even when some
|
|
42
|
+
* comments fail. A non-empty `failed` array is treated as a partial
|
|
43
|
+
* failure: the returned shape matches the GraphQL impl so the caller
|
|
44
|
+
* cannot tell which transport ran.
|
|
45
|
+
*
|
|
46
|
+
* Endpoint override: hosts that diverge from the default may set
|
|
47
|
+
* `features.pending_review_comments_endpoint` to a template string
|
|
48
|
+
* containing `{owner}`, `{repo}`, `{pull_number}`, `{review_id}`
|
|
49
|
+
* placeholders. The template must be a relative path (starting with
|
|
50
|
+
* `/repos/` or similar) — absolute URLs are rejected at config validation.
|
|
51
|
+
*
|
|
52
|
+
* Note on `reviewId`: this argument is the *host's* review identifier
|
|
53
|
+
* (e.g. a numeric REST id), not a GraphQL node id. The REST/host
|
|
54
|
+
* `review_lifecycle` impl (Phase 4) returns this id when it creates the
|
|
55
|
+
* pending review; the caller passes it through unchanged.
|
|
56
|
+
*
|
|
57
|
+
* Note on `batchSize`: the original parameter is kept in the signature
|
|
58
|
+
* for API compatibility with the GraphQL impl, but the host endpoint
|
|
59
|
+
* accepts arbitrary batch sizes in a single call. We send all comments
|
|
60
|
+
* in one POST and let the server enforce its own limits. The argument
|
|
61
|
+
* is otherwise ignored.
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
const DEFAULT_ENDPOINT_TEMPLATE =
|
|
65
|
+
'/repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments';
|
|
66
|
+
|
|
67
|
+
const REQUIRED_PLACEHOLDERS = ['{owner}', '{repo}', '{pull_number}', '{review_id}'];
|
|
68
|
+
|
|
69
|
+
// Matches any of the four supported placeholder names. Used globally so
|
|
70
|
+
// that templates which repeat a placeholder (e.g. `{repo}` in both the
|
|
71
|
+
// path and a query string) get every occurrence substituted, not just
|
|
72
|
+
// the first. `validateRepoConfig()` only asserts each required
|
|
73
|
+
// placeholder appears *somewhere*, so a chained per-name single
|
|
74
|
+
// `String.replace` would leave later occurrences literal in the URL.
|
|
75
|
+
const PLACEHOLDER_RE = /\{(owner|repo|pull_number|review_id)\}/g;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Substitute placeholders in an endpoint template. URL-encodes each value
|
|
79
|
+
* so that an `owner` like `my org` or a repo containing a slash cannot
|
|
80
|
+
* break the path. The four required placeholders are validated at startup
|
|
81
|
+
* by `validateRepoConfig()`, so missing placeholders here would indicate
|
|
82
|
+
* a bug rather than user error — we still throw a clear error so the
|
|
83
|
+
* failure is loud rather than producing a malformed request path.
|
|
84
|
+
*
|
|
85
|
+
* All occurrences of each placeholder are replaced (global substitution),
|
|
86
|
+
* mirroring the behaviour of `substituteUrlTemplate` in
|
|
87
|
+
* `src/links/repo-links.js`.
|
|
88
|
+
*
|
|
89
|
+
* @param {string} template - Endpoint template with `{...}` placeholders
|
|
90
|
+
* @param {Object} values - { owner, repo, pull_number, review_id }
|
|
91
|
+
* @returns {string} Substituted endpoint path
|
|
92
|
+
*/
|
|
93
|
+
function substituteEndpoint(template, values) {
|
|
94
|
+
return template.replace(PLACEHOLDER_RE, (_match, name) => {
|
|
95
|
+
const value = values ? values[name] : undefined;
|
|
96
|
+
if (value === undefined || value === null) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Host pending_review_comments: endpoint template references {${name}} ` +
|
|
99
|
+
'but no value was provided. This should have been caught by ' +
|
|
100
|
+
'validateRepoConfig — please report this as a bug.'
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return encodeURIComponent(String(value));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Map an internal comment shape to the host-extension wire shape. The
|
|
109
|
+
* internal shape matches what callers already pass to the GraphQL impl:
|
|
110
|
+
* `{ path, line?, start_line?, side?, body, isFileLevel? }`.
|
|
111
|
+
*
|
|
112
|
+
* - File-level comments (no `line` or explicit `isFileLevel`) are sent
|
|
113
|
+
* with `subject_type: "file"`.
|
|
114
|
+
* - Line comments default to `side: "RIGHT"` to match GraphQL behaviour.
|
|
115
|
+
* - Range comments include `start_line` and `start_side` (defaulting
|
|
116
|
+
* `start_side` to the same side as the end line, matching GitHub's
|
|
117
|
+
* own REST conventions).
|
|
118
|
+
* - `commitId` (the PR head SHA) is added as `commit_id` to every wire
|
|
119
|
+
* comment when supplied. GitHub-compatible hosts validate comments like
|
|
120
|
+
* `pulls.createReviewComment` and reject a missing `commit_id` with a
|
|
121
|
+
* 422. When `commitId` is empty/undefined the field is omitted entirely
|
|
122
|
+
* (we never send `commit_id: undefined`).
|
|
123
|
+
*
|
|
124
|
+
* @param {Object} comment - Internal comment shape.
|
|
125
|
+
* @param {string} [commitId] - PR head SHA. Added as `commit_id` when a
|
|
126
|
+
* non-empty string; omitted otherwise.
|
|
127
|
+
*/
|
|
128
|
+
function toWireComment(comment, commitId) {
|
|
129
|
+
const hasCommitId = typeof commitId === 'string' && commitId.length > 0;
|
|
130
|
+
const isFileLevel = comment.isFileLevel || !comment.line;
|
|
131
|
+
if (isFileLevel) {
|
|
132
|
+
const wire = {
|
|
133
|
+
path: comment.path,
|
|
134
|
+
body: comment.body,
|
|
135
|
+
subject_type: 'file'
|
|
136
|
+
};
|
|
137
|
+
if (hasCommitId) wire.commit_id = commitId;
|
|
138
|
+
return wire;
|
|
139
|
+
}
|
|
140
|
+
const side = comment.side || 'RIGHT';
|
|
141
|
+
const wire = {
|
|
142
|
+
path: comment.path,
|
|
143
|
+
body: comment.body,
|
|
144
|
+
side,
|
|
145
|
+
line: comment.line,
|
|
146
|
+
subject_type: 'line'
|
|
147
|
+
};
|
|
148
|
+
if (comment.start_line) {
|
|
149
|
+
wire.start_line = comment.start_line;
|
|
150
|
+
wire.start_side = comment.start_side || side;
|
|
151
|
+
}
|
|
152
|
+
if (hasCommitId) wire.commit_id = commitId;
|
|
153
|
+
return wire;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Add a list of comments to a pending review via the host extension.
|
|
158
|
+
*
|
|
159
|
+
* @param {Object} octokit - Octokit instance bound to the host's baseUrl.
|
|
160
|
+
* `octokit.request()` will attach `Authorization: Bearer <token>` from
|
|
161
|
+
* the binding token automatically.
|
|
162
|
+
* @param {Object} features - Feature-flag object from the host binding.
|
|
163
|
+
* May include `pending_review_comments_endpoint` to override the
|
|
164
|
+
* default endpoint path.
|
|
165
|
+
* @param {Object} prContext - `{ owner, repo, prNumber, headSha? }`.
|
|
166
|
+
* Required — the host endpoint is path-shaped, so the GraphQL node IDs
|
|
167
|
+
* are not sufficient on their own. `headSha` (the PR head SHA) is
|
|
168
|
+
* forwarded as each comment's `commit_id`, required by
|
|
169
|
+
* GitHub-compatible hosts; omitted when absent.
|
|
170
|
+
* @param {string} reviewId - The *host's* review identifier (returned
|
|
171
|
+
* by the host's M2 `review_lifecycle` impl, not a GraphQL node id).
|
|
172
|
+
* @param {Array} comments - Comments with `{ path, line?, start_line?,
|
|
173
|
+
* side?, body, isFileLevel? }`. Same shape the GraphQL impl accepts.
|
|
174
|
+
* @param {number} [_batchSize] - Ignored; kept for signature parity.
|
|
175
|
+
* @returns {Promise<{successCount: number, failed: boolean, failedDetails: string[]}>}
|
|
176
|
+
*/
|
|
177
|
+
async function addCommentsInBatches(octokit, features, prContext, reviewId, comments, _batchSize) {
|
|
178
|
+
if (!comments || comments.length === 0) {
|
|
179
|
+
return { successCount: 0, failed: false, failedDetails: [] };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!prContext || typeof prContext !== 'object') {
|
|
183
|
+
throw new Error(
|
|
184
|
+
'Host pending_review_comments: prContext is required ' +
|
|
185
|
+
'({ owner, repo, prNumber }). The host endpoint is path-shaped, ' +
|
|
186
|
+
'so GraphQL node IDs alone are not sufficient.'
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
const { owner, repo, prNumber } = prContext;
|
|
190
|
+
if (!owner || !repo || prNumber === undefined || prNumber === null) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
'Host pending_review_comments: prContext must include owner, repo, and prNumber.'
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Resolve the *numeric* review id. The host endpoint is REST-shaped:
|
|
197
|
+
// it identifies a review by its numeric database id, not its GraphQL
|
|
198
|
+
// node id. Prefer `prContext.reviewId` (set by the orchestration in
|
|
199
|
+
// `client.js` from the `databaseId` returned by addPullRequestReview)
|
|
200
|
+
// and fall back to the positional `reviewId` argument only when it is
|
|
201
|
+
// itself numeric. If only a node id was supplied, fail fast with a
|
|
202
|
+
// clear message so regressions surface immediately rather than as a
|
|
203
|
+
// 404 from the host.
|
|
204
|
+
let resolvedReviewId = null;
|
|
205
|
+
if (prContext && (typeof prContext.reviewId === 'number' || typeof prContext.reviewId === 'string')) {
|
|
206
|
+
const fromCtx = String(prContext.reviewId);
|
|
207
|
+
if (/^\d+$/.test(fromCtx)) {
|
|
208
|
+
resolvedReviewId = fromCtx;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (resolvedReviewId === null) {
|
|
212
|
+
if (typeof reviewId === 'number') {
|
|
213
|
+
resolvedReviewId = String(reviewId);
|
|
214
|
+
} else if (typeof reviewId === 'string' && /^\d+$/.test(reviewId)) {
|
|
215
|
+
resolvedReviewId = reviewId;
|
|
216
|
+
} else if (!reviewId && reviewId !== 0) {
|
|
217
|
+
throw new Error('Host pending_review_comments: reviewId is required.');
|
|
218
|
+
} else {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`Host extension addCommentsInBatches requires a numeric review id; received "${reviewId}". ` +
|
|
221
|
+
'Set prContext.reviewId or ensure the upstream addPullRequestReview returned a numeric databaseId.'
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const template = (features && features.pending_review_comments_endpoint) || DEFAULT_ENDPOINT_TEMPLATE;
|
|
227
|
+
const endpoint = substituteEndpoint(template, {
|
|
228
|
+
owner,
|
|
229
|
+
repo,
|
|
230
|
+
pull_number: prNumber,
|
|
231
|
+
review_id: resolvedReviewId
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// The PR head SHA is threaded through `prContext.headSha` from the
|
|
235
|
+
// submit site (see src/routes/pr.js). GitHub-compatible hosts require
|
|
236
|
+
// each comment to carry `commit_id`; when the SHA is absent we omit the
|
|
237
|
+
// field and let the host surface its own validation error.
|
|
238
|
+
const commitId = prContext.headSha;
|
|
239
|
+
const wireComments = comments.map((c) => toWireComment(c, commitId));
|
|
240
|
+
logger.info(
|
|
241
|
+
`Posting ${wireComments.length} comment(s) to host endpoint ${endpoint}`
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
let response;
|
|
245
|
+
try {
|
|
246
|
+
response = await octokit.request(`POST ${endpoint}`, {
|
|
247
|
+
headers: {
|
|
248
|
+
'content-type': 'application/json',
|
|
249
|
+
accept: 'application/json'
|
|
250
|
+
},
|
|
251
|
+
data: { comments: wireComments }
|
|
252
|
+
});
|
|
253
|
+
} catch (error) {
|
|
254
|
+
const status = error && (error.status || error.statusCode);
|
|
255
|
+
const message = (error && error.message) || 'Unknown error';
|
|
256
|
+
logger.error(
|
|
257
|
+
`Host pending_review_comments request failed (${status || 'no status'}): ${message}`
|
|
258
|
+
);
|
|
259
|
+
// Normalise host request failures to the same partial-failure shape
|
|
260
|
+
// the GraphQL impl returns, so callers can branch uniformly on
|
|
261
|
+
// `batchResult.failed` without needing a try/catch. The orchestration
|
|
262
|
+
// in `src/github/client.js` remains defensive against throws as the
|
|
263
|
+
// primary safety guarantee — this is a secondary tidy-up so the
|
|
264
|
+
// failure surface matches across transports.
|
|
265
|
+
const failedDetails = comments.map((c) => {
|
|
266
|
+
const location = c.line ? `${c.path}:${c.line}` : `${c.path}:file-level`;
|
|
267
|
+
return `${location} - ${status || 'network error'}: ${message}`;
|
|
268
|
+
});
|
|
269
|
+
return {
|
|
270
|
+
successCount: 0,
|
|
271
|
+
failed: true,
|
|
272
|
+
failedDetails
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const body = response && response.data ? response.data : {};
|
|
277
|
+
// Distinguish "host explicitly reported a count (including 0)" from
|
|
278
|
+
// "host omitted the field". An explicit `added: 0` with no `failed[]`
|
|
279
|
+
// must NOT be treated as "all succeeded" — it means the host accepted
|
|
280
|
+
// none. Only fall back to `comments.length` when the field is absent.
|
|
281
|
+
const hasExplicitAdded = typeof body.added === 'number';
|
|
282
|
+
const added = hasExplicitAdded ? body.added : 0;
|
|
283
|
+
const failedList = Array.isArray(body.failed) ? body.failed : [];
|
|
284
|
+
|
|
285
|
+
const failedDetails = [];
|
|
286
|
+
for (const entry of failedList) {
|
|
287
|
+
const idx = typeof entry.index === 'number' ? entry.index : null;
|
|
288
|
+
const errMsg = (entry && (entry.error_message || entry.message)) || 'Unknown error';
|
|
289
|
+
const source = idx !== null && idx >= 0 && idx < comments.length ? comments[idx] : null;
|
|
290
|
+
const location = source
|
|
291
|
+
? `${source.path}:${source.line || 'file-level'}`
|
|
292
|
+
: idx !== null ? `comment[${idx}]` : 'comment[?]';
|
|
293
|
+
failedDetails.push(`${location} - ${errMsg}`);
|
|
294
|
+
logger.warn(`Host comment failed: ${location} - ${errMsg}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Sanity check: if the host reported an explicit count, the accounted-
|
|
298
|
+
// for items (added + failed) should equal the total submitted. If they
|
|
299
|
+
// don't, the host response is internally inconsistent — log a warning
|
|
300
|
+
// but still trust the explicit counts.
|
|
301
|
+
if (hasExplicitAdded && added + failedList.length !== comments.length) {
|
|
302
|
+
logger.warn(
|
|
303
|
+
`Host pending_review_comments inconsistent counts: added=${added}, ` +
|
|
304
|
+
`failed=${failedList.length}, submitted=${comments.length}`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (failedList.length > 0) {
|
|
309
|
+
logger.error(
|
|
310
|
+
`Host pending_review_comments partial failure: ${added} added, ${failedList.length} failed`
|
|
311
|
+
);
|
|
312
|
+
return {
|
|
313
|
+
successCount: added,
|
|
314
|
+
failed: true,
|
|
315
|
+
failedDetails
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// No partial failures. Trust an explicit `added` value (including 0).
|
|
320
|
+
// Only fall back to `comments.length` when the host omitted the field
|
|
321
|
+
// entirely — some hosts that don't report counts rely on this.
|
|
322
|
+
const successCount = hasExplicitAdded ? added : comments.length;
|
|
323
|
+
logger.info(`Host pending_review_comments complete: ${successCount} comment(s) added`);
|
|
324
|
+
return {
|
|
325
|
+
successCount,
|
|
326
|
+
failed: false,
|
|
327
|
+
failedDetails: []
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
module.exports = {
|
|
332
|
+
addCommentsInBatches,
|
|
333
|
+
DEFAULT_ENDPOINT_TEMPLATE,
|
|
334
|
+
REQUIRED_PLACEHOLDERS,
|
|
335
|
+
// Exported for direct unit testing.
|
|
336
|
+
substituteEndpoint,
|
|
337
|
+
toWireComment
|
|
338
|
+
};
|