| author | |
| committer | |
| log | 854541a04e07b7960698b381bcfd2fda73eb276c |
| tree | ef75ff2224c1cee716bafdba554999489c4e4834 |
| parent | 48ba76bc26337f23d1f0991bbac51f996a3333dc |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>9 files changed, 439 insertions(+), 165 deletions(-)
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+71-3| ... | ... | @@ -25,7 +25,10 @@ import * as Stream from "effect/Stream"; |
| 25 | 25 | import { TestClock } from "effect/testing"; |
| 26 | 26 | |
| 27 | 27 | import { GitManager } from "../git/GitManager.ts"; |
| 28 | import { PullRequestService } from "../pullRequest/PullRequestService.ts"; | |
| 28 | import { | |
| 29 | PullRequestService, | |
| 30 | type PullRequestMergeEvent, | |
| 31 | } from "../pullRequest/PullRequestService.ts"; | |
| 29 | 32 | import { ServerActivation } from "../serverActivation.ts"; |
| 30 | 33 | import { ServerSettingsService } from "../serverSettings.ts"; |
| 31 | 34 | import { OrchestrationCommandInvariantError } from "./Errors.ts"; |
| ... | ... | @@ -143,6 +146,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 143 | 146 | const snapshotReads = yield* Queue.unbounded<number>(); |
| 144 | 147 | const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS); |
| 145 | 148 | const settingsChanges = yield* PubSub.unbounded<ServerSettings>(); |
| 149 | const mergedPullRequests = yield* PubSub.unbounded<PullRequestMergeEvent>(); | |
| 146 | 150 | const commands = yield* Ref.make<ReadonlyArray<AutoSettleCommand>>([]); |
| 147 | 151 | const branchCalls = yield* Ref.make< |
| 148 | 152 | ReadonlyArray<{ readonly cwd: string; readonly branch: string }> |
| ... | ... | @@ -168,7 +172,6 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 168 | 172 | Ref.update(branchCalls, (calls) => [...calls, input]).pipe( |
| 169 | 173 | Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), |
| 170 | 174 | ); |
| 171 | ||
| 172 | 175 | const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => |
| 173 | 176 | Effect.gen(function* () { |
| 174 | 177 | yield* Ref.update(summaryCalls, (calls) => [...calls, input]); |
| ... | ... | @@ -217,7 +220,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 217 | 220 | ), |
| 218 | 221 | }), |
| 219 | 222 | Layer.mock(GitManager)({ branchPullRequest }), |
| 220 | Layer.mock(PullRequestService)({ summary: pullRequestSummary }), | |
| 223 | Layer.mock(PullRequestService)({ | |
| 224 | summary: pullRequestSummary, | |
| 225 | subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( | |
| 226 | Effect.map((subscription) => Stream.fromSubscription(subscription)), | |
| 227 | ), | |
| 228 | }), | |
| 221 | 229 | Layer.mock(OrchestrationEngineService)({ |
| 222 | 230 | readEvents: () => Stream.empty, |
| 223 | 231 | dispatch, |
| ... | ... | @@ -239,6 +247,12 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: |
| 239 | 247 | summaryCalls, |
| 240 | 248 | summaryRecovery, |
| 241 | 249 | updateSettings, |
| 250 | publishMerge: PubSub.publish(mergedPullRequests, { | |
| 251 | projectId: PROJECT_ID, | |
| 252 | repository: "owner/repository", | |
| 253 | number: 42, | |
| 254 | mergedAt: NOW, | |
| 255 | }), | |
| 242 | 256 | layer: ThreadSettlementReactor.layer.pipe(Layer.provide(dependencies)), |
| 243 | 257 | }; |
| 244 | 258 | }); |
| ... | ... | @@ -373,6 +387,60 @@ describe("ThreadSettlementReactor", () => { |
| 373 | 387 | ), |
| 374 | 388 | ); |
| 375 | 389 | |
| 390 | it.effect("reevaluates immediately after a pull request merge", () => | |
| 391 | Effect.scoped( | |
| 392 | Effect.gen(function* () { | |
| 393 | yield* TestClock.setTime(Date.parse(NOW)); | |
| 394 | const periodicLookupStarted = yield* Deferred.make<void>(); | |
| 395 | const releasePeriodicLookup = yield* Deferred.make<void>(); | |
| 396 | const mergedThreadSettled = yield* Deferred.make<void>(); | |
| 397 | const branchLookupCount = yield* Ref.make(0); | |
| 398 | const fixture = yield* makeHarness({ | |
| 399 | snapshot: makeSnapshot([ | |
| 400 | makeThread("merged-in-app", { | |
| 401 | linkedPullRequest: { | |
| 402 | projectId: PROJECT_ID, | |
| 403 | repository: "owner/repository", | |
| 404 | number: 42, | |
| 405 | url: "https://example.test/owner/repository/pull/42", | |
| 406 | }, | |
| 407 | }), | |
| 408 | makeThread("slow-periodic-lookup", { branch: "another-feature" }), | |
| 409 | ]), | |
| 410 | branchPullRequest: () => | |
| 411 | Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe( | |
| 412 | Effect.flatMap((count) => | |
| 413 | count === 1 | |
| 414 | ? Effect.succeed({ state: "open" as const, updatedAt: NOW }) | |
| 415 | : Deferred.succeed(periodicLookupStarted, undefined).pipe( | |
| 416 | Effect.andThen(Deferred.await(releasePeriodicLookup)), | |
| 417 | Effect.as({ state: "open" as const, updatedAt: NOW }), | |
| 418 | ), | |
| 419 | ), | |
| 420 | ), | |
| 421 | onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), | |
| 422 | }); | |
| 423 | ||
| 424 | yield* Effect.gen(function* () { | |
| 425 | const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; | |
| 426 | yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); | |
| 427 | yield* fixture.updateSettings({ sidebarAutoSettleAfterDays: 4 }); | |
| 428 | yield* Deferred.await(periodicLookupStarted); | |
| 429 | ||
| 430 | yield* fixture.publishMerge; | |
| 431 | yield* Deferred.await(mergedThreadSettled); | |
| 432 | ||
| 433 | assert.deepStrictEqual( | |
| 434 | (yield* Ref.get(fixture.commands)).map((command) => command.threadId), | |
| 435 | [ThreadId.make("merged-in-app")], | |
| 436 | ); | |
| 437 | yield* Deferred.succeed(releasePeriodicLookup, undefined); | |
| 438 | yield* reactor.drain; | |
| 439 | }).pipe(Effect.provide(fixture.layer)); | |
| 440 | }), | |
| 441 | ), | |
| 442 | ); | |
| 443 | ||
| 376 | 444 | it.effect("uses fresh settlement settings after lookup and ignores unrelated changes", () => |
| 377 | 445 | Effect.scoped( |
| 378 | 446 | Effect.gen(function* () { |
apps/server/src/orchestration/ThreadSettlementReactor.ts+25-6| ... | ... | @@ -38,11 +38,22 @@ export const make = Effect.gen(function* () { |
| 38 | 38 | const pullRequests = yield* PullRequestService.PullRequestService; |
| 39 | 39 | const crypto = yield* Crypto.Crypto; |
| 40 | 40 | |
| 41 | const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* () { | |
| 41 | const sweep = Effect.fn("ThreadSettlementReactor.sweep")(function* ( | |
| 42 | mergedPullRequest: PullRequestService.PullRequestMergeEvent | null, | |
| 43 | ) { | |
| 42 | 44 | const snapshot = yield* snapshots.getShellSnapshot(); |
| 43 | 45 | const now = DateTime.formatIso(yield* DateTime.now); |
| 44 | 46 | const projects = new Map(snapshot.projects.map((project) => [project.id, project])); |
| 45 | const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); | |
| 47 | const candidates = snapshot.threads.filter( | |
| 48 | (thread) => | |
| 49 | isAutoSettlementCandidate(thread, now) && | |
| 50 | (mergedPullRequest === null || | |
| 51 | (thread.linkedPullRequest != null && | |
| 52 | thread.linkedPullRequest.projectId === mergedPullRequest.projectId && | |
| 53 | thread.linkedPullRequest.repository.toLowerCase() === | |
| 54 | mergedPullRequest.repository.toLowerCase() && | |
| 55 | thread.linkedPullRequest.number === mergedPullRequest.number)), | |
| 56 | ); | |
| 46 | 57 | const lookupKey = (thread: (typeof candidates)[number]) => { |
| 47 | 58 | if (thread.linkedPullRequest != null) { |
| 48 | 59 | return JSON.stringify([ |
| ... | ... | @@ -66,6 +77,12 @@ export const make = Effect.gen(function* () { |
| 66 | 77 | thread: (typeof candidates)[number], |
| 67 | 78 | ) { |
| 68 | 79 | if (thread.linkedPullRequest != null) { |
| 80 | if (mergedPullRequest !== null) { | |
| 81 | return { | |
| 82 | state: "merged", | |
| 83 | updatedAt: mergedPullRequest.mergedAt, | |
| 84 | } satisfies SettlementPullRequest; | |
| 85 | } | |
| 69 | 86 | if (!projects.has(thread.linkedPullRequest.projectId)) { |
| 70 | 87 | return yield* Effect.die(new Error("linked pull request project not found")); |
| 71 | 88 | } |
| ... | ... | @@ -145,8 +162,8 @@ export const make = Effect.gen(function* () { |
| 145 | 162 | ); |
| 146 | 163 | }); |
| 147 | 164 | |
| 148 | const worker = yield* makeDrainableWorker(() => | |
| 149 | sweep().pipe( | |
| 165 | const runSweep = (mergedPullRequest: PullRequestService.PullRequestMergeEvent | null) => | |
| 166 | sweep(mergedPullRequest).pipe( | |
| 150 | 167 | Effect.catchCause((cause) => |
| 151 | 168 | Cause.hasInterruptsOnly(cause) |
| 152 | 169 | ? Effect.failCause(cause) |
| ... | ... | @@ -154,13 +171,14 @@ export const make = Effect.gen(function* () { |
| 154 | 171 | cause: Cause.pretty(cause), |
| 155 | 172 | }), |
| 156 | 173 | ), |
| 157 | ), | |
| 158 | ); | |
| 174 | ); | |
| 175 | const worker = yield* makeDrainableWorker(() => runSweep(null)); | |
| 159 | 176 | |
| 160 | 177 | const start: ThreadSettlementReactor["Service"]["start"] = Effect.fn( |
| 161 | 178 | "ThreadSettlementReactor.start", |
| 162 | 179 | )(function* () { |
| 163 | 180 | const settingsChanges = yield* settingsService.subscribeChanges; |
| 181 | const mergedPullRequests = yield* pullRequests.subscribeMerges; | |
| 164 | 182 | const initialSettings = yield* settingsService.getSettings.pipe(Effect.orDie); |
| 165 | 183 | let lastAfterDays = initialSettings.sidebarAutoSettleAfterDays; |
| 166 | 184 | let lastOnMerge = initialSettings.sidebarAutoSettleOnMerge; |
| ... | ... | @@ -183,6 +201,7 @@ export const make = Effect.gen(function* () { |
| 183 | 201 | return worker.enqueue(undefined); |
| 184 | 202 | }), |
| 185 | 203 | ); |
| 204 | yield* forkParked(Stream.runForEach(mergedPullRequests, runSweep)); | |
| 186 | 205 | }); |
| 187 | 206 | |
| 188 | 207 | return { start, drain: worker.drain } satisfies ThreadSettlementReactor["Service"]; |
apps/server/src/pullRequest/PullRequestService.test.ts+64| ... | ... | @@ -1,7 +1,10 @@ |
| 1 | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | 2 | import * as Deferred from "effect/Deferred"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Fiber from "effect/Fiber"; | |
| 4 | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | |
| 7 | import * as Stream from "effect/Stream"; | |
| 5 | 8 | import * as TestClock from "effect/testing/TestClock"; |
| 6 | 9 | import type { |
| 7 | 10 | OrchestrationProjectShell, |
| ... | ... | @@ -1002,6 +1005,41 @@ it.effect("refuses an action the host never claimed it could run", () => |
| 1002 | 1005 | }), |
| 1003 | 1006 | ); |
| 1004 | 1007 | |
| 1008 | it.effect("publishes a successful merge for immediate settlement", () => | |
| 1009 | Effect.scoped( | |
| 1010 | Effect.gen(function* () { | |
| 1011 | const mergedAt = "2026-09-03T02:00:00.000Z"; | |
| 1012 | const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; | |
| 1013 | const service = yield* makeService({ | |
| 1014 | projects: [ | |
| 1015 | project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" }), | |
| 1016 | ], | |
| 1017 | providers: [ | |
| 1018 | fakeProvider("github", { | |
| 1019 | runAction: () => TestClock.setTime(Date.parse(mergedAt)), | |
| 1020 | }), | |
| 1021 | ], | |
| 1022 | }); | |
| 1023 | const merges = yield* service.subscribeMerges; | |
| 1024 | const observedMerge = yield* Stream.runHead(merges).pipe( | |
| 1025 | Effect.forkChild({ startImmediately: true }), | |
| 1026 | ); | |
| 1027 | ||
| 1028 | yield* service.runAction({ | |
| 1029 | ...reference, | |
| 1030 | repository: " ACME/WEB ", | |
| 1031 | action: "merge", | |
| 1032 | mergeMethod: "merge", | |
| 1033 | }); | |
| 1034 | ||
| 1035 | assert.deepStrictEqual(Option.getOrThrow(yield* Fiber.join(observedMerge)), { | |
| 1036 | ...reference, | |
| 1037 | mergedAt, | |
| 1038 | }); | |
| 1039 | }), | |
| 1040 | ), | |
| 1041 | ); | |
| 1042 | ||
| 1005 | 1043 | it.effect("refuses an action this viewer may not take, and says what access it takes", () => |
| 1006 | 1044 | Effect.gen(function* () { |
| 1007 | 1045 | let ran: string | null = null; |
| ... | ... | @@ -3192,6 +3230,32 @@ it.effect("does not ask the host again for a linked summary it already holds", ( |
| 3192 | 3230 | }), |
| 3193 | 3231 | ); |
| 3194 | 3232 | |
| 3233 | it.effect("reuses an observed merged state for strict settlement reads", () => | |
| 3234 | Effect.gen(function* () { | |
| 3235 | const reference = { projectId: "p1" as ProjectId, repository: "acme/web", number: 1 }; | |
| 3236 | const service = yield* makeService({ | |
| 3237 | projects: [project({ id: "p1", title: "web", workspaceRoot: "/a", repository: "acme/web" })], | |
| 3238 | providers: [ | |
| 3239 | fakeProvider("github", { | |
| 3240 | getChangeRequest: () => | |
| 3241 | Effect.succeed({ | |
| 3242 | ...hostedChangeRequest("merged body", 4), | |
| 3243 | state: "merged", | |
| 3244 | updatedAt: "2026-07-03T00:00:00Z", | |
| 3245 | }), | |
| 3246 | getChangeRequestSummary: () => Effect.die("strict merged state must not refresh"), | |
| 3247 | }), | |
| 3248 | ], | |
| 3249 | }); | |
| 3250 | ||
| 3251 | yield* service.detail(reference); | |
| 3252 | ||
| 3253 | const summary = yield* service.summary(reference, { recoverTransientFailure: false }); | |
| 3254 | assert.strictEqual(summary.state, "merged"); | |
| 3255 | assert.strictEqual(summary.updatedAt, "2026-07-03T00:00:00Z"); | |
| 3256 | }), | |
| 3257 | ); | |
| 3258 | ||
| 3195 | 3259 | it.effect("does not let a stale detail reopen overwrite a fresher linked summary", () => |
| 3196 | 3260 | Effect.gen(function* () { |
| 3197 | 3261 | const gate = yield* Deferred.make<void>(); |
apps/server/src/pullRequest/PullRequestService.ts+47-8| ... | ... | @@ -1,11 +1,15 @@ |
| 1 | 1 | import * as Cache from "effect/Cache"; |
| 2 | 2 | import * as Clock from "effect/Clock"; |
| 3 | 3 | import * as Context from "effect/Context"; |
| 4 | import * as DateTime from "effect/DateTime"; | |
| 4 | 5 | import * as Duration from "effect/Duration"; |
| 5 | 6 | import * as Effect from "effect/Effect"; |
| 6 | 7 | import * as Exit from "effect/Exit"; |
| 7 | 8 | import * as Layer from "effect/Layer"; |
| 9 | import * as PubSub from "effect/PubSub"; | |
| 8 | 10 | import * as Schema from "effect/Schema"; |
| 11 | import type * as Scope from "effect/Scope"; | |
| 12 | import * as Stream from "effect/Stream"; | |
| 9 | 13 | import { |
| 10 | 14 | PullRequestOperationError, |
| 11 | 15 | PullRequestUnavailableError, |
| ... | ... | @@ -63,6 +67,10 @@ import { |
| 63 | 67 | } from "./PullRequestProvider.ts"; |
| 64 | 68 | import { PullRequestProviderRegistry } from "./PullRequestProviderRegistry.ts"; |
| 65 | 69 | |
| 70 | export interface PullRequestMergeEvent extends PullRequestRef { | |
| 71 | readonly mergedAt: string; | |
| 72 | } | |
| 73 | ||
| 66 | 74 | /** |
| 67 | 75 | * Rows per repository when the client does not ask for a page size, and rows per slice when a |
| 68 | 76 | * listing is carried on from a cursor. |
| ... | ... | @@ -135,6 +143,11 @@ export class PullRequestService extends Context.Service< |
| 135 | 143 | input: PullRequestRef, |
| 136 | 144 | options?: { readonly recoverTransientFailure?: boolean }, |
| 137 | 145 | ) => Effect.Effect<PullRequestSummary, PullRequestError>; |
| 146 | readonly subscribeMerges: Effect.Effect< | |
| 147 | Stream.Stream<PullRequestMergeEvent>, | |
| 148 | never, | |
| 149 | Scope.Scope | |
| 150 | >; | |
| 138 | 151 | readonly detail: (input: PullRequestRef) => Effect.Effect<PullRequestDetail, PullRequestError>; |
| 139 | 152 | readonly activity: ( |
| 140 | 153 | input: PullRequestRef, |
| ... | ... | @@ -517,6 +530,7 @@ export function repositoryIdentityOf(project: OrchestrationProjectShell): string |
| 517 | 530 | } |
| 518 | 531 | |
| 519 | 532 | export const make = Effect.gen(function* () { |
| 533 | const mergedPullRequests = yield* PubSub.sliding<PullRequestMergeEvent>(64); | |
| 520 | 534 | const registry = yield* PullRequestProviderRegistry; |
| 521 | 535 | const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; |
| 522 | 536 | const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; |
| ... | ... | @@ -1410,9 +1424,9 @@ export const make = Effect.gen(function* () { |
| 1410 | 1424 | }), |
| 1411 | 1425 | ); |
| 1412 | 1426 | |
| 1413 | const runAction: PullRequestService["Service"]["runAction"] = (input) => | |
| 1427 | const runAction = (input: PullRequestActionInput): Effect.Effect<string, PullRequestError> => | |
| 1414 | 1428 | requireProject(input).pipe( |
| 1415 | Effect.flatMap((project): Effect.Effect<void, PullRequestError> => { | |
| 1429 | Effect.flatMap((project): Effect.Effect<string, PullRequestError> => { | |
| 1416 | 1430 | // The surface hides what a host cannot do, and this refuses it as well: a request that |
| 1417 | 1431 | // reached here anyway must not be handed to a provider that never claimed the action. |
| 1418 | 1432 | if (!project.api.capabilities.actions.includes(input.action)) { |
| ... | ... | @@ -1454,7 +1468,7 @@ export const make = Effect.gen(function* () { |
| 1454 | 1468 | // have to say yes. The second is asked last, because it costs a request and the checks |
| 1455 | 1469 | // above do not. |
| 1456 | 1470 | return viewerPermissionsOf(project, input, "runAction").pipe( |
| 1457 | Effect.flatMap((viewer): Effect.Effect<void, PullRequestError> => { | |
| 1471 | Effect.flatMap((viewer): Effect.Effect<string, PullRequestError> => { | |
| 1458 | 1472 | if (!viewer.actions.includes(input.action)) { |
| 1459 | 1473 | return Effect.fail( |
| 1460 | 1474 | new PullRequestOperationError({ |
| ... | ... | @@ -1484,7 +1498,10 @@ export const make = Effect.gen(function* () { |
| 1484 | 1498 | ...(input.mergeMethod === undefined ? {} : { mergeMethod: input.mergeMethod }), |
| 1485 | 1499 | ...(input.updateMethod === undefined ? {} : { updateMethod: input.updateMethod }), |
| 1486 | 1500 | }) |
| 1487 | .pipe(Effect.mapError(toPullRequestError("runAction"))); | |
| 1501 | .pipe( | |
| 1502 | Effect.mapError(toPullRequestError("runAction")), | |
| 1503 | Effect.as(project.repository), | |
| 1504 | ); | |
| 1488 | 1505 | }), |
| 1489 | 1506 | ); |
| 1490 | 1507 | }), |
| ... | ... | @@ -2151,9 +2168,13 @@ export const make = Effect.gen(function* () { |
| 2151 | 2168 | const summary: PullRequestService["Service"]["summary"] = (input, options) => { |
| 2152 | 2169 | const key = refCacheKey(input); |
| 2153 | 2170 | const cached = Cache.get(summaryCache, key); |
| 2154 | return options?.recoverTransientFailure === false | |
| 2155 | ? cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value))) | |
| 2156 | : lastGoodSummary.serveHeld(key, cached, "reuse"); | |
| 2171 | if (options?.recoverTransientFailure !== false) { | |
| 2172 | return lastGoodSummary.serveHeld(key, cached, "reuse"); | |
| 2173 | } | |
| 2174 | const held = lastGoodSummary.peek(key); | |
| 2175 | return held?.state === "merged" | |
| 2176 | ? Effect.succeed(held) | |
| 2177 | : cached.pipe(Effect.tap((value) => lastGoodSummary.record(key, value))); | |
| 2157 | 2178 | }; |
| 2158 | 2179 | |
| 2159 | 2180 | // Keys serialize positionally and parse back in the lookup, so the cache is the only holder |
| ... | ... | @@ -2391,17 +2412,35 @@ export const make = Effect.gen(function* () { |
| 2391 | 2412 | }), |
| 2392 | 2413 | ), |
| 2393 | 2414 | ); |
| 2415 | const runActionAndInvalidate: PullRequestService["Service"]["runAction"] = Effect.fn( | |
| 2416 | "PullRequestService.runActionAndInvalidate", | |
| 2417 | )(function* (input) { | |
| 2418 | const repository = yield* runAction(input); | |
| 2419 | bumpRefEpoch({ ...input, repository }); | |
| 2420 | listingsEpoch = ++epochCounter; | |
| 2421 | if (input.action === "merge") { | |
| 2422 | yield* PubSub.publish(mergedPullRequests, { | |
| 2423 | projectId: input.projectId, | |
| 2424 | repository, | |
| 2425 | number: input.number, | |
| 2426 | mergedAt: DateTime.formatIso(yield* DateTime.now), | |
| 2427 | }); | |
| 2428 | } | |
| 2429 | }); | |
| 2394 | 2430 | |
| 2395 | 2431 | return PullRequestService.of({ |
| 2396 | 2432 | list, |
| 2397 | 2433 | listStats, |
| 2398 | 2434 | summary, |
| 2435 | subscribeMerges: PubSub.subscribe(mergedPullRequests).pipe( | |
| 2436 | Effect.map((subscription) => Stream.fromSubscription(subscription)), | |
| 2437 | ), | |
| 2399 | 2438 | detail, |
| 2400 | 2439 | activity, |
| 2401 | 2440 | threadComments, |
| 2402 | 2441 | diff, |
| 2403 | 2442 | diffFileContents, |
| 2404 | runAction: invalidatedByMutation(runAction), | |
| 2443 | runAction: runActionAndInvalidate, | |
| 2405 | 2444 | update: invalidatedByMutation(update), |
| 2406 | 2445 | comment: invalidatedByMutation(comment), |
| 2407 | 2446 | updateComment: invalidatedByMutation(updateComment), |
apps/web/src/components/ChatView.tsx+131-54| ... | ... | @@ -265,7 +265,6 @@ import { environmentCatalog } from "../connection/catalog"; |
| 265 | 265 | import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; |
| 266 | 266 | import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 267 | 267 | import { projectEnvironment } from "../state/projects"; |
| 268 | import { linkedPullRequestDetailAtom } from "../state/pullRequests"; | |
| 269 | 268 | import { useEnvironmentQuery } from "../state/query"; |
| 270 | 269 | import { |
| 271 | 270 | environmentServerConfigsAtom, |
| ... | ... | @@ -325,7 +324,6 @@ import { |
| 325 | 324 | } from "./chat/ThreadErrorBanner"; |
| 326 | 325 | import { |
| 327 | 326 | resolveDisplayedThreadPr, |
| 328 | threadPullRequestRefreshSource, | |
| 329 | 327 | threadChangeRequestSnapshotsAtom, |
| 330 | 328 | useLinkedThreadPullRequest, |
| 331 | 329 | } from "./ThreadStatusIndicators"; |
| ... | ... | @@ -1787,6 +1785,8 @@ function ChatViewContent(props: ChatViewProps) { |
| 1787 | 1785 | ); |
| 1788 | 1786 | const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); |
| 1789 | 1787 | const sidebarPrRefreshKeyRef = useRef<string | null>(null); |
| 1788 | const threadPrRelinkKeysRef = useRef(new Map<string, string>()); | |
| 1789 | const threadPrRelinkWriteRef = useRef(Promise.resolve()); | |
| 1790 | 1790 | const activePreviewState = useThreadPreviewState(activeThreadRef); |
| 1791 | 1791 | const activePreviewServerEpoch = activePreviewState.serverEpoch; |
| 1792 | 1792 | const resolvePreviewRuntimeTabId = useMemo( |
| ... | ... | @@ -3645,9 +3645,50 @@ function ChatViewContent(props: ChatViewProps) { |
| 3645 | 3645 | ); |
| 3646 | 3646 | // The thread's own change request, placed against the project it belongs to. Without a |
| 3647 | 3647 | // project there is nothing to resolve it against, so the caller falls back to the browser. |
| 3648 | const linkedThreadPullRequest = isServerThread | |
| 3648 | const persistedLinkedThreadPullRequest = isServerThread | |
| 3649 | 3649 | ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null) |
| 3650 | 3650 | : (activeThread?.linkedPullRequest ?? null); |
| 3651 | const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; | |
| 3652 | const persistedLinkedThreadPullRequestStatus = useLinkedThreadPullRequest( | |
| 3653 | activeThreadRef?.environmentId ?? null, | |
| 3654 | persistedLinkedThreadPullRequest, | |
| 3655 | ); | |
| 3656 | const replacementLinkedThreadPullRequest = useMemo(() => { | |
| 3657 | const detected = gitStatusQuery.data?.pr; | |
| 3658 | const threadBranch = activeThread?.branch; | |
| 3659 | const projectId = activeProject?.id; | |
| 3660 | if ( | |
| 3661 | persistedLinkedThreadPullRequest === null || | |
| 3662 | (persistedLinkedThreadPullRequestStatus?.pr.state !== "merged" && | |
| 3663 | persistedLinkedThreadPullRequestStatus?.pr.state !== "closed") || | |
| 3664 | gitStatusQuery.data?.refName !== threadBranch || | |
| 3665 | detected?.state !== "open" || | |
| 3666 | detected.headRef !== threadBranch || | |
| 3667 | projectId === undefined || | |
| 3668 | activeProjectRepository === null || | |
| 3669 | (persistedLinkedThreadPullRequest.projectId === projectId && | |
| 3670 | persistedLinkedThreadPullRequest.repository.toLowerCase() === | |
| 3671 | activeProjectRepository.toLowerCase() && | |
| 3672 | persistedLinkedThreadPullRequest.number === detected.number) | |
| 3673 | ) { | |
| 3674 | return null; | |
| 3675 | } | |
| 3676 | return { | |
| 3677 | projectId, | |
| 3678 | repository: activeProjectRepository, | |
| 3679 | number: detected.number, | |
| 3680 | url: detected.url, | |
| 3681 | }; | |
| 3682 | }, [ | |
| 3683 | activeProject?.id, | |
| 3684 | activeProjectRepository, | |
| 3685 | activeThread?.branch, | |
| 3686 | gitStatusQuery.data, | |
| 3687 | persistedLinkedThreadPullRequest, | |
| 3688 | persistedLinkedThreadPullRequestStatus?.pr.state, | |
| 3689 | ]); | |
| 3690 | const linkedThreadPullRequest = | |
| 3691 | replacementLinkedThreadPullRequest ?? persistedLinkedThreadPullRequest; | |
| 3651 | 3692 | const linkedThreadPullRequestKey = linkedThreadPullRequest |
| 3652 | 3693 | ? JSON.stringify([ |
| 3653 | 3694 | linkedThreadPullRequest.projectId, |
| ... | ... | @@ -3655,7 +3696,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 3655 | 3696 | linkedThreadPullRequest.number, |
| 3656 | 3697 | ]) |
| 3657 | 3698 | : null; |
| 3658 | const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; | |
| 3659 | 3699 | const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; |
| 3660 | 3700 | const openThreadPullRequest = useCallback( |
| 3661 | 3701 | (number: number) => { |
| ... | ... | @@ -3679,6 +3719,63 @@ function ChatViewContent(props: ChatViewProps) { |
| 3679 | 3719 | supportsPullRequests, |
| 3680 | 3720 | ], |
| 3681 | 3721 | ); |
| 3722 | useEffect(() => { | |
| 3723 | if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { | |
| 3724 | return; | |
| 3725 | } | |
| 3726 | if (replacementLinkedThreadPullRequest === null) { | |
| 3727 | threadPrRelinkKeysRef.current.delete(activeThreadKey); | |
| 3728 | return; | |
| 3729 | } | |
| 3730 | const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`; | |
| 3731 | if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return; | |
| 3732 | threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey); | |
| 3733 | const openSurface = selectActiveRightPanelSurface( | |
| 3734 | useRightPanelStore.getState().byThreadKey, | |
| 3735 | activeThreadRef, | |
| 3736 | ); | |
| 3737 | if ( | |
| 3738 | openSurface?.kind === "pull-request" && | |
| 3739 | persistedLinkedThreadPullRequest !== null && | |
| 3740 | openSurface.projectId === persistedLinkedThreadPullRequest.projectId && | |
| 3741 | openSurface.repository.toLowerCase() === | |
| 3742 | persistedLinkedThreadPullRequest.repository.toLowerCase() && | |
| 3743 | openSurface.number === persistedLinkedThreadPullRequest.number | |
| 3744 | ) { | |
| 3745 | useRightPanelStore | |
| 3746 | .getState() | |
| 3747 | .openPullRequest(activeThreadRef, replacementLinkedThreadPullRequest); | |
| 3748 | } | |
| 3749 | ||
| 3750 | threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => { | |
| 3751 | if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; | |
| 3752 | const result = await updateThreadMetadata({ | |
| 3753 | environmentId: activeThreadRef.environmentId, | |
| 3754 | input: { | |
| 3755 | threadId: activeThreadRef.threadId, | |
| 3756 | linkedPullRequest: replacementLinkedThreadPullRequest, | |
| 3757 | }, | |
| 3758 | }); | |
| 3759 | if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; | |
| 3760 | if (result._tag !== "Failure") return; | |
| 3761 | threadPrRelinkKeysRef.current.delete(activeThreadKey); | |
| 3762 | if (isAtomCommandInterrupted(result)) return; | |
| 3763 | toastManager.add( | |
| 3764 | stackedThreadToast({ | |
| 3765 | type: "error", | |
| 3766 | title: "Unable to update the thread pull request", | |
| 3767 | description: chatActionErrorMessage(squashAtomCommandFailure(result)), | |
| 3768 | }), | |
| 3769 | ); | |
| 3770 | }); | |
| 3771 | }, [ | |
| 3772 | activeThreadKey, | |
| 3773 | activeThreadRef, | |
| 3774 | isServerThread, | |
| 3775 | persistedLinkedThreadPullRequest, | |
| 3776 | replacementLinkedThreadPullRequest, | |
| 3777 | updateThreadMetadata, | |
| 3778 | ]); | |
| 3682 | 3779 | const openProjectPullRequest = useCallback( |
| 3683 | 3780 | (number: number) => { |
| 3684 | 3781 | if ( |
| ... | ... | @@ -4782,60 +4879,39 @@ function ChatViewContent(props: ChatViewProps) { |
| 4782 | 4879 | resizeObserver.disconnect(); |
| 4783 | 4880 | }; |
| 4784 | 4881 | }, [composerOverlayElement]); |
| 4785 | const linkedPullRequestStatus = useLinkedThreadPullRequest( | |
| 4786 | activeThreadRef?.environmentId ?? null, | |
| 4787 | linkedThreadPullRequest, | |
| 4788 | ); | |
| 4789 | const activeThreadPr = resolveDisplayedThreadPr({ | |
| 4790 | threadBranch: activeThread?.branch ?? null, | |
| 4791 | gitStatus: gitStatusQuery.data ?? null, | |
| 4792 | snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, | |
| 4793 | retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, | |
| 4794 | linkedPullRequest: linkedThreadPullRequest, | |
| 4795 | linkedPullRequestStatus, | |
| 4796 | }); | |
| 4882 | const activeThreadPr = | |
| 4883 | replacementLinkedThreadPullRequest !== null | |
| 4884 | ? (gitStatusQuery.data?.pr ?? null) | |
| 4885 | : resolveDisplayedThreadPr({ | |
| 4886 | threadBranch: activeThread?.branch ?? null, | |
| 4887 | gitStatus: gitStatusQuery.data ?? null, | |
| 4888 | snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, | |
| 4889 | retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, | |
| 4890 | linkedPullRequest: linkedThreadPullRequest, | |
| 4891 | linkedPullRequestStatus: persistedLinkedThreadPullRequestStatus, | |
| 4892 | }); | |
| 4797 | 4893 | const handlePullRequestTabStatusChange = useCallback( |
| 4798 | (status: PullRequestTabStatus) => { | |
| 4799 | const source = threadPullRequestRefreshSource({ | |
| 4800 | panel: status, | |
| 4801 | thread: { | |
| 4802 | repository: threadRepository, | |
| 4803 | number: linkedThreadPullRequest?.number ?? activeThreadPr?.number ?? null, | |
| 4804 | state: activeThreadPr?.state ?? null, | |
| 4805 | linked: linkedThreadPullRequest !== null, | |
| 4806 | }, | |
| 4807 | }); | |
| 4808 | if (source === null) { | |
| 4894 | (status: Pick<PullRequestTabStatus, "repository" | "number" | "state">) => { | |
| 4895 | if ( | |
| 4896 | threadRepository?.toLowerCase() !== status.repository.toLowerCase() || | |
| 4897 | activeThreadPr?.number !== status.number || | |
| 4898 | activeThreadPr.state === status.state | |
| 4899 | ) { | |
| 4809 | 4900 | sidebarPrRefreshKeyRef.current = null; |
| 4810 | 4901 | return; |
| 4811 | 4902 | } |
| 4812 | const refreshKey = `${activeThreadKey}:${source}:${status.repository}#${status.number}:${status.state}`; | |
| 4903 | const refreshKey = `${activeThreadKey}:vcs:${status.repository}#${status.number}:${status.state}`; | |
| 4813 | 4904 | if (sidebarPrRefreshKeyRef.current === refreshKey) return; |
| 4814 | 4905 | sidebarPrRefreshKeyRef.current = refreshKey; |
| 4815 | ||
| 4816 | if (source === "linked-detail" && activeThreadRef && linkedThreadPullRequest) { | |
| 4817 | appAtomRegistry.refresh( | |
| 4818 | linkedPullRequestDetailAtom({ | |
| 4819 | environmentId: activeThreadRef.environmentId, | |
| 4820 | input: { | |
| 4821 | projectId: linkedThreadPullRequest.projectId, | |
| 4822 | repository: linkedThreadPullRequest.repository, | |
| 4823 | number: linkedThreadPullRequest.number, | |
| 4824 | }, | |
| 4825 | }), | |
| 4826 | ); | |
| 4827 | return; | |
| 4828 | } | |
| 4829 | if (source === "vcs" && activeThreadRef && gitCwd !== null) { | |
| 4830 | void refreshVcsStatus({ | |
| 4831 | environmentId: activeThreadRef.environmentId, | |
| 4832 | input: { cwd: gitCwd }, | |
| 4833 | }).then(() => { | |
| 4834 | if (sidebarPrRefreshKeyRef.current === refreshKey) { | |
| 4835 | sidebarPrRefreshKeyRef.current = null; | |
| 4836 | } | |
| 4837 | }); | |
| 4838 | } | |
| 4906 | if (activeThreadRef === null || gitCwd === null) return; | |
| 4907 | void refreshVcsStatus({ | |
| 4908 | environmentId: activeThreadRef.environmentId, | |
| 4909 | input: { cwd: gitCwd }, | |
| 4910 | }).then(() => { | |
| 4911 | if (sidebarPrRefreshKeyRef.current === refreshKey) { | |
| 4912 | sidebarPrRefreshKeyRef.current = null; | |
| 4913 | } | |
| 4914 | }); | |
| 4839 | 4915 | }, |
| 4840 | 4916 | [ |
| 4841 | 4917 | activeThreadKey, |
| ... | ... | @@ -4843,7 +4919,6 @@ function ChatViewContent(props: ChatViewProps) { |
| 4843 | 4919 | activeThreadPr?.state, |
| 4844 | 4920 | activeThreadRef, |
| 4845 | 4921 | gitCwd, |
| 4846 | linkedThreadPullRequest, | |
| 4847 | 4922 | refreshVcsStatus, |
| 4848 | 4923 | threadRepository, |
| 4849 | 4924 | ], |
| ... | ... | @@ -7346,7 +7421,9 @@ function ChatViewContent(props: ChatViewProps) { |
| 7346 | 7421 | : "page" |
| 7347 | 7422 | } |
| 7348 | 7423 | composerDraftTarget={composerDraftTarget} |
| 7349 | onStateChange={handlePullRequestTabStatusChange} | |
| 7424 | {...(linkedThreadPullRequest === null | |
| 7425 | ? { onStateChange: handlePullRequestTabStatusChange } | |
| 7426 | : {})} | |
| 7350 | 7427 | /> |
| 7351 | 7428 | ) : renderedRightPanelSurface?.kind === "agents" ? ( |
| 7352 | 7429 | <AgentsPanel |
apps/web/src/components/ThreadStatusIndicators.test.ts+33-47| ... | ... | @@ -1,4 +1,4 @@ |
| 1 | import { ProjectId, type VcsStatusResult } from "@t3tools/contracts"; | |
| 1 | import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; | |
| 2 | 2 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | import { AtomRegistry } from "effect/unstable/reactivity"; |
| ... | ... | @@ -10,10 +10,10 @@ import { |
| 10 | 10 | resolveDisplayedThreadPrProvider, |
| 11 | 11 | resolveThreadPr, |
| 12 | 12 | settledPrHoverColorClass, |
| 13 | threadPullRequestRefreshSource, | |
| 14 | 13 | threadChangeRequestSnapshotsAtom, |
| 15 | 14 | type ThreadChangeRequestSnapshot, |
| 16 | 15 | } from "./ThreadStatusIndicators"; |
| 16 | import { newestPullRequestSummary } from "../state/pullRequests"; | |
| 17 | 17 | |
| 18 | 18 | function status(overrides: Partial<VcsStatusResult> = {}): VcsStatusResult { |
| 19 | 19 | return { |
| ... | ... | @@ -57,58 +57,44 @@ function snapshotFor( |
| 57 | 57 | return { branch, pr, sourceControlProvider }; |
| 58 | 58 | } |
| 59 | 59 | |
| 60 | describe("threadPullRequestRefreshSource", () => { | |
| 61 | const panel = { repository: "pingdotgg/t3code", number: 42, state: "merged" as const }; | |
| 60 | function pullRequestSummary( | |
| 61 | state: PullRequestSummary["state"], | |
| 62 | updatedAt: string, | |
| 63 | ): PullRequestSummary { | |
| 64 | return { | |
| 65 | provider: "github", | |
| 66 | projectId: ProjectId.make("project-1"), | |
| 67 | repository: "pingdotgg/t3code", | |
| 68 | number: 42, | |
| 69 | title: "Feature PR", | |
| 70 | url: "https://github.com/pingdotgg/t3code/pull/42", | |
| 71 | state, | |
| 72 | headBranch: "feature/current", | |
| 73 | baseBranch: "main", | |
| 74 | updatedAt, | |
| 75 | }; | |
| 76 | } | |
| 62 | 77 | |
| 63 | it("refreshes the VCS stream when the open panel is newer than an inferred sidebar PR", () => { | |
| 64 | expect( | |
| 65 | threadPullRequestRefreshSource({ | |
| 66 | panel, | |
| 67 | thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false }, | |
| 68 | }), | |
| 69 | ).toBe("vcs"); | |
| 70 | }); | |
| 78 | describe("shared pull request state", () => { | |
| 79 | it("shows a panel-observed merge instead of an older sidebar summary", () => { | |
| 80 | const open = pullRequestSummary("open", "2026-09-03T01:00:00.000Z"); | |
| 81 | const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z"); | |
| 71 | 82 | |
| 72 | it("refreshes linked detail when the open panel is newer than a linked sidebar PR", () => { | |
| 73 | expect( | |
| 74 | threadPullRequestRefreshSource({ | |
| 75 | panel, | |
| 76 | thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: true }, | |
| 77 | }), | |
| 78 | ).toBe("linked-detail"); | |
| 83 | expect(newestPullRequestSummary(open, merged)).toBe(merged); | |
| 79 | 84 | }); |
| 80 | 85 | |
| 81 | it("refreshes when the sidebar has not resolved state yet", () => { | |
| 82 | expect( | |
| 83 | threadPullRequestRefreshSource({ | |
| 84 | panel, | |
| 85 | thread: { repository: "pingdotgg/t3code", number: 42, state: null, linked: false }, | |
| 86 | }), | |
| 87 | ).toBe("vcs"); | |
| 88 | }); | |
| 86 | it("never lets a stale open response regress a merged observation", () => { | |
| 87 | const merged = pullRequestSummary("merged", "2026-09-03T01:01:00.000Z"); | |
| 88 | const staleOpen = pullRequestSummary("open", "2026-09-03T01:00:00.000Z"); | |
| 89 | 89 | |
| 90 | it("does nothing once sidebar state matches or the panel shows another PR", () => { | |
| 91 | expect( | |
| 92 | threadPullRequestRefreshSource({ | |
| 93 | panel, | |
| 94 | thread: { repository: "pingdotgg/t3code", number: 42, state: "merged", linked: false }, | |
| 95 | }), | |
| 96 | ).toBeNull(); | |
| 97 | expect( | |
| 98 | threadPullRequestRefreshSource({ | |
| 99 | panel, | |
| 100 | thread: { repository: "pingdotgg/t3code", number: 41, state: "open", linked: false }, | |
| 101 | }), | |
| 102 | ).toBeNull(); | |
| 90 | expect(newestPullRequestSummary(merged, staleOpen)).toBe(merged); | |
| 103 | 91 | }); |
| 104 | 92 | |
| 105 | it("matches repository identity without case sensitivity", () => { | |
| 106 | expect( | |
| 107 | threadPullRequestRefreshSource({ | |
| 108 | panel: { ...panel, repository: "PingDotGG/T3Code" }, | |
| 109 | thread: { repository: "pingdotgg/t3code", number: 42, state: "open", linked: false }, | |
| 110 | }), | |
| 111 | ).toBe("vcs"); | |
| 93 | it("accepts a newer open state after a closed pull request is reopened", () => { | |
| 94 | const closed = pullRequestSummary("closed", "2026-09-03T01:00:00.000Z"); | |
| 95 | const reopened = pullRequestSummary("open", "2026-09-03T01:01:00.000Z"); | |
| 96 | ||
| 97 | expect(newestPullRequestSummary(closed, reopened)).toBe(reopened); | |
| 112 | 98 | }); |
| 113 | 99 | }); |
| 114 | 100 |
apps/web/src/components/ThreadStatusIndicators.tsx+3-28| ... | ... | @@ -12,7 +12,7 @@ import { appAtomRegistry } from "../rpc/atomRegistry"; |
| 12 | 12 | import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; |
| 13 | 13 | import { useProject } from "../state/entities"; |
| 14 | 14 | import { useEnvironmentQuery } from "../state/query"; |
| 15 | import { linkedPullRequestDetailAtom } from "../state/pullRequests"; | |
| 15 | import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; | |
| 16 | 16 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 17 | 17 | import { vcsEnvironment } from "../state/vcs"; |
| 18 | 18 | import { useUiStateStore } from "../uiStateStore"; |
| ... | ... | @@ -39,32 +39,6 @@ export interface TerminalStatusIndicator { |
| 39 | 39 | |
| 40 | 40 | export type ThreadPr = VcsStatusResult["pr"]; |
| 41 | 41 | |
| 42 | export type ThreadPullRequestRefreshSource = "linked-detail" | "vcs"; | |
| 43 | ||
| 44 | /** Refresh only when the panel has newer state for this thread's own pull request. */ | |
| 45 | export function threadPullRequestRefreshSource(input: { | |
| 46 | readonly panel: { | |
| 47 | readonly repository: string; | |
| 48 | readonly number: number; | |
| 49 | readonly state: NonNullable<ThreadPr>["state"]; | |
| 50 | }; | |
| 51 | readonly thread: { | |
| 52 | readonly repository: string | null; | |
| 53 | readonly number: number | null; | |
| 54 | readonly state: NonNullable<ThreadPr>["state"] | null; | |
| 55 | readonly linked: boolean; | |
| 56 | }; | |
| 57 | }): ThreadPullRequestRefreshSource | null { | |
| 58 | if ( | |
| 59 | input.thread.repository?.toLowerCase() !== input.panel.repository.toLowerCase() || | |
| 60 | input.thread.number !== input.panel.number || | |
| 61 | input.thread.state === input.panel.state | |
| 62 | ) { | |
| 63 | return null; | |
| 64 | } | |
| 65 | return input.thread.linked ? "linked-detail" : "vcs"; | |
| 66 | } | |
| 67 | ||
| 68 | 42 | export interface LinkedThreadPullRequestStatus { |
| 69 | 43 | readonly pr: NonNullable<ThreadPr>; |
| 70 | 44 | readonly sourceControlProvider: NonNullable<VcsStatusResult["sourceControlProvider"]>; |
| ... | ... | @@ -74,7 +48,7 @@ export function useLinkedThreadPullRequest( |
| 74 | 48 | environmentId: EnvironmentId | null, |
| 75 | 49 | linkedPullRequest: ThreadLinkedPullRequest | null | undefined, |
| 76 | 50 | ): LinkedThreadPullRequestStatus | null { |
| 77 | const detail = useEnvironmentQuery( | |
| 51 | const queried = useEnvironmentQuery( | |
| 78 | 52 | environmentId === null || linkedPullRequest == null |
| 79 | 53 | ? null |
| 80 | 54 | : linkedPullRequestDetailAtom({ |
| ... | ... | @@ -86,6 +60,7 @@ export function useLinkedThreadPullRequest( |
| 86 | 60 | }, |
| 87 | 61 | }), |
| 88 | 62 | ).data; |
| 63 | const detail = useSharedPullRequestSummary(environmentId, linkedPullRequest ?? null, queried); | |
| 89 | 64 | |
| 90 | 65 | return useMemo( |
| 91 | 66 | () => |
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+18-18| ... | ... | @@ -63,7 +63,7 @@ import { useProjects } from "~/state/entities"; |
| 63 | 63 | import { useEnvironments } from "~/state/environments"; |
| 64 | 64 | import { useEnvironmentQuery } from "~/state/query"; |
| 65 | 65 | import { useLiveRefresh } from "~/hooks/useLiveRefresh"; |
| 66 | import { pullRequestEnvironment } from "~/state/pullRequests"; | |
| 66 | import { pullRequestEnvironment, useSharedPullRequestSummary } from "~/state/pullRequests"; | |
| 67 | 67 | import { useAtomCommand } from "~/state/use-atom-command"; |
| 68 | 68 | import { vcsEnvironment } from "~/state/vcs"; |
| 69 | 69 | import { formatRelativeTimeLabel } from "~/timestampFormat"; |
| ... | ... | @@ -476,14 +476,8 @@ export function PullRequestDetailPanel({ |
| 476 | 476 | onActed?: () => void; |
| 477 | 477 | /** Page-owned detail columns use this to clear the selected pull request. */ |
| 478 | 478 | onClose?: () => void; |
| 479 | /** Keeps surrounding thread state in step with refreshed host state. */ | |
| 480 | onStateChange?: (status: { | |
| 481 | projectId: string; | |
| 482 | repository: string; | |
| 483 | number: number; | |
| 484 | state: PullRequestState; | |
| 485 | isDraft: boolean; | |
| 486 | }) => void; | |
| 479 | /** Keeps surrounding inferred thread state in step with refreshed host state. */ | |
| 480 | onStateChange?: (status: { repository: string; number: number; state: PullRequestState }) => void; | |
| 487 | 481 | /** |
| 488 | 482 | * Beside a thread, the checkout affordance disappears: the panel is showing that thread's |
| 489 | 483 | * own pull request, so the branch is already under the reader's feet — and checking it out |
| ... | ... | @@ -606,11 +600,19 @@ export function PullRequestDetailPanel({ |
| 606 | 600 | reference.repository, |
| 607 | 601 | reference.number, |
| 608 | 602 | ]); |
| 609 | const coreDetail = resolveDisplayedPullRequestDetail({ | |
| 603 | const resolvedCoreDetail = resolveDisplayedPullRequestDetail({ | |
| 610 | 604 | live: detailQuery.data, |
| 611 | 605 | cached: cachedDetail, |
| 612 | 606 | reference, |
| 613 | 607 | }); |
| 608 | const sharedSummary = useSharedPullRequestSummary(environmentId, reference, resolvedCoreDetail); | |
| 609 | const coreDetail = useMemo( | |
| 610 | () => | |
| 611 | resolvedCoreDetail === null || sharedSummary === null || sharedSummary === resolvedCoreDetail | |
| 612 | ? resolvedCoreDetail | |
| 613 | : { ...resolvedCoreDetail, ...sharedSummary }, | |
| 614 | [resolvedCoreDetail, sharedSummary], | |
| 615 | ); | |
| 614 | 616 | const activity = activityQuery.data; |
| 615 | 617 | const detail = useMemo( |
| 616 | 618 | () => |
| ... | ... | @@ -674,16 +676,14 @@ export function PullRequestDetailPanel({ |
| 674 | 676 | } |
| 675 | 677 | activityRevision.current = next; |
| 676 | 678 | }, [activityQuery.refresh, coreDetail, pullRequestKey]); |
| 677 | useEffect(() => { | |
| 678 | if (!detail) return; | |
| 679 | useLayoutEffect(() => { | |
| 680 | if (!resolvedCoreDetail) return; | |
| 679 | 681 | onStateChange?.({ |
| 680 | projectId: detail.projectId, | |
| 681 | repository: detail.repository, | |
| 682 | number: detail.number, | |
| 683 | state: detail.state, | |
| 684 | isDraft: detail.isDraft, | |
| 682 | repository: resolvedCoreDetail.repository, | |
| 683 | number: resolvedCoreDetail.number, | |
| 684 | state: resolvedCoreDetail.state, | |
| 685 | 685 | }); |
| 686 | }, [detail, onStateChange]); | |
| 686 | }, [onStateChange, resolvedCoreDetail]); | |
| 687 | 687 | // Core detail is cheap enough to re-read while this stays open. Activity is heavier, so the |
| 688 | 688 | // revision effect above reads it only after this same pull request reports a change. Keyed by |
| 689 | 689 | // the pull request rather than by the panel, because this one panel shows a different pull |
apps/web/src/state/pullRequests.ts+47-1| ... | ... | @@ -7,10 +7,12 @@ import type { |
| 7 | 7 | EnvironmentId, |
| 8 | 8 | PullRequestListInput, |
| 9 | 9 | PullRequestListStatsInput, |
| 10 | PullRequestRef, | |
| 11 | PullRequestSummary, | |
| 10 | 12 | } from "@t3tools/contracts"; |
| 11 | 13 | import * as Option from "effect/Option"; |
| 12 | 14 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 13 | import { useCallback, useMemo } from "react"; | |
| 15 | import { useCallback, useLayoutEffect, useMemo } from "react"; | |
| 14 | 16 | |
| 15 | 17 | import { connectionAtomRuntime } from "../connection/runtime"; |
| 16 | 18 | import { appAtomRegistry } from "../rpc/atomRegistry"; |
| ... | ... | @@ -25,6 +27,50 @@ export const pullRequestEnvironment = createPullRequestEnvironmentAtoms(connecti |
| 25 | 27 | export const linkedPullRequestDetailAtom = |
| 26 | 28 | createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); |
| 27 | 29 | |
| 30 | const observedPullRequestSummaryAtom = Atom.family((key: string) => | |
| 31 | Atom.make<PullRequestSummary | null>(null).pipe( | |
| 32 | Atom.setIdleTTL(5 * 60_000), | |
| 33 | Atom.withLabel(`web-pull-requests:observed-summary:${key}`), | |
| 34 | ), | |
| 35 | ); | |
| 36 | ||
| 37 | export function newestPullRequestSummary( | |
| 38 | current: PullRequestSummary | null, | |
| 39 | observed: PullRequestSummary | null, | |
| 40 | ): PullRequestSummary | null { | |
| 41 | if (current === null) return observed; | |
| 42 | if (observed === null) return current; | |
| 43 | if (current.state === "merged") return current; | |
| 44 | if (observed.state === "merged") return observed; | |
| 45 | return Date.parse(observed.updatedAt) >= Date.parse(current.updatedAt) ? observed : current; | |
| 46 | } | |
| 47 | ||
| 48 | export function useSharedPullRequestSummary( | |
| 49 | environmentId: EnvironmentId | null, | |
| 50 | reference: PullRequestRef | null, | |
| 51 | current: PullRequestSummary | null, | |
| 52 | ): PullRequestSummary | null { | |
| 53 | const key = | |
| 54 | environmentId === null || reference === null | |
| 55 | ? "none" | |
| 56 | : JSON.stringify([ | |
| 57 | environmentId, | |
| 58 | reference.projectId, | |
| 59 | reference.repository.toLowerCase(), | |
| 60 | reference.number, | |
| 61 | ]); | |
| 62 | const atom = observedPullRequestSummaryAtom(key); | |
| 63 | const observed = useAtomValue(atom); | |
| 64 | useLayoutEffect(() => { | |
| 65 | if (environmentId === null || current === null) return; | |
| 66 | appAtomRegistry.modify(atom, (previous) => { | |
| 67 | const next = newestPullRequestSummary(previous, current); | |
| 68 | return next === previous ? [false, previous] : [true, next]; | |
| 69 | }); | |
| 70 | }, [atom, current, environmentId]); | |
| 71 | return newestPullRequestSummary(current, observed); | |
| 72 | } | |
| 73 | ||
| 28 | 74 | export interface EnvironmentQueryTarget<Input> { |
| 29 | 75 | readonly environmentId: EnvironmentId; |
| 30 | 76 | readonly input: Input; |