| author | |
| committer | |
| log | 06a84de3bd613b00f6a1fc61f7f71ea26534ef09 |
| tree | 6ce0bc28a5f0a0d830f927da1fa51df6c5863d1e |
| parent | 7ee2839cb58263742301895a6be826505daa64ea |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>2 files changed, 73 insertions(+), 4 deletions(-)
apps/server/src/pullRequest/PullRequestService.test.ts+51| ... | ... | @@ -309,6 +309,57 @@ it.effect("shares detail and activity for a minute, and for ten once merged", () |
| 309 | 309 | }), |
| 310 | 310 | ); |
| 311 | 311 | |
| 312 | it.effect("re-reads an open pull request after a turn, but not a merged one", () => | |
| 313 | Effect.gen(function* () { | |
| 314 | const reads: Array<string> = []; | |
| 315 | const service = yield* makeService({ | |
| 316 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/w", repository: "acme/web" })], | |
| 317 | providers: [ | |
| 318 | fakeProvider(SourceControlProviderKind.make("github"), { | |
| 319 | getChangeRequest: ({ number }) => | |
| 320 | Effect.sync(() => { | |
| 321 | reads.push(`detail #${number}`); | |
| 322 | return { | |
| 323 | ...hostedChangeRequest("Description"), | |
| 324 | number, | |
| 325 | state: number === 1 ? ("open" as const) : ("merged" as const), | |
| 326 | }; | |
| 327 | }), | |
| 328 | getChangeRequestActivity: ({ number }) => | |
| 329 | Effect.sync(() => { | |
| 330 | reads.push(`activity #${number}`); | |
| 331 | return { | |
| 332 | comments: [], | |
| 333 | commentCount: 0, | |
| 334 | commentsTruncated: false, | |
| 335 | reviewThreads: [], | |
| 336 | commits: [], | |
| 337 | }; | |
| 338 | }), | |
| 339 | }), | |
| 340 | ], | |
| 341 | }); | |
| 342 | // What every client showing #1 (open) and #2 (merged) does on each refresh signal. | |
| 343 | const merged = { projectId: "p1" as ProjectId, repository: "acme/web", number: 2 }; | |
| 344 | const reread = Effect.forEach([1, 2], (number) => { | |
| 345 | const ref = { ...merged, number }; | |
| 346 | return Effect.andThen(service.detail(ref), service.activity(ref)); | |
| 347 | }).pipe( | |
| 348 | Effect.andThen(Effect.yieldNow), | |
| 349 | Effect.andThen(Effect.sync(() => reads.splice(0).toSorted())), | |
| 350 | ); | |
| 351 | ||
| 352 | assert.deepStrictEqual(yield* reread, ["activity #1", "activity #2", "detail #1", "detail #2"]); | |
| 353 | for (let turn = 0; turn < 3; turn++) { | |
| 354 | yield* service.refreshAfterTurn(merged.projectId); | |
| 355 | assert.deepStrictEqual(yield* reread, ["activity #1", "detail #1"]); | |
| 356 | } | |
| 357 | // The refresh button still reads a merged pull request again. | |
| 358 | yield* service.invalidate({ reference: merged }); | |
| 359 | assert.deepStrictEqual(yield* reread, ["activity #2", "detail #2"]); | |
| 360 | }), | |
| 361 | ); | |
| 362 | ||
| 312 | 363 | it.effect("does not wait for an in-flight detail read to display a preview", () => |
| 313 | 364 | Effect.gen(function* () { |
| 314 | 365 | const detailStarted = yield* Deferred.make<void>(); |
apps/server/src/pullRequest/PullRequestService.ts+22-4| ... | ... | @@ -2800,11 +2800,28 @@ export const make = Effect.gen(function* () { |
| 2800 | 2800 | ref.repository.toLowerCase(), |
| 2801 | 2801 | ref.number, |
| 2802 | 2802 | ]); |
| 2803 | const refEpoch = (ref: PullRequestRef) => | |
| 2804 | Math.max( | |
| 2805 | projectEpochs.get(ref.projectId) ?? projectEpochFloor, | |
| 2806 | refEpochs.get(refScope(ref)) ?? 0, | |
| 2803 | /** | |
| 2804 | * The project epoch a pull request was first read merged under. A merged pull request cannot | |
| 2805 | * change again, so a turn in its project no longer moves its key; otherwise every reader showing | |
| 2806 | * it would read the host again after every turn. A refresh or a mutation still moves it. | |
| 2807 | */ | |
| 2808 | const mergedEpochs = new Map<string, number>(); | |
| 2809 | const refEpoch = (ref: PullRequestRef) => { | |
| 2810 | const scope = refScope(ref); | |
| 2811 | return Math.max( | |
| 2812 | mergedEpochs.get(scope) ?? projectEpochs.get(ref.projectId) ?? projectEpochFloor, | |
| 2813 | refEpochs.get(scope) ?? 0, | |
| 2807 | 2814 | ); |
| 2815 | }; | |
| 2816 | const noteMerged = (ref: PullRequestRef) => { | |
| 2817 | const scope = refScope(ref); | |
| 2818 | if (mergedEpochs.has(scope)) return; | |
| 2819 | if (mergedEpochs.size >= REF_EPOCH_CAPACITY) { | |
| 2820 | const oldest = mergedEpochs.keys().next().value; | |
| 2821 | if (oldest !== undefined) mergedEpochs.delete(oldest); | |
| 2822 | } | |
| 2823 | mergedEpochs.set(scope, projectEpochs.get(ref.projectId) ?? projectEpochFloor); | |
| 2824 | }; | |
| 2808 | 2825 | // Keys carry the reference back out of the cache loader, so the slot layout is shared with |
| 2809 | 2826 | // `refOfCacheKey` rather than read positionally at every loader. |
| 2810 | 2827 | const refCacheKey = (ref: CredentialRef) => |
| ... | ... | @@ -3213,6 +3230,7 @@ export const make = Effect.gen(function* () { |
| 3213 | 3230 | // regression and never ask the host again. |
| 3214 | 3231 | const read = Cache.get(detailCache, key).pipe( |
| 3215 | 3232 | Effect.tap((value) => { |
| 3233 | if (value.state === "merged") noteMerged(input); | |
| 3216 | 3234 | const summary = summaryFromDetail(value, lastGoodSummary.peek(key)); |
| 3217 | 3235 | return noteDetailReading(input, summary).pipe( |
| 3218 | 3236 | Effect.andThen( |