| author | |
| committer | |
| log | f4f3abf71e98361918e360a89d48780cc65cafd1 |
| tree | accb387096d42e4b4a0293aecaba99043ad0ff54 |
| parent | daa1d0ed947c276cb74b08025b5903e2ffe31278 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>11 files changed, 223 insertions(+), 35 deletions(-)
apps/server/src/orchestration-v2/PullRequestWatchReactor.ts+68-7| ... | @@ -2,6 +2,10 @@ import { | ... | @@ -2,6 +2,10 @@ import { |
| 2 | CommandId, | 2 | CommandId, |
| 3 | MessageId, | 3 | MessageId, |
| 4 | type OrchestrationV2Notification, | 4 | type OrchestrationV2Notification, |
| 5 | type PullRequestActivity, | ||
| 6 | type PullRequestComment, | ||
| 7 | type PullRequestRef, | ||
| 8 | type PullRequestThreadCommentsResult, | ||
| 5 | type ThreadPullRequestLink, | 9 | type ThreadPullRequestLink, |
| 6 | type ThreadPullRequestWatch, | 10 | type ThreadPullRequestWatch, |
| 7 | } from "@t3tools/contracts"; | 11 | } from "@t3tools/contracts"; |
| ... | @@ -81,6 +85,12 @@ export const make = Effect.gen(function* () { | ... | @@ -81,6 +85,12 @@ export const make = Effect.gen(function* () { |
| 81 | 85 | ||
| 82 | // Passes in a row that failed, per watch. Kept in memory: a restart only delays the stop. | 86 | // Passes in a row that failed, per watch. Kept in memory: a restart only delays the stop. |
| 83 | const readFailures = new Map<string, number>(); | 87 | const readFailures = new Map<string, number>(); |
| 88 | // Replies past each long thread's first page, per watch, so a pass pages a thread again only | ||
| 89 | // when the host's count of it moves. Kept in memory: a restart pages each thread once more. | ||
| 90 | const threadTails = new Map< | ||
| 91 | string, | ||
| 92 | Map<string, { readonly count: number; readonly comments: ReadonlyArray<PullRequestComment> }> | ||
| 93 | >(); | ||
| 84 | 94 | ||
| 85 | // Host-level identity, with the repository as linked, the way pull request sync reads it. | 95 | // Host-level identity, with the repository as linked, the way pull request sync reads it. |
| 86 | const identityOf = (link: ThreadPullRequestLink) => ({ | 96 | const identityOf = (link: ThreadPullRequestLink) => ({ |
| ... | @@ -125,6 +135,60 @@ export const make = Effect.gen(function* () { | ... | @@ -125,6 +135,60 @@ export const make = Effect.gen(function* () { |
| 125 | }, | 135 | }, |
| 126 | }).pipe(Effect.catch(() => record(target, null))); | 136 | }).pipe(Effect.catch(() => record(target, null))); |
| 127 | 137 | ||
| 138 | const readRemarks = Effect.fn("PullRequestWatchReactor.readRemarks")( | ||
| 139 | function* (target: WatchTarget, reference: PullRequestRef, activity: PullRequestActivity) { | ||
| 140 | // Comment cursors cannot account for missing threads. Only finish a truncated read | ||
| 141 | // when the host confirms that every thread was listed. | ||
| 142 | if (activity.commentsTruncated && activity.reviewThreadsTruncated !== false) return null; | ||
| 143 | |||
| 144 | const key = failureKey(target); | ||
| 145 | let tails = threadTails.get(key); | ||
| 146 | if (tails === undefined) { | ||
| 147 | tails = new Map(); | ||
| 148 | threadTails.set(key, tails); | ||
| 149 | } | ||
| 150 | const remarks = [...activity.comments]; | ||
| 151 | for (const thread of activity.reviewThreads) { | ||
| 152 | let cursor = thread.nextCommentsCursor ?? null; | ||
| 153 | if (cursor === null) continue; | ||
| 154 | const count = thread.commentCount ?? 0; | ||
| 155 | let tail = tails.get(thread.id); | ||
| 156 | if (tail?.count !== count) { | ||
| 157 | const comments = new Map<string, PullRequestComment>(); | ||
| 158 | const cursors = new Set<string>(); | ||
| 159 | while (cursor !== null) { | ||
| 160 | if (cursors.has(cursor)) return null; | ||
| 161 | cursors.add(cursor); | ||
| 162 | const page: PullRequestThreadCommentsResult = yield* pullRequests.threadComments({ | ||
| 163 | ...reference, | ||
| 164 | threadId: thread.id, | ||
| 165 | cursor, | ||
| 166 | }); | ||
| 167 | for (const comment of page.comments) { | ||
| 168 | comments.set(comment.id, { | ||
| 169 | ...comment, | ||
| 170 | kind: "review-comment", | ||
| 171 | path: thread.path, | ||
| 172 | reviewState: null, | ||
| 173 | }); | ||
| 174 | } | ||
| 175 | cursor = page.nextCursor; | ||
| 176 | } | ||
| 177 | if (thread.comments.length + comments.size < count) return null; | ||
| 178 | tail = { count, comments: [...comments.values()] }; | ||
| 179 | tails.set(thread.id, tail); | ||
| 180 | } | ||
| 181 | remarks.push(...tail.comments); | ||
| 182 | } | ||
| 183 | return remarks.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); | ||
| 184 | }, | ||
| 185 | Effect.catch((error) => | ||
| 186 | Effect.logWarning("pull request watch comment pagination failed", { error }).pipe( | ||
| 187 | Effect.as(null), | ||
| 188 | ), | ||
| 189 | ), | ||
| 190 | ); | ||
| 191 | |||
| 128 | const check = Effect.fn("PullRequestWatchReactor.check")(function* (target: WatchTarget) { | 192 | const check = Effect.fn("PullRequestWatchReactor.check")(function* (target: WatchTarget) { |
| 129 | const { thread, link, watch } = target; | 193 | const { thread, link, watch } = target; |
| 130 | const pullRequest = identityOf(link); | 194 | const pullRequest = identityOf(link); |
| ... | @@ -160,13 +224,9 @@ export const make = Effect.gen(function* () { | ... | @@ -160,13 +224,9 @@ export const make = Effect.gen(function* () { |
| 160 | const [detail, activity] = read.value; | 224 | const [detail, activity] = read.value; |
| 161 | if (detail.state !== "open") return yield* record(target, null); | 225 | if (detail.state !== "open") return yield* record(target, null); |
| 162 | 226 | ||
| 163 | // A degraded read (GitHub's review thread query failed) is truncated with no long thread to | 227 | // Never advance the remark watermark past comments an incomplete read could have missed. |
| 164 | // explain it, and would skip review comments, so remarks wait for a later pass. Replies past | 228 | const remarks = yield* readRemarks(target, reference, activity); |
| 165 | // the first ten of a long review thread are not read. | 229 | const report = evaluatePullRequestWatch(watch, detail, remarks); |
| 166 | const degraded = | ||
| 167 | activity.commentsTruncated && | ||
| 168 | !activity.reviewThreads.some((reviewThread) => reviewThread.nextCommentsCursor !== undefined); | ||
| 169 | const report = evaluatePullRequestWatch(watch, detail, degraded ? null : activity.comments); | ||
| 170 | if (report.changes.length > 0) { | 230 | if (report.changes.length > 0) { |
| 171 | return yield* record( | 231 | return yield* record( |
| 172 | target, | 232 | target, |
| ... | @@ -192,6 +252,7 @@ export const make = Effect.gen(function* () { | ... | @@ -192,6 +252,7 @@ export const make = Effect.gen(function* () { |
| 192 | ); | 252 | ); |
| 193 | const keys = new Set(targets.map(failureKey)); | 253 | const keys = new Set(targets.map(failureKey)); |
| 194 | for (const key of readFailures.keys()) if (!keys.has(key)) readFailures.delete(key); | 254 | for (const key of readFailures.keys()) if (!keys.has(key)) readFailures.delete(key); |
| 255 | for (const key of threadTails.keys()) if (!keys.has(key)) threadTails.delete(key); | ||
| 195 | yield* Effect.forEach( | 256 | yield* Effect.forEach( |
| 196 | targets, | 257 | targets, |
| 197 | (target) => | 258 | (target) => |
apps/server/src/orchestration-v2/runtimeLayer.test.ts+124-23| ... | @@ -18,6 +18,8 @@ import { | ... | @@ -18,6 +18,8 @@ import { |
| 18 | type OrchestrationV2Run, | 18 | type OrchestrationV2Run, |
| 19 | ProjectId, | 19 | ProjectId, |
| 20 | type PullRequestDetail, | 20 | type PullRequestDetail, |
| 21 | type PullRequestComment, | ||
| 22 | PullRequestOperationError, | ||
| 21 | ProviderDriverKind, | 23 | ProviderDriverKind, |
| 22 | ProviderInstanceId, | 24 | ProviderInstanceId, |
| 23 | ProviderThreadId, | 25 | ProviderThreadId, |
| ... | @@ -2307,11 +2309,18 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { | ... | @@ -2307,11 +2309,18 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2307 | }), | 2309 | }), |
| 2308 | ); | 2310 | ); |
| 2309 | 2311 | ||
| 2310 | it.effect("wakes a watched thread once for failed checks and a review comment", () => | 2312 | it.effect.each([ |
| 2313 | "single page", | ||
| 2314 | "paginated", | ||
| 2315 | "page failure", | ||
| 2316 | "repeated cursor", | ||
| 2317 | "missing comments", | ||
| 2318 | "thread list truncated", | ||
| 2319 | ])("wakes a watched thread once: %s", (mode) => | ||
| 2311 | Effect.gen(function* () { | 2320 | Effect.gen(function* () { |
| 2312 | const orchestrator = yield* Orchestrator.OrchestratorV2; | 2321 | const orchestrator = yield* Orchestrator.OrchestratorV2; |
| 2313 | const threadId = ThreadId.make("runtime-pull-request-watch-wake"); | 2322 | const threadId = ThreadId.make(`runtime-pull-request-watch-wake-${mode}`); |
| 2314 | const projectId = ProjectId.make("pr-watch-wake-project"); | 2323 | const projectId = ProjectId.make(`pr-watch-wake-project-${mode}`); |
| 2315 | yield* seedProject({ | 2324 | yield* seedProject({ |
| 2316 | projectId, | 2325 | projectId, |
| 2317 | title: "Watch wake", | 2326 | title: "Watch wake", |
| ... | @@ -2323,7 +2332,7 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { | ... | @@ -2323,7 +2332,7 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2323 | type: "thread.create", | 2332 | type: "thread.create", |
| 2324 | createdBy: "user", | 2333 | createdBy: "user", |
| 2325 | creationSource: "web", | 2334 | creationSource: "web", |
| 2326 | commandId: CommandId.make("pr-watch-wake-create"), | 2335 | commandId: CommandId.make(`pr-watch-wake-create-${mode}`), |
| 2327 | threadId, | 2336 | threadId, |
| 2328 | projectId, | 2337 | projectId, |
| 2329 | title: "Watch wake", | 2338 | title: "Watch wake", |
| ... | @@ -2337,7 +2346,7 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { | ... | @@ -2337,7 +2346,7 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2337 | const url = "https://github.com/pingdotgg/t3code/pull/7"; | 2346 | const url = "https://github.com/pingdotgg/t3code/pull/7"; |
| 2338 | yield* orchestrator.dispatch({ | 2347 | yield* orchestrator.dispatch({ |
| 2339 | type: "thread.pull-request.link", | 2348 | type: "thread.pull-request.link", |
| 2340 | commandId: CommandId.make("pr-watch-wake-link"), | 2349 | commandId: CommandId.make(`pr-watch-wake-link-${mode}`), |
| 2341 | threadId, | 2350 | threadId, |
| 2342 | ...key, | 2351 | ...key, |
| 2343 | url, | 2352 | url, |
| ... | @@ -2345,7 +2354,7 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { | ... | @@ -2345,7 +2354,7 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2345 | }); | 2354 | }); |
| 2346 | yield* orchestrator.dispatch({ | 2355 | yield* orchestrator.dispatch({ |
| 2347 | type: "thread.pull-request.watch", | 2356 | type: "thread.pull-request.watch", |
| 2348 | commandId: CommandId.make("pr-watch-wake-start"), | 2357 | commandId: CommandId.make(`pr-watch-wake-start-${mode}`), |
| 2349 | threadId, | 2358 | threadId, |
| 2350 | ...key, | 2359 | ...key, |
| 2351 | watching: true, | 2360 | watching: true, |
| ... | @@ -2398,6 +2407,38 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { | ... | @@ -2398,6 +2407,38 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2398 | mergeCapabilities: { merge: true, squash: true, rebase: true }, | 2407 | mergeCapabilities: { merge: true, squash: true, rebase: true }, |
| 2399 | viewer: "agent-user", | 2408 | viewer: "agent-user", |
| 2400 | }; | 2409 | }; |
| 2410 | const initialWatch = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch; | ||
| 2411 | assert.isDefined(initialWatch); | ||
| 2412 | const remark: PullRequestComment = { | ||
| 2413 | id: "review-1", | ||
| 2414 | kind: "review-comment", | ||
| 2415 | author: { login: "reviewer", name: null, avatarUrl: null }, | ||
| 2416 | body: "One more thing.", | ||
| 2417 | createdAt: "2999-01-01T00:00:03.000Z", | ||
| 2418 | url: null, | ||
| 2419 | path: "src/index.ts", | ||
| 2420 | reviewState: null, | ||
| 2421 | }; | ||
| 2422 | const firstTen = Array.from({ length: 10 }, (_, index) => ({ | ||
| 2423 | ...remark, | ||
| 2424 | id: `old-${index}`, | ||
| 2425 | createdAt: "1900-01-01T00:00:00.000Z", | ||
| 2426 | })); | ||
| 2427 | const eleventh = { | ||
| 2428 | ...remark, | ||
| 2429 | id: "reply-11", | ||
| 2430 | body: "Eleventh reply.", | ||
| 2431 | createdAt: "2999-01-01T00:00:01.000Z", | ||
| 2432 | }; | ||
| 2433 | const twelfth = { | ||
| 2434 | ...remark, | ||
| 2435 | id: "reply-12", | ||
| 2436 | body: "Twelfth reply.", | ||
| 2437 | createdAt: "2999-01-01T00:00:02.000Z", | ||
| 2438 | }; | ||
| 2439 | const incomplete = mode !== "single page" && mode !== "paginated"; | ||
| 2440 | let recovering = false; | ||
| 2441 | let pagesRead = 0; | ||
| 2401 | const reactor = yield* PullRequestWatchReactor.make.pipe( | 2442 | const reactor = yield* PullRequestWatchReactor.make.pipe( |
| 2402 | Effect.provide( | 2443 | Effect.provide( |
| 2403 | Layer.mergeAll( | 2444 | Layer.mergeAll( |
| ... | @@ -2406,41 +2447,101 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { | ... | @@ -2406,41 +2447,101 @@ it.layer(TestLayer)("OrchestrationV2LayerLive lifecycle", (it) => { |
| 2406 | detail: () => Effect.succeed(detail), | 2447 | detail: () => Effect.succeed(detail), |
| 2407 | activity: () => | 2448 | activity: () => |
| 2408 | Effect.succeed({ | 2449 | Effect.succeed({ |
| 2409 | comments: [ | 2450 | comments: |
| 2410 | { | 2451 | mode === "single page" |
| 2411 | id: "review-1", | 2452 | ? [remark] |
| 2412 | kind: "review-comment", | 2453 | : [...firstTen, { ...remark, kind: "issue-comment" }], |
| 2413 | author: { login: "reviewer", name: null, avatarUrl: null }, | 2454 | commentCount: mode === "single page" ? 1 : 13, |
| 2414 | body: "One more thing.", | 2455 | commentsTruncated: mode !== "single page", |
| 2415 | createdAt: "2999-01-01T00:00:00.000Z", | 2456 | reviewThreadsTruncated: mode === "thread list truncated" && !recovering, |
| 2416 | url: null, | 2457 | reviewThreads: |
| 2417 | path: "src/index.ts", | 2458 | mode === "single page" |
| 2418 | reviewState: null, | 2459 | ? [] |
| 2419 | }, | 2460 | : [ |
| 2420 | ], | 2461 | { |
| 2421 | commentCount: 1, | 2462 | id: "review-thread", |
| 2422 | commentsTruncated: false, | 2463 | path: "src/index.ts", |
| 2423 | reviewThreads: [], | 2464 | line: 1, |
| 2465 | side: "right", | ||
| 2466 | isResolved: false, | ||
| 2467 | isOutdated: false, | ||
| 2468 | comments: firstTen, | ||
| 2469 | commentCount: 12, | ||
| 2470 | nextCommentsCursor: "after-10", | ||
| 2471 | }, | ||
| 2472 | ], | ||
| 2424 | commits: [], | 2473 | commits: [], |
| 2425 | }), | 2474 | }), |
| 2475 | threadComments: (input) => { | ||
| 2476 | pagesRead += 1; | ||
| 2477 | assert.equal(input.threadId, "review-thread"); | ||
| 2478 | if (input.cursor === "after-10") { | ||
| 2479 | return Effect.succeed({ comments: [eleventh], nextCursor: "after-11" }); | ||
| 2480 | } | ||
| 2481 | assert.equal(input.cursor, "after-11"); | ||
| 2482 | if (!recovering && mode === "page failure") { | ||
| 2483 | return Effect.fail( | ||
| 2484 | new PullRequestOperationError({ | ||
| 2485 | operation: "threadComments", | ||
| 2486 | detail: "Page unavailable", | ||
| 2487 | }), | ||
| 2488 | ); | ||
| 2489 | } | ||
| 2490 | if (!recovering && mode === "repeated cursor") { | ||
| 2491 | return Effect.succeed({ comments: [eleventh], nextCursor: "after-11" }); | ||
| 2492 | } | ||
| 2493 | if (!recovering && mode === "missing comments") { | ||
| 2494 | return Effect.succeed({ comments: [], nextCursor: null }); | ||
| 2495 | } | ||
| 2496 | // Overlapping pages must not report the same reply twice. | ||
| 2497 | return Effect.succeed({ comments: [eleventh, twelfth], nextCursor: null }); | ||
| 2498 | }, | ||
| 2426 | }), | 2499 | }), |
| 2427 | ), | 2500 | ), |
| 2428 | ), | 2501 | ), |
| 2429 | ); | 2502 | ); |
| 2503 | if (incomplete) { | ||
| 2504 | yield* reactor.sweep; | ||
| 2505 | const held = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch; | ||
| 2506 | assert.equal(held?.remarksThrough, initialWatch?.remarksThrough); | ||
| 2507 | assert.deepEqual(held?.remarkIds, initialWatch?.remarkIds); | ||
| 2508 | assert.deepEqual(held?.failedChecks, ["lint"]); | ||
| 2509 | const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); | ||
| 2510 | assert.deepEqual( | ||
| 2511 | messages.map((message) => message.notification?.summary), | ||
| 2512 | ["#7: checks failed"], | ||
| 2513 | ); | ||
| 2514 | // Keep the two notifications ordered independently of their random message IDs. | ||
| 2515 | yield* TestClock.adjust("1 millis"); | ||
| 2516 | recovering = true; | ||
| 2517 | } | ||
| 2430 | yield* reactor.sweep; | 2518 | yield* reactor.sweep; |
| 2519 | // A thread whose count has not moved is not paged again. | ||
| 2520 | const pagesBefore = pagesRead; | ||
| 2431 | yield* reactor.sweep; | 2521 | yield* reactor.sweep; |
| 2522 | assert.equal(pagesRead, pagesBefore); | ||
| 2432 | 2523 | ||
| 2433 | const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); | 2524 | const { messages } = yield* orchestrator.getThreadRecords(threadId, ["messages"]); |
| 2434 | assert.deepEqual( | 2525 | assert.deepEqual( |
| 2435 | messages.flatMap((message) => | 2526 | messages.flatMap((message) => |
| 2436 | message.notification === undefined ? [] : [message.notification.summary], | 2527 | message.notification === undefined ? [] : [message.notification.summary], |
| 2437 | ), | 2528 | ), |
| 2438 | ["#7: checks failed, new comments"], | 2529 | incomplete |
| 2530 | ? ["#7: checks failed", "#7: new comments"] | ||
| 2531 | : ["#7: checks failed, new comments"], | ||
| 2439 | ); | 2532 | ); |
| 2533 | if (mode !== "single page") { | ||
| 2534 | const wake = messages.at(-1); | ||
| 2535 | assert.include(wake?.text ?? "", "3 new comments"); | ||
| 2536 | assert.include(wake?.text ?? "", "Eleventh reply."); | ||
| 2537 | assert.include(wake?.text ?? "", "Twelfth reply."); | ||
| 2538 | } | ||
| 2440 | const watch = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch; | 2539 | const watch = (yield* orchestrator.getThreadShell(threadId))?.pullRequests?.[0]?.watch; |
| 2540 | assert.equal(watch?.remarksThrough, remark.createdAt); | ||
| 2541 | assert.deepEqual(watch?.remarkIds, [remark.id]); | ||
| 2441 | assert.deepEqual( | 2542 | assert.deepEqual( |
| 2442 | { headSha: watch?.headSha, failedChecks: watch?.failedChecks, wakes: watch?.wakes }, | 2543 | { headSha: watch?.headSha, failedChecks: watch?.failedChecks, wakes: watch?.wakes }, |
| 2443 | { headSha: "abc1234def", failedChecks: ["lint"], wakes: 0 }, | 2544 | { headSha: "abc1234def", failedChecks: ["lint"], wakes: incomplete ? 1 : 0 }, |
| 2444 | ); | 2545 | ); |
| 2445 | }), | 2546 | }), |
| 2446 | ); | 2547 | ); |
apps/server/src/pullRequest/GitHubPullRequestCli.test.ts+10-1| ... | @@ -3612,7 +3612,14 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3612,7 +3612,14 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3612 | Effect.gen(function* () { | 3612 | Effect.gen(function* () { |
| 3613 | // A host that never runs out of pages: the walk has to end itself. | 3613 | // A host that never runs out of pages: the walk has to end itself. |
| 3614 | mockedExecute.mockReturnValue( | 3614 | mockedExecute.mockReturnValue( |
| 3615 | Effect.succeed(output(reviewThreadsPage([thread("PRRT_1", "c1")], "Y3Vyc29yOjE"))), | 3615 | Effect.succeed( |
| 3616 | output( | ||
| 3617 | reviewThreadsPage( | ||
| 3618 | [{ ...thread("PRRT_1", "c1"), comments: threadComments(["c1"], "Y3Vyc29yOjI", 3) }], | ||
| 3619 | "Y3Vyc29yOjE", | ||
| 3620 | ), | ||
| 3621 | ), | ||
| 3622 | ), | ||
| 3616 | ); | 3623 | ); |
| 3617 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; | 3624 | const cli = yield* GitHubPullRequestCli.GitHubPullRequestCli; |
| 3618 | 3625 | ||
| ... | @@ -3625,6 +3632,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3625,6 +3632,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3625 | 3632 | ||
| 3626 | assert.strictEqual(mockedExecute.mock.calls.length, 10); | 3633 | assert.strictEqual(mockedExecute.mock.calls.length, 10); |
| 3627 | assert.isTrue(conversation.truncated); | 3634 | assert.isTrue(conversation.truncated); |
| 3635 | assert.isTrue(conversation.reviewThreadsTruncated); | ||
| 3628 | }), | 3636 | }), |
| 3629 | ); | 3637 | ); |
| 3630 | 3638 | ||
| ... | @@ -3656,6 +3664,7 @@ layer("GitHubPullRequestCli.layer", (it) => { | ... | @@ -3656,6 +3664,7 @@ layer("GitHubPullRequestCli.layer", (it) => { |
| 3656 | nextCommentsCursor: "Y3Vyc29yOjI", | 3664 | nextCommentsCursor: "Y3Vyc29yOjI", |
| 3657 | }); | 3665 | }); |
| 3658 | assert.isTrue(conversation.truncated); | 3666 | assert.isTrue(conversation.truncated); |
| 3667 | assert.isFalse(conversation.reviewThreadsTruncated); | ||
| 3659 | }), | 3668 | }), |
| 3660 | ); | 3669 | ); |
| 3661 | 3670 |
apps/server/src/pullRequest/GitHubPullRequestCli.ts+1| ... | @@ -2409,6 +2409,7 @@ export const make = Effect.gen(function* () { | ... | @@ -2409,6 +2409,7 @@ export const make = Effect.gen(function* () { |
| 2409 | // where a bound kept some of the words on GitHub. | 2409 | // where a bound kept some of the words on GitHub. |
| 2410 | commentCount: entries.reduce((total, entry) => total + entry.commentCount, 0), | 2410 | commentCount: entries.reduce((total, entry) => total + entry.commentCount, 0), |
| 2411 | truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null), | 2411 | truncated: cursor !== null || entries.some((entry) => entry.nextCommentCursor !== null), |
| 2412 | reviewThreadsTruncated: cursor !== null, | ||
| 2412 | reactions, | 2413 | reactions, |
| 2413 | reactionsById, | 2414 | reactionsById, |
| 2414 | reviewers, | 2415 | reviewers, |
apps/server/src/pullRequest/GitHubPullRequestProvider.test.ts+2| ... | @@ -882,6 +882,7 @@ describe("getChangeRequest commits", () => { | ... | @@ -882,6 +882,7 @@ describe("getChangeRequest commits", () => { |
| 882 | reviewThreads: [], | 882 | reviewThreads: [], |
| 883 | commentCount: 0, | 883 | commentCount: 0, |
| 884 | truncated: false, | 884 | truncated: false, |
| 885 | reviewThreadsTruncated: false, | ||
| 885 | reactions: [], | 886 | reactions: [], |
| 886 | reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(), | 887 | reactionsById: new Map<string, ReadonlyArray<PullRequestReaction>>(), |
| 887 | reviewers: [], | 888 | reviewers: [], |
| ... | @@ -967,6 +968,7 @@ describe("getChangeRequestActivity dismissed reviews", () => { | ... | @@ -967,6 +968,7 @@ describe("getChangeRequestActivity dismissed reviews", () => { |
| 967 | reviewThreads: [], | 968 | reviewThreads: [], |
| 968 | commentCount: 0, | 969 | commentCount: 0, |
| 969 | truncated: false, | 970 | truncated: false, |
| 971 | reviewThreadsTruncated: false, | ||
| 970 | reactions: [], | 972 | reactions: [], |
| 971 | reactionsById: new Map(), | 973 | reactionsById: new Map(), |
| 972 | reviewers: [], | 974 | reviewers: [], |
apps/server/src/pullRequest/GitHubPullRequestProvider.ts+2| ... | @@ -412,6 +412,7 @@ export const make = Effect.gen(function* () { | ... | @@ -412,6 +412,7 @@ export const make = Effect.gen(function* () { |
| 412 | reviewThreads: [], | 412 | reviewThreads: [], |
| 413 | commentCount: 0, | 413 | commentCount: 0, |
| 414 | truncated: true, | 414 | truncated: true, |
| 415 | reviewThreadsTruncated: true, | ||
| 415 | reviewers: [], | 416 | reviewers: [], |
| 416 | avatarsByLogin: new Map<string, string>(), | 417 | avatarsByLogin: new Map<string, string>(), |
| 417 | botLogins: new Set<string>(), | 418 | botLogins: new Set<string>(), |
| ... | @@ -480,6 +481,7 @@ export const make = Effect.gen(function* () { | ... | @@ -480,6 +481,7 @@ export const make = Effect.gen(function* () { |
| 480 | // are always whole and only the thread walk can stop short of the host. | 481 | // are always whole and only the thread walk can stop short of the host. |
| 481 | commentCount: pullRequest.comments.length + reviewThreads.commentCount, | 482 | commentCount: pullRequest.comments.length + reviewThreads.commentCount, |
| 482 | commentsTruncated: reviewThreads.truncated, | 483 | commentsTruncated: reviewThreads.truncated, |
| 484 | reviewThreadsTruncated: reviewThreads.reviewThreadsTruncated, | ||
| 483 | reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ | 485 | reviewThreads: reviewThreads.reviewThreads.map((thread) => ({ |
| 484 | ...thread, | 486 | ...thread, |
| 485 | comments: thread.comments.map((comment) => ({ | 487 | comments: thread.comments.map((comment) => ({ |
apps/server/src/pullRequest/PullRequestProvider.ts+1| ... | @@ -254,6 +254,7 @@ export interface ProviderChangeRequestActivity { | ... | @@ -254,6 +254,7 @@ export interface ProviderChangeRequestActivity { |
| 254 | */ | 254 | */ |
| 255 | readonly commentCount: number; | 255 | readonly commentCount: number; |
| 256 | readonly commentsTruncated: boolean; | 256 | readonly commentsTruncated: boolean; |
| 257 | readonly reviewThreadsTruncated?: boolean; | ||
| 257 | readonly reviewThreads: ReadonlyArray<PullRequestReviewThread>; | 258 | readonly reviewThreads: ReadonlyArray<PullRequestReviewThread>; |
| 258 | readonly commits: ReadonlyArray<PullRequestCommit>; | 259 | readonly commits: ReadonlyArray<PullRequestCommit>; |
| 259 | /** The change request's own reactions, from a host that has them. */ | 260 | /** The change request's own reactions, from a host that has them. */ |
apps/server/src/pullRequest/PullRequestService.test.ts+9-4| ... | @@ -4318,7 +4318,8 @@ it.effect( | ... | @@ -4318,7 +4318,8 @@ it.effect( |
| 4318 | return Effect.succeed({ | 4318 | return Effect.succeed({ |
| 4319 | comments: [], | 4319 | comments: [], |
| 4320 | commentCount: 0, | 4320 | commentCount: 0, |
| 4321 | commentsTruncated: false, | 4321 | commentsTruncated: true, |
| 4322 | reviewThreadsTruncated: true, | ||
| 4322 | reviewThreads: [], | 4323 | reviewThreads: [], |
| 4323 | commits: [], | 4324 | commits: [], |
| 4324 | }); | 4325 | }); |
| ... | @@ -4342,10 +4343,14 @@ it.effect( | ... | @@ -4342,10 +4343,14 @@ it.effect( |
| 4342 | }, | 4343 | }, |
| 4343 | ]); | 4344 | ]); |
| 4344 | 4345 | ||
| 4345 | yield* Effect.all([service.activity(reference), service.activity(reference)], { | 4346 | const activities = yield* Effect.all( |
| 4346 | concurrency: 2, | 4347 | [service.activity(reference), service.activity(reference)], |
| 4347 | }); | 4348 | { |
| 4349 | concurrency: 2, | ||
| 4350 | }, | ||
| 4351 | ); | ||
| 4348 | assert.strictEqual(activityCalls, 1); | 4352 | assert.strictEqual(activityCalls, 1); |
| 4353 | assert.isTrue(activities.every((activity) => activity.reviewThreadsTruncated === true)); | ||
| 4349 | 4354 | ||
| 4350 | yield* service.invalidate({ reference }); | 4355 | yield* service.invalidate({ reference }); |
| 4351 | yield* service.activity(reference); | 4356 | yield* service.activity(reference); |
apps/server/src/pullRequest/PullRequestService.ts+3| ... | @@ -1783,6 +1783,9 @@ export const make = Effect.gen(function* () { | ... | @@ -1783,6 +1783,9 @@ export const make = Effect.gen(function* () { |
| 1783 | comments: activity.comments, | 1783 | comments: activity.comments, |
| 1784 | commentCount: activity.commentCount, | 1784 | commentCount: activity.commentCount, |
| 1785 | commentsTruncated: activity.commentsTruncated, | 1785 | commentsTruncated: activity.commentsTruncated, |
| 1786 | ...(activity.reviewThreadsTruncated === undefined | ||
| 1787 | ? {} | ||
| 1788 | : { reviewThreadsTruncated: activity.reviewThreadsTruncated }), | ||
| 1786 | reviewThreads: activity.reviewThreads, | 1789 | reviewThreads: activity.reviewThreads, |
| 1787 | commits: activity.commits, | 1790 | commits: activity.commits, |
| 1788 | ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), | 1791 | ...(activity.reactions === undefined ? {} : { reactions: activity.reactions }), |
apps/server/src/pullRequest/gitHubPullRequestJson.ts+1| ... | @@ -2093,6 +2093,7 @@ export interface GitHubReviewThreadComments { | ... | @@ -2093,6 +2093,7 @@ export interface GitHubReviewThreadComments { |
| 2093 | /** The host's own count of the conversation, which a bounded read can fall short of. */ | 2093 | /** The host's own count of the conversation, which a bounded read can fall short of. */ |
| 2094 | readonly commentCount: number; | 2094 | readonly commentCount: number; |
| 2095 | readonly truncated: boolean; | 2095 | readonly truncated: boolean; |
| 2096 | readonly reviewThreadsTruncated: boolean; | ||
| 2096 | /** The pull request's own reactions, which sit on its description. */ | 2097 | /** The pull request's own reactions, which sit on its description. */ |
| 2097 | readonly reactions: ReadonlyArray<PullRequestReaction>; | 2098 | readonly reactions: ReadonlyArray<PullRequestReaction>; |
| 2098 | /** Reactions by node id, for the comments and reviews the `gh` JSON read carries no reaction on. */ | 2099 | /** Reactions by node id, for the comments and reviews the `gh` JSON read carries no reaction on. */ |
packages/contracts/src/pullRequest.ts+2| ... | @@ -916,6 +916,8 @@ export const PullRequestActivity = Schema.Struct({ | ... | @@ -916,6 +916,8 @@ export const PullRequestActivity = Schema.Struct({ |
| 916 | * however long it is. | 916 | * however long it is. |
| 917 | */ | 917 | */ |
| 918 | commentsTruncated: Schema.Boolean, | 918 | commentsTruncated: Schema.Boolean, |
| 919 | /** Whether the thread listing itself is incomplete, apart from pages within a thread. */ | ||
| 920 | reviewThreadsTruncated: Schema.optional(Schema.Boolean), | ||
| 919 | reviewThreads: Schema.Array(PullRequestReviewThread), | 921 | reviewThreads: Schema.Array(PullRequestReviewThread), |
| 920 | commits: Schema.Array(PullRequestCommit), | 922 | commits: Schema.Array(PullRequestCommit), |
| 921 | /** | 923 | /** |