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.
Files changed (4) hide show
  1. package/README.md +34 -14
  2. package/dist/index.js +364 -41
  3. package/dist/tui.js +262 -28
  4. 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 review. GitHub also shows a requested change. Unknown mergeability stays
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 | Not supported |
238
- | New human review feedback | MR comments | Not supported |
239
-
240
- - **Automated reviews**: Duo's final state of `REVIEWED` or `REQUESTED_CHANGES` asks the
241
- agent to read its comments. Other final states, such as `APPROVED`, send nothing. The
242
- server notifies only when it sees a review go from running to finished, so a review that
243
- finished before the server first saw it running isn't announced. Reviews that finish in
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`, `src/human-review-watch.ts`, and
382
- `src/gitlab-feedback.ts` decide when to notify the agent.
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/gitlab-feedback.ts
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: ${PAGE_SIZE}, before: $before, filter: ONLY_COMMENTS) {
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 fetchComments(transport, host, project, iid) {
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 page = 0;page < MAX_COMMENT_PAGES; page++) {
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 page === 0 ? undefined : snapshot(false);
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 PAGE_SIZE2 = 100;
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: ${PAGE_SIZE2}) { ${DISCUSSIONS} }
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: ${PAGE_SIZE2}, after: $after) { ${DISCUSSIONS} } }
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 = (page) => page.nodes.filter((discussion) => discussion.resolvable && !discussion.resolved).length;
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 ? fetchComments(glabGraphQL(run, directory), ref.host, ref.project, ref.iid) : undefined;
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 page = node.discussions.pageInfo;
617
+ let page2 = node.discussions.pageInfo;
450
618
  let pages = 1;
451
- while (page.hasNextPage && page.endCursor && pages < MAX_DISCUSSION_PAGES) {
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: page.endCursor
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
- page = next.pageInfo;
629
+ page2 = next.pageInfo;
462
630
  pages++;
463
631
  }
464
- return { count, complete: !page.hasNextPage };
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 page = 0;page < MAX_PAGES; page++) {
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 page = node.reviewThreads.pageInfo;
683
- for (let count = 1;page?.hasNextPage && page.endCursor && count < MAX_PAGES; count++) {
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: page.endCursor });
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
- page = next.pageInfo;
897
+ page2 = next.pageInfo;
693
898
  }
694
899
  return {
695
900
  unresolved: threads.filter((thread) => !thread.isResolved).length,
696
- complete: !page?.hasNextPage
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
- awaitingReviewers: node.reviewRequests.nodes.flatMap((request) => {
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: !node.reviewRequests.pageInfo?.hasNextPage,
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 running = new Set(requests(previous).filter((request) => automatedReview(request)?.state === "running").map((request) => request.url));
1028
- return requests(next).filter((request) => running.has(request.url) && automatedReview(request)?.state === "feedback");
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 = automatedReview(request)?.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 "${automatedReview(request)?.label ?? ""}"`;
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)}: ${automatedReview(request)?.label ?? ""}`).join(", ");
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
- if (request && automatedReview(request)?.state === "feedback" && at - entry.since < PENDING_RETENTION)
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
- pending[key2] = { sessionID, url: request.url, since: pending[key2]?.since ?? at, retryAt };
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: (sessionID) => void known.delete(sessionID)
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
- return { baseline: record?.baseline ?? change.baseline ?? 0, announced, seenAt: now };
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) ?? { baseline: change.baseline ?? 0, announced: new Set };
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
- return remember(key2, { baseline: baselineOf(snapshot2) }, at);
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 page = await ctx.storage.scan({ prefix, ...after ? { after } : {} });
1872
- entries.push(...page.entries);
1873
- after = page.next;
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/gitlab-feedback.ts
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: ${PAGE_SIZE}, before: $before, filter: ONLY_COMMENTS) {
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 fetchComments(transport, host, project, iid) {
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 page = 0;page < MAX_COMMENT_PAGES; page++) {
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 page === 0 ? undefined : snapshot(false);
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 PAGE_SIZE2 = 100;
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: ${PAGE_SIZE2}) { ${DISCUSSIONS} }
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: ${PAGE_SIZE2}, after: $after) { ${DISCUSSIONS} } }
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 = (page) => page.nodes.filter((discussion) => discussion.resolvable && !discussion.resolved).length;
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 ? fetchComments(glabGraphQL(run, directory), ref.host, ref.project, ref.iid) : undefined;
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 page = node.discussions.pageInfo;
501
+ let page2 = node.discussions.pageInfo;
334
502
  let pages = 1;
335
- while (page.hasNextPage && page.endCursor && pages < MAX_DISCUSSION_PAGES) {
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: page.endCursor
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
- page = next.pageInfo;
513
+ page2 = next.pageInfo;
346
514
  pages++;
347
515
  }
348
- return { count, complete: !page.hasNextPage };
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 page = 0;page < MAX_PAGES; page++) {
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 page = node.reviewThreads.pageInfo;
567
- for (let count = 1;page?.hasNextPage && page.endCursor && count < MAX_PAGES; count++) {
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: page.endCursor });
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
- page = next.pageInfo;
781
+ page2 = next.pageInfo;
577
782
  }
578
783
  return {
579
784
  unresolved: threads.filter((thread) => !thread.isResolved).length,
580
- complete: !page?.hasNextPage
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
- awaitingReviewers: node.reviewRequests.nodes.flatMap((request) => {
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: !node.reviewRequests.pageInfo?.hasNextPage,
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 time = (value) => new Date(value).toLocaleTimeString();
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 ${time(error.at)}: ${error.message}`);
1093
+ lines.push("", `Last refresh failed at ${time2(error.at)}: ${error.message}`);
860
1094
  if (fetchedAt !== undefined)
861
- lines.push("", `Last fetched: ${time(fetchedAt)}`);
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.1.1",
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",