opencode-forgekeeper 2.1.1 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +34 -14
  2. package/dist/index.js +376 -44
  3. package/dist/tui.js +429 -200
  4. package/package.json +1 -1
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 }
@@ -561,22 +731,31 @@ var CONCLUSIONS = [
561
731
  ];
562
732
  var STATES = ["SUCCESS", "FAILURE", "ERROR", "PENDING", "EXPECTED"];
563
733
  function checks(nodes, complete, url) {
734
+ const count = (value) => `${value} check${value === 1 ? "" : "s"}`;
564
735
  const failed = nodes.filter((node) => FAILED.includes(node.conclusion ?? node.state ?? "")).length;
565
736
  const pending = nodes.filter((node) => node.status ? node.status !== "COMPLETED" : ["PENDING", "EXPECTED"].includes(node.state ?? "")).length;
566
737
  const unknown = nodes.some((node) => node.status === "COMPLETED" ? !CONCLUSIONS.includes(node.conclusion ?? "") : !node.status && !STATES.includes(node.state ?? ""));
567
738
  if (failed)
568
739
  return {
569
740
  status: "FAILED",
570
- label: `${failed} failed${pending ? `, ${pending} pending` : ""}${complete ? "" : " (partial)"}`,
741
+ label: `${count(failed)} failed${pending ? `, ${count(pending)} pending` : ""}${complete ? "" : " (partial)"}`,
571
742
  url
572
743
  };
573
744
  if (pending || !complete || unknown)
574
745
  return {
575
746
  status: "PENDING",
576
- label: `${pending ? `${pending} pending` : "unknown"}${complete ? "" : " (partial)"}`,
747
+ label: `${pending ? `${count(pending)} pending` : "unknown"}${complete ? "" : " (partial)"}`,
577
748
  url
578
749
  };
579
- return { status: "SUCCESS", label: `${nodes.length} passed or skipped`, url };
750
+ const label = [
751
+ ["SUCCESS", "passed"],
752
+ ["SKIPPED", "skipped"],
753
+ ["NEUTRAL", "neutral"]
754
+ ].flatMap(([result, text]) => {
755
+ const total = nodes.filter((node) => (node.conclusion ?? node.state) === result).length;
756
+ return total ? [`${count(total)} ${text}`] : [];
757
+ }).join(", ");
758
+ return { status: "SUCCESS", label: label || "0 checks", url };
580
759
  }
581
760
  function parseGitHubUrl(url, pattern) {
582
761
  const ref = parseWebUrl("github", url, pattern);
@@ -597,8 +776,43 @@ var githubTraits = {
597
776
  indicators: (request) => request.reviewDecision === "CHANGES_REQUESTED" ? [{ text: "changes requested", tone: "warning", essential: true }] : [],
598
777
  details: (request) => [
599
778
  `Review decision: ${request.reviewDecision ? humanize(request.reviewDecision) : "not reported"}`
600
- ]
779
+ ],
780
+ automatedReview: {
781
+ name: "GitHub Copilot",
782
+ status: (request) => copilotStatus(request.copilotReview)
783
+ },
784
+ feedback: {
785
+ async fetch(run, directory, request) {
786
+ const ref = githubTraits.parseUrl(request.url);
787
+ return ref ? fetchComments(ghGraphQL(run, directory), ref.host, ref.project, ref.iid) : undefined;
788
+ }
789
+ }
601
790
  };
791
+ function copilotStatus(review) {
792
+ if (!review)
793
+ return;
794
+ const feedback = review.comments > 0 && !review.dismissed;
795
+ const label = review.dismissed ? "dismissed" : feedback ? "commented" : "no comments";
796
+ const completed = review.reviewID ? { id: review.reviewID, feedback, label } : undefined;
797
+ if (review.requested)
798
+ return { state: "running", label: "reviewing", completed };
799
+ if (!completed)
800
+ return;
801
+ return { state: feedback ? "feedback" : "settled", label, completed };
802
+ }
803
+ var isCopilotReviewer = (login) => /^copilot-pull-request-reviewer(\[bot\])?$/i.test(login ?? "");
804
+ function copilotReview(node) {
805
+ const requested = node.reviewRequests.nodes.some((request) => isCopilotReviewer(request.requestedReviewer?.login));
806
+ const latest = node.copilotReviews?.nodes?.find((review) => review);
807
+ if (!requested && !latest)
808
+ return;
809
+ return {
810
+ requested,
811
+ reviewID: latest?.id,
812
+ comments: latest?.comments.totalCount ?? 0,
813
+ ...latest?.state === "DISMISSED" ? { dismissed: true } : {}
814
+ };
815
+ }
602
816
 
603
817
  class GitHubForge {
604
818
  traits = githubTraits;
@@ -649,7 +863,7 @@ class GitHubForge {
649
863
  const [owner, repo] = target.path.split("/");
650
864
  let after;
651
865
  let complete = false;
652
- for (let page = 0;page < MAX_PAGES; page++) {
866
+ for (let page2 = 0;page2 < MAX_PAGES; page2++) {
653
867
  const data = await this.graphql(target.host, query(`query($owner:String!,$repo:String!,$branch:String!${after ? ",$after:String!" : ""}) {
654
868
  repository(owner:$owner,name:$repo) { pullRequests(first:100,states:OPEN,headRefName:$branch${after ? ",after:$after" : ""}) {
655
869
  nodes { ${fields} } pageInfo { hasNextPage endCursor }
@@ -679,21 +893,21 @@ class GitHubForge {
679
893
  async threads(host, node) {
680
894
  const [owner, repo] = node.baseRepository.nameWithOwner.split("/");
681
895
  const threads = [...node.reviewThreads.nodes];
682
- let page = node.reviewThreads.pageInfo;
683
- for (let count = 1;page?.hasNextPage && page.endCursor && count < MAX_PAGES; count++) {
896
+ let page2 = node.reviewThreads.pageInfo;
897
+ for (let count = 1;page2?.hasNextPage && page2.endCursor && count < MAX_PAGES; count++) {
684
898
  const data = await this.graphql(host, query(`query($owner:String!,$repo:String!,$number:Int!,$after:String!) {
685
899
  repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
686
900
  reviewThreads(first:100,after:$after) { nodes { isResolved } pageInfo { hasNextPage endCursor } }
687
- } } }`), { owner, repo, number: node.number, after: page.endCursor });
901
+ } } }`), { owner, repo, number: node.number, after: page2.endCursor });
688
902
  const next = data.repository?.pullRequest?.reviewThreads;
689
903
  if (!next)
690
904
  break;
691
905
  threads.push(...next.nodes);
692
- page = next.pageInfo;
906
+ page2 = next.pageInfo;
693
907
  }
694
908
  return {
695
909
  unresolved: threads.filter((thread) => !thread.isResolved).length,
696
- complete: !page?.hasNextPage
910
+ complete: !page2?.hasNextPage
697
911
  };
698
912
  }
699
913
  async contexts(host, node) {
@@ -714,7 +928,33 @@ class GitHubForge {
714
928
  }
715
929
  return { nodes: contexts, complete: !cursor?.hasNextPage };
716
930
  }
931
+ async reviewers(host, node) {
932
+ const nodes = [...node.reviewRequests.nodes];
933
+ let reviewerPage = node.reviewRequests.pageInfo;
934
+ for (let count = 1;reviewerPage?.hasNextPage && count < MAX_PAGES; count++) {
935
+ if (!reviewerPage.endCursor)
936
+ throw new ForgeError("request", "GitHub reviewer pagination cursor missing");
937
+ const [owner, repo] = node.baseRepository.nameWithOwner.split("/");
938
+ const data = await this.graphql(host, query(`query($owner:String!,$repo:String!,$number:Int!,$after:String!) {
939
+ repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
940
+ reviewRequests(first:100,after:$after) {
941
+ nodes { requestedReviewer { __typename ... on User { login } ... on Bot { login } ... on Team { name } } }
942
+ pageInfo { hasNextPage endCursor }
943
+ }
944
+ } }
945
+ }`), { owner, repo, number: node.number, after: reviewerPage.endCursor });
946
+ const next = data.repository?.pullRequest?.reviewRequests;
947
+ if (!next)
948
+ throw new ForgeError("request", "GitHub reviewers unavailable");
949
+ nodes.push(...next.nodes);
950
+ reviewerPage = next.pageInfo;
951
+ }
952
+ if (reviewerPage?.hasNextPage)
953
+ throw new ForgeError("request", "GitHub reviewers exceeded the pagination limit");
954
+ return { nodes, pageInfo: reviewerPage };
955
+ }
717
956
  async normalize(host, node) {
957
+ const reviewers = await this.reviewers(host, node);
718
958
  const [threads, contexts] = [await this.threads(host, node), await this.contexts(host, node)];
719
959
  return {
720
960
  forge: "github",
@@ -727,11 +967,14 @@ class GitHubForge {
727
967
  conflictsKnown: node.mergeable !== "UNKNOWN",
728
968
  approved: node.reviewDecision === "APPROVED",
729
969
  reviewDecision: node.reviewDecision ?? undefined,
730
- awaitingReviewers: node.reviewRequests.nodes.flatMap((request) => {
970
+ copilotReview: copilotReview({ ...node, reviewRequests: reviewers }),
971
+ awaitingReviewers: reviewers.nodes.flatMap((request) => {
731
972
  const reviewer = request.requestedReviewer;
973
+ if (reviewer?.__typename === "Bot")
974
+ return [];
732
975
  return reviewer?.login ? [reviewer.login] : reviewer?.name ? [reviewer.name] : [];
733
976
  }),
734
- reviewersComplete: !node.reviewRequests.pageInfo?.hasNextPage,
977
+ reviewersComplete: !reviewers.pageInfo?.hasNextPage,
735
978
  targetProject: node.baseRepository.nameWithOwner,
736
979
  targetBranch: node.baseRefName,
737
980
  headSha: node.headRefOid,
@@ -1023,11 +1266,33 @@ function automatedReview(request) {
1023
1266
  return reviewer && status2 ? { name: reviewer.name, ...status2 } : undefined;
1024
1267
  }
1025
1268
  var isReviewRunning = (lookup) => requests(lookup).some((request) => automatedReview(request)?.state === "running");
1269
+ function hasFeedback(review) {
1270
+ if (!review)
1271
+ return false;
1272
+ if (review.completed)
1273
+ return review.completed.feedback;
1274
+ return review.state === "feedback";
1275
+ }
1026
1276
  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");
1277
+ const before = new Map(requests(previous).map((request) => [request.url, request]));
1278
+ return requests(next).filter((request) => {
1279
+ const review = automatedReview(request);
1280
+ if (!hasFeedback(review))
1281
+ return false;
1282
+ const earlier = before.get(request.url);
1283
+ if (!earlier)
1284
+ return false;
1285
+ const was = automatedReview(earlier);
1286
+ if (review?.completed)
1287
+ return review.completed.id !== was?.completed?.id;
1288
+ return was?.state === "running";
1289
+ });
1029
1290
  }
1030
1291
  var reviewer = (request) => automatedReview(request)?.name ?? "The automated reviewer";
1292
+ function outcomeLabel(request) {
1293
+ const review = automatedReview(request);
1294
+ return review?.completed?.label ?? review?.label ?? "";
1295
+ }
1031
1296
  var shared = (requests2, value, fallback) => {
1032
1297
  const values = new Set(requests2.map(value));
1033
1298
  return values.size === 1 ? [...values][0] : fallback;
@@ -1035,7 +1300,7 @@ var shared = (requests2, value, fallback) => {
1035
1300
  var reviewers = (requests2) => shared(requests2, reviewer, "Automated reviewers");
1036
1301
  var nouns = (requests2) => shared(requests2, (request) => `${traitsOf(request).noun}s`, "PRs/MRs");
1037
1302
  function reviewOutcome(request) {
1038
- const label = automatedReview(request)?.label ?? "";
1303
+ const label = outcomeLabel(request);
1039
1304
  return label.charAt(0).toUpperCase() + label.slice(1);
1040
1305
  }
1041
1306
  function reviewTitle(requests2) {
@@ -1044,7 +1309,7 @@ function reviewTitle(requests2) {
1044
1309
  return `${reviewers(requests2)} finished reviewing ${requests2.length} ${nouns(requests2)}`;
1045
1310
  }
1046
1311
  function reviewMessage(requests2) {
1047
- const state = (request) => `the state "${automatedReview(request)?.label ?? ""}"`;
1312
+ const state = (request) => `the state "${outcomeLabel(request)}"`;
1048
1313
  if (requests2.length === 1) {
1049
1314
  const [request] = requests2;
1050
1315
  const name2 = reviewer(request);
@@ -1062,7 +1327,7 @@ function reviewMessage(requests2) {
1062
1327
  `);
1063
1328
  }
1064
1329
  function reviewToast(requests2) {
1065
- const outcome = requests2.length === 1 ? reviewOutcome(requests2[0]) : requests2.map((request) => `${reference(request)}: ${automatedReview(request)?.label ?? ""}`).join(", ");
1330
+ const outcome = requests2.length === 1 ? reviewOutcome(requests2[0]) : requests2.map((request) => `${reference(request)}: ${outcomeLabel(request)}`).join(", ");
1066
1331
  return { title: reviewTitle(requests2), message: `${outcome}. Sent to the agent.` };
1067
1332
  }
1068
1333
  var RETRY_DELAY = 10 * 60000;
@@ -1073,7 +1338,23 @@ function createAutomatedReviewWatcher(options) {
1073
1338
  const now = options.now ?? Date.now;
1074
1339
  const known = new Map;
1075
1340
  const sending = new Set;
1341
+ const histories = new Map;
1076
1342
  const write = (sessionID, url, delivery) => options.write?.(sessionID, url, delivery);
1343
+ async function observeCompletion(sessionID, request) {
1344
+ const completed = automatedReview(request)?.completed;
1345
+ if (!completed)
1346
+ return;
1347
+ const key2 = notificationKey(sessionID, request);
1348
+ const history = histories.get(key2) ?? await options.readHistory?.(sessionID, request.url);
1349
+ const alreadyObserved = history?.observed.includes(completed.id) === true;
1350
+ const current = history ?? { observed: [], delivered: [] };
1351
+ histories.set(key2, current);
1352
+ if (!alreadyObserved) {
1353
+ current.observed.push(completed.id);
1354
+ options.writeHistory?.(sessionID, request.url, current);
1355
+ }
1356
+ return { alreadyObserved, newFeedback: !!history && !alreadyObserved && completed.feedback };
1357
+ }
1077
1358
  return {
1078
1359
  async observe(sessionID, previous, next) {
1079
1360
  const pending = known.get(sessionID) ?? { ...await options.read?.(sessionID) };
@@ -1082,7 +1363,8 @@ function createAutomatedReviewWatcher(options) {
1082
1363
  const targets = requests(next).filter((request) => isOpen(request) && options.isTarget(sessionID, request.url));
1083
1364
  for (const [key2, entry] of Object.entries(pending)) {
1084
1365
  const request = targets.find((target2) => target2.url === entry.url);
1085
- if (request && automatedReview(request)?.state === "feedback" && at - entry.since < PENDING_RETENTION)
1366
+ const completed = request && automatedReview(request)?.completed;
1367
+ if (request && hasFeedback(automatedReview(request)) && at - entry.since < PENDING_RETENTION && (!entry.reviewID || completed?.id === entry.reviewID))
1086
1368
  continue;
1087
1369
  if (sending.has(key2))
1088
1370
  continue;
@@ -1090,6 +1372,16 @@ function createAutomatedReviewWatcher(options) {
1090
1372
  write(sessionID, entry.url);
1091
1373
  }
1092
1374
  const finished = finishedReviews(previous, next).filter((request) => isOpen(request) && options.isTarget(sessionID, request.url));
1375
+ for (const request of targets) {
1376
+ const observation = await observeCompletion(sessionID, request);
1377
+ if (!observation)
1378
+ continue;
1379
+ const index = finished.findIndex((item) => item.url === request.url);
1380
+ if (observation.alreadyObserved && index >= 0)
1381
+ finished.splice(index, 1);
1382
+ if (observation.newFeedback && index < 0)
1383
+ finished.push(request);
1384
+ }
1093
1385
  const due = targets.filter((request) => (pending[notificationKey(sessionID, request)]?.retryAt ?? Infinity) <= at);
1094
1386
  const batch = [
1095
1387
  ...new Map([...finished, ...due].map((request) => [request.url, request])).values()
@@ -1102,6 +1394,12 @@ function createAutomatedReviewWatcher(options) {
1102
1394
  try {
1103
1395
  await options.send(sessionID, batch);
1104
1396
  batch.forEach((request, index) => {
1397
+ const history = histories.get(keys[index]);
1398
+ const completed = automatedReview(request)?.completed;
1399
+ if (history && completed && !history.delivered.includes(completed.id)) {
1400
+ history.delivered.push(completed.id);
1401
+ options.writeHistory?.(sessionID, request.url, history);
1402
+ }
1105
1403
  delete pending[keys[index]];
1106
1404
  write(sessionID, request.url);
1107
1405
  });
@@ -1109,7 +1407,14 @@ function createAutomatedReviewWatcher(options) {
1109
1407
  const retryAt = now() + RETRY_DELAY;
1110
1408
  batch.forEach((request, index) => {
1111
1409
  const key2 = keys[index];
1112
- pending[key2] = { sessionID, url: request.url, since: pending[key2]?.since ?? at, retryAt };
1410
+ const reviewID = automatedReview(request)?.completed?.id;
1411
+ pending[key2] = {
1412
+ sessionID,
1413
+ url: request.url,
1414
+ since: pending[key2]?.since ?? at,
1415
+ retryAt,
1416
+ ...reviewID ? { reviewID } : {}
1417
+ };
1113
1418
  write(sessionID, request.url, pending[key2]);
1114
1419
  });
1115
1420
  options.onSendError?.(error, sessionID, batch);
@@ -1119,7 +1424,13 @@ function createAutomatedReviewWatcher(options) {
1119
1424
  }
1120
1425
  },
1121
1426
  hasPending: (sessionID) => Object.keys(known.get(sessionID) ?? {}).length > 0,
1122
- forget: (sessionID) => void known.delete(sessionID)
1427
+ forget(sessionID) {
1428
+ known.delete(sessionID);
1429
+ for (const key2 of histories.keys()) {
1430
+ if (key2.startsWith(`${sessionID} `))
1431
+ histories.delete(key2);
1432
+ }
1433
+ }
1123
1434
  };
1124
1435
  }
1125
1436
 
@@ -1136,11 +1447,17 @@ function applyFeedback(record, change, now) {
1136
1447
  for (const id of change.announced ?? [])
1137
1448
  if (!announced.includes(id))
1138
1449
  announced.push(id);
1139
- return { baseline: record?.baseline ?? change.baseline ?? 0, announced, seenAt: now };
1450
+ const baselineIDs = record?.baselineIDs ?? change.baselineIDs;
1451
+ return {
1452
+ baseline: record?.baseline ?? change.baseline ?? 0,
1453
+ ...baselineIDs ? { baselineIDs } : {},
1454
+ announced,
1455
+ seenAt: now
1456
+ };
1140
1457
  }
1141
1458
  var isExpired = (record, now) => now - record.seenAt > RECORD_RETENTION;
1142
1459
  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);
1460
+ 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
1461
  var humanFeedback = (snapshot2) => snapshot2.comments.filter((comment) => !comment.bot && comment.authorID !== snapshot2.viewerID && !comment.resolved);
1145
1462
  function eligibleFeedback(snapshot2, seen, now) {
1146
1463
  const human = humanFeedback(snapshot2);
@@ -1188,13 +1505,19 @@ function createFeedbackWatcher(options) {
1188
1505
  const mine = local.get(key2);
1189
1506
  if (!shared2 && !mine)
1190
1507
  return;
1508
+ const latest = (mine?.baseline ?? -1) >= (shared2?.baseline ?? -1) ? mine : shared2;
1191
1509
  return {
1192
1510
  baseline: Math.max(shared2?.baseline ?? 0, mine?.baseline ?? 0),
1511
+ baselineIDs: latest?.baselineIDs,
1193
1512
  announced: new Set([...shared2?.announced ?? [], ...mine?.announced ?? []])
1194
1513
  };
1195
1514
  };
1196
1515
  const remember = (key2, change, at) => {
1197
- const mine = local.get(key2) ?? { baseline: change.baseline ?? 0, announced: new Set };
1516
+ const mine = local.get(key2) ?? {
1517
+ baseline: change.baseline ?? 0,
1518
+ baselineIDs: change.baselineIDs,
1519
+ announced: new Set
1520
+ };
1198
1521
  for (const id of change.announced ?? [])
1199
1522
  mine.announced.add(id);
1200
1523
  local.set(key2, mine);
@@ -1207,8 +1530,11 @@ function createFeedbackWatcher(options) {
1207
1530
  const stored = await options.record(key2);
1208
1531
  const at = now();
1209
1532
  const previous = seen(key2, stored);
1210
- if (!previous)
1211
- return remember(key2, { baseline: baselineOf(snapshot2) }, at);
1533
+ if (!previous) {
1534
+ const baseline = baselineOf(snapshot2);
1535
+ const baselineIDs = snapshot2.comments.filter((comment) => comment.order === baseline).map((comment) => comment.id);
1536
+ return remember(key2, { baseline, baselineIDs }, at);
1537
+ }
1212
1538
  if (at < (retryAt.get(key2) ?? 0))
1213
1539
  return;
1214
1540
  const comments = eligibleFeedback(snapshot2, previous, at).filter((comment) => !reserved.has(`${key2} ${comment.id}`));
@@ -1845,6 +2171,7 @@ var FEEDBACK_PREFIX = "status/feedback/";
1845
2171
  var pendingPrefix = (sessionID) => `${PENDING_PREFIX}${encodeURIComponent(sessionID)}/`;
1846
2172
  var pendingKey = (sessionID, url) => `${pendingPrefix(sessionID)}${encodeURIComponent(url)}`;
1847
2173
  var feedbackRecordKey = (key2) => `${FEEDBACK_PREFIX}${encodeURIComponent(key2)}`;
2174
+ var reviewHistoryKey = (sessionID, url) => `status/reviews/${encodeURIComponent(sessionID)}/${encodeURIComponent(url)}`;
1848
2175
  function userTitle(title, prefix) {
1849
2176
  if (!title || !prefix)
1850
2177
  return title;
@@ -1868,9 +2195,9 @@ async function setupStatus(ctx, settings, deps, host) {
1868
2195
  const entries = [];
1869
2196
  let after;
1870
2197
  do {
1871
- const page = await ctx.storage.scan({ prefix, ...after ? { after } : {} });
1872
- entries.push(...page.entries);
1873
- after = page.next;
2198
+ const page2 = await ctx.storage.scan({ prefix, ...after ? { after } : {} });
2199
+ entries.push(...page2.entries);
2200
+ after = page2.next;
1874
2201
  } while (after);
1875
2202
  return entries;
1876
2203
  }
@@ -1926,6 +2253,11 @@ async function setupStatus(ctx, settings, deps, host) {
1926
2253
  const automated = settings.notifyAutomatedReviews ? createAutomatedReviewWatcher({
1927
2254
  isTarget,
1928
2255
  now: deps.now,
2256
+ readHistory: async (sessionID, url) => await ctx.storage.get(reviewHistoryKey(sessionID, url)),
2257
+ writeHistory: (sessionID, url, history) => {
2258
+ const snapshot2 = { observed: [...history.observed], delivered: [...history.delivered] };
2259
+ write(() => ctx.storage.set(reviewHistoryKey(sessionID, url), snapshot2));
2260
+ },
1929
2261
  async read(sessionID) {
1930
2262
  const deliveries = {};
1931
2263
  for (const { value } of await scanAll(pendingPrefix(sessionID))) {