opencode-forgekeeper 2.1.1 → 2.2.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 +34 -14
- package/dist/index.js +364 -41
- package/dist/tui.js +262 -28
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -168,8 +168,8 @@ With several PR/MR targets, the footer shows counts instead of one PR/MR's statu
|
|
|
168
168
|
status dialog, which lists each PR/MR.
|
|
169
169
|
|
|
170
170
|
The footer shows checks or the pipeline, unresolved threads, conflicts, approval, and a
|
|
171
|
-
running GitLab Duo
|
|
172
|
-
unknown rather than appearing conflict-free.
|
|
171
|
+
running automated review, from GitLab Duo or GitHub Copilot. GitHub also shows a requested
|
|
172
|
+
change. Unknown mergeability stays unknown rather than appearing conflict-free.
|
|
173
173
|
|
|
174
174
|
The footer shows `approved` only when GitLab reports that approval requirements are met,
|
|
175
175
|
at least one person approved, and every human reviewer approved. Bot approvals, such as
|
|
@@ -232,16 +232,23 @@ Before it sends a notification, the server checks that the PR/MR is still one of
|
|
|
232
232
|
session's targets. A lookup that started before the targets changed is discarded, so a
|
|
233
233
|
removed target never triggers a notification.
|
|
234
234
|
|
|
235
|
-
| Notification | GitLab | GitHub
|
|
236
|
-
| ------------------------------------------ | ----------- |
|
|
237
|
-
| An automated review finished with feedback | GitLab Duo |
|
|
238
|
-
| New human review feedback | MR comments |
|
|
239
|
-
|
|
240
|
-
- **Automated reviews**: Duo's final state of `REVIEWED` or `REQUESTED_CHANGES`
|
|
241
|
-
agent to read its comments. Other final states, such as `APPROVED`, send nothing.
|
|
242
|
-
server notifies
|
|
243
|
-
finished before the server first saw it running isn't announced. Reviews that finish
|
|
244
|
-
the same poll share one message.
|
|
235
|
+
| Notification | GitLab | GitHub |
|
|
236
|
+
| ------------------------------------------ | ----------- | -------------- |
|
|
237
|
+
| An automated review finished with feedback | GitLab Duo | GitHub Copilot |
|
|
238
|
+
| New human review feedback | MR comments | PR comments |
|
|
239
|
+
|
|
240
|
+
- **Automated reviews**: for GitLab, Duo's final state of `REVIEWED` or `REQUESTED_CHANGES`
|
|
241
|
+
asks the agent to read its comments. Other final states, such as `APPROVED`, send nothing.
|
|
242
|
+
The server notifies when it sees a review go from running to finished, so a Duo review
|
|
243
|
+
that finished before the server first saw it running isn't announced. Reviews that finish
|
|
244
|
+
in the same poll share one message.
|
|
245
|
+
- **Copilot reviews**: the server notifies when a submitted, undismissed review has at least
|
|
246
|
+
one inline comment. A review without inline comments, such as one that
|
|
247
|
+
found nothing or couldn't run for lack of quota, sends nothing. Copilot reviews are
|
|
248
|
+
identified, so a review that started and finished between two polls is announced too. A
|
|
249
|
+
review that the server finds on a PR's first look isn't announced. Observed and delivered
|
|
250
|
+
review IDs persist across restarts. A completed review can be announced while another
|
|
251
|
+
review is requested.
|
|
245
252
|
- **Human reviews**: new comments and replies from people other than you and bots are
|
|
246
253
|
announced once none of their unresolved comments has changed for 5 minutes. Expect one
|
|
247
254
|
message 5 to 7 minutes after the review goes quiet. Resolved threads are skipped.
|
|
@@ -261,6 +268,17 @@ records. A failed storage write shows an error toast once.
|
|
|
261
268
|
On GitLab, the server reads the newest 2,000 comments. On busier MRs, edits to older
|
|
262
269
|
comments don't count as activity.
|
|
263
270
|
|
|
271
|
+
On GitHub, the server reads three lists: conversation comments, the text of submitted
|
|
272
|
+
reviews, and the inline comments of review threads. It keeps the newest 2,000 of each.
|
|
273
|
+
It scans every thread to include recent replies in old threads, within a limit of 200 API
|
|
274
|
+
requests per fetch. If it cannot complete that scan, it defers notifications until a complete
|
|
275
|
+
fetch succeeds. Review text counts when it isn't empty, whether the
|
|
276
|
+
review approves, requests changes, or only comments. Comments from GitHub Apps, such as
|
|
277
|
+
Copilot, count as bots. Hidden comments and unsubmitted review comments are skipped.
|
|
278
|
+
The baseline also records comment IDs at its timestamp, so a new comment posted in the same
|
|
279
|
+
second can still be announced. Baselines stored by older plugin versions retain their
|
|
280
|
+
timestamp-only behavior until a new baseline is created.
|
|
281
|
+
|
|
264
282
|
## Options
|
|
265
283
|
|
|
266
284
|
Set options with the object form of the `plugins` entry in `opencode.json`:
|
|
@@ -368,6 +386,8 @@ notifications resume the session. Set `reviewStatus` to `false` to keep only tit
|
|
|
368
386
|
`src/tui.tsx` connects it to the RPC and renders the footer, dialog, commands, and notices.
|
|
369
387
|
- `src/forge.ts` defines the forge-neutral types, the `Forge` adapter interface, and
|
|
370
388
|
`ForgeTraits` with its optional `automatedReview` and `feedback` capabilities.
|
|
389
|
+
- `src/gitlab-feedback.ts` and `src/github-feedback.ts` read each forge's comments for its
|
|
390
|
+
`feedback` capability.
|
|
371
391
|
- `src/gitlab.ts` and `src/github.ts` provide each forge's adapter and traits. Adapters own
|
|
372
392
|
API calls, pagination, error classification, and status normalization. Traits cover
|
|
373
393
|
everything else: hosts, URL parsing, reference syntax, vocabulary, and forge-specific
|
|
@@ -378,8 +398,8 @@ notifications resume the session. Set `reviewStatus` to `false` to keep only tit
|
|
|
378
398
|
- `src/locate.ts` picks each key's PRs/MRs. `src/store.ts` caches lookups, polls, backs off
|
|
379
399
|
after failures, and discards lookups that started before a change. `src/format.ts`
|
|
380
400
|
renders the footer and dialog.
|
|
381
|
-
- `src/automated-review-watch.ts
|
|
382
|
-
|
|
401
|
+
- `src/automated-review-watch.ts` and `src/human-review-watch.ts` decide when to notify the
|
|
402
|
+
agent.
|
|
383
403
|
|
|
384
404
|
Shared code never compares a forge against a specific name. It asks the forge's traits, or
|
|
385
405
|
checks for a capability, instead. To add a forge, add its kind to `ForgeKind` and
|
package/dist/index.js
CHANGED
|
@@ -189,14 +189,182 @@ class ForgeError extends Error {
|
|
|
189
189
|
var query = (text) => text;
|
|
190
190
|
var typed = (transport) => async (host, query2, variables) => await transport(host, query2, variables);
|
|
191
191
|
|
|
192
|
-
// packages/forgekeeper/src/
|
|
192
|
+
// packages/forgekeeper/src/github-feedback.ts
|
|
193
193
|
var PAGE_SIZE = 100;
|
|
194
194
|
var MAX_COMMENT_PAGES = 20;
|
|
195
|
+
var MAX_FETCH_REQUESTS = 200;
|
|
196
|
+
var author = "author { __typename login ... on User { id } ... on Bot { id } ... on Mannequin { id } }";
|
|
197
|
+
var page = "pageInfo { hasPreviousPage startCursor }";
|
|
198
|
+
var INLINE_FIELDS = `id url state createdAt publishedAt lastEditedAt isMinimized ${author}`;
|
|
199
|
+
var listQuery = (field, connection) => query(`query($owner: String!, $repo: String!, $number: Int!, $before: String) {
|
|
200
|
+
viewer { id }
|
|
201
|
+
repository(owner: $owner, name: $repo) {
|
|
202
|
+
pullRequest(number: $number) {
|
|
203
|
+
${field}(last: ${PAGE_SIZE}, before: $before) { ${page} nodes { ${connection} } }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}`);
|
|
207
|
+
var ISSUE_COMMENTS_QUERY = listQuery("comments", `id url createdAt publishedAt lastEditedAt isMinimized ${author}`);
|
|
208
|
+
var REVIEWS_QUERY = listQuery("reviews", `id url state body submittedAt lastEditedAt isMinimized ${author}`);
|
|
209
|
+
var THREADS_QUERY = listQuery("reviewThreads", `id isResolved comments(last: ${PAGE_SIZE}) { ${page} nodes { ${INLINE_FIELDS} } }`);
|
|
210
|
+
var THREAD_COMMENTS_QUERY = query(`query($id: ID!, $before: String) {
|
|
211
|
+
node(id: $id) {
|
|
212
|
+
... on PullRequestReviewThread {
|
|
213
|
+
comments(last: ${PAGE_SIZE}, before: $before) { ${page} nodes { ${INLINE_FIELDS} } }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}`);
|
|
217
|
+
var REVIEW_STATES = new Set(["COMMENTED", "CHANGES_REQUESTED", "APPROVED"]);
|
|
218
|
+
var time = (...values) => {
|
|
219
|
+
const value = values.find((candidate) => candidate);
|
|
220
|
+
return value ? Date.parse(value) : NaN;
|
|
221
|
+
};
|
|
222
|
+
var activity = (lastEditedAt, postedAt) => {
|
|
223
|
+
const edited = lastEditedAt ? Date.parse(lastEditedAt) : NaN;
|
|
224
|
+
return Number.isFinite(edited) ? Math.max(edited, postedAt) : postedAt;
|
|
225
|
+
};
|
|
226
|
+
function build({ node, postedAt, editedAt, resolved }) {
|
|
227
|
+
if (node.isMinimized || !node.author?.id || !Number.isFinite(postedAt) || !Number.isFinite(editedAt))
|
|
228
|
+
return;
|
|
229
|
+
const { __typename, login } = node.author;
|
|
230
|
+
return {
|
|
231
|
+
id: node.id,
|
|
232
|
+
order: postedAt,
|
|
233
|
+
url: node.url ?? "",
|
|
234
|
+
editedAt,
|
|
235
|
+
resolved,
|
|
236
|
+
bot: __typename === "Bot" || login.endsWith("[bot]"),
|
|
237
|
+
authorID: node.author.id,
|
|
238
|
+
username: login
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function toIssueComment(node) {
|
|
242
|
+
const postedAt = time(node.publishedAt, node.createdAt);
|
|
243
|
+
return build({
|
|
244
|
+
node,
|
|
245
|
+
postedAt,
|
|
246
|
+
editedAt: activity(node.lastEditedAt, postedAt),
|
|
247
|
+
resolved: false
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function toReview(node) {
|
|
251
|
+
if (!REVIEW_STATES.has(node.state) || !node.body.trim())
|
|
252
|
+
return;
|
|
253
|
+
const postedAt = time(node.submittedAt);
|
|
254
|
+
return build({
|
|
255
|
+
node,
|
|
256
|
+
postedAt,
|
|
257
|
+
editedAt: activity(node.lastEditedAt, postedAt),
|
|
258
|
+
resolved: false
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
function toInlineComment(node, resolved) {
|
|
262
|
+
if (node.state === "PENDING")
|
|
263
|
+
return;
|
|
264
|
+
const postedAt = time(node.publishedAt, node.createdAt);
|
|
265
|
+
return build({
|
|
266
|
+
node,
|
|
267
|
+
postedAt,
|
|
268
|
+
editedAt: activity(node.lastEditedAt, postedAt),
|
|
269
|
+
resolved
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
var commentWindow = { pages: MAX_COMMENT_PAGES, completeAtLimit: true };
|
|
273
|
+
var allThreads = { pages: MAX_FETCH_REQUESTS, completeAtLimit: false };
|
|
274
|
+
async function readList(graphql, host, base, text, field, limit = commentWindow) {
|
|
275
|
+
const pages = [];
|
|
276
|
+
let viewer;
|
|
277
|
+
let before;
|
|
278
|
+
const result = (complete) => ({
|
|
279
|
+
items: pages.toReversed().flat(),
|
|
280
|
+
viewer,
|
|
281
|
+
complete
|
|
282
|
+
});
|
|
283
|
+
for (let count = 0;count < limit.pages; count++) {
|
|
284
|
+
const variables = before ? { ...base, before } : base;
|
|
285
|
+
const data = await graphql(host, text, variables);
|
|
286
|
+
viewer ??= data.viewer?.id ?? undefined;
|
|
287
|
+
const connection = data.repository?.pullRequest?.[field];
|
|
288
|
+
if (!connection)
|
|
289
|
+
return count === 0 ? { viewer, complete: false } : result(false);
|
|
290
|
+
pages.push(connection.nodes);
|
|
291
|
+
if (!connection.pageInfo.hasPreviousPage)
|
|
292
|
+
return result(true);
|
|
293
|
+
before = connection.pageInfo.startCursor ?? undefined;
|
|
294
|
+
if (!before)
|
|
295
|
+
return result(false);
|
|
296
|
+
}
|
|
297
|
+
return result(limit.completeAtLimit);
|
|
298
|
+
}
|
|
299
|
+
async function readOlderComments(graphql, host, thread) {
|
|
300
|
+
if (!thread.comments)
|
|
301
|
+
return { comments: [], complete: false };
|
|
302
|
+
const pages = [];
|
|
303
|
+
let before = thread.comments?.pageInfo.hasPreviousPage ? thread.comments.pageInfo.startCursor ?? undefined : undefined;
|
|
304
|
+
if (thread.comments?.pageInfo.hasPreviousPage && !before)
|
|
305
|
+
return { comments: [], complete: false };
|
|
306
|
+
for (let count = 1;before && count < MAX_COMMENT_PAGES; count++) {
|
|
307
|
+
const data = await graphql(host, THREAD_COMMENTS_QUERY, { id: thread.id, before });
|
|
308
|
+
const connection = data.node?.comments;
|
|
309
|
+
if (!connection)
|
|
310
|
+
return { comments: pages.toReversed().flat(), complete: false };
|
|
311
|
+
pages.push(connection.nodes);
|
|
312
|
+
before = connection.pageInfo.hasPreviousPage ? connection.pageInfo.startCursor ?? undefined : undefined;
|
|
313
|
+
if (connection.pageInfo.hasPreviousPage && !before)
|
|
314
|
+
return { comments: pages.toReversed().flat(), complete: false };
|
|
315
|
+
}
|
|
316
|
+
return { comments: pages.toReversed().flat(), complete: true };
|
|
317
|
+
}
|
|
318
|
+
async function fetchComments(transport, host, project, number) {
|
|
319
|
+
let requests = 0;
|
|
320
|
+
const graphql = typed(async (...args) => {
|
|
321
|
+
if (requests++ >= MAX_FETCH_REQUESTS)
|
|
322
|
+
return {};
|
|
323
|
+
return transport(...args);
|
|
324
|
+
});
|
|
325
|
+
const [owner, repo] = project.split("/");
|
|
326
|
+
const base = { owner, repo, number: Number(number) };
|
|
327
|
+
const [conversation, reviews, threads] = await Promise.all([
|
|
328
|
+
readList(graphql, host, base, ISSUE_COMMENTS_QUERY, "comments"),
|
|
329
|
+
readList(graphql, host, base, REVIEWS_QUERY, "reviews"),
|
|
330
|
+
readList(graphql, host, base, THREADS_QUERY, "reviewThreads", allThreads)
|
|
331
|
+
]);
|
|
332
|
+
if (!conversation.items || !reviews.items || !threads.items)
|
|
333
|
+
return;
|
|
334
|
+
const viewerID = conversation.viewer ?? reviews.viewer ?? threads.viewer;
|
|
335
|
+
let threadsComplete = threads.complete;
|
|
336
|
+
const inline = [];
|
|
337
|
+
for (const thread of threads.items) {
|
|
338
|
+
const older = await readOlderComments(graphql, host, thread);
|
|
339
|
+
threadsComplete &&= older.complete;
|
|
340
|
+
for (const node of [...older.comments, ...thread.comments?.nodes ?? []]) {
|
|
341
|
+
const comment = toInlineComment(node, thread.isResolved);
|
|
342
|
+
if (comment)
|
|
343
|
+
inline.push(comment);
|
|
344
|
+
}
|
|
345
|
+
inline.sort((a, b) => b.order - a.order);
|
|
346
|
+
inline.splice(PAGE_SIZE * MAX_COMMENT_PAGES);
|
|
347
|
+
}
|
|
348
|
+
const comments = [
|
|
349
|
+
...conversation.items.map(toIssueComment),
|
|
350
|
+
...reviews.items.map(toReview),
|
|
351
|
+
...inline.sort((a, b) => a.order - b.order).slice(-PAGE_SIZE * MAX_COMMENT_PAGES)
|
|
352
|
+
].flatMap((comment) => comment ?? []);
|
|
353
|
+
return {
|
|
354
|
+
complete: conversation.complete && reviews.complete && threadsComplete && viewerID !== undefined,
|
|
355
|
+
viewerID,
|
|
356
|
+
comments: comments.sort((a, b) => a.order - b.order)
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// packages/forgekeeper/src/gitlab-feedback.ts
|
|
361
|
+
var PAGE_SIZE2 = 100;
|
|
362
|
+
var MAX_COMMENT_PAGES2 = 20;
|
|
195
363
|
var COMMENTS_QUERY = query(`query($project: ID!, $iid: String!, $before: String) {
|
|
196
364
|
currentUser { id }
|
|
197
365
|
project(fullPath: $project) {
|
|
198
366
|
mergeRequest(iid: $iid) {
|
|
199
|
-
notes(last: ${
|
|
367
|
+
notes(last: ${PAGE_SIZE2}, before: $before, filter: ONLY_COMMENTS) {
|
|
200
368
|
pageInfo { hasPreviousPage startCursor }
|
|
201
369
|
nodes {
|
|
202
370
|
id url createdAt updatedAt lastEditedAt system
|
|
@@ -225,7 +393,7 @@ function toComment(node) {
|
|
|
225
393
|
username: node.author.username
|
|
226
394
|
};
|
|
227
395
|
}
|
|
228
|
-
async function
|
|
396
|
+
async function fetchComments2(transport, host, project, iid) {
|
|
229
397
|
const graphql = typed(transport);
|
|
230
398
|
const pages = [];
|
|
231
399
|
let viewerID;
|
|
@@ -235,7 +403,7 @@ async function fetchComments(transport, host, project, iid) {
|
|
|
235
403
|
viewerID,
|
|
236
404
|
comments: pages.toReversed().flat()
|
|
237
405
|
});
|
|
238
|
-
for (let
|
|
406
|
+
for (let page2 = 0;page2 < MAX_COMMENT_PAGES2; page2++) {
|
|
239
407
|
const variables = { project, iid };
|
|
240
408
|
if (before)
|
|
241
409
|
variables.before = before;
|
|
@@ -243,7 +411,7 @@ async function fetchComments(transport, host, project, iid) {
|
|
|
243
411
|
viewerID ??= data.currentUser?.id ?? undefined;
|
|
244
412
|
const connection = data.project?.mergeRequest?.notes;
|
|
245
413
|
if (!connection)
|
|
246
|
-
return
|
|
414
|
+
return page2 === 0 ? undefined : snapshot(false);
|
|
247
415
|
pages.push(connection.nodes.flatMap((node) => toComment(node) ?? []));
|
|
248
416
|
if (!connection.pageInfo.hasPreviousPage)
|
|
249
417
|
return snapshot(true);
|
|
@@ -256,7 +424,7 @@ async function fetchComments(transport, host, project, iid) {
|
|
|
256
424
|
|
|
257
425
|
// packages/forgekeeper/src/gitlab.ts
|
|
258
426
|
var DISCUSSIONS = "pageInfo { hasNextPage endCursor } nodes { resolvable resolved }";
|
|
259
|
-
var
|
|
427
|
+
var PAGE_SIZE3 = 100;
|
|
260
428
|
var MAX_DISCUSSION_PAGES = 50;
|
|
261
429
|
var MERGE_REQUEST_FIELDS = `
|
|
262
430
|
iid title webUrl state draft conflicts approved detailedMergeStatus diffHeadSha updatedAt targetBranch
|
|
@@ -265,7 +433,7 @@ var MERGE_REQUEST_FIELDS = `
|
|
|
265
433
|
targetProject { fullPath }
|
|
266
434
|
headPipeline { status path detailedStatus { label } }
|
|
267
435
|
reviewers(first: 100) { nodes { type username bot mergeRequestInteraction { reviewState approved } } }
|
|
268
|
-
discussions(first: ${
|
|
436
|
+
discussions(first: ${PAGE_SIZE3}) { ${DISCUSSIONS} }
|
|
269
437
|
`;
|
|
270
438
|
function mergeRequestsQuery(projectCount) {
|
|
271
439
|
const variables = Array.from({ length: projectCount }, (_, index) => `$p${index}: ID!`).join(", ");
|
|
@@ -300,7 +468,7 @@ var SOURCE_BRANCH_QUERY = query(`query($project: ID!, $iid: String!) {
|
|
|
300
468
|
}`);
|
|
301
469
|
var DISCUSSIONS_QUERY = query(`query($project: ID!, $iid: String!, $after: String) {
|
|
302
470
|
project(fullPath: $project) {
|
|
303
|
-
mergeRequest(iid: $iid) { discussions(first: ${
|
|
471
|
+
mergeRequest(iid: $iid) { discussions(first: ${PAGE_SIZE3}, after: $after) { ${DISCUSSIONS} } }
|
|
304
472
|
}
|
|
305
473
|
}`);
|
|
306
474
|
function classifyError(message, code) {
|
|
@@ -339,7 +507,7 @@ function glabGraphQL(run, cwd) {
|
|
|
339
507
|
return body.data;
|
|
340
508
|
};
|
|
341
509
|
}
|
|
342
|
-
var countUnresolved = (
|
|
510
|
+
var countUnresolved = (page2) => page2.nodes.filter((discussion) => discussion.resolvable && !discussion.resolved).length;
|
|
343
511
|
var gitlabTraits = {
|
|
344
512
|
kind: "gitlab",
|
|
345
513
|
noun: "MR",
|
|
@@ -374,7 +542,7 @@ var gitlabTraits = {
|
|
|
374
542
|
feedback: {
|
|
375
543
|
async fetch(run, directory, request) {
|
|
376
544
|
const ref = gitlabTraits.parseUrl(request.url);
|
|
377
|
-
return ref ?
|
|
545
|
+
return ref ? fetchComments2(glabGraphQL(run, directory), ref.host, ref.project, ref.iid) : undefined;
|
|
378
546
|
}
|
|
379
547
|
}
|
|
380
548
|
};
|
|
@@ -446,22 +614,22 @@ class GitLabForge {
|
|
|
446
614
|
}
|
|
447
615
|
async unresolvedThreads(host, node) {
|
|
448
616
|
let count = countUnresolved(node.discussions);
|
|
449
|
-
let
|
|
617
|
+
let page2 = node.discussions.pageInfo;
|
|
450
618
|
let pages = 1;
|
|
451
|
-
while (
|
|
619
|
+
while (page2.hasNextPage && page2.endCursor && pages < MAX_DISCUSSION_PAGES) {
|
|
452
620
|
const data = await this.graphql(host, DISCUSSIONS_QUERY, {
|
|
453
621
|
project: node.targetProject.fullPath,
|
|
454
622
|
iid: node.iid,
|
|
455
|
-
after:
|
|
623
|
+
after: page2.endCursor
|
|
456
624
|
});
|
|
457
625
|
const next = data.project?.mergeRequest?.discussions;
|
|
458
626
|
if (!next)
|
|
459
627
|
break;
|
|
460
628
|
count += countUnresolved(next);
|
|
461
|
-
|
|
629
|
+
page2 = next.pageInfo;
|
|
462
630
|
pages++;
|
|
463
631
|
}
|
|
464
|
-
return { count, complete: !
|
|
632
|
+
return { count, complete: !page2.hasNextPage };
|
|
465
633
|
}
|
|
466
634
|
async normalize(host, node) {
|
|
467
635
|
const threads = await this.unresolvedThreads(host, node);
|
|
@@ -500,10 +668,12 @@ class GitLabForge {
|
|
|
500
668
|
}
|
|
501
669
|
|
|
502
670
|
// packages/forgekeeper/src/github.ts
|
|
671
|
+
var COPILOT_AUTHOR = "copilot-pull-request-reviewer[bot]";
|
|
503
672
|
var fields = `number title url state isDraft mergeable reviewDecision headRefOid headRefName
|
|
504
673
|
headRepository { nameWithOwner } baseRepository { nameWithOwner } baseRefName updatedAt
|
|
505
674
|
reviewThreads(first:100) { nodes { isResolved } pageInfo { hasNextPage endCursor } }
|
|
506
|
-
reviewRequests(first:100) { nodes { requestedReviewer { ... on User { login } ... on Team { name } } } pageInfo { hasNextPage } }
|
|
675
|
+
reviewRequests(first:100) { nodes { requestedReviewer { __typename ... on User { login } ... on Bot { login } ... on Team { name } } } pageInfo { hasNextPage endCursor } }
|
|
676
|
+
copilotReviews: reviews(last:1, author:"${COPILOT_AUTHOR}", states:[COMMENTED,CHANGES_REQUESTED,APPROVED,DISMISSED]) { nodes { id state comments { totalCount } } }
|
|
507
677
|
commits(last:1) { nodes { commit { statusCheckRollup { contexts(first:100) {
|
|
508
678
|
nodes { __typename ... on CheckRun { status conclusion detailsUrl } ... on StatusContext { state targetUrl } }
|
|
509
679
|
pageInfo { hasNextPage endCursor }
|
|
@@ -597,8 +767,43 @@ var githubTraits = {
|
|
|
597
767
|
indicators: (request) => request.reviewDecision === "CHANGES_REQUESTED" ? [{ text: "changes requested", tone: "warning", essential: true }] : [],
|
|
598
768
|
details: (request) => [
|
|
599
769
|
`Review decision: ${request.reviewDecision ? humanize(request.reviewDecision) : "not reported"}`
|
|
600
|
-
]
|
|
770
|
+
],
|
|
771
|
+
automatedReview: {
|
|
772
|
+
name: "GitHub Copilot",
|
|
773
|
+
status: (request) => copilotStatus(request.copilotReview)
|
|
774
|
+
},
|
|
775
|
+
feedback: {
|
|
776
|
+
async fetch(run, directory, request) {
|
|
777
|
+
const ref = githubTraits.parseUrl(request.url);
|
|
778
|
+
return ref ? fetchComments(ghGraphQL(run, directory), ref.host, ref.project, ref.iid) : undefined;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
601
781
|
};
|
|
782
|
+
function copilotStatus(review) {
|
|
783
|
+
if (!review)
|
|
784
|
+
return;
|
|
785
|
+
const feedback = review.comments > 0 && !review.dismissed;
|
|
786
|
+
const label = review.dismissed ? "dismissed" : feedback ? "commented" : "no comments";
|
|
787
|
+
const completed = review.reviewID ? { id: review.reviewID, feedback, label } : undefined;
|
|
788
|
+
if (review.requested)
|
|
789
|
+
return { state: "running", label: "reviewing", completed };
|
|
790
|
+
if (!completed)
|
|
791
|
+
return;
|
|
792
|
+
return { state: feedback ? "feedback" : "settled", label, completed };
|
|
793
|
+
}
|
|
794
|
+
var isCopilotReviewer = (login) => /^copilot-pull-request-reviewer(\[bot\])?$/i.test(login ?? "");
|
|
795
|
+
function copilotReview(node) {
|
|
796
|
+
const requested = node.reviewRequests.nodes.some((request) => isCopilotReviewer(request.requestedReviewer?.login));
|
|
797
|
+
const latest = node.copilotReviews?.nodes?.find((review) => review);
|
|
798
|
+
if (!requested && !latest)
|
|
799
|
+
return;
|
|
800
|
+
return {
|
|
801
|
+
requested,
|
|
802
|
+
reviewID: latest?.id,
|
|
803
|
+
comments: latest?.comments.totalCount ?? 0,
|
|
804
|
+
...latest?.state === "DISMISSED" ? { dismissed: true } : {}
|
|
805
|
+
};
|
|
806
|
+
}
|
|
602
807
|
|
|
603
808
|
class GitHubForge {
|
|
604
809
|
traits = githubTraits;
|
|
@@ -649,7 +854,7 @@ class GitHubForge {
|
|
|
649
854
|
const [owner, repo] = target.path.split("/");
|
|
650
855
|
let after;
|
|
651
856
|
let complete = false;
|
|
652
|
-
for (let
|
|
857
|
+
for (let page2 = 0;page2 < MAX_PAGES; page2++) {
|
|
653
858
|
const data = await this.graphql(target.host, query(`query($owner:String!,$repo:String!,$branch:String!${after ? ",$after:String!" : ""}) {
|
|
654
859
|
repository(owner:$owner,name:$repo) { pullRequests(first:100,states:OPEN,headRefName:$branch${after ? ",after:$after" : ""}) {
|
|
655
860
|
nodes { ${fields} } pageInfo { hasNextPage endCursor }
|
|
@@ -679,21 +884,21 @@ class GitHubForge {
|
|
|
679
884
|
async threads(host, node) {
|
|
680
885
|
const [owner, repo] = node.baseRepository.nameWithOwner.split("/");
|
|
681
886
|
const threads = [...node.reviewThreads.nodes];
|
|
682
|
-
let
|
|
683
|
-
for (let count = 1;
|
|
887
|
+
let page2 = node.reviewThreads.pageInfo;
|
|
888
|
+
for (let count = 1;page2?.hasNextPage && page2.endCursor && count < MAX_PAGES; count++) {
|
|
684
889
|
const data = await this.graphql(host, query(`query($owner:String!,$repo:String!,$number:Int!,$after:String!) {
|
|
685
890
|
repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
|
|
686
891
|
reviewThreads(first:100,after:$after) { nodes { isResolved } pageInfo { hasNextPage endCursor } }
|
|
687
|
-
} } }`), { owner, repo, number: node.number, after:
|
|
892
|
+
} } }`), { owner, repo, number: node.number, after: page2.endCursor });
|
|
688
893
|
const next = data.repository?.pullRequest?.reviewThreads;
|
|
689
894
|
if (!next)
|
|
690
895
|
break;
|
|
691
896
|
threads.push(...next.nodes);
|
|
692
|
-
|
|
897
|
+
page2 = next.pageInfo;
|
|
693
898
|
}
|
|
694
899
|
return {
|
|
695
900
|
unresolved: threads.filter((thread) => !thread.isResolved).length,
|
|
696
|
-
complete: !
|
|
901
|
+
complete: !page2?.hasNextPage
|
|
697
902
|
};
|
|
698
903
|
}
|
|
699
904
|
async contexts(host, node) {
|
|
@@ -714,7 +919,33 @@ class GitHubForge {
|
|
|
714
919
|
}
|
|
715
920
|
return { nodes: contexts, complete: !cursor?.hasNextPage };
|
|
716
921
|
}
|
|
922
|
+
async reviewers(host, node) {
|
|
923
|
+
const nodes = [...node.reviewRequests.nodes];
|
|
924
|
+
let reviewerPage = node.reviewRequests.pageInfo;
|
|
925
|
+
for (let count = 1;reviewerPage?.hasNextPage && count < MAX_PAGES; count++) {
|
|
926
|
+
if (!reviewerPage.endCursor)
|
|
927
|
+
throw new ForgeError("request", "GitHub reviewer pagination cursor missing");
|
|
928
|
+
const [owner, repo] = node.baseRepository.nameWithOwner.split("/");
|
|
929
|
+
const data = await this.graphql(host, query(`query($owner:String!,$repo:String!,$number:Int!,$after:String!) {
|
|
930
|
+
repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
|
|
931
|
+
reviewRequests(first:100,after:$after) {
|
|
932
|
+
nodes { requestedReviewer { __typename ... on User { login } ... on Bot { login } ... on Team { name } } }
|
|
933
|
+
pageInfo { hasNextPage endCursor }
|
|
934
|
+
}
|
|
935
|
+
} }
|
|
936
|
+
}`), { owner, repo, number: node.number, after: reviewerPage.endCursor });
|
|
937
|
+
const next = data.repository?.pullRequest?.reviewRequests;
|
|
938
|
+
if (!next)
|
|
939
|
+
throw new ForgeError("request", "GitHub reviewers unavailable");
|
|
940
|
+
nodes.push(...next.nodes);
|
|
941
|
+
reviewerPage = next.pageInfo;
|
|
942
|
+
}
|
|
943
|
+
if (reviewerPage?.hasNextPage)
|
|
944
|
+
throw new ForgeError("request", "GitHub reviewers exceeded the pagination limit");
|
|
945
|
+
return { nodes, pageInfo: reviewerPage };
|
|
946
|
+
}
|
|
717
947
|
async normalize(host, node) {
|
|
948
|
+
const reviewers = await this.reviewers(host, node);
|
|
718
949
|
const [threads, contexts] = [await this.threads(host, node), await this.contexts(host, node)];
|
|
719
950
|
return {
|
|
720
951
|
forge: "github",
|
|
@@ -727,11 +958,14 @@ class GitHubForge {
|
|
|
727
958
|
conflictsKnown: node.mergeable !== "UNKNOWN",
|
|
728
959
|
approved: node.reviewDecision === "APPROVED",
|
|
729
960
|
reviewDecision: node.reviewDecision ?? undefined,
|
|
730
|
-
|
|
961
|
+
copilotReview: copilotReview({ ...node, reviewRequests: reviewers }),
|
|
962
|
+
awaitingReviewers: reviewers.nodes.flatMap((request) => {
|
|
731
963
|
const reviewer = request.requestedReviewer;
|
|
964
|
+
if (reviewer?.__typename === "Bot")
|
|
965
|
+
return [];
|
|
732
966
|
return reviewer?.login ? [reviewer.login] : reviewer?.name ? [reviewer.name] : [];
|
|
733
967
|
}),
|
|
734
|
-
reviewersComplete: !
|
|
968
|
+
reviewersComplete: !reviewers.pageInfo?.hasNextPage,
|
|
735
969
|
targetProject: node.baseRepository.nameWithOwner,
|
|
736
970
|
targetBranch: node.baseRefName,
|
|
737
971
|
headSha: node.headRefOid,
|
|
@@ -1023,11 +1257,33 @@ function automatedReview(request) {
|
|
|
1023
1257
|
return reviewer && status2 ? { name: reviewer.name, ...status2 } : undefined;
|
|
1024
1258
|
}
|
|
1025
1259
|
var isReviewRunning = (lookup) => requests(lookup).some((request) => automatedReview(request)?.state === "running");
|
|
1260
|
+
function hasFeedback(review) {
|
|
1261
|
+
if (!review)
|
|
1262
|
+
return false;
|
|
1263
|
+
if (review.completed)
|
|
1264
|
+
return review.completed.feedback;
|
|
1265
|
+
return review.state === "feedback";
|
|
1266
|
+
}
|
|
1026
1267
|
function finishedReviews(previous, next) {
|
|
1027
|
-
const
|
|
1028
|
-
return requests(next).filter((request) =>
|
|
1268
|
+
const before = new Map(requests(previous).map((request) => [request.url, request]));
|
|
1269
|
+
return requests(next).filter((request) => {
|
|
1270
|
+
const review = automatedReview(request);
|
|
1271
|
+
if (!hasFeedback(review))
|
|
1272
|
+
return false;
|
|
1273
|
+
const earlier = before.get(request.url);
|
|
1274
|
+
if (!earlier)
|
|
1275
|
+
return false;
|
|
1276
|
+
const was = automatedReview(earlier);
|
|
1277
|
+
if (review?.completed)
|
|
1278
|
+
return review.completed.id !== was?.completed?.id;
|
|
1279
|
+
return was?.state === "running";
|
|
1280
|
+
});
|
|
1029
1281
|
}
|
|
1030
1282
|
var reviewer = (request) => automatedReview(request)?.name ?? "The automated reviewer";
|
|
1283
|
+
function outcomeLabel(request) {
|
|
1284
|
+
const review = automatedReview(request);
|
|
1285
|
+
return review?.completed?.label ?? review?.label ?? "";
|
|
1286
|
+
}
|
|
1031
1287
|
var shared = (requests2, value, fallback) => {
|
|
1032
1288
|
const values = new Set(requests2.map(value));
|
|
1033
1289
|
return values.size === 1 ? [...values][0] : fallback;
|
|
@@ -1035,7 +1291,7 @@ var shared = (requests2, value, fallback) => {
|
|
|
1035
1291
|
var reviewers = (requests2) => shared(requests2, reviewer, "Automated reviewers");
|
|
1036
1292
|
var nouns = (requests2) => shared(requests2, (request) => `${traitsOf(request).noun}s`, "PRs/MRs");
|
|
1037
1293
|
function reviewOutcome(request) {
|
|
1038
|
-
const label =
|
|
1294
|
+
const label = outcomeLabel(request);
|
|
1039
1295
|
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
1040
1296
|
}
|
|
1041
1297
|
function reviewTitle(requests2) {
|
|
@@ -1044,7 +1300,7 @@ function reviewTitle(requests2) {
|
|
|
1044
1300
|
return `${reviewers(requests2)} finished reviewing ${requests2.length} ${nouns(requests2)}`;
|
|
1045
1301
|
}
|
|
1046
1302
|
function reviewMessage(requests2) {
|
|
1047
|
-
const state = (request) => `the state "${
|
|
1303
|
+
const state = (request) => `the state "${outcomeLabel(request)}"`;
|
|
1048
1304
|
if (requests2.length === 1) {
|
|
1049
1305
|
const [request] = requests2;
|
|
1050
1306
|
const name2 = reviewer(request);
|
|
@@ -1062,7 +1318,7 @@ function reviewMessage(requests2) {
|
|
|
1062
1318
|
`);
|
|
1063
1319
|
}
|
|
1064
1320
|
function reviewToast(requests2) {
|
|
1065
|
-
const outcome = requests2.length === 1 ? reviewOutcome(requests2[0]) : requests2.map((request) => `${reference(request)}: ${
|
|
1321
|
+
const outcome = requests2.length === 1 ? reviewOutcome(requests2[0]) : requests2.map((request) => `${reference(request)}: ${outcomeLabel(request)}`).join(", ");
|
|
1066
1322
|
return { title: reviewTitle(requests2), message: `${outcome}. Sent to the agent.` };
|
|
1067
1323
|
}
|
|
1068
1324
|
var RETRY_DELAY = 10 * 60000;
|
|
@@ -1073,7 +1329,23 @@ function createAutomatedReviewWatcher(options) {
|
|
|
1073
1329
|
const now = options.now ?? Date.now;
|
|
1074
1330
|
const known = new Map;
|
|
1075
1331
|
const sending = new Set;
|
|
1332
|
+
const histories = new Map;
|
|
1076
1333
|
const write = (sessionID, url, delivery) => options.write?.(sessionID, url, delivery);
|
|
1334
|
+
async function observeCompletion(sessionID, request) {
|
|
1335
|
+
const completed = automatedReview(request)?.completed;
|
|
1336
|
+
if (!completed)
|
|
1337
|
+
return;
|
|
1338
|
+
const key2 = notificationKey(sessionID, request);
|
|
1339
|
+
const history = histories.get(key2) ?? await options.readHistory?.(sessionID, request.url);
|
|
1340
|
+
const alreadyObserved = history?.observed.includes(completed.id) === true;
|
|
1341
|
+
const current = history ?? { observed: [], delivered: [] };
|
|
1342
|
+
histories.set(key2, current);
|
|
1343
|
+
if (!alreadyObserved) {
|
|
1344
|
+
current.observed.push(completed.id);
|
|
1345
|
+
options.writeHistory?.(sessionID, request.url, current);
|
|
1346
|
+
}
|
|
1347
|
+
return { alreadyObserved, newFeedback: !!history && !alreadyObserved && completed.feedback };
|
|
1348
|
+
}
|
|
1077
1349
|
return {
|
|
1078
1350
|
async observe(sessionID, previous, next) {
|
|
1079
1351
|
const pending = known.get(sessionID) ?? { ...await options.read?.(sessionID) };
|
|
@@ -1082,7 +1354,8 @@ function createAutomatedReviewWatcher(options) {
|
|
|
1082
1354
|
const targets = requests(next).filter((request) => isOpen(request) && options.isTarget(sessionID, request.url));
|
|
1083
1355
|
for (const [key2, entry] of Object.entries(pending)) {
|
|
1084
1356
|
const request = targets.find((target2) => target2.url === entry.url);
|
|
1085
|
-
|
|
1357
|
+
const completed = request && automatedReview(request)?.completed;
|
|
1358
|
+
if (request && hasFeedback(automatedReview(request)) && at - entry.since < PENDING_RETENTION && (!entry.reviewID || completed?.id === entry.reviewID))
|
|
1086
1359
|
continue;
|
|
1087
1360
|
if (sending.has(key2))
|
|
1088
1361
|
continue;
|
|
@@ -1090,6 +1363,16 @@ function createAutomatedReviewWatcher(options) {
|
|
|
1090
1363
|
write(sessionID, entry.url);
|
|
1091
1364
|
}
|
|
1092
1365
|
const finished = finishedReviews(previous, next).filter((request) => isOpen(request) && options.isTarget(sessionID, request.url));
|
|
1366
|
+
for (const request of targets) {
|
|
1367
|
+
const observation = await observeCompletion(sessionID, request);
|
|
1368
|
+
if (!observation)
|
|
1369
|
+
continue;
|
|
1370
|
+
const index = finished.findIndex((item) => item.url === request.url);
|
|
1371
|
+
if (observation.alreadyObserved && index >= 0)
|
|
1372
|
+
finished.splice(index, 1);
|
|
1373
|
+
if (observation.newFeedback && index < 0)
|
|
1374
|
+
finished.push(request);
|
|
1375
|
+
}
|
|
1093
1376
|
const due = targets.filter((request) => (pending[notificationKey(sessionID, request)]?.retryAt ?? Infinity) <= at);
|
|
1094
1377
|
const batch = [
|
|
1095
1378
|
...new Map([...finished, ...due].map((request) => [request.url, request])).values()
|
|
@@ -1102,6 +1385,12 @@ function createAutomatedReviewWatcher(options) {
|
|
|
1102
1385
|
try {
|
|
1103
1386
|
await options.send(sessionID, batch);
|
|
1104
1387
|
batch.forEach((request, index) => {
|
|
1388
|
+
const history = histories.get(keys[index]);
|
|
1389
|
+
const completed = automatedReview(request)?.completed;
|
|
1390
|
+
if (history && completed && !history.delivered.includes(completed.id)) {
|
|
1391
|
+
history.delivered.push(completed.id);
|
|
1392
|
+
options.writeHistory?.(sessionID, request.url, history);
|
|
1393
|
+
}
|
|
1105
1394
|
delete pending[keys[index]];
|
|
1106
1395
|
write(sessionID, request.url);
|
|
1107
1396
|
});
|
|
@@ -1109,7 +1398,14 @@ function createAutomatedReviewWatcher(options) {
|
|
|
1109
1398
|
const retryAt = now() + RETRY_DELAY;
|
|
1110
1399
|
batch.forEach((request, index) => {
|
|
1111
1400
|
const key2 = keys[index];
|
|
1112
|
-
|
|
1401
|
+
const reviewID = automatedReview(request)?.completed?.id;
|
|
1402
|
+
pending[key2] = {
|
|
1403
|
+
sessionID,
|
|
1404
|
+
url: request.url,
|
|
1405
|
+
since: pending[key2]?.since ?? at,
|
|
1406
|
+
retryAt,
|
|
1407
|
+
...reviewID ? { reviewID } : {}
|
|
1408
|
+
};
|
|
1113
1409
|
write(sessionID, request.url, pending[key2]);
|
|
1114
1410
|
});
|
|
1115
1411
|
options.onSendError?.(error, sessionID, batch);
|
|
@@ -1119,7 +1415,13 @@ function createAutomatedReviewWatcher(options) {
|
|
|
1119
1415
|
}
|
|
1120
1416
|
},
|
|
1121
1417
|
hasPending: (sessionID) => Object.keys(known.get(sessionID) ?? {}).length > 0,
|
|
1122
|
-
forget
|
|
1418
|
+
forget(sessionID) {
|
|
1419
|
+
known.delete(sessionID);
|
|
1420
|
+
for (const key2 of histories.keys()) {
|
|
1421
|
+
if (key2.startsWith(`${sessionID} `))
|
|
1422
|
+
histories.delete(key2);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1123
1425
|
};
|
|
1124
1426
|
}
|
|
1125
1427
|
|
|
@@ -1136,11 +1438,17 @@ function applyFeedback(record, change, now) {
|
|
|
1136
1438
|
for (const id of change.announced ?? [])
|
|
1137
1439
|
if (!announced.includes(id))
|
|
1138
1440
|
announced.push(id);
|
|
1139
|
-
|
|
1441
|
+
const baselineIDs = record?.baselineIDs ?? change.baselineIDs;
|
|
1442
|
+
return {
|
|
1443
|
+
baseline: record?.baseline ?? change.baseline ?? 0,
|
|
1444
|
+
...baselineIDs ? { baselineIDs } : {},
|
|
1445
|
+
announced,
|
|
1446
|
+
seenAt: now
|
|
1447
|
+
};
|
|
1140
1448
|
}
|
|
1141
1449
|
var isExpired = (record, now) => now - record.seenAt > RECORD_RETENTION;
|
|
1142
1450
|
var baselineOf = (snapshot2) => snapshot2.comments.reduce((highest, comment) => Math.max(highest, comment.order), 0);
|
|
1143
|
-
var isNew = (comment, seen) => comment.order > seen.baseline && !seen.announced.has(comment.id);
|
|
1451
|
+
var isNew = (comment, seen) => (comment.order > seen.baseline || comment.order === seen.baseline && seen.baselineIDs !== undefined && !seen.baselineIDs.includes(comment.id)) && !seen.announced.has(comment.id);
|
|
1144
1452
|
var humanFeedback = (snapshot2) => snapshot2.comments.filter((comment) => !comment.bot && comment.authorID !== snapshot2.viewerID && !comment.resolved);
|
|
1145
1453
|
function eligibleFeedback(snapshot2, seen, now) {
|
|
1146
1454
|
const human = humanFeedback(snapshot2);
|
|
@@ -1188,13 +1496,19 @@ function createFeedbackWatcher(options) {
|
|
|
1188
1496
|
const mine = local.get(key2);
|
|
1189
1497
|
if (!shared2 && !mine)
|
|
1190
1498
|
return;
|
|
1499
|
+
const latest = (mine?.baseline ?? -1) >= (shared2?.baseline ?? -1) ? mine : shared2;
|
|
1191
1500
|
return {
|
|
1192
1501
|
baseline: Math.max(shared2?.baseline ?? 0, mine?.baseline ?? 0),
|
|
1502
|
+
baselineIDs: latest?.baselineIDs,
|
|
1193
1503
|
announced: new Set([...shared2?.announced ?? [], ...mine?.announced ?? []])
|
|
1194
1504
|
};
|
|
1195
1505
|
};
|
|
1196
1506
|
const remember = (key2, change, at) => {
|
|
1197
|
-
const mine = local.get(key2) ?? {
|
|
1507
|
+
const mine = local.get(key2) ?? {
|
|
1508
|
+
baseline: change.baseline ?? 0,
|
|
1509
|
+
baselineIDs: change.baselineIDs,
|
|
1510
|
+
announced: new Set
|
|
1511
|
+
};
|
|
1198
1512
|
for (const id of change.announced ?? [])
|
|
1199
1513
|
mine.announced.add(id);
|
|
1200
1514
|
local.set(key2, mine);
|
|
@@ -1207,8 +1521,11 @@ function createFeedbackWatcher(options) {
|
|
|
1207
1521
|
const stored = await options.record(key2);
|
|
1208
1522
|
const at = now();
|
|
1209
1523
|
const previous = seen(key2, stored);
|
|
1210
|
-
if (!previous)
|
|
1211
|
-
|
|
1524
|
+
if (!previous) {
|
|
1525
|
+
const baseline = baselineOf(snapshot2);
|
|
1526
|
+
const baselineIDs = snapshot2.comments.filter((comment) => comment.order === baseline).map((comment) => comment.id);
|
|
1527
|
+
return remember(key2, { baseline, baselineIDs }, at);
|
|
1528
|
+
}
|
|
1212
1529
|
if (at < (retryAt.get(key2) ?? 0))
|
|
1213
1530
|
return;
|
|
1214
1531
|
const comments = eligibleFeedback(snapshot2, previous, at).filter((comment) => !reserved.has(`${key2} ${comment.id}`));
|
|
@@ -1845,6 +2162,7 @@ var FEEDBACK_PREFIX = "status/feedback/";
|
|
|
1845
2162
|
var pendingPrefix = (sessionID) => `${PENDING_PREFIX}${encodeURIComponent(sessionID)}/`;
|
|
1846
2163
|
var pendingKey = (sessionID, url) => `${pendingPrefix(sessionID)}${encodeURIComponent(url)}`;
|
|
1847
2164
|
var feedbackRecordKey = (key2) => `${FEEDBACK_PREFIX}${encodeURIComponent(key2)}`;
|
|
2165
|
+
var reviewHistoryKey = (sessionID, url) => `status/reviews/${encodeURIComponent(sessionID)}/${encodeURIComponent(url)}`;
|
|
1848
2166
|
function userTitle(title, prefix) {
|
|
1849
2167
|
if (!title || !prefix)
|
|
1850
2168
|
return title;
|
|
@@ -1868,9 +2186,9 @@ async function setupStatus(ctx, settings, deps, host) {
|
|
|
1868
2186
|
const entries = [];
|
|
1869
2187
|
let after;
|
|
1870
2188
|
do {
|
|
1871
|
-
const
|
|
1872
|
-
entries.push(...
|
|
1873
|
-
after =
|
|
2189
|
+
const page2 = await ctx.storage.scan({ prefix, ...after ? { after } : {} });
|
|
2190
|
+
entries.push(...page2.entries);
|
|
2191
|
+
after = page2.next;
|
|
1874
2192
|
} while (after);
|
|
1875
2193
|
return entries;
|
|
1876
2194
|
}
|
|
@@ -1926,6 +2244,11 @@ async function setupStatus(ctx, settings, deps, host) {
|
|
|
1926
2244
|
const automated = settings.notifyAutomatedReviews ? createAutomatedReviewWatcher({
|
|
1927
2245
|
isTarget,
|
|
1928
2246
|
now: deps.now,
|
|
2247
|
+
readHistory: async (sessionID, url) => await ctx.storage.get(reviewHistoryKey(sessionID, url)),
|
|
2248
|
+
writeHistory: (sessionID, url, history) => {
|
|
2249
|
+
const snapshot2 = { observed: [...history.observed], delivered: [...history.delivered] };
|
|
2250
|
+
write(() => ctx.storage.set(reviewHistoryKey(sessionID, url), snapshot2));
|
|
2251
|
+
},
|
|
1929
2252
|
async read(sessionID) {
|
|
1930
2253
|
const deliveries = {};
|
|
1931
2254
|
for (const { value } of await scanAll(pendingPrefix(sessionID))) {
|
package/dist/tui.js
CHANGED
|
@@ -73,14 +73,182 @@ class ForgeError extends Error {
|
|
|
73
73
|
var query = (text) => text;
|
|
74
74
|
var typed = (transport) => async (host, query2, variables) => await transport(host, query2, variables);
|
|
75
75
|
|
|
76
|
-
// packages/forgekeeper/src/
|
|
76
|
+
// packages/forgekeeper/src/github-feedback.ts
|
|
77
77
|
var PAGE_SIZE = 100;
|
|
78
78
|
var MAX_COMMENT_PAGES = 20;
|
|
79
|
+
var MAX_FETCH_REQUESTS = 200;
|
|
80
|
+
var author = "author { __typename login ... on User { id } ... on Bot { id } ... on Mannequin { id } }";
|
|
81
|
+
var page = "pageInfo { hasPreviousPage startCursor }";
|
|
82
|
+
var INLINE_FIELDS = `id url state createdAt publishedAt lastEditedAt isMinimized ${author}`;
|
|
83
|
+
var listQuery = (field, connection) => query(`query($owner: String!, $repo: String!, $number: Int!, $before: String) {
|
|
84
|
+
viewer { id }
|
|
85
|
+
repository(owner: $owner, name: $repo) {
|
|
86
|
+
pullRequest(number: $number) {
|
|
87
|
+
${field}(last: ${PAGE_SIZE}, before: $before) { ${page} nodes { ${connection} } }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}`);
|
|
91
|
+
var ISSUE_COMMENTS_QUERY = listQuery("comments", `id url createdAt publishedAt lastEditedAt isMinimized ${author}`);
|
|
92
|
+
var REVIEWS_QUERY = listQuery("reviews", `id url state body submittedAt lastEditedAt isMinimized ${author}`);
|
|
93
|
+
var THREADS_QUERY = listQuery("reviewThreads", `id isResolved comments(last: ${PAGE_SIZE}) { ${page} nodes { ${INLINE_FIELDS} } }`);
|
|
94
|
+
var THREAD_COMMENTS_QUERY = query(`query($id: ID!, $before: String) {
|
|
95
|
+
node(id: $id) {
|
|
96
|
+
... on PullRequestReviewThread {
|
|
97
|
+
comments(last: ${PAGE_SIZE}, before: $before) { ${page} nodes { ${INLINE_FIELDS} } }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}`);
|
|
101
|
+
var REVIEW_STATES = new Set(["COMMENTED", "CHANGES_REQUESTED", "APPROVED"]);
|
|
102
|
+
var time = (...values) => {
|
|
103
|
+
const value = values.find((candidate) => candidate);
|
|
104
|
+
return value ? Date.parse(value) : NaN;
|
|
105
|
+
};
|
|
106
|
+
var activity = (lastEditedAt, postedAt) => {
|
|
107
|
+
const edited = lastEditedAt ? Date.parse(lastEditedAt) : NaN;
|
|
108
|
+
return Number.isFinite(edited) ? Math.max(edited, postedAt) : postedAt;
|
|
109
|
+
};
|
|
110
|
+
function build({ node, postedAt, editedAt, resolved }) {
|
|
111
|
+
if (node.isMinimized || !node.author?.id || !Number.isFinite(postedAt) || !Number.isFinite(editedAt))
|
|
112
|
+
return;
|
|
113
|
+
const { __typename, login } = node.author;
|
|
114
|
+
return {
|
|
115
|
+
id: node.id,
|
|
116
|
+
order: postedAt,
|
|
117
|
+
url: node.url ?? "",
|
|
118
|
+
editedAt,
|
|
119
|
+
resolved,
|
|
120
|
+
bot: __typename === "Bot" || login.endsWith("[bot]"),
|
|
121
|
+
authorID: node.author.id,
|
|
122
|
+
username: login
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function toIssueComment(node) {
|
|
126
|
+
const postedAt = time(node.publishedAt, node.createdAt);
|
|
127
|
+
return build({
|
|
128
|
+
node,
|
|
129
|
+
postedAt,
|
|
130
|
+
editedAt: activity(node.lastEditedAt, postedAt),
|
|
131
|
+
resolved: false
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
function toReview(node) {
|
|
135
|
+
if (!REVIEW_STATES.has(node.state) || !node.body.trim())
|
|
136
|
+
return;
|
|
137
|
+
const postedAt = time(node.submittedAt);
|
|
138
|
+
return build({
|
|
139
|
+
node,
|
|
140
|
+
postedAt,
|
|
141
|
+
editedAt: activity(node.lastEditedAt, postedAt),
|
|
142
|
+
resolved: false
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
function toInlineComment(node, resolved) {
|
|
146
|
+
if (node.state === "PENDING")
|
|
147
|
+
return;
|
|
148
|
+
const postedAt = time(node.publishedAt, node.createdAt);
|
|
149
|
+
return build({
|
|
150
|
+
node,
|
|
151
|
+
postedAt,
|
|
152
|
+
editedAt: activity(node.lastEditedAt, postedAt),
|
|
153
|
+
resolved
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
var commentWindow = { pages: MAX_COMMENT_PAGES, completeAtLimit: true };
|
|
157
|
+
var allThreads = { pages: MAX_FETCH_REQUESTS, completeAtLimit: false };
|
|
158
|
+
async function readList(graphql, host, base, text, field, limit = commentWindow) {
|
|
159
|
+
const pages = [];
|
|
160
|
+
let viewer;
|
|
161
|
+
let before;
|
|
162
|
+
const result = (complete) => ({
|
|
163
|
+
items: pages.toReversed().flat(),
|
|
164
|
+
viewer,
|
|
165
|
+
complete
|
|
166
|
+
});
|
|
167
|
+
for (let count = 0;count < limit.pages; count++) {
|
|
168
|
+
const variables = before ? { ...base, before } : base;
|
|
169
|
+
const data = await graphql(host, text, variables);
|
|
170
|
+
viewer ??= data.viewer?.id ?? undefined;
|
|
171
|
+
const connection = data.repository?.pullRequest?.[field];
|
|
172
|
+
if (!connection)
|
|
173
|
+
return count === 0 ? { viewer, complete: false } : result(false);
|
|
174
|
+
pages.push(connection.nodes);
|
|
175
|
+
if (!connection.pageInfo.hasPreviousPage)
|
|
176
|
+
return result(true);
|
|
177
|
+
before = connection.pageInfo.startCursor ?? undefined;
|
|
178
|
+
if (!before)
|
|
179
|
+
return result(false);
|
|
180
|
+
}
|
|
181
|
+
return result(limit.completeAtLimit);
|
|
182
|
+
}
|
|
183
|
+
async function readOlderComments(graphql, host, thread) {
|
|
184
|
+
if (!thread.comments)
|
|
185
|
+
return { comments: [], complete: false };
|
|
186
|
+
const pages = [];
|
|
187
|
+
let before = thread.comments?.pageInfo.hasPreviousPage ? thread.comments.pageInfo.startCursor ?? undefined : undefined;
|
|
188
|
+
if (thread.comments?.pageInfo.hasPreviousPage && !before)
|
|
189
|
+
return { comments: [], complete: false };
|
|
190
|
+
for (let count = 1;before && count < MAX_COMMENT_PAGES; count++) {
|
|
191
|
+
const data = await graphql(host, THREAD_COMMENTS_QUERY, { id: thread.id, before });
|
|
192
|
+
const connection = data.node?.comments;
|
|
193
|
+
if (!connection)
|
|
194
|
+
return { comments: pages.toReversed().flat(), complete: false };
|
|
195
|
+
pages.push(connection.nodes);
|
|
196
|
+
before = connection.pageInfo.hasPreviousPage ? connection.pageInfo.startCursor ?? undefined : undefined;
|
|
197
|
+
if (connection.pageInfo.hasPreviousPage && !before)
|
|
198
|
+
return { comments: pages.toReversed().flat(), complete: false };
|
|
199
|
+
}
|
|
200
|
+
return { comments: pages.toReversed().flat(), complete: true };
|
|
201
|
+
}
|
|
202
|
+
async function fetchComments(transport, host, project, number) {
|
|
203
|
+
let requests = 0;
|
|
204
|
+
const graphql = typed(async (...args) => {
|
|
205
|
+
if (requests++ >= MAX_FETCH_REQUESTS)
|
|
206
|
+
return {};
|
|
207
|
+
return transport(...args);
|
|
208
|
+
});
|
|
209
|
+
const [owner, repo] = project.split("/");
|
|
210
|
+
const base = { owner, repo, number: Number(number) };
|
|
211
|
+
const [conversation, reviews, threads] = await Promise.all([
|
|
212
|
+
readList(graphql, host, base, ISSUE_COMMENTS_QUERY, "comments"),
|
|
213
|
+
readList(graphql, host, base, REVIEWS_QUERY, "reviews"),
|
|
214
|
+
readList(graphql, host, base, THREADS_QUERY, "reviewThreads", allThreads)
|
|
215
|
+
]);
|
|
216
|
+
if (!conversation.items || !reviews.items || !threads.items)
|
|
217
|
+
return;
|
|
218
|
+
const viewerID = conversation.viewer ?? reviews.viewer ?? threads.viewer;
|
|
219
|
+
let threadsComplete = threads.complete;
|
|
220
|
+
const inline = [];
|
|
221
|
+
for (const thread of threads.items) {
|
|
222
|
+
const older = await readOlderComments(graphql, host, thread);
|
|
223
|
+
threadsComplete &&= older.complete;
|
|
224
|
+
for (const node of [...older.comments, ...thread.comments?.nodes ?? []]) {
|
|
225
|
+
const comment = toInlineComment(node, thread.isResolved);
|
|
226
|
+
if (comment)
|
|
227
|
+
inline.push(comment);
|
|
228
|
+
}
|
|
229
|
+
inline.sort((a, b) => b.order - a.order);
|
|
230
|
+
inline.splice(PAGE_SIZE * MAX_COMMENT_PAGES);
|
|
231
|
+
}
|
|
232
|
+
const comments = [
|
|
233
|
+
...conversation.items.map(toIssueComment),
|
|
234
|
+
...reviews.items.map(toReview),
|
|
235
|
+
...inline.sort((a, b) => a.order - b.order).slice(-PAGE_SIZE * MAX_COMMENT_PAGES)
|
|
236
|
+
].flatMap((comment) => comment ?? []);
|
|
237
|
+
return {
|
|
238
|
+
complete: conversation.complete && reviews.complete && threadsComplete && viewerID !== undefined,
|
|
239
|
+
viewerID,
|
|
240
|
+
comments: comments.sort((a, b) => a.order - b.order)
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// packages/forgekeeper/src/gitlab-feedback.ts
|
|
245
|
+
var PAGE_SIZE2 = 100;
|
|
246
|
+
var MAX_COMMENT_PAGES2 = 20;
|
|
79
247
|
var COMMENTS_QUERY = query(`query($project: ID!, $iid: String!, $before: String) {
|
|
80
248
|
currentUser { id }
|
|
81
249
|
project(fullPath: $project) {
|
|
82
250
|
mergeRequest(iid: $iid) {
|
|
83
|
-
notes(last: ${
|
|
251
|
+
notes(last: ${PAGE_SIZE2}, before: $before, filter: ONLY_COMMENTS) {
|
|
84
252
|
pageInfo { hasPreviousPage startCursor }
|
|
85
253
|
nodes {
|
|
86
254
|
id url createdAt updatedAt lastEditedAt system
|
|
@@ -109,7 +277,7 @@ function toComment(node) {
|
|
|
109
277
|
username: node.author.username
|
|
110
278
|
};
|
|
111
279
|
}
|
|
112
|
-
async function
|
|
280
|
+
async function fetchComments2(transport, host, project, iid) {
|
|
113
281
|
const graphql = typed(transport);
|
|
114
282
|
const pages = [];
|
|
115
283
|
let viewerID;
|
|
@@ -119,7 +287,7 @@ async function fetchComments(transport, host, project, iid) {
|
|
|
119
287
|
viewerID,
|
|
120
288
|
comments: pages.toReversed().flat()
|
|
121
289
|
});
|
|
122
|
-
for (let
|
|
290
|
+
for (let page2 = 0;page2 < MAX_COMMENT_PAGES2; page2++) {
|
|
123
291
|
const variables = { project, iid };
|
|
124
292
|
if (before)
|
|
125
293
|
variables.before = before;
|
|
@@ -127,7 +295,7 @@ async function fetchComments(transport, host, project, iid) {
|
|
|
127
295
|
viewerID ??= data.currentUser?.id ?? undefined;
|
|
128
296
|
const connection = data.project?.mergeRequest?.notes;
|
|
129
297
|
if (!connection)
|
|
130
|
-
return
|
|
298
|
+
return page2 === 0 ? undefined : snapshot(false);
|
|
131
299
|
pages.push(connection.nodes.flatMap((node) => toComment(node) ?? []));
|
|
132
300
|
if (!connection.pageInfo.hasPreviousPage)
|
|
133
301
|
return snapshot(true);
|
|
@@ -140,7 +308,7 @@ async function fetchComments(transport, host, project, iid) {
|
|
|
140
308
|
|
|
141
309
|
// packages/forgekeeper/src/gitlab.ts
|
|
142
310
|
var DISCUSSIONS = "pageInfo { hasNextPage endCursor } nodes { resolvable resolved }";
|
|
143
|
-
var
|
|
311
|
+
var PAGE_SIZE3 = 100;
|
|
144
312
|
var MAX_DISCUSSION_PAGES = 50;
|
|
145
313
|
var MERGE_REQUEST_FIELDS = `
|
|
146
314
|
iid title webUrl state draft conflicts approved detailedMergeStatus diffHeadSha updatedAt targetBranch
|
|
@@ -149,7 +317,7 @@ var MERGE_REQUEST_FIELDS = `
|
|
|
149
317
|
targetProject { fullPath }
|
|
150
318
|
headPipeline { status path detailedStatus { label } }
|
|
151
319
|
reviewers(first: 100) { nodes { type username bot mergeRequestInteraction { reviewState approved } } }
|
|
152
|
-
discussions(first: ${
|
|
320
|
+
discussions(first: ${PAGE_SIZE3}) { ${DISCUSSIONS} }
|
|
153
321
|
`;
|
|
154
322
|
function mergeRequestsQuery(projectCount) {
|
|
155
323
|
const variables = Array.from({ length: projectCount }, (_, index) => `$p${index}: ID!`).join(", ");
|
|
@@ -184,7 +352,7 @@ var SOURCE_BRANCH_QUERY = query(`query($project: ID!, $iid: String!) {
|
|
|
184
352
|
}`);
|
|
185
353
|
var DISCUSSIONS_QUERY = query(`query($project: ID!, $iid: String!, $after: String) {
|
|
186
354
|
project(fullPath: $project) {
|
|
187
|
-
mergeRequest(iid: $iid) { discussions(first: ${
|
|
355
|
+
mergeRequest(iid: $iid) { discussions(first: ${PAGE_SIZE3}, after: $after) { ${DISCUSSIONS} } }
|
|
188
356
|
}
|
|
189
357
|
}`);
|
|
190
358
|
function classifyError(message, code) {
|
|
@@ -223,7 +391,7 @@ function glabGraphQL(run, cwd) {
|
|
|
223
391
|
return body.data;
|
|
224
392
|
};
|
|
225
393
|
}
|
|
226
|
-
var countUnresolved = (
|
|
394
|
+
var countUnresolved = (page2) => page2.nodes.filter((discussion) => discussion.resolvable && !discussion.resolved).length;
|
|
227
395
|
var gitlabTraits = {
|
|
228
396
|
kind: "gitlab",
|
|
229
397
|
noun: "MR",
|
|
@@ -258,7 +426,7 @@ var gitlabTraits = {
|
|
|
258
426
|
feedback: {
|
|
259
427
|
async fetch(run, directory, request) {
|
|
260
428
|
const ref = gitlabTraits.parseUrl(request.url);
|
|
261
|
-
return ref ?
|
|
429
|
+
return ref ? fetchComments2(glabGraphQL(run, directory), ref.host, ref.project, ref.iid) : undefined;
|
|
262
430
|
}
|
|
263
431
|
}
|
|
264
432
|
};
|
|
@@ -330,22 +498,22 @@ class GitLabForge {
|
|
|
330
498
|
}
|
|
331
499
|
async unresolvedThreads(host, node) {
|
|
332
500
|
let count = countUnresolved(node.discussions);
|
|
333
|
-
let
|
|
501
|
+
let page2 = node.discussions.pageInfo;
|
|
334
502
|
let pages = 1;
|
|
335
|
-
while (
|
|
503
|
+
while (page2.hasNextPage && page2.endCursor && pages < MAX_DISCUSSION_PAGES) {
|
|
336
504
|
const data = await this.graphql(host, DISCUSSIONS_QUERY, {
|
|
337
505
|
project: node.targetProject.fullPath,
|
|
338
506
|
iid: node.iid,
|
|
339
|
-
after:
|
|
507
|
+
after: page2.endCursor
|
|
340
508
|
});
|
|
341
509
|
const next = data.project?.mergeRequest?.discussions;
|
|
342
510
|
if (!next)
|
|
343
511
|
break;
|
|
344
512
|
count += countUnresolved(next);
|
|
345
|
-
|
|
513
|
+
page2 = next.pageInfo;
|
|
346
514
|
pages++;
|
|
347
515
|
}
|
|
348
|
-
return { count, complete: !
|
|
516
|
+
return { count, complete: !page2.hasNextPage };
|
|
349
517
|
}
|
|
350
518
|
async normalize(host, node) {
|
|
351
519
|
const threads = await this.unresolvedThreads(host, node);
|
|
@@ -384,10 +552,12 @@ class GitLabForge {
|
|
|
384
552
|
}
|
|
385
553
|
|
|
386
554
|
// packages/forgekeeper/src/github.ts
|
|
555
|
+
var COPILOT_AUTHOR = "copilot-pull-request-reviewer[bot]";
|
|
387
556
|
var fields = `number title url state isDraft mergeable reviewDecision headRefOid headRefName
|
|
388
557
|
headRepository { nameWithOwner } baseRepository { nameWithOwner } baseRefName updatedAt
|
|
389
558
|
reviewThreads(first:100) { nodes { isResolved } pageInfo { hasNextPage endCursor } }
|
|
390
|
-
reviewRequests(first:100) { nodes { requestedReviewer { ... on User { login } ... on Team { name } } } pageInfo { hasNextPage } }
|
|
559
|
+
reviewRequests(first:100) { nodes { requestedReviewer { __typename ... on User { login } ... on Bot { login } ... on Team { name } } } pageInfo { hasNextPage endCursor } }
|
|
560
|
+
copilotReviews: reviews(last:1, author:"${COPILOT_AUTHOR}", states:[COMMENTED,CHANGES_REQUESTED,APPROVED,DISMISSED]) { nodes { id state comments { totalCount } } }
|
|
391
561
|
commits(last:1) { nodes { commit { statusCheckRollup { contexts(first:100) {
|
|
392
562
|
nodes { __typename ... on CheckRun { status conclusion detailsUrl } ... on StatusContext { state targetUrl } }
|
|
393
563
|
pageInfo { hasNextPage endCursor }
|
|
@@ -481,8 +651,43 @@ var githubTraits = {
|
|
|
481
651
|
indicators: (request) => request.reviewDecision === "CHANGES_REQUESTED" ? [{ text: "changes requested", tone: "warning", essential: true }] : [],
|
|
482
652
|
details: (request) => [
|
|
483
653
|
`Review decision: ${request.reviewDecision ? humanize(request.reviewDecision) : "not reported"}`
|
|
484
|
-
]
|
|
654
|
+
],
|
|
655
|
+
automatedReview: {
|
|
656
|
+
name: "GitHub Copilot",
|
|
657
|
+
status: (request) => copilotStatus(request.copilotReview)
|
|
658
|
+
},
|
|
659
|
+
feedback: {
|
|
660
|
+
async fetch(run, directory, request) {
|
|
661
|
+
const ref = githubTraits.parseUrl(request.url);
|
|
662
|
+
return ref ? fetchComments(ghGraphQL(run, directory), ref.host, ref.project, ref.iid) : undefined;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
485
665
|
};
|
|
666
|
+
function copilotStatus(review) {
|
|
667
|
+
if (!review)
|
|
668
|
+
return;
|
|
669
|
+
const feedback = review.comments > 0 && !review.dismissed;
|
|
670
|
+
const label = review.dismissed ? "dismissed" : feedback ? "commented" : "no comments";
|
|
671
|
+
const completed = review.reviewID ? { id: review.reviewID, feedback, label } : undefined;
|
|
672
|
+
if (review.requested)
|
|
673
|
+
return { state: "running", label: "reviewing", completed };
|
|
674
|
+
if (!completed)
|
|
675
|
+
return;
|
|
676
|
+
return { state: feedback ? "feedback" : "settled", label, completed };
|
|
677
|
+
}
|
|
678
|
+
var isCopilotReviewer = (login) => /^copilot-pull-request-reviewer(\[bot\])?$/i.test(login ?? "");
|
|
679
|
+
function copilotReview(node) {
|
|
680
|
+
const requested = node.reviewRequests.nodes.some((request) => isCopilotReviewer(request.requestedReviewer?.login));
|
|
681
|
+
const latest = node.copilotReviews?.nodes?.find((review) => review);
|
|
682
|
+
if (!requested && !latest)
|
|
683
|
+
return;
|
|
684
|
+
return {
|
|
685
|
+
requested,
|
|
686
|
+
reviewID: latest?.id,
|
|
687
|
+
comments: latest?.comments.totalCount ?? 0,
|
|
688
|
+
...latest?.state === "DISMISSED" ? { dismissed: true } : {}
|
|
689
|
+
};
|
|
690
|
+
}
|
|
486
691
|
|
|
487
692
|
class GitHubForge {
|
|
488
693
|
traits = githubTraits;
|
|
@@ -533,7 +738,7 @@ class GitHubForge {
|
|
|
533
738
|
const [owner, repo] = target.path.split("/");
|
|
534
739
|
let after;
|
|
535
740
|
let complete = false;
|
|
536
|
-
for (let
|
|
741
|
+
for (let page2 = 0;page2 < MAX_PAGES; page2++) {
|
|
537
742
|
const data = await this.graphql(target.host, query(`query($owner:String!,$repo:String!,$branch:String!${after ? ",$after:String!" : ""}) {
|
|
538
743
|
repository(owner:$owner,name:$repo) { pullRequests(first:100,states:OPEN,headRefName:$branch${after ? ",after:$after" : ""}) {
|
|
539
744
|
nodes { ${fields} } pageInfo { hasNextPage endCursor }
|
|
@@ -563,21 +768,21 @@ class GitHubForge {
|
|
|
563
768
|
async threads(host, node) {
|
|
564
769
|
const [owner, repo] = node.baseRepository.nameWithOwner.split("/");
|
|
565
770
|
const threads = [...node.reviewThreads.nodes];
|
|
566
|
-
let
|
|
567
|
-
for (let count = 1;
|
|
771
|
+
let page2 = node.reviewThreads.pageInfo;
|
|
772
|
+
for (let count = 1;page2?.hasNextPage && page2.endCursor && count < MAX_PAGES; count++) {
|
|
568
773
|
const data = await this.graphql(host, query(`query($owner:String!,$repo:String!,$number:Int!,$after:String!) {
|
|
569
774
|
repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
|
|
570
775
|
reviewThreads(first:100,after:$after) { nodes { isResolved } pageInfo { hasNextPage endCursor } }
|
|
571
|
-
} } }`), { owner, repo, number: node.number, after:
|
|
776
|
+
} } }`), { owner, repo, number: node.number, after: page2.endCursor });
|
|
572
777
|
const next = data.repository?.pullRequest?.reviewThreads;
|
|
573
778
|
if (!next)
|
|
574
779
|
break;
|
|
575
780
|
threads.push(...next.nodes);
|
|
576
|
-
|
|
781
|
+
page2 = next.pageInfo;
|
|
577
782
|
}
|
|
578
783
|
return {
|
|
579
784
|
unresolved: threads.filter((thread) => !thread.isResolved).length,
|
|
580
|
-
complete: !
|
|
785
|
+
complete: !page2?.hasNextPage
|
|
581
786
|
};
|
|
582
787
|
}
|
|
583
788
|
async contexts(host, node) {
|
|
@@ -598,7 +803,33 @@ class GitHubForge {
|
|
|
598
803
|
}
|
|
599
804
|
return { nodes: contexts, complete: !cursor?.hasNextPage };
|
|
600
805
|
}
|
|
806
|
+
async reviewers(host, node) {
|
|
807
|
+
const nodes = [...node.reviewRequests.nodes];
|
|
808
|
+
let reviewerPage = node.reviewRequests.pageInfo;
|
|
809
|
+
for (let count = 1;reviewerPage?.hasNextPage && count < MAX_PAGES; count++) {
|
|
810
|
+
if (!reviewerPage.endCursor)
|
|
811
|
+
throw new ForgeError("request", "GitHub reviewer pagination cursor missing");
|
|
812
|
+
const [owner, repo] = node.baseRepository.nameWithOwner.split("/");
|
|
813
|
+
const data = await this.graphql(host, query(`query($owner:String!,$repo:String!,$number:Int!,$after:String!) {
|
|
814
|
+
repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
|
|
815
|
+
reviewRequests(first:100,after:$after) {
|
|
816
|
+
nodes { requestedReviewer { __typename ... on User { login } ... on Bot { login } ... on Team { name } } }
|
|
817
|
+
pageInfo { hasNextPage endCursor }
|
|
818
|
+
}
|
|
819
|
+
} }
|
|
820
|
+
}`), { owner, repo, number: node.number, after: reviewerPage.endCursor });
|
|
821
|
+
const next = data.repository?.pullRequest?.reviewRequests;
|
|
822
|
+
if (!next)
|
|
823
|
+
throw new ForgeError("request", "GitHub reviewers unavailable");
|
|
824
|
+
nodes.push(...next.nodes);
|
|
825
|
+
reviewerPage = next.pageInfo;
|
|
826
|
+
}
|
|
827
|
+
if (reviewerPage?.hasNextPage)
|
|
828
|
+
throw new ForgeError("request", "GitHub reviewers exceeded the pagination limit");
|
|
829
|
+
return { nodes, pageInfo: reviewerPage };
|
|
830
|
+
}
|
|
601
831
|
async normalize(host, node) {
|
|
832
|
+
const reviewers = await this.reviewers(host, node);
|
|
602
833
|
const [threads, contexts] = [await this.threads(host, node), await this.contexts(host, node)];
|
|
603
834
|
return {
|
|
604
835
|
forge: "github",
|
|
@@ -611,11 +842,14 @@ class GitHubForge {
|
|
|
611
842
|
conflictsKnown: node.mergeable !== "UNKNOWN",
|
|
612
843
|
approved: node.reviewDecision === "APPROVED",
|
|
613
844
|
reviewDecision: node.reviewDecision ?? undefined,
|
|
614
|
-
|
|
845
|
+
copilotReview: copilotReview({ ...node, reviewRequests: reviewers }),
|
|
846
|
+
awaitingReviewers: reviewers.nodes.flatMap((request) => {
|
|
615
847
|
const reviewer = request.requestedReviewer;
|
|
848
|
+
if (reviewer?.__typename === "Bot")
|
|
849
|
+
return [];
|
|
616
850
|
return reviewer?.login ? [reviewer.login] : reviewer?.name ? [reviewer.name] : [];
|
|
617
851
|
}),
|
|
618
|
-
reviewersComplete: !
|
|
852
|
+
reviewersComplete: !reviewers.pageInfo?.hasNextPage,
|
|
619
853
|
targetProject: node.baseRepository.nameWithOwner,
|
|
620
854
|
targetBranch: node.baseRefName,
|
|
621
855
|
headSha: node.headRefOid,
|
|
@@ -808,7 +1042,7 @@ function responsiveFooterSegments(snapshot, width) {
|
|
|
808
1042
|
}
|
|
809
1043
|
return fitted;
|
|
810
1044
|
}
|
|
811
|
-
var
|
|
1045
|
+
var time2 = (value) => new Date(value).toLocaleTimeString();
|
|
812
1046
|
function describe(request, head) {
|
|
813
1047
|
const traits = traitsOf(request);
|
|
814
1048
|
const lines = [
|
|
@@ -856,9 +1090,9 @@ function detailsMessage(snapshot) {
|
|
|
856
1090
|
lines.push("", ...describe(request, repository?.head ?? ""));
|
|
857
1091
|
}
|
|
858
1092
|
if (error)
|
|
859
|
-
lines.push("", `Last refresh failed at ${
|
|
1093
|
+
lines.push("", `Last refresh failed at ${time2(error.at)}: ${error.message}`);
|
|
860
1094
|
if (fetchedAt !== undefined)
|
|
861
|
-
lines.push("", `Last fetched: ${
|
|
1095
|
+
lines.push("", `Last fetched: ${time2(fetchedAt)}`);
|
|
862
1096
|
return lines.join(`
|
|
863
1097
|
`);
|
|
864
1098
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-forgekeeper",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "OpenCode plugin that tracks each session's GitHub and GitLab issues and PRs/MRs: title prefixes, a PR/MR status footer, and review notifications",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Pedro Pombeiro",
|