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.
- package/README.md +39 -14
- package/dist/index.js +543 -193
- package/dist/tui.js +263 -29
- 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/
|
|
192
|
+
// packages/forgekeeper/src/github-feedback.ts
|
|
193
193
|
var PAGE_SIZE = 100;
|
|
194
194
|
var MAX_COMMENT_PAGES = 20;
|
|
195
|
+
var MAX_FETCH_REQUESTS = 200;
|
|
196
|
+
var author = "author { __typename login ... on User { id } ... on Bot { id } ... on Mannequin { id } }";
|
|
197
|
+
var page = "pageInfo { hasPreviousPage startCursor }";
|
|
198
|
+
var INLINE_FIELDS = `id url state createdAt publishedAt lastEditedAt isMinimized ${author}`;
|
|
199
|
+
var listQuery = (field, connection) => query(`query($owner: String!, $repo: String!, $number: Int!, $before: String) {
|
|
200
|
+
viewer { id }
|
|
201
|
+
repository(owner: $owner, name: $repo) {
|
|
202
|
+
pullRequest(number: $number) {
|
|
203
|
+
${field}(last: ${PAGE_SIZE}, before: $before) { ${page} nodes { ${connection} } }
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}`);
|
|
207
|
+
var ISSUE_COMMENTS_QUERY = listQuery("comments", `id url createdAt publishedAt lastEditedAt isMinimized ${author}`);
|
|
208
|
+
var REVIEWS_QUERY = listQuery("reviews", `id url state body submittedAt lastEditedAt isMinimized ${author}`);
|
|
209
|
+
var THREADS_QUERY = listQuery("reviewThreads", `id isResolved comments(last: ${PAGE_SIZE}) { ${page} nodes { ${INLINE_FIELDS} } }`);
|
|
210
|
+
var THREAD_COMMENTS_QUERY = query(`query($id: ID!, $before: String) {
|
|
211
|
+
node(id: $id) {
|
|
212
|
+
... on PullRequestReviewThread {
|
|
213
|
+
comments(last: ${PAGE_SIZE}, before: $before) { ${page} nodes { ${INLINE_FIELDS} } }
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}`);
|
|
217
|
+
var REVIEW_STATES = new Set(["COMMENTED", "CHANGES_REQUESTED", "APPROVED"]);
|
|
218
|
+
var time = (...values) => {
|
|
219
|
+
const value = values.find((candidate) => candidate);
|
|
220
|
+
return value ? Date.parse(value) : NaN;
|
|
221
|
+
};
|
|
222
|
+
var activity = (lastEditedAt, postedAt) => {
|
|
223
|
+
const edited = lastEditedAt ? Date.parse(lastEditedAt) : NaN;
|
|
224
|
+
return Number.isFinite(edited) ? Math.max(edited, postedAt) : postedAt;
|
|
225
|
+
};
|
|
226
|
+
function build({ node, postedAt, editedAt, resolved }) {
|
|
227
|
+
if (node.isMinimized || !node.author?.id || !Number.isFinite(postedAt) || !Number.isFinite(editedAt))
|
|
228
|
+
return;
|
|
229
|
+
const { __typename, login } = node.author;
|
|
230
|
+
return {
|
|
231
|
+
id: node.id,
|
|
232
|
+
order: postedAt,
|
|
233
|
+
url: node.url ?? "",
|
|
234
|
+
editedAt,
|
|
235
|
+
resolved,
|
|
236
|
+
bot: __typename === "Bot" || login.endsWith("[bot]"),
|
|
237
|
+
authorID: node.author.id,
|
|
238
|
+
username: login
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function toIssueComment(node) {
|
|
242
|
+
const postedAt = time(node.publishedAt, node.createdAt);
|
|
243
|
+
return build({
|
|
244
|
+
node,
|
|
245
|
+
postedAt,
|
|
246
|
+
editedAt: activity(node.lastEditedAt, postedAt),
|
|
247
|
+
resolved: false
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function toReview(node) {
|
|
251
|
+
if (!REVIEW_STATES.has(node.state) || !node.body.trim())
|
|
252
|
+
return;
|
|
253
|
+
const postedAt = time(node.submittedAt);
|
|
254
|
+
return build({
|
|
255
|
+
node,
|
|
256
|
+
postedAt,
|
|
257
|
+
editedAt: activity(node.lastEditedAt, postedAt),
|
|
258
|
+
resolved: false
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
function toInlineComment(node, resolved) {
|
|
262
|
+
if (node.state === "PENDING")
|
|
263
|
+
return;
|
|
264
|
+
const postedAt = time(node.publishedAt, node.createdAt);
|
|
265
|
+
return build({
|
|
266
|
+
node,
|
|
267
|
+
postedAt,
|
|
268
|
+
editedAt: activity(node.lastEditedAt, postedAt),
|
|
269
|
+
resolved
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
var commentWindow = { pages: MAX_COMMENT_PAGES, completeAtLimit: true };
|
|
273
|
+
var allThreads = { pages: MAX_FETCH_REQUESTS, completeAtLimit: false };
|
|
274
|
+
async function readList(graphql, host, base, text, field, limit = commentWindow) {
|
|
275
|
+
const pages = [];
|
|
276
|
+
let viewer;
|
|
277
|
+
let before;
|
|
278
|
+
const result = (complete) => ({
|
|
279
|
+
items: pages.toReversed().flat(),
|
|
280
|
+
viewer,
|
|
281
|
+
complete
|
|
282
|
+
});
|
|
283
|
+
for (let count = 0;count < limit.pages; count++) {
|
|
284
|
+
const variables = before ? { ...base, before } : base;
|
|
285
|
+
const data = await graphql(host, text, variables);
|
|
286
|
+
viewer ??= data.viewer?.id ?? undefined;
|
|
287
|
+
const connection = data.repository?.pullRequest?.[field];
|
|
288
|
+
if (!connection)
|
|
289
|
+
return count === 0 ? { viewer, complete: false } : result(false);
|
|
290
|
+
pages.push(connection.nodes);
|
|
291
|
+
if (!connection.pageInfo.hasPreviousPage)
|
|
292
|
+
return result(true);
|
|
293
|
+
before = connection.pageInfo.startCursor ?? undefined;
|
|
294
|
+
if (!before)
|
|
295
|
+
return result(false);
|
|
296
|
+
}
|
|
297
|
+
return result(limit.completeAtLimit);
|
|
298
|
+
}
|
|
299
|
+
async function readOlderComments(graphql, host, thread) {
|
|
300
|
+
if (!thread.comments)
|
|
301
|
+
return { comments: [], complete: false };
|
|
302
|
+
const pages = [];
|
|
303
|
+
let before = thread.comments?.pageInfo.hasPreviousPage ? thread.comments.pageInfo.startCursor ?? undefined : undefined;
|
|
304
|
+
if (thread.comments?.pageInfo.hasPreviousPage && !before)
|
|
305
|
+
return { comments: [], complete: false };
|
|
306
|
+
for (let count = 1;before && count < MAX_COMMENT_PAGES; count++) {
|
|
307
|
+
const data = await graphql(host, THREAD_COMMENTS_QUERY, { id: thread.id, before });
|
|
308
|
+
const connection = data.node?.comments;
|
|
309
|
+
if (!connection)
|
|
310
|
+
return { comments: pages.toReversed().flat(), complete: false };
|
|
311
|
+
pages.push(connection.nodes);
|
|
312
|
+
before = connection.pageInfo.hasPreviousPage ? connection.pageInfo.startCursor ?? undefined : undefined;
|
|
313
|
+
if (connection.pageInfo.hasPreviousPage && !before)
|
|
314
|
+
return { comments: pages.toReversed().flat(), complete: false };
|
|
315
|
+
}
|
|
316
|
+
return { comments: pages.toReversed().flat(), complete: true };
|
|
317
|
+
}
|
|
318
|
+
async function fetchComments(transport, host, project, number) {
|
|
319
|
+
let requests = 0;
|
|
320
|
+
const graphql = typed(async (...args) => {
|
|
321
|
+
if (requests++ >= MAX_FETCH_REQUESTS)
|
|
322
|
+
return {};
|
|
323
|
+
return transport(...args);
|
|
324
|
+
});
|
|
325
|
+
const [owner, repo] = project.split("/");
|
|
326
|
+
const base = { owner, repo, number: Number(number) };
|
|
327
|
+
const [conversation, reviews, threads] = await Promise.all([
|
|
328
|
+
readList(graphql, host, base, ISSUE_COMMENTS_QUERY, "comments"),
|
|
329
|
+
readList(graphql, host, base, REVIEWS_QUERY, "reviews"),
|
|
330
|
+
readList(graphql, host, base, THREADS_QUERY, "reviewThreads", allThreads)
|
|
331
|
+
]);
|
|
332
|
+
if (!conversation.items || !reviews.items || !threads.items)
|
|
333
|
+
return;
|
|
334
|
+
const viewerID = conversation.viewer ?? reviews.viewer ?? threads.viewer;
|
|
335
|
+
let threadsComplete = threads.complete;
|
|
336
|
+
const inline = [];
|
|
337
|
+
for (const thread of threads.items) {
|
|
338
|
+
const older = await readOlderComments(graphql, host, thread);
|
|
339
|
+
threadsComplete &&= older.complete;
|
|
340
|
+
for (const node of [...older.comments, ...thread.comments?.nodes ?? []]) {
|
|
341
|
+
const comment = toInlineComment(node, thread.isResolved);
|
|
342
|
+
if (comment)
|
|
343
|
+
inline.push(comment);
|
|
344
|
+
}
|
|
345
|
+
inline.sort((a, b) => b.order - a.order);
|
|
346
|
+
inline.splice(PAGE_SIZE * MAX_COMMENT_PAGES);
|
|
347
|
+
}
|
|
348
|
+
const comments = [
|
|
349
|
+
...conversation.items.map(toIssueComment),
|
|
350
|
+
...reviews.items.map(toReview),
|
|
351
|
+
...inline.sort((a, b) => a.order - b.order).slice(-PAGE_SIZE * MAX_COMMENT_PAGES)
|
|
352
|
+
].flatMap((comment) => comment ?? []);
|
|
353
|
+
return {
|
|
354
|
+
complete: conversation.complete && reviews.complete && threadsComplete && viewerID !== undefined,
|
|
355
|
+
viewerID,
|
|
356
|
+
comments: comments.sort((a, b) => a.order - b.order)
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// packages/forgekeeper/src/gitlab-feedback.ts
|
|
361
|
+
var PAGE_SIZE2 = 100;
|
|
362
|
+
var MAX_COMMENT_PAGES2 = 20;
|
|
195
363
|
var COMMENTS_QUERY = query(`query($project: ID!, $iid: String!, $before: String) {
|
|
196
364
|
currentUser { id }
|
|
197
365
|
project(fullPath: $project) {
|
|
198
366
|
mergeRequest(iid: $iid) {
|
|
199
|
-
notes(last: ${
|
|
367
|
+
notes(last: ${PAGE_SIZE2}, before: $before, filter: ONLY_COMMENTS) {
|
|
200
368
|
pageInfo { hasPreviousPage startCursor }
|
|
201
369
|
nodes {
|
|
202
370
|
id url createdAt updatedAt lastEditedAt system
|
|
@@ -225,7 +393,7 @@ function toComment(node) {
|
|
|
225
393
|
username: node.author.username
|
|
226
394
|
};
|
|
227
395
|
}
|
|
228
|
-
async function
|
|
396
|
+
async function fetchComments2(transport, host, project, iid) {
|
|
229
397
|
const graphql = typed(transport);
|
|
230
398
|
const pages = [];
|
|
231
399
|
let viewerID;
|
|
@@ -235,7 +403,7 @@ async function fetchComments(transport, host, project, iid) {
|
|
|
235
403
|
viewerID,
|
|
236
404
|
comments: pages.toReversed().flat()
|
|
237
405
|
});
|
|
238
|
-
for (let
|
|
406
|
+
for (let page2 = 0;page2 < MAX_COMMENT_PAGES2; page2++) {
|
|
239
407
|
const variables = { project, iid };
|
|
240
408
|
if (before)
|
|
241
409
|
variables.before = before;
|
|
@@ -243,7 +411,7 @@ async function fetchComments(transport, host, project, iid) {
|
|
|
243
411
|
viewerID ??= data.currentUser?.id ?? undefined;
|
|
244
412
|
const connection = data.project?.mergeRequest?.notes;
|
|
245
413
|
if (!connection)
|
|
246
|
-
return
|
|
414
|
+
return page2 === 0 ? undefined : snapshot(false);
|
|
247
415
|
pages.push(connection.nodes.flatMap((node) => toComment(node) ?? []));
|
|
248
416
|
if (!connection.pageInfo.hasPreviousPage)
|
|
249
417
|
return snapshot(true);
|
|
@@ -256,7 +424,7 @@ async function fetchComments(transport, host, project, iid) {
|
|
|
256
424
|
|
|
257
425
|
// packages/forgekeeper/src/gitlab.ts
|
|
258
426
|
var DISCUSSIONS = "pageInfo { hasNextPage endCursor } nodes { resolvable resolved }";
|
|
259
|
-
var
|
|
427
|
+
var PAGE_SIZE3 = 100;
|
|
260
428
|
var MAX_DISCUSSION_PAGES = 50;
|
|
261
429
|
var MERGE_REQUEST_FIELDS = `
|
|
262
430
|
iid title webUrl state draft conflicts approved detailedMergeStatus diffHeadSha updatedAt targetBranch
|
|
@@ -265,7 +433,7 @@ var MERGE_REQUEST_FIELDS = `
|
|
|
265
433
|
targetProject { fullPath }
|
|
266
434
|
headPipeline { status path detailedStatus { label } }
|
|
267
435
|
reviewers(first: 100) { nodes { type username bot mergeRequestInteraction { reviewState approved } } }
|
|
268
|
-
discussions(first: ${
|
|
436
|
+
discussions(first: ${PAGE_SIZE3}) { ${DISCUSSIONS} }
|
|
269
437
|
`;
|
|
270
438
|
function mergeRequestsQuery(projectCount) {
|
|
271
439
|
const variables = Array.from({ length: projectCount }, (_, index) => `$p${index}: ID!`).join(", ");
|
|
@@ -300,7 +468,7 @@ var SOURCE_BRANCH_QUERY = query(`query($project: ID!, $iid: String!) {
|
|
|
300
468
|
}`);
|
|
301
469
|
var DISCUSSIONS_QUERY = query(`query($project: ID!, $iid: String!, $after: String) {
|
|
302
470
|
project(fullPath: $project) {
|
|
303
|
-
mergeRequest(iid: $iid) { discussions(first: ${
|
|
471
|
+
mergeRequest(iid: $iid) { discussions(first: ${PAGE_SIZE3}, after: $after) { ${DISCUSSIONS} } }
|
|
304
472
|
}
|
|
305
473
|
}`);
|
|
306
474
|
function classifyError(message, code) {
|
|
@@ -339,7 +507,7 @@ function glabGraphQL(run, cwd) {
|
|
|
339
507
|
return body.data;
|
|
340
508
|
};
|
|
341
509
|
}
|
|
342
|
-
var countUnresolved = (
|
|
510
|
+
var countUnresolved = (page2) => page2.nodes.filter((discussion) => discussion.resolvable && !discussion.resolved).length;
|
|
343
511
|
var gitlabTraits = {
|
|
344
512
|
kind: "gitlab",
|
|
345
513
|
noun: "MR",
|
|
@@ -374,7 +542,7 @@ var gitlabTraits = {
|
|
|
374
542
|
feedback: {
|
|
375
543
|
async fetch(run, directory, request) {
|
|
376
544
|
const ref = gitlabTraits.parseUrl(request.url);
|
|
377
|
-
return ref ?
|
|
545
|
+
return ref ? fetchComments2(glabGraphQL(run, directory), ref.host, ref.project, ref.iid) : undefined;
|
|
378
546
|
}
|
|
379
547
|
}
|
|
380
548
|
};
|
|
@@ -446,22 +614,22 @@ class GitLabForge {
|
|
|
446
614
|
}
|
|
447
615
|
async unresolvedThreads(host, node) {
|
|
448
616
|
let count = countUnresolved(node.discussions);
|
|
449
|
-
let
|
|
617
|
+
let page2 = node.discussions.pageInfo;
|
|
450
618
|
let pages = 1;
|
|
451
|
-
while (
|
|
619
|
+
while (page2.hasNextPage && page2.endCursor && pages < MAX_DISCUSSION_PAGES) {
|
|
452
620
|
const data = await this.graphql(host, DISCUSSIONS_QUERY, {
|
|
453
621
|
project: node.targetProject.fullPath,
|
|
454
622
|
iid: node.iid,
|
|
455
|
-
after:
|
|
623
|
+
after: page2.endCursor
|
|
456
624
|
});
|
|
457
625
|
const next = data.project?.mergeRequest?.discussions;
|
|
458
626
|
if (!next)
|
|
459
627
|
break;
|
|
460
628
|
count += countUnresolved(next);
|
|
461
|
-
|
|
629
|
+
page2 = next.pageInfo;
|
|
462
630
|
pages++;
|
|
463
631
|
}
|
|
464
|
-
return { count, complete: !
|
|
632
|
+
return { count, complete: !page2.hasNextPage };
|
|
465
633
|
}
|
|
466
634
|
async normalize(host, node) {
|
|
467
635
|
const threads = await this.unresolvedThreads(host, node);
|
|
@@ -500,10 +668,12 @@ class GitLabForge {
|
|
|
500
668
|
}
|
|
501
669
|
|
|
502
670
|
// packages/forgekeeper/src/github.ts
|
|
671
|
+
var COPILOT_AUTHOR = "copilot-pull-request-reviewer[bot]";
|
|
503
672
|
var fields = `number title url state isDraft mergeable reviewDecision headRefOid headRefName
|
|
504
673
|
headRepository { nameWithOwner } baseRepository { nameWithOwner } baseRefName updatedAt
|
|
505
674
|
reviewThreads(first:100) { nodes { isResolved } pageInfo { hasNextPage endCursor } }
|
|
506
|
-
reviewRequests(first:100) { nodes { requestedReviewer { ... on User { login } ... on Team { name } } } pageInfo { hasNextPage } }
|
|
675
|
+
reviewRequests(first:100) { nodes { requestedReviewer { __typename ... on User { login } ... on Bot { login } ... on Team { name } } } pageInfo { hasNextPage endCursor } }
|
|
676
|
+
copilotReviews: reviews(last:1, author:"${COPILOT_AUTHOR}", states:[COMMENTED,CHANGES_REQUESTED,APPROVED,DISMISSED]) { nodes { id state comments { totalCount } } }
|
|
507
677
|
commits(last:1) { nodes { commit { statusCheckRollup { contexts(first:100) {
|
|
508
678
|
nodes { __typename ... on CheckRun { status conclusion detailsUrl } ... on StatusContext { state targetUrl } }
|
|
509
679
|
pageInfo { hasNextPage endCursor }
|
|
@@ -597,8 +767,43 @@ var githubTraits = {
|
|
|
597
767
|
indicators: (request) => request.reviewDecision === "CHANGES_REQUESTED" ? [{ text: "changes requested", tone: "warning", essential: true }] : [],
|
|
598
768
|
details: (request) => [
|
|
599
769
|
`Review decision: ${request.reviewDecision ? humanize(request.reviewDecision) : "not reported"}`
|
|
600
|
-
]
|
|
770
|
+
],
|
|
771
|
+
automatedReview: {
|
|
772
|
+
name: "GitHub Copilot",
|
|
773
|
+
status: (request) => copilotStatus(request.copilotReview)
|
|
774
|
+
},
|
|
775
|
+
feedback: {
|
|
776
|
+
async fetch(run, directory, request) {
|
|
777
|
+
const ref = githubTraits.parseUrl(request.url);
|
|
778
|
+
return ref ? fetchComments(ghGraphQL(run, directory), ref.host, ref.project, ref.iid) : undefined;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
601
781
|
};
|
|
782
|
+
function copilotStatus(review) {
|
|
783
|
+
if (!review)
|
|
784
|
+
return;
|
|
785
|
+
const feedback = review.comments > 0 && !review.dismissed;
|
|
786
|
+
const label = review.dismissed ? "dismissed" : feedback ? "commented" : "no comments";
|
|
787
|
+
const completed = review.reviewID ? { id: review.reviewID, feedback, label } : undefined;
|
|
788
|
+
if (review.requested)
|
|
789
|
+
return { state: "running", label: "reviewing", completed };
|
|
790
|
+
if (!completed)
|
|
791
|
+
return;
|
|
792
|
+
return { state: feedback ? "feedback" : "settled", label, completed };
|
|
793
|
+
}
|
|
794
|
+
var isCopilotReviewer = (login) => /^copilot-pull-request-reviewer(\[bot\])?$/i.test(login ?? "");
|
|
795
|
+
function copilotReview(node) {
|
|
796
|
+
const requested = node.reviewRequests.nodes.some((request) => isCopilotReviewer(request.requestedReviewer?.login));
|
|
797
|
+
const latest = node.copilotReviews?.nodes?.find((review) => review);
|
|
798
|
+
if (!requested && !latest)
|
|
799
|
+
return;
|
|
800
|
+
return {
|
|
801
|
+
requested,
|
|
802
|
+
reviewID: latest?.id,
|
|
803
|
+
comments: latest?.comments.totalCount ?? 0,
|
|
804
|
+
...latest?.state === "DISMISSED" ? { dismissed: true } : {}
|
|
805
|
+
};
|
|
806
|
+
}
|
|
602
807
|
|
|
603
808
|
class GitHubForge {
|
|
604
809
|
traits = githubTraits;
|
|
@@ -649,7 +854,7 @@ class GitHubForge {
|
|
|
649
854
|
const [owner, repo] = target.path.split("/");
|
|
650
855
|
let after;
|
|
651
856
|
let complete = false;
|
|
652
|
-
for (let
|
|
857
|
+
for (let page2 = 0;page2 < MAX_PAGES; page2++) {
|
|
653
858
|
const data = await this.graphql(target.host, query(`query($owner:String!,$repo:String!,$branch:String!${after ? ",$after:String!" : ""}) {
|
|
654
859
|
repository(owner:$owner,name:$repo) { pullRequests(first:100,states:OPEN,headRefName:$branch${after ? ",after:$after" : ""}) {
|
|
655
860
|
nodes { ${fields} } pageInfo { hasNextPage endCursor }
|
|
@@ -679,21 +884,21 @@ class GitHubForge {
|
|
|
679
884
|
async threads(host, node) {
|
|
680
885
|
const [owner, repo] = node.baseRepository.nameWithOwner.split("/");
|
|
681
886
|
const threads = [...node.reviewThreads.nodes];
|
|
682
|
-
let
|
|
683
|
-
for (let count = 1;
|
|
887
|
+
let page2 = node.reviewThreads.pageInfo;
|
|
888
|
+
for (let count = 1;page2?.hasNextPage && page2.endCursor && count < MAX_PAGES; count++) {
|
|
684
889
|
const data = await this.graphql(host, query(`query($owner:String!,$repo:String!,$number:Int!,$after:String!) {
|
|
685
890
|
repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
|
|
686
891
|
reviewThreads(first:100,after:$after) { nodes { isResolved } pageInfo { hasNextPage endCursor } }
|
|
687
|
-
} } }`), { owner, repo, number: node.number, after:
|
|
892
|
+
} } }`), { owner, repo, number: node.number, after: page2.endCursor });
|
|
688
893
|
const next = data.repository?.pullRequest?.reviewThreads;
|
|
689
894
|
if (!next)
|
|
690
895
|
break;
|
|
691
896
|
threads.push(...next.nodes);
|
|
692
|
-
|
|
897
|
+
page2 = next.pageInfo;
|
|
693
898
|
}
|
|
694
899
|
return {
|
|
695
900
|
unresolved: threads.filter((thread) => !thread.isResolved).length,
|
|
696
|
-
complete: !
|
|
901
|
+
complete: !page2?.hasNextPage
|
|
697
902
|
};
|
|
698
903
|
}
|
|
699
904
|
async contexts(host, node) {
|
|
@@ -714,7 +919,33 @@ class GitHubForge {
|
|
|
714
919
|
}
|
|
715
920
|
return { nodes: contexts, complete: !cursor?.hasNextPage };
|
|
716
921
|
}
|
|
922
|
+
async reviewers(host, node) {
|
|
923
|
+
const nodes = [...node.reviewRequests.nodes];
|
|
924
|
+
let reviewerPage = node.reviewRequests.pageInfo;
|
|
925
|
+
for (let count = 1;reviewerPage?.hasNextPage && count < MAX_PAGES; count++) {
|
|
926
|
+
if (!reviewerPage.endCursor)
|
|
927
|
+
throw new ForgeError("request", "GitHub reviewer pagination cursor missing");
|
|
928
|
+
const [owner, repo] = node.baseRepository.nameWithOwner.split("/");
|
|
929
|
+
const data = await this.graphql(host, query(`query($owner:String!,$repo:String!,$number:Int!,$after:String!) {
|
|
930
|
+
repository(owner:$owner,name:$repo) { pullRequest(number:$number) {
|
|
931
|
+
reviewRequests(first:100,after:$after) {
|
|
932
|
+
nodes { requestedReviewer { __typename ... on User { login } ... on Bot { login } ... on Team { name } } }
|
|
933
|
+
pageInfo { hasNextPage endCursor }
|
|
934
|
+
}
|
|
935
|
+
} }
|
|
936
|
+
}`), { owner, repo, number: node.number, after: reviewerPage.endCursor });
|
|
937
|
+
const next = data.repository?.pullRequest?.reviewRequests;
|
|
938
|
+
if (!next)
|
|
939
|
+
throw new ForgeError("request", "GitHub reviewers unavailable");
|
|
940
|
+
nodes.push(...next.nodes);
|
|
941
|
+
reviewerPage = next.pageInfo;
|
|
942
|
+
}
|
|
943
|
+
if (reviewerPage?.hasNextPage)
|
|
944
|
+
throw new ForgeError("request", "GitHub reviewers exceeded the pagination limit");
|
|
945
|
+
return { nodes, pageInfo: reviewerPage };
|
|
946
|
+
}
|
|
717
947
|
async normalize(host, node) {
|
|
948
|
+
const reviewers = await this.reviewers(host, node);
|
|
718
949
|
const [threads, contexts] = [await this.threads(host, node), await this.contexts(host, node)];
|
|
719
950
|
return {
|
|
720
951
|
forge: "github",
|
|
@@ -727,11 +958,14 @@ class GitHubForge {
|
|
|
727
958
|
conflictsKnown: node.mergeable !== "UNKNOWN",
|
|
728
959
|
approved: node.reviewDecision === "APPROVED",
|
|
729
960
|
reviewDecision: node.reviewDecision ?? undefined,
|
|
730
|
-
|
|
961
|
+
copilotReview: copilotReview({ ...node, reviewRequests: reviewers }),
|
|
962
|
+
awaitingReviewers: reviewers.nodes.flatMap((request) => {
|
|
731
963
|
const reviewer = request.requestedReviewer;
|
|
964
|
+
if (reviewer?.__typename === "Bot")
|
|
965
|
+
return [];
|
|
732
966
|
return reviewer?.login ? [reviewer.login] : reviewer?.name ? [reviewer.name] : [];
|
|
733
967
|
}),
|
|
734
|
-
reviewersComplete: !
|
|
968
|
+
reviewersComplete: !reviewers.pageInfo?.hasNextPage,
|
|
735
969
|
targetProject: node.baseRepository.nameWithOwner,
|
|
736
970
|
targetBranch: node.baseRefName,
|
|
737
971
|
headSha: node.headRefOid,
|
|
@@ -1023,11 +1257,33 @@ function automatedReview(request) {
|
|
|
1023
1257
|
return reviewer && status2 ? { name: reviewer.name, ...status2 } : undefined;
|
|
1024
1258
|
}
|
|
1025
1259
|
var isReviewRunning = (lookup) => requests(lookup).some((request) => automatedReview(request)?.state === "running");
|
|
1260
|
+
function hasFeedback(review) {
|
|
1261
|
+
if (!review)
|
|
1262
|
+
return false;
|
|
1263
|
+
if (review.completed)
|
|
1264
|
+
return review.completed.feedback;
|
|
1265
|
+
return review.state === "feedback";
|
|
1266
|
+
}
|
|
1026
1267
|
function finishedReviews(previous, next) {
|
|
1027
|
-
const
|
|
1028
|
-
return requests(next).filter((request) =>
|
|
1268
|
+
const before = new Map(requests(previous).map((request) => [request.url, request]));
|
|
1269
|
+
return requests(next).filter((request) => {
|
|
1270
|
+
const review = automatedReview(request);
|
|
1271
|
+
if (!hasFeedback(review))
|
|
1272
|
+
return false;
|
|
1273
|
+
const earlier = before.get(request.url);
|
|
1274
|
+
if (!earlier)
|
|
1275
|
+
return false;
|
|
1276
|
+
const was = automatedReview(earlier);
|
|
1277
|
+
if (review?.completed)
|
|
1278
|
+
return review.completed.id !== was?.completed?.id;
|
|
1279
|
+
return was?.state === "running";
|
|
1280
|
+
});
|
|
1029
1281
|
}
|
|
1030
1282
|
var reviewer = (request) => automatedReview(request)?.name ?? "The automated reviewer";
|
|
1283
|
+
function outcomeLabel(request) {
|
|
1284
|
+
const review = automatedReview(request);
|
|
1285
|
+
return review?.completed?.label ?? review?.label ?? "";
|
|
1286
|
+
}
|
|
1031
1287
|
var shared = (requests2, value, fallback) => {
|
|
1032
1288
|
const values = new Set(requests2.map(value));
|
|
1033
1289
|
return values.size === 1 ? [...values][0] : fallback;
|
|
@@ -1035,7 +1291,7 @@ var shared = (requests2, value, fallback) => {
|
|
|
1035
1291
|
var reviewers = (requests2) => shared(requests2, reviewer, "Automated reviewers");
|
|
1036
1292
|
var nouns = (requests2) => shared(requests2, (request) => `${traitsOf(request).noun}s`, "PRs/MRs");
|
|
1037
1293
|
function reviewOutcome(request) {
|
|
1038
|
-
const label =
|
|
1294
|
+
const label = outcomeLabel(request);
|
|
1039
1295
|
return label.charAt(0).toUpperCase() + label.slice(1);
|
|
1040
1296
|
}
|
|
1041
1297
|
function reviewTitle(requests2) {
|
|
@@ -1044,7 +1300,7 @@ function reviewTitle(requests2) {
|
|
|
1044
1300
|
return `${reviewers(requests2)} finished reviewing ${requests2.length} ${nouns(requests2)}`;
|
|
1045
1301
|
}
|
|
1046
1302
|
function reviewMessage(requests2) {
|
|
1047
|
-
const state = (request) => `the state "${
|
|
1303
|
+
const state = (request) => `the state "${outcomeLabel(request)}"`;
|
|
1048
1304
|
if (requests2.length === 1) {
|
|
1049
1305
|
const [request] = requests2;
|
|
1050
1306
|
const name2 = reviewer(request);
|
|
@@ -1062,7 +1318,7 @@ function reviewMessage(requests2) {
|
|
|
1062
1318
|
`);
|
|
1063
1319
|
}
|
|
1064
1320
|
function reviewToast(requests2) {
|
|
1065
|
-
const outcome = requests2.length === 1 ? reviewOutcome(requests2[0]) : requests2.map((request) => `${reference(request)}: ${
|
|
1321
|
+
const outcome = requests2.length === 1 ? reviewOutcome(requests2[0]) : requests2.map((request) => `${reference(request)}: ${outcomeLabel(request)}`).join(", ");
|
|
1066
1322
|
return { title: reviewTitle(requests2), message: `${outcome}. Sent to the agent.` };
|
|
1067
1323
|
}
|
|
1068
1324
|
var RETRY_DELAY = 10 * 60000;
|
|
@@ -1073,7 +1329,23 @@ function createAutomatedReviewWatcher(options) {
|
|
|
1073
1329
|
const now = options.now ?? Date.now;
|
|
1074
1330
|
const known = new Map;
|
|
1075
1331
|
const sending = new Set;
|
|
1332
|
+
const histories = new Map;
|
|
1076
1333
|
const write = (sessionID, url, delivery) => options.write?.(sessionID, url, delivery);
|
|
1334
|
+
async function observeCompletion(sessionID, request) {
|
|
1335
|
+
const completed = automatedReview(request)?.completed;
|
|
1336
|
+
if (!completed)
|
|
1337
|
+
return;
|
|
1338
|
+
const key2 = notificationKey(sessionID, request);
|
|
1339
|
+
const history = histories.get(key2) ?? await options.readHistory?.(sessionID, request.url);
|
|
1340
|
+
const alreadyObserved = history?.observed.includes(completed.id) === true;
|
|
1341
|
+
const current = history ?? { observed: [], delivered: [] };
|
|
1342
|
+
histories.set(key2, current);
|
|
1343
|
+
if (!alreadyObserved) {
|
|
1344
|
+
current.observed.push(completed.id);
|
|
1345
|
+
options.writeHistory?.(sessionID, request.url, current);
|
|
1346
|
+
}
|
|
1347
|
+
return { alreadyObserved, newFeedback: !!history && !alreadyObserved && completed.feedback };
|
|
1348
|
+
}
|
|
1077
1349
|
return {
|
|
1078
1350
|
async observe(sessionID, previous, next) {
|
|
1079
1351
|
const pending = known.get(sessionID) ?? { ...await options.read?.(sessionID) };
|
|
@@ -1082,7 +1354,8 @@ function createAutomatedReviewWatcher(options) {
|
|
|
1082
1354
|
const targets = requests(next).filter((request) => isOpen(request) && options.isTarget(sessionID, request.url));
|
|
1083
1355
|
for (const [key2, entry] of Object.entries(pending)) {
|
|
1084
1356
|
const request = targets.find((target2) => target2.url === entry.url);
|
|
1085
|
-
|
|
1357
|
+
const completed = request && automatedReview(request)?.completed;
|
|
1358
|
+
if (request && hasFeedback(automatedReview(request)) && at - entry.since < PENDING_RETENTION && (!entry.reviewID || completed?.id === entry.reviewID))
|
|
1086
1359
|
continue;
|
|
1087
1360
|
if (sending.has(key2))
|
|
1088
1361
|
continue;
|
|
@@ -1090,6 +1363,16 @@ function createAutomatedReviewWatcher(options) {
|
|
|
1090
1363
|
write(sessionID, entry.url);
|
|
1091
1364
|
}
|
|
1092
1365
|
const finished = finishedReviews(previous, next).filter((request) => isOpen(request) && options.isTarget(sessionID, request.url));
|
|
1366
|
+
for (const request of targets) {
|
|
1367
|
+
const observation = await observeCompletion(sessionID, request);
|
|
1368
|
+
if (!observation)
|
|
1369
|
+
continue;
|
|
1370
|
+
const index = finished.findIndex((item) => item.url === request.url);
|
|
1371
|
+
if (observation.alreadyObserved && index >= 0)
|
|
1372
|
+
finished.splice(index, 1);
|
|
1373
|
+
if (observation.newFeedback && index < 0)
|
|
1374
|
+
finished.push(request);
|
|
1375
|
+
}
|
|
1093
1376
|
const due = targets.filter((request) => (pending[notificationKey(sessionID, request)]?.retryAt ?? Infinity) <= at);
|
|
1094
1377
|
const batch = [
|
|
1095
1378
|
...new Map([...finished, ...due].map((request) => [request.url, request])).values()
|
|
@@ -1102,6 +1385,12 @@ function createAutomatedReviewWatcher(options) {
|
|
|
1102
1385
|
try {
|
|
1103
1386
|
await options.send(sessionID, batch);
|
|
1104
1387
|
batch.forEach((request, index) => {
|
|
1388
|
+
const history = histories.get(keys[index]);
|
|
1389
|
+
const completed = automatedReview(request)?.completed;
|
|
1390
|
+
if (history && completed && !history.delivered.includes(completed.id)) {
|
|
1391
|
+
history.delivered.push(completed.id);
|
|
1392
|
+
options.writeHistory?.(sessionID, request.url, history);
|
|
1393
|
+
}
|
|
1105
1394
|
delete pending[keys[index]];
|
|
1106
1395
|
write(sessionID, request.url);
|
|
1107
1396
|
});
|
|
@@ -1109,7 +1398,14 @@ function createAutomatedReviewWatcher(options) {
|
|
|
1109
1398
|
const retryAt = now() + RETRY_DELAY;
|
|
1110
1399
|
batch.forEach((request, index) => {
|
|
1111
1400
|
const key2 = keys[index];
|
|
1112
|
-
|
|
1401
|
+
const reviewID = automatedReview(request)?.completed?.id;
|
|
1402
|
+
pending[key2] = {
|
|
1403
|
+
sessionID,
|
|
1404
|
+
url: request.url,
|
|
1405
|
+
since: pending[key2]?.since ?? at,
|
|
1406
|
+
retryAt,
|
|
1407
|
+
...reviewID ? { reviewID } : {}
|
|
1408
|
+
};
|
|
1113
1409
|
write(sessionID, request.url, pending[key2]);
|
|
1114
1410
|
});
|
|
1115
1411
|
options.onSendError?.(error, sessionID, batch);
|
|
@@ -1119,7 +1415,13 @@ function createAutomatedReviewWatcher(options) {
|
|
|
1119
1415
|
}
|
|
1120
1416
|
},
|
|
1121
1417
|
hasPending: (sessionID) => Object.keys(known.get(sessionID) ?? {}).length > 0,
|
|
1122
|
-
forget
|
|
1418
|
+
forget(sessionID) {
|
|
1419
|
+
known.delete(sessionID);
|
|
1420
|
+
for (const key2 of histories.keys()) {
|
|
1421
|
+
if (key2.startsWith(`${sessionID} `))
|
|
1422
|
+
histories.delete(key2);
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1123
1425
|
};
|
|
1124
1426
|
}
|
|
1125
1427
|
|
|
@@ -1136,11 +1438,17 @@ function applyFeedback(record, change, now) {
|
|
|
1136
1438
|
for (const id of change.announced ?? [])
|
|
1137
1439
|
if (!announced.includes(id))
|
|
1138
1440
|
announced.push(id);
|
|
1139
|
-
|
|
1441
|
+
const baselineIDs = record?.baselineIDs ?? change.baselineIDs;
|
|
1442
|
+
return {
|
|
1443
|
+
baseline: record?.baseline ?? change.baseline ?? 0,
|
|
1444
|
+
...baselineIDs ? { baselineIDs } : {},
|
|
1445
|
+
announced,
|
|
1446
|
+
seenAt: now
|
|
1447
|
+
};
|
|
1140
1448
|
}
|
|
1141
1449
|
var isExpired = (record, now) => now - record.seenAt > RECORD_RETENTION;
|
|
1142
1450
|
var baselineOf = (snapshot2) => snapshot2.comments.reduce((highest, comment) => Math.max(highest, comment.order), 0);
|
|
1143
|
-
var isNew = (comment, seen) => comment.order > seen.baseline && !seen.announced.has(comment.id);
|
|
1451
|
+
var isNew = (comment, seen) => (comment.order > seen.baseline || comment.order === seen.baseline && seen.baselineIDs !== undefined && !seen.baselineIDs.includes(comment.id)) && !seen.announced.has(comment.id);
|
|
1144
1452
|
var humanFeedback = (snapshot2) => snapshot2.comments.filter((comment) => !comment.bot && comment.authorID !== snapshot2.viewerID && !comment.resolved);
|
|
1145
1453
|
function eligibleFeedback(snapshot2, seen, now) {
|
|
1146
1454
|
const human = humanFeedback(snapshot2);
|
|
@@ -1188,13 +1496,19 @@ function createFeedbackWatcher(options) {
|
|
|
1188
1496
|
const mine = local.get(key2);
|
|
1189
1497
|
if (!shared2 && !mine)
|
|
1190
1498
|
return;
|
|
1499
|
+
const latest = (mine?.baseline ?? -1) >= (shared2?.baseline ?? -1) ? mine : shared2;
|
|
1191
1500
|
return {
|
|
1192
1501
|
baseline: Math.max(shared2?.baseline ?? 0, mine?.baseline ?? 0),
|
|
1502
|
+
baselineIDs: latest?.baselineIDs,
|
|
1193
1503
|
announced: new Set([...shared2?.announced ?? [], ...mine?.announced ?? []])
|
|
1194
1504
|
};
|
|
1195
1505
|
};
|
|
1196
1506
|
const remember = (key2, change, at) => {
|
|
1197
|
-
const mine = local.get(key2) ?? {
|
|
1507
|
+
const mine = local.get(key2) ?? {
|
|
1508
|
+
baseline: change.baseline ?? 0,
|
|
1509
|
+
baselineIDs: change.baselineIDs,
|
|
1510
|
+
announced: new Set
|
|
1511
|
+
};
|
|
1198
1512
|
for (const id of change.announced ?? [])
|
|
1199
1513
|
mine.announced.add(id);
|
|
1200
1514
|
local.set(key2, mine);
|
|
@@ -1207,8 +1521,11 @@ function createFeedbackWatcher(options) {
|
|
|
1207
1521
|
const stored = await options.record(key2);
|
|
1208
1522
|
const at = now();
|
|
1209
1523
|
const previous = seen(key2, stored);
|
|
1210
|
-
if (!previous)
|
|
1211
|
-
|
|
1524
|
+
if (!previous) {
|
|
1525
|
+
const baseline = baselineOf(snapshot2);
|
|
1526
|
+
const baselineIDs = snapshot2.comments.filter((comment) => comment.order === baseline).map((comment) => comment.id);
|
|
1527
|
+
return remember(key2, { baseline, baselineIDs }, at);
|
|
1528
|
+
}
|
|
1212
1529
|
if (at < (retryAt.get(key2) ?? 0))
|
|
1213
1530
|
return;
|
|
1214
1531
|
const comments = eligibleFeedback(snapshot2, previous, at).filter((comment) => !reserved.has(`${key2} ${comment.id}`));
|
|
@@ -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
|
|
1849
|
-
entries.push(...
|
|
1850
|
-
after =
|
|
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
|
|
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
|
});
|