opencode-forgekeeper 2.1.0 → 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 +39 -14
  2. package/dist/index.js +543 -193
  3. package/dist/tui.js +263 -29
  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 }
@@ -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}`));
@@ -1255,6 +1572,175 @@ function createFeedbackWatcher(options) {
1255
1572
  };
1256
1573
  }
1257
1574
 
1575
+ // packages/forgekeeper/src/store.ts
1576
+ class TargetLookupError extends Error {
1577
+ cause;
1578
+ constructor(cause) {
1579
+ super(cause instanceof Error ? cause.message : String(cause));
1580
+ this.cause = cause;
1581
+ }
1582
+ }
1583
+ function createStatusStore(options) {
1584
+ const now = options.now ?? Date.now;
1585
+ const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay));
1586
+ const clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer));
1587
+ const interval = options.interval ?? 120000;
1588
+ const maxBackoff = options.maxBackoff ?? 900000;
1589
+ const minEventInterval = options.minEventInterval ?? 15000;
1590
+ const entries = new Map;
1591
+ let disposed = false;
1592
+ let revision = now() * 1000;
1593
+ const entry = (key2) => {
1594
+ let value = entries.get(key2);
1595
+ if (!value) {
1596
+ value = { snapshot: { loading: false }, refs: 0, failures: 0 };
1597
+ entries.set(key2, value);
1598
+ }
1599
+ return value;
1600
+ };
1601
+ const update = (key2, value, patch) => {
1602
+ value.snapshot = { ...value.snapshot, ...patch, revision: ++revision };
1603
+ if (!disposed)
1604
+ options.onChange(key2);
1605
+ };
1606
+ const cancelTimer = (value) => {
1607
+ if (value.timer === undefined)
1608
+ return;
1609
+ clearTimer(value.timer);
1610
+ value.timer = undefined;
1611
+ };
1612
+ const baseInterval = (value) => options.active?.(value.snapshot.lookup) ? options.activeInterval ?? interval : interval;
1613
+ const pollDelay = (value) => Math.min(baseInterval(value) * 2 ** value.failures, maxBackoff);
1614
+ const lastAttempt = (value) => Math.max(value.snapshot.fetchedAt ?? -Infinity, value.snapshot.error?.at ?? -Infinity);
1615
+ const schedule = (key2, value, delay = pollDelay(value)) => {
1616
+ cancelTimer(value);
1617
+ if (disposed || value.refs === 0)
1618
+ return;
1619
+ const lookup = value.snapshot.lookup;
1620
+ if (!value.snapshot.error && (lookup?.kind !== "found" || lookup.requests.length === 0))
1621
+ return;
1622
+ value.timer = setTimer(() => {
1623
+ value.timer = undefined;
1624
+ refresh(key2);
1625
+ }, delay);
1626
+ };
1627
+ function refresh(key2) {
1628
+ if (disposed)
1629
+ return Promise.resolve({ loading: false });
1630
+ const value = entry(key2);
1631
+ if (value.inflight)
1632
+ return value.inflight;
1633
+ value.stale = false;
1634
+ update(key2, value, { loading: true });
1635
+ value.inflight = options.load(key2).then((lookup) => {
1636
+ if (disposed || value.invalidated)
1637
+ return;
1638
+ const previous = value.snapshot.lookup;
1639
+ value.failures = 0;
1640
+ update(key2, value, {
1641
+ lookup,
1642
+ fetchedAt: now(),
1643
+ error: undefined,
1644
+ targeted: lookup.explicitTarget === true ? true : undefined,
1645
+ loading: false
1646
+ });
1647
+ try {
1648
+ if (!disposed)
1649
+ options.onLoad?.(key2, previous, lookup);
1650
+ } catch (error) {
1651
+ try {
1652
+ options.onLoadError?.(error);
1653
+ } catch {}
1654
+ }
1655
+ }, (error) => {
1656
+ if (disposed || value.invalidated)
1657
+ return;
1658
+ value.failures++;
1659
+ const target2 = error instanceof TargetLookupError;
1660
+ const cause = target2 ? error.cause : error;
1661
+ update(key2, value, {
1662
+ loading: false,
1663
+ error: {
1664
+ kind: cause instanceof ForgeError ? cause.kind : "request",
1665
+ message: cause instanceof Error ? cause.message : String(cause),
1666
+ at: now()
1667
+ },
1668
+ ...target2 ? { targeted: true } : {}
1669
+ });
1670
+ }).then(() => {
1671
+ value.inflight = undefined;
1672
+ if (value.invalidated && !disposed) {
1673
+ value.invalidated = false;
1674
+ return refresh(key2);
1675
+ }
1676
+ schedule(key2, value);
1677
+ return value.snapshot;
1678
+ });
1679
+ return value.inflight;
1680
+ }
1681
+ return {
1682
+ get(key2) {
1683
+ return entries.get(key2)?.snapshot ?? { loading: false };
1684
+ },
1685
+ acquire(key2) {
1686
+ const value = entry(key2);
1687
+ value.refs++;
1688
+ if (value.refs === 1 && !value.inflight) {
1689
+ const remaining = lastAttempt(value) + pollDelay(value) - now();
1690
+ if (value.stale || remaining <= 0)
1691
+ refresh(key2);
1692
+ else
1693
+ schedule(key2, value, remaining);
1694
+ }
1695
+ let released = false;
1696
+ return () => {
1697
+ if (released)
1698
+ return;
1699
+ released = true;
1700
+ value.refs--;
1701
+ if (value.refs === 0)
1702
+ cancelTimer(value);
1703
+ };
1704
+ },
1705
+ refresh,
1706
+ keys() {
1707
+ return [...entries.keys()];
1708
+ },
1709
+ notify(key2) {
1710
+ const value = entries.get(key2);
1711
+ if (!value || value.refs === 0 || value.inflight)
1712
+ return;
1713
+ const last = Math.max(value.snapshot.fetchedAt ?? 0, value.snapshot.error?.at ?? 0);
1714
+ if (now() - last < minEventInterval)
1715
+ return;
1716
+ refresh(key2);
1717
+ },
1718
+ invalidate(key2) {
1719
+ const value = entries.get(key2);
1720
+ if (!value)
1721
+ return;
1722
+ if (value.inflight)
1723
+ value.invalidated = true;
1724
+ else if (value.refs > 0)
1725
+ refresh(key2);
1726
+ else
1727
+ value.stale = true;
1728
+ },
1729
+ setTargeted(key2, targeted) {
1730
+ const value = entries.get(key2);
1731
+ if (!value || disposed || value.snapshot.targeted === true === targeted)
1732
+ return;
1733
+ update(key2, value, { targeted: targeted ? true : undefined });
1734
+ },
1735
+ dispose() {
1736
+ disposed = true;
1737
+ for (const value of entries.values())
1738
+ cancelTimer(value);
1739
+ entries.clear();
1740
+ }
1741
+ };
1742
+ }
1743
+
1258
1744
  // packages/forgekeeper/src/target.ts
1259
1745
  var MAX_TARGETS = 50;
1260
1746
  var PREFIX_REFERENCES = 4;
@@ -1466,11 +1952,12 @@ async function locateTargets(forges, directory, targets, previous) {
1466
1952
  });
1467
1953
  });
1468
1954
  if (error !== undefined && !outcomes.some((outcome) => ("request" in outcome)))
1469
- throw error;
1955
+ throw new TargetLookupError(error);
1470
1956
  if (found.length === 0)
1471
1957
  return {
1472
1958
  kind: "none",
1473
- reason: requests2.length === 1 ? failed[0].reason : "No session target PR/MR could be resolved"
1959
+ reason: requests2.length === 1 ? failed[0].reason : "No session target PR/MR could be resolved",
1960
+ explicitTarget: true
1474
1961
  };
1475
1962
  return {
1476
1963
  kind: "found",
@@ -1516,154 +2003,6 @@ async function locate(forges, hints) {
1516
2003
  };
1517
2004
  }
1518
2005
 
1519
- // packages/forgekeeper/src/store.ts
1520
- function createStatusStore(options) {
1521
- const now = options.now ?? Date.now;
1522
- const setTimer = options.setTimer ?? ((callback, delay) => setTimeout(callback, delay));
1523
- const clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer));
1524
- const interval = options.interval ?? 120000;
1525
- const maxBackoff = options.maxBackoff ?? 900000;
1526
- const minEventInterval = options.minEventInterval ?? 15000;
1527
- const entries = new Map;
1528
- let disposed = false;
1529
- let revision = now() * 1000;
1530
- const entry = (key2) => {
1531
- let value = entries.get(key2);
1532
- if (!value) {
1533
- value = { snapshot: { loading: false }, refs: 0, failures: 0 };
1534
- entries.set(key2, value);
1535
- }
1536
- return value;
1537
- };
1538
- const update = (key2, value, patch) => {
1539
- value.snapshot = { ...value.snapshot, ...patch, revision: ++revision };
1540
- if (!disposed)
1541
- options.onChange(key2);
1542
- };
1543
- const cancelTimer = (value) => {
1544
- if (value.timer === undefined)
1545
- return;
1546
- clearTimer(value.timer);
1547
- value.timer = undefined;
1548
- };
1549
- const baseInterval = (value) => options.active?.(value.snapshot.lookup) ? options.activeInterval ?? interval : interval;
1550
- const pollDelay = (value) => Math.min(baseInterval(value) * 2 ** value.failures, maxBackoff);
1551
- const lastAttempt = (value) => Math.max(value.snapshot.fetchedAt ?? -Infinity, value.snapshot.error?.at ?? -Infinity);
1552
- const schedule = (key2, value, delay = pollDelay(value)) => {
1553
- cancelTimer(value);
1554
- if (disposed || value.refs === 0)
1555
- return;
1556
- const lookup = value.snapshot.lookup;
1557
- if (!value.snapshot.error && (lookup?.kind !== "found" || lookup.requests.length === 0))
1558
- return;
1559
- value.timer = setTimer(() => {
1560
- value.timer = undefined;
1561
- refresh(key2);
1562
- }, delay);
1563
- };
1564
- function refresh(key2) {
1565
- if (disposed)
1566
- return Promise.resolve({ loading: false });
1567
- const value = entry(key2);
1568
- if (value.inflight)
1569
- return value.inflight;
1570
- value.stale = false;
1571
- update(key2, value, { loading: true });
1572
- value.inflight = options.load(key2).then((lookup) => {
1573
- if (disposed || value.invalidated)
1574
- return;
1575
- const previous = value.snapshot.lookup;
1576
- value.failures = 0;
1577
- update(key2, value, { lookup, fetchedAt: now(), error: undefined, loading: false });
1578
- try {
1579
- if (!disposed)
1580
- options.onLoad?.(key2, previous, lookup);
1581
- } catch (error) {
1582
- try {
1583
- options.onLoadError?.(error);
1584
- } catch {}
1585
- }
1586
- }, (error) => {
1587
- if (disposed || value.invalidated)
1588
- return;
1589
- value.failures++;
1590
- const known = error instanceof ForgeError;
1591
- update(key2, value, {
1592
- loading: false,
1593
- error: {
1594
- kind: known ? error.kind : "request",
1595
- message: error instanceof Error ? error.message : String(error),
1596
- at: now()
1597
- }
1598
- });
1599
- }).then(() => {
1600
- value.inflight = undefined;
1601
- if (value.invalidated && !disposed) {
1602
- value.invalidated = false;
1603
- return refresh(key2);
1604
- }
1605
- schedule(key2, value);
1606
- return value.snapshot;
1607
- });
1608
- return value.inflight;
1609
- }
1610
- return {
1611
- get(key2) {
1612
- return entries.get(key2)?.snapshot ?? { loading: false };
1613
- },
1614
- acquire(key2) {
1615
- const value = entry(key2);
1616
- value.refs++;
1617
- if (value.refs === 1 && !value.inflight) {
1618
- const remaining = lastAttempt(value) + pollDelay(value) - now();
1619
- if (value.stale || remaining <= 0)
1620
- refresh(key2);
1621
- else
1622
- schedule(key2, value, remaining);
1623
- }
1624
- let released = false;
1625
- return () => {
1626
- if (released)
1627
- return;
1628
- released = true;
1629
- value.refs--;
1630
- if (value.refs === 0)
1631
- cancelTimer(value);
1632
- };
1633
- },
1634
- refresh,
1635
- keys() {
1636
- return [...entries.keys()];
1637
- },
1638
- notify(key2) {
1639
- const value = entries.get(key2);
1640
- if (!value || value.refs === 0 || value.inflight)
1641
- return;
1642
- const last = Math.max(value.snapshot.fetchedAt ?? 0, value.snapshot.error?.at ?? 0);
1643
- if (now() - last < minEventInterval)
1644
- return;
1645
- refresh(key2);
1646
- },
1647
- invalidate(key2) {
1648
- const value = entries.get(key2);
1649
- if (!value)
1650
- return;
1651
- if (value.inflight)
1652
- value.invalidated = true;
1653
- else if (value.refs > 0)
1654
- refresh(key2);
1655
- else
1656
- value.stale = true;
1657
- },
1658
- dispose() {
1659
- disposed = true;
1660
- for (const value of entries.values())
1661
- cancelTimer(value);
1662
- entries.clear();
1663
- }
1664
- };
1665
- }
1666
-
1667
2006
  // packages/forgekeeper/src/status-service.ts
1668
2007
  function createStatusService(options) {
1669
2008
  const now = options.now ?? Date.now;
@@ -1791,6 +2130,7 @@ function createStatusService(options) {
1791
2130
  if (!disposed && !holds.has(key2) && liveLeases(key2).length > 0)
1792
2131
  store.refresh(key2);
1793
2132
  },
2133
+ setTargeted: (key2, targeted) => store.setTargeted(key2, targeted),
1794
2134
  notify: (key2) => store.notify(key2),
1795
2135
  keys: () => store.keys(),
1796
2136
  polling: (key2) => holds.has(key2),
@@ -1822,6 +2162,7 @@ var FEEDBACK_PREFIX = "status/feedback/";
1822
2162
  var pendingPrefix = (sessionID) => `${PENDING_PREFIX}${encodeURIComponent(sessionID)}/`;
1823
2163
  var pendingKey = (sessionID, url) => `${pendingPrefix(sessionID)}${encodeURIComponent(url)}`;
1824
2164
  var feedbackRecordKey = (key2) => `${FEEDBACK_PREFIX}${encodeURIComponent(key2)}`;
2165
+ var reviewHistoryKey = (sessionID, url) => `status/reviews/${encodeURIComponent(sessionID)}/${encodeURIComponent(url)}`;
1825
2166
  function userTitle(title, prefix) {
1826
2167
  if (!title || !prefix)
1827
2168
  return title;
@@ -1845,9 +2186,9 @@ async function setupStatus(ctx, settings, deps, host) {
1845
2186
  const entries = [];
1846
2187
  let after;
1847
2188
  do {
1848
- const page = await ctx.storage.scan({ prefix, ...after ? { after } : {} });
1849
- entries.push(...page.entries);
1850
- after = page.next;
2189
+ const page2 = await ctx.storage.scan({ prefix, ...after ? { after } : {} });
2190
+ entries.push(...page2.entries);
2191
+ after = page2.next;
1851
2192
  } while (after);
1852
2193
  return entries;
1853
2194
  }
@@ -1903,6 +2244,11 @@ async function setupStatus(ctx, settings, deps, host) {
1903
2244
  const automated = settings.notifyAutomatedReviews ? createAutomatedReviewWatcher({
1904
2245
  isTarget,
1905
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
+ },
1906
2252
  async read(sessionID) {
1907
2253
  const deliveries = {};
1908
2254
  for (const { value } of await scanAll(pendingPrefix(sessionID))) {
@@ -1995,7 +2341,11 @@ async function setupStatus(ctx, settings, deps, host) {
1995
2341
  });
1996
2342
  return {
1997
2343
  service,
1998
- targetsChanged: (sessionID) => service.invalidate(sessionID),
2344
+ targetsChanged(sessionID, targets) {
2345
+ const classified = classifyTargets(targetOutput(targets)) ?? [];
2346
+ service.setTargeted(sessionID, classified.some((target2) => target2.kind === "merge-request"));
2347
+ service.invalidate(sessionID);
2348
+ },
1999
2349
  async titleChanged(sessionID) {
2000
2350
  if (!service.keys().includes(sessionID))
2001
2351
  return;
@@ -2243,7 +2593,7 @@ async function setupServer(ctx, lookups, status2) {
2243
2593
  targets: result.targets,
2244
2594
  ...state.prefix ? { prefix: state.prefix } : {}
2245
2595
  });
2246
- watcher?.targetsChanged(sessionID);
2596
+ watcher?.targetsChanged(sessionID, result.targets);
2247
2597
  await rpc.events.emit("targetChanged", { sessionID, ...targetOutput(result.targets) });
2248
2598
  await update(sessionID);
2249
2599
  });