diff --git a/apps/mobile/src/features/threads/NewTaskCheckoutScreen.tsx b/apps/mobile/src/features/threads/NewTaskCheckoutScreen.tsx index cd01e720833..def74def514 100644 --- a/apps/mobile/src/features/threads/NewTaskCheckoutScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskCheckoutScreen.tsx @@ -165,6 +165,7 @@ export function NewTaskCheckoutScreen() { flow.selectPreparedCheckout({ branch: result.value.branch, worktreePath: result.value.worktreePath, + changeRequest: result.value.changeRequest, }); flow.setBranchQuery(""); navigation.goBack(); diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index a7190166c34..b830945e56a 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -879,6 +879,9 @@ export function NewTaskDraftScreen(props: { envMode: workspaceMode, branch: selectedBranchName, worktreePath: workspaceMode === "worktree" ? null : selectedWorktreePath, + ...(draft.workspaceSelection?.changeRequest + ? { changeRequest: draft.workspaceSelection.changeRequest } + : {}), startFromOrigin, runtimeMode, interactionMode, diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 64fd25b70d0..653d8fd68ec 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { + ChangeRequestAssociation, EnvironmentId, ModelSelection, ProviderInteractionMode, @@ -147,6 +148,7 @@ type NewTaskFlowContextValue = { readonly selectPreparedCheckout: (input: { readonly branch: string; readonly worktreePath: string | null; + readonly changeRequest: ChangeRequestAssociation; }) => void; readonly setStartFromOrigin: (value: boolean) => void; readonly beginEditingPendingTask: (messageId: string) => boolean; @@ -355,6 +357,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; const startFromOrigin = selectedProjectDraft.workspaceSelection?.startFromOrigin ?? false; + const selectedChangeRequest = selectedProjectDraft.workspaceSelection?.changeRequest; const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; @@ -554,10 +557,17 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { branch: selectedBranchName, worktreePath: selectedWorktreePath, startFromOrigin, + ...(selectedChangeRequest ? { changeRequest: selectedChangeRequest } : {}), }, }); }, - [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, startFromOrigin], + [ + selectedBranchName, + selectedChangeRequest, + selectedProjectDraftKey, + selectedWorktreePath, + startFromOrigin, + ], ); const selectBranch = useCallback( @@ -578,7 +588,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); const selectPreparedCheckout = useCallback( - (input: { readonly branch: string; readonly worktreePath: string | null }) => { + (input: { + readonly branch: string; + readonly worktreePath: string | null; + readonly changeRequest: ChangeRequestAssociation; + }) => { if (!selectedProjectDraftKey) return; updateComposerDraftSettings(selectedProjectDraftKey, { workspaceSelection: { @@ -588,10 +602,16 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { branch: input.branch, worktreePath: input.worktreePath, startFromOrigin: false, + ...(selectedEnvironmentServerConfig?.settings.enableDurableChangeRequestStatus + ? { changeRequest: input.changeRequest } + : {}), }, }); }, - [selectedProjectDraftKey], + [ + selectedEnvironmentServerConfig?.settings.enableDurableChangeRequestStatus, + selectedProjectDraftKey, + ], ); const setStartFromOrigin = useCallback( @@ -605,10 +625,17 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { branch: selectedBranchName, worktreePath: selectedWorktreePath, startFromOrigin: value, + ...(selectedChangeRequest && !value ? { changeRequest: selectedChangeRequest } : {}), }, }); }, - [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, workspaceMode], + [ + selectedBranchName, + selectedChangeRequest, + selectedProjectDraftKey, + selectedWorktreePath, + workspaceMode, + ], ); const refreshBranches = branchState.refresh; @@ -669,6 +696,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { branch: message.creation.branch, worktreePath: message.creation.worktreePath, startFromOrigin: message.creation.startFromOrigin ?? false, + ...(message.creation.changeRequest + ? { changeRequest: message.creation.changeRequest } + : {}), }, }); } @@ -724,6 +754,9 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { branch: workspaceSelection?.branch ?? null, worktreePath: mode === "worktree" ? null : (workspaceSelection?.worktreePath ?? null), ...(workspaceSelection?.startFromOrigin ? { startFromOrigin: true } : {}), + ...(workspaceSelection?.changeRequest + ? { changeRequest: workspaceSelection.changeRequest } + : {}), }, createdAt: metadata.createdAt, }; diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 9d03dde59a9..39cf88a8eb7 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -5,6 +5,7 @@ import { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { mapAtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { ThreadId, + type ChangeRequestAssociation, type ModelSelection, type ProviderInteractionMode, type RuntimeMode, @@ -32,6 +33,7 @@ export function useCreateProjectThread() { readonly envMode: "local" | "worktree"; readonly branch: string | null; readonly worktreePath: string | null; + readonly changeRequest?: ChangeRequestAssociation; readonly startFromOrigin?: boolean; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; @@ -73,6 +75,7 @@ export function useCreateProjectThread() { workspaceMode: input.envMode, branch: input.branch, worktreePath: input.worktreePath, + ...(input.changeRequest ? { changeRequest: input.changeRequest } : {}), startFromOrigin: input.startFromOrigin ?? false, worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 85523175a2f..1027529056e 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -3,6 +3,7 @@ import { MessageId, ThreadId, type ModelSelection, + type ChangeRequestAssociation, type ProjectId, type ProviderInteractionMode, type RuntimeMode, @@ -35,6 +36,7 @@ export interface ProjectThreadStartTurnSpec { readonly workspaceMode: "local" | "worktree"; readonly branch: string | null; readonly worktreePath: string | null; + readonly changeRequest?: ChangeRequestAssociation; readonly startFromOrigin: boolean; /** Generated temp branch for worktree mode; unused for local mode. */ readonly worktreeBranchName: string; @@ -70,6 +72,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe interactionMode: spec.interactionMode, branch: spec.branch, worktreePath: isWorktree ? null : spec.worktreePath, + ...(spec.changeRequest ? { changeRequest: spec.changeRequest } : {}), createdAt: spec.createdAt, }, ...(isWorktree diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index 3ba61be3872..4e781034496 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -2,6 +2,7 @@ import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/error import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; import { CommandId, + ChangeRequestAssociation, EnvironmentId, IsoDateTime, MessageId, @@ -11,6 +12,7 @@ import { RuntimeMode, ThreadId, type ModelSelection as ModelSelectionType, + type ChangeRequestAssociation as ChangeRequestAssociationType, type ProjectId as ProjectIdType, type ProviderInteractionMode as ProviderInteractionModeType, type RuntimeMode as RuntimeModeType, @@ -34,6 +36,7 @@ const QueuedThreadCreationSchema = Schema.Struct({ branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), startFromOrigin: Schema.optional(Schema.Boolean), + changeRequest: Schema.optional(ChangeRequestAssociation), }); export const QueuedThreadMessageSchema = Schema.Struct({ @@ -64,6 +67,7 @@ export interface QueuedThreadCreation { readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin?: boolean; + readonly changeRequest?: ChangeRequestAssociationType; } export interface QueuedThreadMessage { diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index 601e29fa444..f4fb323b685 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -1,4 +1,8 @@ -import type { VcsStatusResult } from "@t3tools/contracts"; +import type { + SourceControlProviderInfo, + SourceControlProviderKind, + VcsStatusResult, +} from "@t3tools/contracts"; import { resolveChangeRequestPresentation } from "@t3tools/shared/sourceControl"; export type ThreadPr = NonNullable; @@ -22,7 +26,7 @@ const PR_STATE_TEXT_CLASS: Record = { export function presentThreadPr( pr: ThreadPr, - provider: VcsStatusResult["sourceControlProvider"] | null | undefined, + provider: SourceControlProviderInfo | SourceControlProviderKind | null | undefined, ): ThreadPrPresentation { const presentation = resolveChangeRequestPresentation(provider); return { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index cdef999e043..5869e43d1b4 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1,10 +1,12 @@ import { useAtomValue } from "@effect/atom-react"; import { + ChangeRequestAssociation, ModelSelection as ModelSelectionSchema, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, ProviderInteractionMode as ProviderInteractionModeSchema, RuntimeMode as RuntimeModeSchema, type EnvironmentId, + type ChangeRequestAssociation as ChangeRequestAssociationType, type ModelSelection, type ProviderInteractionMode, type RuntimeMode, @@ -58,6 +60,7 @@ export interface ComposerDraftWorkspaceSelection { readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin?: boolean; + readonly changeRequest?: ChangeRequestAssociationType; } export type ComposerDraftSettingsUpdate = Pick< @@ -70,6 +73,7 @@ const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), startFromOrigin: Schema.optional(Schema.Boolean), + changeRequest: Schema.optional(ChangeRequestAssociation), }); const ComposerDraftSchema = Schema.Struct({ diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 3559fa140fe..e34c6088396 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -269,6 +269,7 @@ export function useThreadOutboxDrain(): void { workspaceMode: creation.workspaceMode, branch: creation.branch, worktreePath: creation.worktreePath, + ...(creation.changeRequest ? { changeRequest: creation.changeRequest } : {}), startFromOrigin: creation.startFromOrigin ?? false, worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index a3440cd4848..adb567d2d64 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -1,5 +1,11 @@ import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { + resolveThreadChangeRequestProviderKind, + resolveThreadChangeRequestStatus, + shouldQueryThreadVcsStatus, +} from "@t3tools/shared/sourceControl"; +import { useEnvironmentServerConfig } from "./entities"; import { useEnvironmentQuery } from "./query"; import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; import { vcsEnvironment } from "./vcs"; @@ -11,31 +17,50 @@ export { } from "./thread-pr-presentation"; /** - * Live PR status for a thread's branch. Subscriptions are deduplicated per - * (environmentId, cwd) by the atom family, so many rows on the same worktree - * or project root share one stream — and virtualization means only visible - * rows subscribe at all. + * Live PR status for a thread's branch. Known PR identities remain distinct; + * otherwise subscriptions are deduplicated per (environmentId, cwd). List + * virtualization means only visible rows subscribe. */ export function useThreadPr( thread: EnvironmentThreadShell, projectCwd: string | null, ): ThreadPrPresentation | null { const cwd = thread.worktreePath ?? projectCwd; + const durableChangeRequestStatusEnabled = + useEnvironmentServerConfig(thread.environmentId)?.settings.enableDurableChangeRequestStatus ?? + false; + const changeRequest = durableChangeRequestStatusEnabled ? thread.changeRequest : undefined; const gitStatus = useEnvironmentQuery( - thread.branch !== null && cwd !== null + cwd !== null && + shouldQueryThreadVcsStatus({ + threadBranch: thread.branch, + ...(changeRequest ? { changeRequest } : {}), + durableChangeRequestStatusEnabled, + }) ? vcsEnvironment.status({ environmentId: thread.environmentId, - input: { cwd }, + input: { + cwd, + ...(changeRequest ? { changeRequest } : {}), + }, }) : null, ); const status = gitStatus.data; - if (status === null || thread.branch === null || status.refName !== thread.branch) { + const pr = resolveThreadChangeRequestStatus({ + threadBranch: thread.branch, + ...(changeRequest ? { changeRequest } : {}), + gitStatus: status, + durableChangeRequestStatusEnabled, + }); + if (!pr) { return null; } - if (!status.pr) { - return null; - } - return presentThreadPr(status.pr, status.sourceControlProvider); + const providerKind = resolveThreadChangeRequestProviderKind({ + ...(changeRequest ? { changeRequest } : {}), + gitStatus: status, + durableChangeRequestStatusEnabled, + }); + return presentThreadPr(pr, providerKind); } diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index adeba057e77..8a553db454a 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -469,7 +469,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? (input.state === "open" ? 1 : 20)), "--repo", - scenario.baseRepository ?? "pingdotgg/codething-mvp", + input.repository ?? scenario.baseRepository ?? "pingdotgg/codething-mvp", "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], @@ -531,7 +531,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "view", input.reference, "--repo", - scenario.baseRepository ?? "pingdotgg/codething-mvp", + input.repository ?? scenario.baseRepository ?? "pingdotgg/codething-mvp", "--json", "number,title,url,baseRefName,headRefName,state,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", ], @@ -618,6 +618,7 @@ function makeManager(input?: { ghScenario?: FakeGhScenario; textGeneration?: Partial; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; + serverSettings?: Parameters[0]; }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -625,7 +626,7 @@ function makeManager(input?: { prefix: "t3-git-manager-test-", }); - const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); + const serverSettingsLayer = ServerSettings.ServerSettingsService.layerTest(input?.serverSettings); const vcsDriverLayer = GitVcsDriver.layer.pipe( Layer.provideMerge(VcsProcess.layer), @@ -716,6 +717,339 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status refreshes an explicitly associated PR by number after a branch rename", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-explicit-pr-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/renamed-locally"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/renamed-locally"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 42, + title: "Canonical pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/original-name", + state: "merged", + }, + }, + }); + + const status = yield* manager.status({ + cwd: repoDir, + changeRequest: { + provider: "github", + number: 42, + title: "Canonical pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/original-name", + state: "open", + }, + }); + + expect(status.pr).toEqual({ + number: 42, + title: "Canonical pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRef: "main", + headRef: "feature/original-name", + state: "merged", + }); + expect( + ghCalls.some((call) => + call.startsWith("pr view https://github.com/pingdotgg/codething-mvp/pull/42 "), + ), + ).toBe(true); + expect(ghCalls.some((call) => call.startsWith("pr list "))).toBe(false); + }), + ); + + it.effect("status refreshes an explicitly associated PR from detached HEAD", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-branchless-explicit-pr-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "--detach"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 44, + title: "Branchless pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/44", + baseRefName: "main", + headRefName: "feature/removed-locally", + state: "open", + }, + }, + }); + + const status = yield* manager.status({ + cwd: repoDir, + changeRequest: { + provider: "github", + number: 44, + title: "Branchless pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/44", + baseRefName: "main", + headRefName: "feature/removed-locally", + state: "open", + }, + }); + + expect(status.refName).toBeNull(); + expect(status.pr).toEqual({ + number: 44, + title: "Branchless pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/44", + baseRef: "main", + headRef: "feature/removed-locally", + state: "open", + }); + expect( + ghCalls.some((call) => + call.startsWith("pr view https://github.com/pingdotgg/codething-mvp/pull/44 "), + ), + ).toBe(true); + expect(ghCalls.some((call) => call.startsWith("pr list "))).toBe(false); + }), + ); + + it.effect("coalesces the same explicitly associated PR across worktree paths", () => + Effect.gen(function* () { + const firstRepoDir = yield* makeTempDir("t3code-git-manager-shared-pr-first-"); + const secondRepoDir = yield* makeTempDir("t3code-git-manager-shared-pr-second-"); + yield* initRepo(firstRepoDir); + yield* initRepo(secondRepoDir); + const firstRemoteDir = yield* createBareRemote(); + const secondRemoteDir = yield* createBareRemote(); + yield* runGit(firstRepoDir, ["remote", "add", "origin", firstRemoteDir]); + yield* runGit(secondRepoDir, ["remote", "add", "origin", secondRemoteDir]); + yield* runGit(firstRepoDir, ["push", "-u", "origin", "main"]); + yield* runGit(secondRepoDir, ["push", "-u", "origin", "main"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + pullRequest: { + number: 46, + title: "Shared pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/46", + baseRefName: "main", + headRefName: "feature/shared", + state: "open", + }, + }, + }); + const changeRequest = { + provider: "github" as const, + number: 46, + title: "Shared pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/46", + baseRefName: "main", + headRefName: "feature/shared", + state: "open" as const, + }; + + const [first, second] = yield* Effect.all( + [ + manager.status({ cwd: firstRepoDir, changeRequest }), + manager.status({ cwd: secondRepoDir, changeRequest }), + ], + { concurrency: "unbounded" }, + ); + + expect(first.pr?.number).toBe(46); + expect(second.pr?.number).toBe(46); + expect( + ghCalls.filter((call) => + call.startsWith("pr view https://github.com/pingdotgg/codething-mvp/pull/46 "), + ), + ).toHaveLength(1); + }), + ); + + it.effect("status preserves an explicitly associated PR as stale when refresh fails", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-stale-pr-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: repoDir, + cause: new Error("GitHub unavailable"), + }), + }, + }); + + const input = { + cwd: repoDir, + changeRequest: { + provider: "github", + number: 43, + title: "Last-known pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/43", + baseRefName: "main", + headRefName: "feature/stale", + state: "merged", + }, + } as const; + const status = yield* manager.status(input); + const repeatedStatus = yield* manager.status(input); + + expect(status.pr).toEqual({ + number: 43, + title: "Last-known pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/43", + baseRef: "main", + headRef: "feature/stale", + state: "merged", + stale: true, + }); + expect(repeatedStatus.pr).toEqual(status.pr); + expect( + ghCalls.filter((call) => + call.startsWith("pr view https://github.com/pingdotgg/codething-mvp/pull/43 "), + ), + ).toHaveLength(1); + }), + ); + + it.effect("bounds concurrent failed provider polling and returns stale associations", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-provider-concurrency-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + failWith: new GitHubCli.GitHubCliCommandError({ + command: "gh", + cwd: repoDir, + cause: new Error("GitHub unavailable"), + }), + }, + }); + const inputs = Array.from({ length: 12 }, (_, index) => { + const number = index + 100; + return { + cwd: repoDir, + changeRequest: { + provider: "github" as const, + number, + title: `Pull request ${number}`, + url: `https://github.com/pingdotgg/codething-mvp/pull/${number}`, + baseRefName: "main", + headRefName: `feature/${number}`, + state: "open" as const, + }, + }; + }); + + const statuses = yield* Effect.all( + inputs.map((input) => manager.status(input)), + { concurrency: "unbounded" }, + ); + const pullRequestCalls = ghCalls.filter((call) => call.startsWith("pr view ")); + + expect(statuses.every((status) => status.pr?.stale === true)).toBe(true); + expect(pullRequestCalls.length).toBeGreaterThan(0); + expect(pullRequestCalls.length).toBeLessThanOrEqual(4); + }), + ); + + it.effect("status uses branch inference when durable PR association is disabled", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-disabled-explicit-pr-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/flag-off"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/flag-off"]); + + const { manager, ghCalls } = yield* makeManager({ + serverSettings: { enableDurableChangeRequestStatus: false }, + ghScenario: { + prListSequence: [ + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify([ + { + number: 99, + title: "Inferred pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/99", + baseRefName: "main", + headRefName: "feature/flag-off", + }, + ]), + ], + }, + }); + + const status = yield* manager.status({ + cwd: repoDir, + changeRequest: { + provider: "github", + number: 42, + title: "Ignored explicit pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/42", + baseRefName: "main", + headRefName: "feature/old-name", + state: "open", + }, + }); + + expect(status.pr?.number).toBe(99); + expect(ghCalls.some((call) => call.startsWith("pr list "))).toBe(true); + expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); + }), + ); + + it.effect("status does not use a branchless association when durable PR status is disabled", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-disabled-branchless-pr-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["checkout", "--detach"]); + + const { manager, ghCalls } = yield* makeManager({ + serverSettings: { enableDurableChangeRequestStatus: false }, + }); + + const status = yield* manager.status({ + cwd: repoDir, + changeRequest: { + provider: "github", + number: 45, + title: "Ignored branchless pull request", + url: "https://github.com/pingdotgg/codething-mvp/pull/45", + baseRefName: "main", + headRefName: "feature/removed-locally", + state: "open", + }, + }); + + expect(status.refName).toBeNull(); + expect(status.pr).toBeNull(); + expect(ghCalls.some((call) => call.startsWith("pr view "))).toBe(false); + expect(ghCalls.some((call) => call.startsWith("pr list "))).toBe(false); + }), + ); + it.effect("status trims PR metadata returned by gh before publishing it", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); @@ -2855,6 +3189,15 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(result.branch).toBe("feature/pr-local"); expect(result.worktreePath).toBeNull(); + expect(result.changeRequest).toEqual({ + provider: "github", + number: 64, + title: "Local PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/64", + baseRefName: "main", + headRefName: "feature/pr-local", + state: "open", + }); const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); expect(branch).toBe("feature/pr-local"); expect(ghCalls).toContain("pr checkout 64 --force --repo pingdotgg/codething-mvp"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index d2efa9e9af4..a0479ccc7d6 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1,17 +1,21 @@ import * as Arr from "effect/Array"; import * as Cache from "effect/Cache"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; +import * as Equal from "effect/Equal"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; +import * as Hash from "effect/Hash"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Order from "effect/Order"; import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; +import * as Semaphore from "effect/Semaphore"; import { GitActionProgressEvent, GitActionProgressPhase, @@ -50,8 +54,23 @@ import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; import * as ServerSettings from "../serverSettings.ts"; import type { GitManagerServiceError } from "@t3tools/contracts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; +import * as SourceControlProvider from "../sourceControl/SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; -import type { ChangeRequest } from "@t3tools/contracts"; +import { + CHANGE_REQUEST_STATUS_INVALID_TTL, + CHANGE_REQUEST_STATUS_MAX_CONCURRENCY, + recordChangeRequestStatusRequestFailure, + recordChangeRequestStatusRequestSuccess, + successfulChangeRequestStatusTtl, + takeChangeRequestStatusRequestPermit, + throttledChangeRequestStatusTtl, + type ChangeRequestStatusRequestBudgetState, +} from "../sourceControl/ChangeRequestStatusPollingPolicy.ts"; +import type { + ChangeRequest, + ChangeRequestAssociation, + SourceControlProviderKind, +} from "@t3tools/contracts"; export interface GitActionProgressReporter { readonly publish: (event: GitActionProgressEvent) => Effect.Effect; @@ -119,6 +138,36 @@ interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { updatedAt: Option.Option; } +interface ChangeRequestStatusLookup { + readonly pr: PullRequestInfo | null; + readonly refreshState?: "stale"; + readonly refName?: string; +} + +class ExplicitChangeRequestRefreshCacheKey implements Equal.Equal { + readonly identity: string; + readonly cwd: string; + readonly changeRequest: ChangeRequestAssociation; + + constructor(cwd: string, changeRequest: ChangeRequestAssociation) { + this.cwd = cwd; + this.changeRequest = changeRequest; + this.identity = [ + changeRequest.provider, + String(changeRequest.number), + normalizeChangeRequestUrl(changeRequest.url), + ].join("\u0000"); + } + + [Equal.symbol](that: Equal.Equal): boolean { + return that instanceof ExplicitChangeRequestRefreshCacheKey && this.identity === that.identity; + } + + [Hash.symbol](): number { + return Hash.string(this.identity); + } +} + const pullRequestUpdatedAtDescOrder: Order.Order = Order.mapInput( Order.flip(Option.makeOrder(DateTime.Order)), (pullRequest) => pullRequest.updatedAt, @@ -302,6 +351,26 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { }; } +function toChangeRequestAssociation(summary: ChangeRequest): ChangeRequestAssociation { + return { + provider: summary.provider, + number: summary.number, + title: summary.title, + url: summary.url, + baseRefName: summary.baseRefName, + headRefName: summary.headRefName, + state: summary.state, + }; +} + +function normalizeChangeRequestUrl(url: string): string { + return url.trim().replace(/\/+$/u, "").toLowerCase(); +} + +function changeRequestUrlsEqual(left: string, right: string): boolean { + return normalizeChangeRequestUrl(left) === normalizeChangeRequestUrl(right); +} + function limitContext(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return `${value.slice(0, maxChars)}\n\n[truncated]`; @@ -436,13 +505,17 @@ function appendUnique(values: string[], next: string | null | undefined): void { values.push(trimmed); } -function toStatusPr(pr: PullRequestInfo): { +function toStatusPr( + pr: PullRequestInfo, + options?: { readonly stale?: boolean }, +): { number: number; title: string; url: string; baseRef: string; headRef: string; state: "open" | "closed" | "merged"; + stale?: boolean; } { return { number: pr.number, @@ -451,6 +524,19 @@ function toStatusPr(pr: PullRequestInfo): { baseRef: pr.baseRefName, headRef: pr.headRefName, state: pr.state, + ...(options?.stale ? { stale: true } : {}), + }; +} + +function associationToPullRequestInfo(association: ChangeRequestAssociation): PullRequestInfo { + return { + number: association.number, + title: association.title, + url: association.url, + baseRefName: association.baseRefName, + headRefName: association.headRefName, + state: association.state, + updatedAt: Option.none(), }; } @@ -513,6 +599,125 @@ export const make = Effect.gen(function* () { Effect.map((settings) => settings.enableForkPullRequests), Effect.orElseSucceed(() => true), ); + const durableChangeRequestStatusEnabled = serverSettingsService.getSettings.pipe( + Effect.map((settings) => settings.enableDurableChangeRequestStatus), + Effect.orElseSucceed(() => true), + ); + const changeRequestStatusRequestBudgetsRef = yield* Ref.make( + new Map(), + ); + const changeRequestStatusRequestSemaphore = yield* Semaphore.make( + CHANGE_REQUEST_STATUS_MAX_CONCURRENCY, + ); + const takeStatusProviderRequestPermit = Effect.fn("takeStatusProviderRequestPermit")(function* ( + provider: SourceControlProviderKind, + requestedTokens = 1, + ) { + const nowMs = yield* Clock.currentTimeMillis; + return yield* Ref.modify(changeRequestStatusRequestBudgetsRef, (budgets) => { + const permit = takeChangeRequestStatusRequestPermit( + budgets.get(provider), + nowMs, + requestedTokens, + ); + const nextBudgets = new Map(budgets); + nextBudgets.set(provider, permit.state); + return [permit, nextBudgets] as const; + }); + }); + const recordStatusProviderRequestSuccess = Effect.fn("recordStatusProviderRequestSuccess")( + function* (provider: SourceControlProviderKind) { + const nowMs = yield* Clock.currentTimeMillis; + yield* Ref.update(changeRequestStatusRequestBudgetsRef, (budgets) => { + const nextBudgets = new Map(budgets); + nextBudgets.set( + provider, + recordChangeRequestStatusRequestSuccess(budgets.get(provider), nowMs), + ); + return nextBudgets; + }); + }, + ); + const recordStatusProviderRequestFailure = Effect.fn("recordStatusProviderRequestFailure")( + function* (provider: SourceControlProviderKind) { + const nowMs = yield* Clock.currentTimeMillis; + return yield* Ref.modify(changeRequestStatusRequestBudgetsRef, (budgets) => { + const failure = recordChangeRequestStatusRequestFailure(budgets.get(provider), nowMs); + const nextBudgets = new Map(budgets); + nextBudgets.set(provider, failure.state); + return [failure.retryAfter, nextBudgets] as const; + }); + }, + ); + + const refreshExplicitChangeRequestBase = Effect.fn("refreshExplicitChangeRequest")(function* ( + key: ExplicitChangeRequestRefreshCacheKey, + ) { + const { changeRequest, cwd } = key; + const provider = yield* sourceControlProvider(cwd); + if (provider.kind !== changeRequest.provider) { + return { + state: "invalid" as const, + cacheTtl: CHANGE_REQUEST_STATUS_INVALID_TTL, + }; + } + + return yield* changeRequestStatusRequestSemaphore.withPermit( + Effect.gen(function* () { + const permit = yield* takeStatusProviderRequestPermit(provider.kind); + if (!permit.allowed) { + return { + state: "unavailable" as const, + cacheTtl: throttledChangeRequestStatusTtl(permit.retryAfterMs), + }; + } + + const refreshed = yield* provider.getChangeRequest({ + cwd, + reference: provider.kind === "github" ? changeRequest.url : String(changeRequest.number), + }); + yield* recordStatusProviderRequestSuccess(provider.kind); + if (!changeRequestUrlsEqual(refreshed.url, changeRequest.url)) { + return { + state: "invalid" as const, + cacheTtl: CHANGE_REQUEST_STATUS_INVALID_TTL, + }; + } + + return { + state: "found" as const, + pr: toStatusPr(toPullRequestInfo(refreshed)), + cacheTtl: successfulChangeRequestStatusTtl(refreshed.state), + }; + }), + ); + }); + const refreshExplicitChangeRequest = (key: ExplicitChangeRequestRefreshCacheKey) => + refreshExplicitChangeRequestBase(key).pipe( + Effect.catch((error) => + Effect.gen(function* () { + const retryAfter = yield* recordStatusProviderRequestFailure(key.changeRequest.provider); + yield* Effect.logWarning( + "Explicit change request refresh failed; using last-known state", + { + provider: key.changeRequest.provider, + number: key.changeRequest.number, + cwdLength: key.cwd.length, + errorTag: error._tag, + retryAfterMs: Duration.toMillis(retryAfter), + }, + ); + return { + state: "unavailable" as const, + cacheTtl: retryAfter, + }; + }), + ), + ); + const explicitChangeRequestRefreshCache = yield* Cache.makeWith(refreshExplicitChangeRequest, { + capacity: STATUS_RESULT_CACHE_CAPACITY, + timeToLive: (exit) => (Exit.isSuccess(exit) ? exit.value.cacheTtl : Duration.zero), + }); const randomUUIDv4 = (cwd: string) => crypto.randomUUIDv4.pipe( Effect.mapError( @@ -757,6 +962,7 @@ export const make = Effect.gen(function* () { const readRemoteStatus = Effect.fn("readRemoteStatus")(function* ( cwd: string, options?: GitVcsDriver.GitRemoteStatusOptions, + explicitChangeRequest?: ChangeRequestAssociation, ) { const details = yield* gitCore .statusDetailsRemote(cwd, options) @@ -765,22 +971,47 @@ export const make = Effect.gen(function* () { return null; } - const pr = - details.branch !== null - ? yield* findLatestPr(cwd, { + const changeRequestLookup = explicitChangeRequest + ? yield* Cache.get( + explicitChangeRequestRefreshCache, + new ExplicitChangeRequestRefreshCacheKey(cwd, explicitChangeRequest), + ).pipe( + Effect.map((refreshed) => { + if (refreshed.state === "found") { + return { pr: refreshed.pr }; + } + if (refreshed.state === "unavailable") { + return { + pr: toStatusPr(associationToPullRequestInfo(explicitChangeRequest), { + stale: true, + }), + refreshState: "stale" as const, + }; + } + return { pr: null }; + }), + ) + : details.branch !== null + ? yield* findLatestPrForStatus(cwd, { branch: details.branch, upstreamRef: details.upstreamRef, }).pipe( - Effect.map((latest) => { - if (!latest) return null; - // On the default branch, only surface open PRs. - // Merged/closed matches are usually reverse-merge history, not the thread's PR context. - if (details.isDefaultBranch && latest.state !== "open") return null; - return toStatusPr(latest); + Effect.map((lookup) => { + const latest = lookup.pr; + const pr = + latest === null || (details.isDefaultBranch && latest.state !== "open") + ? null + : toStatusPr(latest); + return { + pr, + ...("refreshState" in lookup && lookup.refreshState + ? { refreshState: lookup.refreshState } + : {}), + ...("refName" in lookup && lookup.refName ? { refName: lookup.refName } : {}), + }; }), - Effect.orElseSucceed(() => null), ) - : null; + : { pr: null }; return { hasUpstream: details.hasUpstream, @@ -788,7 +1019,13 @@ export const make = Effect.gen(function* () { behindCount: details.behindCount, aheadOfDefaultCount: details.aheadOfDefaultCount, ...(details.remoteRefHash == null ? {} : { remoteRefHash: details.remoteRefHash }), - pr, + ...("refreshState" in changeRequestLookup && changeRequestLookup.refreshState + ? { changeRequestRefreshState: changeRequestLookup.refreshState } + : {}), + ...("refName" in changeRequestLookup && changeRequestLookup.refName + ? { changeRequestRefName: changeRequestLookup.refName } + : {}), + pr: changeRequestLookup.pr, } satisfies VcsStatusRemoteResult; }); const remoteStatusResultCache = yield* Cache.makeWith((cwd: string) => readRemoteStatus(cwd), { @@ -953,15 +1190,15 @@ export const make = Effect.gen(function* () { return null; }); - const findLatestPr = Effect.fn("findLatestPr")(function* ( + const queryLatestPr = Effect.fn("queryLatestPr")(function* ( cwd: string, - details: { branch: string; upstreamRef: string | null }, + headContext: BranchHeadContext, + provider: SourceControlProvider.SourceControlProvider["Service"], ) { - const headContext = yield* resolveBranchHeadContext(cwd, details); const parsedByNumber = new Map(); for (const headSelector of headContext.headSelectors) { - const pullRequests = yield* (yield* sourceControlProvider(cwd)).listChangeRequests({ + const pullRequests = yield* provider.listChangeRequests({ cwd, headSelector, state: "all", @@ -985,6 +1222,81 @@ export const make = Effect.gen(function* () { return parsed[0] ?? null; }); + const findLatestPrForStatus = Effect.fn("findLatestPrForStatus")( + function* (cwd: string, details: { branch: string; upstreamRef: string | null }) { + const pollingProtectionEnabled = yield* durableChangeRequestStatusEnabled; + const headContext = yield* resolveBranchHeadContext(cwd, details); + const provider = yield* sourceControlProvider(cwd); + + if (!pollingProtectionEnabled) { + const pr = yield* queryLatestPr(cwd, headContext, provider).pipe( + Effect.orElseSucceed(() => null), + ); + return { pr }; + } + + // GitHub CLI may first resolve the checkout's base repository with a + // separate `gh repo view`, so reserve that possible API call too. + const requestCost = headContext.headSelectors.length + (provider.kind === "github" ? 1 : 0); + return yield* changeRequestStatusRequestSemaphore + .withPermit( + Effect.gen(function* () { + const permit = yield* takeStatusProviderRequestPermit(provider.kind, requestCost); + if (!permit.allowed) { + return { + pr: null, + refreshState: "stale", + refName: details.branch, + } satisfies ChangeRequestStatusLookup; + } + const pr = yield* queryLatestPr(cwd, headContext, provider); + yield* recordStatusProviderRequestSuccess(provider.kind); + return { pr, refName: details.branch } satisfies ChangeRequestStatusLookup; + }), + ) + .pipe( + Effect.catch((error) => + Effect.gen(function* () { + const retryAfter = yield* recordStatusProviderRequestFailure(provider.kind); + yield* Effect.logWarning( + "Change request discovery failed; retaining last-known state", + { + provider: provider.kind, + cwdLength: cwd.length, + errorTag: error._tag, + retryAfterMs: Duration.toMillis(retryAfter), + }, + ); + return { + pr: null, + refreshState: "stale", + refName: details.branch, + } satisfies ChangeRequestStatusLookup; + }), + ), + ); + }, + Effect.catch((error) => + durableChangeRequestStatusEnabled.pipe( + Effect.flatMap((pollingProtectionEnabled) => + pollingProtectionEnabled + ? Effect.logWarning( + "Change request discovery setup failed; retaining last-known state", + { + errorTag: error._tag, + }, + ).pipe( + Effect.as({ + pr: null, + refreshState: "stale" as const, + }), + ) + : Effect.succeed({ pr: null }), + ), + ), + ), + ); + const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( cwd: string, result: Pick, @@ -1464,6 +1776,13 @@ export const make = Effect.gen(function* () { const remoteStatus: GitManager["Service"]["remoteStatus"] = Effect.fn("remoteStatus")( function* (input, options) { const cacheKey = yield* normalizeStatusCacheKey(input.cwd); + const explicitChangeRequest = + input.changeRequest && (yield* durableChangeRequestStatusEnabled) + ? input.changeRequest + : undefined; + if (explicitChangeRequest) { + return yield* readRemoteStatus(cacheKey, options, explicitChangeRequest); + } if (options?.refreshUpstream === false) { return yield* readRemoteStatus(cacheKey, options); } @@ -1555,6 +1874,7 @@ export const make = Effect.gen(function* () { ); return { pullRequest, + changeRequest: toChangeRequestAssociation(pullRequestSummary), branch: details.branch ?? pullRequest.headBranch, worktreePath: null, }; @@ -1618,6 +1938,7 @@ export const make = Effect.gen(function* () { yield* ensureExistingWorktreeUpstream(existingBranchBeforeFetch.worktreePath); return { pullRequest, + changeRequest: toChangeRequestAssociation(pullRequestSummary), branch: localPullRequestBranch, worktreePath: existingBranchBeforeFetch.worktreePath, }; @@ -1648,6 +1969,7 @@ export const make = Effect.gen(function* () { yield* ensureExistingWorktreeUpstream(existingBranchAfterFetch.worktreePath); return { pullRequest, + changeRequest: toChangeRequestAssociation(pullRequestSummary), branch: localPullRequestBranch, worktreePath: existingBranchAfterFetch.worktreePath, }; @@ -1671,6 +1993,7 @@ export const make = Effect.gen(function* () { return { pullRequest, + changeRequest: toChangeRequestAssociation(pullRequestSummary), branch: worktree.worktree.refName, worktreePath: worktree.worktree.path, }; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index fdbf53469b6..d65a01bb723 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -604,6 +604,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + changeRequest: event.payload.changeRequest ?? null, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -663,6 +664,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.changeRequest !== undefined + ? { changeRequest: event.payload.changeRequest } + : {}), updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 598f2a92819..fd2bbbce73a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -25,6 +25,7 @@ import { ModelSelection, ProjectId, ThreadId, + ChangeRequestAssociation, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; @@ -79,6 +80,7 @@ const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + changeRequest: Schema.NullOr(Schema.fromJsonString(ChangeRequestAssociation)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -374,6 +376,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + change_request_json AS "changeRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -403,6 +406,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + change_request_json AS "changeRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -434,6 +438,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + change_request_json AS "changeRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -797,6 +802,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + change_request_json AS "changeRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1313,6 +1319,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...(row.changeRequest !== null ? { changeRequest: row.changeRequest } : {}), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1517,6 +1524,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...(row.changeRequest !== null ? { changeRequest: row.changeRequest } : {}), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1650,6 +1658,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...(row.changeRequest !== null ? { changeRequest: row.changeRequest } : {}), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1790,6 +1799,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + ...(row.changeRequest !== null ? { changeRequest: row.changeRequest } : {}), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2034,6 +2044,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + ...(threadRow.value.changeRequest !== null + ? { changeRequest: threadRow.value.changeRequest } + : {}), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, @@ -2132,6 +2145,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + ...(threadRow.value.changeRequest !== null + ? { changeRequest: threadRow.value.changeRequest } + : {}), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, diff --git a/apps/server/src/orchestration/decider.changeRequest.test.ts b/apps/server/src/orchestration/decider.changeRequest.test.ts new file mode 100644 index 00000000000..24bd916f8a4 --- /dev/null +++ b/apps/server/src/orchestration/decider.changeRequest.test.ts @@ -0,0 +1,121 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationEvent, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as HashMap from "effect/HashMap"; +import { expect } from "vite-plus/test"; + +import { createEmptyCommandReadModel } from "./commandReadModel.ts"; +import { decideOrchestrationCommand } from "./decider.ts"; + +const threadId = ThreadId.make("thread-change-request"); +const changeRequest = { + provider: "github" as const, + number: 42, + title: "Durable association", + url: "https://github.com/acme/repo/pull/42", + baseRefName: "main", + headRefName: "feature/original", + state: "open" as const, +}; +const thread: OrchestrationThread = { + id: threadId, + projectId: ProjectId.make("project-change-request"), + context: { kind: "project", projectId: ProjectId.make("project-change-request") }, + title: "Change request thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + runtimeMode: "approval-required", + interactionMode: "default", + branch: "feature/original", + worktreePath: "/repo/worktree", + changeRequest, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, +}; +const readModel = { + ...createEmptyCommandReadModel("2026-01-01T00:00:00.000Z"), + threads: HashMap.make([threadId, thread]), +}; + +const decideMetadataUpdate = ( + command: Omit, "commandId">, +) => + decideOrchestrationCommand({ + command: { + ...command, + commandId: CommandId.make("command-change-request"), + }, + readModel, + }); + +function metadataPayload( + event: Omit | ReadonlyArray>, +) { + if (!("type" in event) || event.type !== "thread.meta-updated") { + throw new Error("Expected a single thread.meta-updated event"); + } + return event.payload as unknown as { + readonly branch?: string | null; + readonly changeRequest?: unknown; + }; +} + +it.layer(NodeServices.layer)("change-request metadata decisions", (it) => { + it.effect("clears the association when the branch changes", () => + Effect.gen(function* () { + const event = yield* decideMetadataUpdate({ + type: "thread.meta.update", + threadId, + branch: "feature/replacement", + expectedBranch: "feature/original", + }); + const payload = metadataPayload(event); + expect(payload.branch).toBe("feature/replacement"); + expect(payload.changeRequest).toBeNull(); + }), + ); + + it.effect("persists an explicit association without changing the branch", () => + Effect.gen(function* () { + const event = yield* decideMetadataUpdate({ + type: "thread.meta.update", + threadId, + changeRequest: { ...changeRequest, state: "merged" }, + }); + const payload = metadataPayload(event); + expect(payload.branch).toBeUndefined(); + expect(payload.changeRequest).toEqual({ ...changeRequest, state: "merged" }); + }), + ); + + it.effect("rejects a paired association when its optimistic branch update is stale", () => + Effect.gen(function* () { + const event = yield* decideMetadataUpdate({ + type: "thread.meta.update", + threadId, + branch: "feature/stale-action", + expectedBranch: "feature/no-longer-current", + changeRequest: { ...changeRequest, headRefName: "feature/stale-action" }, + }); + const payload = metadataPayload(event); + expect(payload.branch).toBe("feature/original"); + expect(payload.changeRequest).toBeUndefined(); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 746ae92c68d..6ac6878953c 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -266,6 +266,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, + ...(command.changeRequest !== undefined ? { changeRequest: command.changeRequest } : {}), createdAt: command.createdAt, updatedAt: command.createdAt, }, @@ -345,12 +346,18 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" command, threadId: command.threadId, }); - const branch = + const branchUpdateRejected = command.branch !== undefined && command.expectedBranch !== undefined && - thread.branch !== command.expectedBranch - ? thread.branch - : command.branch; + thread.branch !== command.expectedBranch; + const branch = branchUpdateRejected ? thread.branch : command.branch; + const changeRequest = branchUpdateRejected + ? undefined + : command.changeRequest !== undefined + ? command.changeRequest + : branch !== undefined && branch !== thread.branch + ? null + : undefined; const occurredAt = yield* nowIso; return { ...(yield* withEventBase({ @@ -368,6 +375,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(branch !== undefined ? { branch } : {}), ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + ...(changeRequest !== undefined ? { changeRequest } : {}), updatedAt: occurredAt, }, }; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 2a23e8106d9..060e4e04d06 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -36,7 +36,9 @@ import { ThreadTurnDiffCompletedPayload, } from "./Schemas.ts"; -type ThreadPatch = Partial>; +type ThreadPatch = Partial> & { + readonly changeRequest?: Exclude | null; +}; const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; @@ -83,7 +85,20 @@ function updateThread( if (Option.isNone(existing)) { return threads; } - return HashMap.set(threads, threadId, { ...existing.value, ...patch }); + const { changeRequest, ...patchWithoutChangeRequest } = patch; + if (changeRequest === null) { + const { changeRequest: _existingChangeRequest, ...existingWithoutChangeRequest } = + existing.value; + return HashMap.set(threads, threadId, { + ...existingWithoutChangeRequest, + ...patchWithoutChangeRequest, + }); + } + return HashMap.set(threads, threadId, { + ...existing.value, + ...patchWithoutChangeRequest, + ...(changeRequest !== undefined ? { changeRequest } : {}), + }); } /** @@ -303,6 +318,9 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + ...(payload.changeRequest !== undefined + ? { changeRequest: payload.changeRequest } + : {}), latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -373,6 +391,11 @@ export function projectEvent( : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + ...(payload.changeRequest !== undefined + ? payload.changeRequest === null + ? { changeRequest: null } + : { changeRequest: payload.changeRequest } + : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 82ac453bacf..334d7614538 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -129,4 +129,49 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }); }), ); + + it.effect("round-trips a durable thread change-request association", () => + Effect.gen(function* () { + const threads = yield* ProjectionThreadRepository; + const changeRequest = { + provider: "github" as const, + number: 42, + title: "Durable association", + url: "https://github.com/acme/repo/pull/42", + baseRefName: "main", + headRefName: "feature/durable", + state: "merged" as const, + }; + + yield* threads.upsert({ + threadId: ThreadId.make("thread-change-request"), + projectId: ProjectId.make("project-change-request"), + contextKind: "project", + title: "Change request thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature/durable", + worktreePath: "/tmp/feature-durable", + changeRequest, + latestTurnId: null, + createdAt: "2026-03-24T00:00:00.000Z", + updatedAt: "2026-03-24T00:00:00.000Z", + archivedAt: null, + latestUserMessageAt: null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: null, + }); + + const persisted = yield* threads.getById({ + threadId: ThreadId.make("thread-change-request"), + }); + assert.deepStrictEqual(Option.getOrNull(persisted)?.changeRequest, changeRequest); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index b46e3d4821b..29f1dd1b55a 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -14,11 +14,12 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { ChangeRequestAssociation, ModelSelection } from "@t3tools/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + changeRequest: Schema.NullOr(Schema.fromJsonString(ChangeRequestAssociation)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -40,6 +41,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + change_request_json, latest_turn_id, created_at, updated_at, @@ -60,6 +62,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${row.changeRequest == null ? null : JSON.stringify(row.changeRequest)}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, @@ -80,6 +83,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + change_request_json = excluded.change_request_json, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -107,6 +111,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + change_request_json AS "changeRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -136,6 +141,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + change_request_json AS "changeRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 92b3e9d88ac..f12f87a4517 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -46,6 +46,7 @@ import Migration0030 from "./Migrations/030_ProjectionThreadShellArchiveIndexes. import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_StandaloneThreadContext.ts"; +import Migration0034 from "./Migrations/034_ProjectionThreadChangeRequest.ts"; /** * Migration loader with all migrations defined inline. @@ -91,6 +92,7 @@ export const migrationEntries = [ [31, "AuthAuthorizationScopes", Migration0031], [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "StandaloneThreadContext", Migration0033], + [34, "ProjectionThreadChangeRequest", Migration0034], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/034_ProjectionThreadChangeRequest.test.ts b/apps/server/src/persistence/Migrations/034_ProjectionThreadChangeRequest.test.ts new file mode 100644 index 00000000000..dce4874b4a0 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_ProjectionThreadChangeRequest.test.ts @@ -0,0 +1,62 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("034_ProjectionThreadChangeRequest", (it) => { + it.effect("adds nullable durable change-request metadata without changing existing rows", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 33 }); + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + context_kind, + title, + model_selection_json, + created_at, + updated_at + ) VALUES ( + 'thread-pr', + 'project-1', + 'project', + 'Pull request thread', + '{"instanceId":"codex","model":"gpt-5-codex"}', + '2026-01-01T00:00:00.000Z', + '2026-01-01T00:00:00.000Z' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 34 }); + + const existing = yield* sql<{ readonly changeRequestJson: string | null }>` + SELECT change_request_json AS changeRequestJson + FROM projection_threads + WHERE thread_id = 'thread-pr' + `; + assert.deepStrictEqual(existing, [{ changeRequestJson: null }]); + + const changeRequestJson = + '{"provider":"github","number":42,"title":"Durable association","url":"https://github.com/acme/repo/pull/42","baseRefName":"main","headRefName":"feature/durable","state":"open"}'; + yield* sql` + UPDATE projection_threads + SET change_request_json = ${changeRequestJson} + WHERE thread_id = 'thread-pr' + `; + + const updated = yield* sql<{ readonly changeRequestJson: string | null }>` + SELECT change_request_json AS changeRequestJson + FROM projection_threads + WHERE thread_id = 'thread-pr' + `; + assert.deepStrictEqual(updated, [{ changeRequestJson }]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/034_ProjectionThreadChangeRequest.ts b/apps/server/src/persistence/Migrations/034_ProjectionThreadChangeRequest.ts new file mode 100644 index 00000000000..07bee3a2741 --- /dev/null +++ b/apps/server/src/persistence/Migrations/034_ProjectionThreadChangeRequest.ts @@ -0,0 +1,8 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** Persist the canonical change-request association on thread projections. */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE projection_threads ADD COLUMN change_request_json TEXT`; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 26f6bda40a0..1912a4af7be 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -16,6 +16,7 @@ import { ThreadContextKind, ThreadId, TurnId, + ChangeRequestAssociation, } from "@t3tools/contracts"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -34,6 +35,7 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + changeRequest: Schema.optionalKey(Schema.NullOr(ChangeRequestAssociation)), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ff3e0086b28..76e7ab76031 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4910,6 +4910,15 @@ it.layer(NodeServices.layer)("server router seam", (it) => { headBranch: "feature/demo", state: "open", }, + changeRequest: { + provider: "github", + number: 1, + title: "Demo PR", + url: "https://example.com/pr/1", + baseRefName: "main", + headRefName: "feature/demo", + state: "open", + }, branch: "feature/demo", worktreePath: null, }), diff --git a/apps/server/src/sourceControl/ChangeRequestStatusPollingPolicy.test.ts b/apps/server/src/sourceControl/ChangeRequestStatusPollingPolicy.test.ts new file mode 100644 index 00000000000..eff09d7f26c --- /dev/null +++ b/apps/server/src/sourceControl/ChangeRequestStatusPollingPolicy.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Duration from "effect/Duration"; + +import { + CHANGE_REQUEST_STATUS_REQUEST_BURST, + changeRequestStatusFailureDelay, + recordChangeRequestStatusRequestFailure, + recordChangeRequestStatusRequestSuccess, + successfulChangeRequestStatusTtl, + takeChangeRequestStatusRequestPermit, + throttledChangeRequestStatusTtl, +} from "./ChangeRequestStatusPollingPolicy.ts"; + +describe("ChangeRequestStatusPollingPolicy", () => { + it("caps background polling and refills the request budget over time", () => { + let state = undefined; + for (let index = 0; index < CHANGE_REQUEST_STATUS_REQUEST_BURST; index += 1) { + const permit = takeChangeRequestStatusRequestPermit(state, 1_000); + expect(permit.allowed).toBe(true); + state = permit.state; + } + + const denied = takeChangeRequestStatusRequestPermit(state, 1_000); + expect(denied.allowed).toBe(false); + expect(denied.retryAfterMs).toBe(2_000); + + const refilled = takeChangeRequestStatusRequestPermit(denied.state, 3_000); + expect(refilled.allowed).toBe(true); + }); + + it("reserves the full cost of multi-query branch discovery", () => { + const first = takeChangeRequestStatusRequestPermit(undefined, 0, 4); + expect(first.allowed).toBe(true); + expect(first.state.availableTokens).toBe(CHANGE_REQUEST_STATUS_REQUEST_BURST - 4); + + const denied = takeChangeRequestStatusRequestPermit(first.state, 0, 7); + expect(denied.allowed).toBe(false); + expect(denied.state.availableTokens).toBe(CHANGE_REQUEST_STATUS_REQUEST_BURST - 4); + }); + + it("backs provider failures off exponentially without a concurrent success clearing cooldown", () => { + const first = recordChangeRequestStatusRequestFailure(undefined, 1_000); + expect(Duration.toMillis(first.retryAfter)).toBe(60_000); + + const second = recordChangeRequestStatusRequestFailure(first.state, 61_000); + expect(Duration.toMillis(second.retryAfter)).toBe(120_000); + + const concurrentSuccess = recordChangeRequestStatusRequestSuccess(second.state, 61_001); + expect(concurrentSuccess.blockedUntilMs).toBe(181_000); + + expect(Duration.toMillis(changeRequestStatusFailureDelay(2))).toBe(120_000); + expect(Duration.toMillis(changeRequestStatusFailureDelay(20))).toBe(900_000); + }); + + it("uses slower refreshes for terminal states and bounds throttled retries", () => { + expect(Duration.toMillis(successfulChangeRequestStatusTtl("open"))).toBe(60_000); + expect(Duration.toMillis(successfulChangeRequestStatusTtl("closed"))).toBe(300_000); + expect(Duration.toMillis(successfulChangeRequestStatusTtl("merged"))).toBe(900_000); + expect(Duration.toMillis(throttledChangeRequestStatusTtl(2_000))).toBe(30_000); + expect(Duration.toMillis(throttledChangeRequestStatusTtl(120_000))).toBe(120_000); + }); +}); diff --git a/apps/server/src/sourceControl/ChangeRequestStatusPollingPolicy.ts b/apps/server/src/sourceControl/ChangeRequestStatusPollingPolicy.ts new file mode 100644 index 00000000000..1332554ac17 --- /dev/null +++ b/apps/server/src/sourceControl/ChangeRequestStatusPollingPolicy.ts @@ -0,0 +1,171 @@ +import * as Duration from "effect/Duration"; + +export const CHANGE_REQUEST_STATUS_REQUEST_LIMIT = 30; +export const CHANGE_REQUEST_STATUS_REQUEST_WINDOW = Duration.minutes(1); +export const CHANGE_REQUEST_STATUS_REQUEST_BURST = 10; +export const CHANGE_REQUEST_STATUS_MAX_CONCURRENCY = 4; +export const CHANGE_REQUEST_STATUS_THROTTLED_MIN_TTL = Duration.seconds(30); +export const CHANGE_REQUEST_STATUS_OPEN_TTL = Duration.minutes(1); +export const CHANGE_REQUEST_STATUS_CLOSED_TTL = Duration.minutes(5); +export const CHANGE_REQUEST_STATUS_MERGED_TTL = Duration.minutes(15); +export const CHANGE_REQUEST_STATUS_INVALID_TTL = Duration.minutes(5); + +const CHANGE_REQUEST_STATUS_FAILURE_BASE_DELAY = Duration.minutes(1); +const CHANGE_REQUEST_STATUS_FAILURE_MAX_DELAY = Duration.minutes(15); + +export interface ChangeRequestStatusRequestBudgetState { + readonly availableTokens: number; + readonly lastRefillAtMs: number; + readonly consecutiveFailures: number; + readonly blockedUntilMs: number; +} + +export interface ChangeRequestStatusRequestPermit { + readonly allowed: boolean; + readonly retryAfterMs: number; + readonly state: ChangeRequestStatusRequestBudgetState; +} + +export function initialChangeRequestStatusRequestBudget( + nowMs: number, +): ChangeRequestStatusRequestBudgetState { + return { + availableTokens: CHANGE_REQUEST_STATUS_REQUEST_BURST, + lastRefillAtMs: nowMs, + consecutiveFailures: 0, + blockedUntilMs: 0, + }; +} + +function refillChangeRequestStatusRequestBudget( + state: ChangeRequestStatusRequestBudgetState, + nowMs: number, +): ChangeRequestStatusRequestBudgetState { + const elapsedMs = Math.max(0, nowMs - state.lastRefillAtMs); + if (elapsedMs === 0) return state; + + const refillPerMs = + CHANGE_REQUEST_STATUS_REQUEST_LIMIT / Duration.toMillis(CHANGE_REQUEST_STATUS_REQUEST_WINDOW); + return { + ...state, + availableTokens: Math.min( + CHANGE_REQUEST_STATUS_REQUEST_BURST, + state.availableTokens + elapsedMs * refillPerMs, + ), + lastRefillAtMs: nowMs, + }; +} + +/** + * A non-blocking token bucket for background provider polling. Callers render + * last-known state when denied instead of queueing an unbounded number of CLI + * processes behind the limiter. + */ +export function takeChangeRequestStatusRequestPermit( + currentState: ChangeRequestStatusRequestBudgetState | undefined, + nowMs: number, + requestedTokens = 1, +): ChangeRequestStatusRequestPermit { + const state = refillChangeRequestStatusRequestBudget( + currentState ?? initialChangeRequestStatusRequestBudget(nowMs), + nowMs, + ); + + if (state.blockedUntilMs > nowMs) { + return { + allowed: false, + retryAfterMs: state.blockedUntilMs - nowMs, + state, + }; + } + + const boundedRequest = Math.max( + 1, + Math.min(requestedTokens, CHANGE_REQUEST_STATUS_REQUEST_BURST), + ); + if (state.availableTokens >= boundedRequest) { + return { + allowed: true, + retryAfterMs: 0, + state: { + ...state, + availableTokens: state.availableTokens - boundedRequest, + }, + }; + } + + const refillPerMs = + CHANGE_REQUEST_STATUS_REQUEST_LIMIT / Duration.toMillis(CHANGE_REQUEST_STATUS_REQUEST_WINDOW); + return { + allowed: false, + retryAfterMs: Math.ceil((boundedRequest - state.availableTokens) / refillPerMs), + state, + }; +} + +export function changeRequestStatusFailureDelay(consecutiveFailures: number): Duration.Duration { + const exponent = Math.max(0, consecutiveFailures - 1); + const delayMs = + Duration.toMillis(CHANGE_REQUEST_STATUS_FAILURE_BASE_DELAY) * Math.pow(2, exponent); + return Duration.min(Duration.millis(delayMs), CHANGE_REQUEST_STATUS_FAILURE_MAX_DELAY); +} + +export function recordChangeRequestStatusRequestFailure( + currentState: ChangeRequestStatusRequestBudgetState | undefined, + nowMs: number, +): { + readonly retryAfter: Duration.Duration; + readonly state: ChangeRequestStatusRequestBudgetState; +} { + const state = refillChangeRequestStatusRequestBudget( + currentState ?? initialChangeRequestStatusRequestBudget(nowMs), + nowMs, + ); + const consecutiveFailures = state.consecutiveFailures + 1; + const retryAfter = changeRequestStatusFailureDelay(consecutiveFailures); + return { + retryAfter, + state: { + ...state, + consecutiveFailures, + blockedUntilMs: Math.max(state.blockedUntilMs, nowMs + Duration.toMillis(retryAfter)), + }, + }; +} + +export function recordChangeRequestStatusRequestSuccess( + currentState: ChangeRequestStatusRequestBudgetState | undefined, + nowMs: number, +): ChangeRequestStatusRequestBudgetState { + const state = refillChangeRequestStatusRequestBudget( + currentState ?? initialChangeRequestStatusRequestBudget(nowMs), + nowMs, + ); + return { + ...state, + consecutiveFailures: 0, + // A success from another request that was already in flight must not undo + // a live provider-wide cooldown established by a concurrent failure. + blockedUntilMs: state.blockedUntilMs <= nowMs ? 0 : state.blockedUntilMs, + }; +} + +export function throttledChangeRequestStatusTtl(retryAfterMs: number): Duration.Duration { + return Duration.max( + CHANGE_REQUEST_STATUS_THROTTLED_MIN_TTL, + Duration.millis(Math.max(0, retryAfterMs)), + ); +} + +export function successfulChangeRequestStatusTtl( + state: "open" | "closed" | "merged", +): Duration.Duration { + switch (state) { + case "open": + return CHANGE_REQUEST_STATUS_OPEN_TTL; + case "closed": + return CHANGE_REQUEST_STATUS_CLOSED_TTL; + case "merged": + return CHANGE_REQUEST_STATUS_MERGED_TTL; + } +} diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index bd303b7c9ba..c842b4ee182 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -68,6 +68,36 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = }), ); +it.effect("uses a pull request URL's repository instead of the current checkout", () => + Effect.gen(function* () { + let getInput: Parameters[0] | null = null; + const provider = yield* makeProvider({ + getPullRequest: (input) => { + getInput = input; + return Effect.succeed({ + number: 42, + title: "Canonical repository", + url: "https://github.com/pingdotgg/t3code/pull/42", + baseRefName: "main", + headRefName: "feature/source-control", + state: "open", + }); + }, + }); + + yield* provider.getChangeRequest({ + cwd: "/unrelated-checkout", + reference: "https://github.com/pingdotgg/t3code/pull/42", + }); + + assert.deepStrictEqual(getInput, { + cwd: "/unrelated-checkout", + reference: "https://github.com/pingdotgg/t3code/pull/42", + repository: "pingdotgg/t3code", + }); + }), +); + it.effect("adds safe request context while retaining GitHub CLI causes", () => Effect.gen(function* () { const cause = new GitHubCli.GitHubPullRequestNotFoundError({ diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index b51f6ceab1c..825306a0bff 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -37,6 +37,20 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq }; } +function repositoryFromPullRequestUrl(reference: string): string | undefined { + try { + const url = new URL(reference.trim()); + if (url.protocol !== "https:" && url.protocol !== "http:") return undefined; + const segments = url.pathname.split("/").filter(Boolean); + const [owner, repository, pullSegment, number] = segments; + return owner && repository && pullSegment === "pull" && /^\d+$/u.test(number ?? "") + ? `${owner}/${repository}` + : undefined; + } catch { + return undefined; + } +} + function parseGitHubAuth(input: SourceControlAuthProbeInput) { const output = combinedAuthOutput(input); const authStatus = parseGitHubAuthStatus(input.stdout); @@ -104,6 +118,13 @@ export const make = Effect.gen(function* () { const repository = repositoryFromContext(context); return repository ? { ...input, repository } : input; }; + const withRepositoryFromPullRequestUrl = ( + input: Input, + context: SourceControlProvider.SourceControlProviderContext | undefined, + ): Input | (Input & { readonly repository: string }) => { + const repository = repositoryFromPullRequestUrl(input.reference); + return repository ? { ...input, repository } : withRepositoryFromContext(input, context); + }; const listChangeRequests: SourceControlProvider.SourceControlProvider["Service"]["listChangeRequests"] = (input) => @@ -141,7 +162,7 @@ export const make = Effect.gen(function* () { kind: "github", listChangeRequests, getChangeRequest: (input) => - github.getPullRequest(withRepositoryFromContext(input, input.context)).pipe( + github.getPullRequest(withRepositoryFromPullRequestUrl(input, input.context)).pipe( Effect.map(toChangeRequest), Effect.mapError( (error) => diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index bfea2a63aee..c443ba16fd8 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -15,6 +15,7 @@ import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import type { VcsStatusLocalResult, + VcsStatusInput, VcsStatusRemoteResult, VcsStatusResult, VcsStatusStreamEvent, @@ -71,6 +72,7 @@ function makeTestLayer(state: { localInvalidationCalls: number; remoteInvalidationCalls: number; remoteStatusRefreshUpstreamValues?: Array; + remoteStatusInputs?: Array; }) { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), @@ -81,10 +83,11 @@ function makeTestLayer(state: { state.localStatusCalls += 1; return state.currentLocalStatus; }), - remoteStatus: (_input, options) => + remoteStatus: (input, options) => Effect.sync(() => { state.remoteStatusCalls += 1; state.remoteStatusRefreshUpstreamValues?.push(options?.refreshUpstream); + state.remoteStatusInputs?.push(input); return state.currentRemoteStatus; }), invalidateLocalStatus: () => @@ -167,6 +170,34 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(makeTestLayer(state))); }); + it.effect("forwards a durable PR association during an explicit refresh", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + remoteStatusInputs: [] as Array, + }; + const changeRequest = { + provider: "github" as const, + number: 2978, + title: "[codex] Rewrite client connection architecture", + url: "https://github.com/pingdotgg/t3code/pull/2978", + baseRefName: "main", + headRefName: "codex/connection-state-audit", + state: "open" as const, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.refreshStatus("/repo", changeRequest); + + assert.deepStrictEqual(state.remoteStatusInputs, [{ cwd: "/repo", changeRequest }]); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + it.effect("keeps the cached snapshot unchanged when a refresh branch fails", () => { const state = { currentLocalStatus: baseLocalStatus, @@ -552,6 +583,7 @@ describe("VcsStatusBroadcaster", () => { "VCS remote status refresh failed", { cwdLength: privateCwd.length, + hasExplicitChangeRequest: false, reasonCount: 1, failureCount: 1, failureTags: ["GitManagerError"], @@ -644,6 +676,158 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); }); + it.effect("keeps the latest successful PR state when an explicit refresh becomes stale", () => { + const mergedRemoteStatus: VcsStatusRemoteResult = { + ...baseRemoteStatus, + pr: { + number: 42, + title: "Canonical pull request", + url: "https://github.com/acme/repo/pull/42", + baseRef: "main", + headRef: "feature/durable", + state: "merged", + }, + }; + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: mergedRemoteStatus as VcsStatusRemoteResult | null, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + const input = { + cwd: "/repo", + changeRequest: { + provider: "github" as const, + number: 42, + title: "Canonical pull request", + url: "https://github.com/acme/repo/pull/42", + baseRefName: "main", + headRefName: "feature/durable", + state: "open" as const, + }, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus(input); + const scope = yield* Scope.make(); + const snapshotDeferred = yield* Deferred.make(); + const remoteUpdatedDeferred = yield* Deferred.make(); + yield* Stream.runForEach( + broadcaster.streamStatus(input, { + automaticRemoteRefreshInterval: Effect.succeed(Duration.minutes(1)), + }), + (event) => { + if (event._tag === "snapshot") { + return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore); + } + if (event._tag === "remoteUpdated") { + return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore); + } + return Effect.void; + }, + ).pipe(Effect.forkIn(scope)); + + yield* Deferred.await(snapshotDeferred); + state.currentRemoteStatus = { + ...baseRemoteStatus, + pr: { + number: 42, + title: "Stored open state", + url: "https://github.com/acme/repo/pull/42", + baseRef: "main", + headRef: "feature/durable", + state: "open", + stale: true, + }, + }; + yield* TestClock.adjust(Duration.minutes(1)); + + const event = yield* Deferred.await(remoteUpdatedDeferred); + assert.deepStrictEqual(event, { + _tag: "remoteUpdated", + remote: { + ...baseRemoteStatus, + pr: { + ...mergedRemoteStatus.pr!, + stale: true, + }, + }, + } satisfies VcsStatusStreamEvent); + yield* Scope.close(scope, Exit.void); + }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); + }); + + it.effect("keeps an inferred PR when provider polling is throttled", () => { + const inferredRemoteStatus = { + ...remoteStatusWithPr, + changeRequestRefName: "feature/status-broadcast", + } satisfies VcsStatusRemoteResult; + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: inferredRemoteStatus as VcsStatusRemoteResult | null, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentRemoteStatus = { + ...baseRemoteStatus, + changeRequestRefreshState: "stale", + changeRequestRefName: "feature/status-broadcast", + pr: null, + }; + const refreshed = yield* broadcaster.refreshStatus("/repo"); + + assert.deepStrictEqual(refreshed.pr, { + ...inferredRemoteStatus.pr!, + stale: true, + }); + assert.equal(refreshed.changeRequestRefreshState, "stale"); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + + it.effect("does not carry a stale inferred PR across a branch switch", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: { + ...remoteStatusWithPr, + changeRequestRefName: "feature/status-broadcast", + } as VcsStatusRemoteResult | null, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + + return Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.getStatus({ cwd: "/repo" }); + + state.currentLocalStatus = { + ...baseLocalStatus, + refName: "feature/other-branch", + }; + state.currentRemoteStatus = { + ...baseRemoteStatus, + changeRequestRefreshState: "stale", + changeRequestRefName: "feature/other-branch", + pr: null, + }; + const refreshed = yield* broadcaster.refreshStatus("/repo"); + + assert.equal(refreshed.refName, "feature/other-branch"); + assert.equal(refreshed.pr, null); + }).pipe(Effect.provide(makeTestLayer(state))); + }); + it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { assert.equal( Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))), diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 9b311651e62..b589af7f1b1 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -115,6 +115,7 @@ export function remoteRefreshFailureDiagnostics(cause: Cause.Cause) { interface VcsStatusChange { readonly cwd: string; + readonly statusKey: string | null; readonly event: VcsStatusStreamEvent; } @@ -124,6 +125,7 @@ interface CachedValue { } interface CachedVcsStatus { + readonly input: VcsStatusInput; readonly local: VcsStatusLocalResult | null; readonly remote: CachedValue | null; readonly localGeneration: number; @@ -161,7 +163,10 @@ export class VcsStatusBroadcaster extends Context.Service< readonly refreshLocalStatus: ( cwd: string, ) => Effect.Effect; - readonly refreshStatus: (cwd: string) => Effect.Effect; + readonly refreshStatus: ( + cwd: string, + changeRequest?: VcsStatusInput["changeRequest"], + ) => Effect.Effect; readonly streamStatus: ( input: VcsStatusInput, options?: StreamStatusOptions, @@ -173,6 +178,39 @@ function fingerprintStatusPart(status: unknown): string { return JSON.stringify(status); } +function preserveStaleChangeRequest( + previous: VcsStatusRemoteResult | null | undefined, + next: VcsStatusRemoteResult | null, +): VcsStatusRemoteResult | null { + const previousPr = previous?.pr; + const nextPr = next?.pr ?? null; + const preservedPr = + previousPr && + ((nextPr?.stale && previousPr.number === nextPr.number) || + (next?.changeRequestRefreshState === "stale" && + nextPr === null && + previous?.changeRequestRefName !== undefined && + previous.changeRequestRefName === next.changeRequestRefName)) + ? { ...previousPr, stale: true as const } + : nextPr; + return next && preservedPr !== nextPr ? { ...next, pr: preservedPr } : next; +} + +function statusInputKey(input: VcsStatusInput): string { + return JSON.stringify({ + cwd: input.cwd, + ...(input.changeRequest + ? { + changeRequest: { + provider: input.changeRequest.provider, + number: input.changeRequest.number, + url: input.changeRequest.url, + }, + } + : {}), + }); +} + const normalizeCwd = (cwd: string) => Effect.service(FileSystem.FileSystem).pipe( Effect.flatMap((fs) => fs.realPath(cwd)), @@ -193,28 +231,45 @@ export const make = Effect.gen(function* () { const pollersRef = yield* SynchronizedRef.make(new Map()); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( - cwd: string, + input: VcsStatusInput, ) { - return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); + return yield* Ref.get(cacheRef).pipe( + Effect.map((cache) => cache.get(statusInputKey(input)) ?? null), + ); }); const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( - function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { + function* ( + input: VcsStatusInput, + local: VcsStatusLocalResult, + options?: { publish?: boolean }, + ) { + const statusKey = statusInputKey(input); const localGeneration = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null, localGeneration: 0 }; - const nextLocalGeneration = previous.localGeneration + 1; const nextCache = new Map(cache); - nextCache.set(cwd, { - ...previous, - local, - localGeneration: nextLocalGeneration, - }); + if (!nextCache.has(statusKey)) { + nextCache.set(statusKey, { + input, + local: null, + remote: null, + localGeneration: 0, + }); + } + + let nextLocalGeneration = 0; + for (const [key, previous] of nextCache) { + if (previous.input.cwd !== input.cwd) continue; + const generation = previous.localGeneration + 1; + nextCache.set(key, { ...previous, local, localGeneration: generation }); + if (key === statusKey) nextLocalGeneration = generation; + } return [nextLocalGeneration, nextCache] as const; }); if (options?.publish) { yield* PubSub.publish(changesPubSub, { - cwd, + cwd: input.cwd, + statusKey: null, event: { _tag: "localUpdated", local, @@ -228,87 +283,117 @@ export const make = Effect.gen(function* () { ); const updateCachedRemoteStatus = Effect.fn("VcsStatusBroadcaster.updateCachedRemoteStatus")( - function* (cwd: string, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }) { - const nextRemote = { - fingerprint: fingerprintStatusPart(remote), - value: remote, - } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null, localGeneration: 0 }; + function* ( + input: VcsStatusInput, + remote: VcsStatusRemoteResult | null, + options?: { publish?: boolean }, + ) { + const statusKey = statusInputKey(input); + const update = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(statusKey) ?? { + input, + local: null, + remote: null, + localGeneration: 0, + }; + const effectiveRemote = preserveStaleChangeRequest(previous.remote?.value, remote); + const nextRemote = { + fingerprint: fingerprintStatusPart(effectiveRemote), + value: effectiveRemote, + } satisfies CachedValue; const nextCache = new Map(cache); - nextCache.set(cwd, { + nextCache.set(statusKey, { ...previous, remote: nextRemote, }); - return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + return [ + { + shouldPublish: previous.remote?.fingerprint !== nextRemote.fingerprint, + remote: effectiveRemote, + }, + nextCache, + ] as const; }); - if (options?.publish && shouldPublish) { + if (options?.publish && update.shouldPublish) { yield* PubSub.publish(changesPubSub, { - cwd, + cwd: input.cwd, + statusKey, event: { _tag: "remoteUpdated", - remote, + remote: update.remote, }, }); } - return remote; + return update.remote; }, ); const updateCachedStatus = Effect.fn("VcsStatusBroadcaster.updateCachedStatus")(function* ( - cwd: string, + input: VcsStatusInput, local: VcsStatusLocalResult, remote: VcsStatusRemoteResult | null, options?: { publish?: boolean }, ) { - const nextRemote = { - fingerprint: fingerprintStatusPart(remote), - value: remote, - } satisfies CachedValue; - const localGeneration = yield* Ref.modify(cacheRef, (cache) => { - const previous = cache.get(cwd) ?? { local: null, remote: null, localGeneration: 0 }; + const statusKey = statusInputKey(input); + const update = yield* Ref.modify(cacheRef, (cache) => { + const previous = cache.get(statusKey) ?? { + input, + local: null, + remote: null, + localGeneration: 0, + }; + const effectiveRemote = preserveStaleChangeRequest(previous.remote?.value, remote); + const nextRemote = { + fingerprint: fingerprintStatusPart(effectiveRemote), + value: effectiveRemote, + } satisfies CachedValue; const nextLocalGeneration = previous.localGeneration + 1; const nextCache = new Map(cache); - nextCache.set(cwd, { + nextCache.set(statusKey, { + input, local, remote: nextRemote, localGeneration: nextLocalGeneration, }); - return [nextLocalGeneration, nextCache] as const; + return [ + { localGeneration: nextLocalGeneration, remote: effectiveRemote }, + nextCache, + ] as const; }); if (options?.publish) { yield* PubSub.publish(changesPubSub, { - cwd, + cwd: input.cwd, + statusKey, event: { _tag: "snapshot", local, - remote, - localGeneration, + remote: update.remote, + localGeneration: update.localGeneration, }, }); } - return mergeGitStatusParts(local, remote); + return mergeGitStatusParts(local, update.remote); }); const loadLocalStatus = Effect.fn("VcsStatusBroadcaster.loadLocalStatus")(function* ( - cwd: string, + input: VcsStatusInput, ) { - const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local); + const local = yield* workflow.localStatus({ cwd: input.cwd }); + return yield* updateCachedLocalStatus(input, local); }); const getOrLoadLocalStatus = Effect.fn("VcsStatusBroadcaster.getOrLoadLocalStatus")(function* ( - cwd: string, + input: VcsStatusInput, ) { - const cached = yield* getCachedStatus(cwd); + const cached = yield* getCachedStatus(input); if (cached?.local) { return cached.local; } - return yield* loadLocalStatus(cwd); + return yield* loadLocalStatus(input); }); const withFileSystem = Effect.provideService(FileSystem.FileSystem, fs); @@ -317,25 +402,28 @@ export const make = Effect.gen(function* () { "VcsStatusBroadcaster.getStatus", )(function* (input) { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); - const cached = yield* getCachedStatus(cwd); + const normalizedInput = { ...input, cwd }; + const cached = yield* getCachedStatus(normalizedInput); if (cached?.local && cached.remote) { return mergeGitStatusParts(cached.local, cached.remote.value); } const [local, remote] = yield* Effect.all( [ cached?.local ? Effect.succeed(cached.local) : workflow.localStatus({ cwd }), - cached?.remote ? Effect.succeed(cached.remote.value) : workflow.remoteStatus({ cwd }), + cached?.remote + ? Effect.succeed(cached.remote.value) + : workflow.remoteStatus(normalizedInput), ], { concurrency: "unbounded" }, ); - return yield* updateCachedStatus(cwd, local, remote); + return yield* updateCachedStatus(normalizedInput, local, remote); }); const refreshLocalStatusCore = Effect.fn("VcsStatusBroadcaster.refreshLocalStatusCore")( function* (cwd: string) { yield* workflow.invalidateLocalStatus(cwd); const local = yield* workflow.localStatus({ cwd }); - return yield* updateCachedLocalStatus(cwd, local, { publish: true }); + return yield* updateCachedLocalStatus({ cwd }, local, { publish: true }); }, ); @@ -347,33 +435,34 @@ export const make = Effect.gen(function* () { }); const refreshRemoteStatus = Effect.fn("VcsStatusBroadcaster.refreshRemoteStatus")(function* ( - cwd: string, + input: VcsStatusInput, options?: { readonly refreshUpstream?: boolean }, ) { if (options?.refreshUpstream !== false) { - yield* workflow.invalidateRemoteStatus(cwd); + yield* workflow.invalidateRemoteStatus(input.cwd); } - const remote = yield* workflow.remoteStatus({ cwd }, options); - return yield* updateCachedRemoteStatus(cwd, remote, { publish: true }); + const remote = yield* workflow.remoteStatus(input, options); + return yield* updateCachedRemoteStatus(input, remote, { publish: true }); }); const refreshStatus: VcsStatusBroadcaster["Service"]["refreshStatus"] = Effect.fn( "VcsStatusBroadcaster.refreshStatus", - )(function* (rawCwd) { + )(function* (rawCwd, changeRequest) { const cwd = yield* withFileSystem(normalizeCwd(rawCwd)); + const input = { cwd, ...(changeRequest ? { changeRequest } : {}) }; yield* Effect.all([workflow.invalidateLocalStatus(cwd), workflow.invalidateRemoteStatus(cwd)], { concurrency: "unbounded", discard: true, }); const [local, remote] = yield* Effect.all( - [workflow.localStatus({ cwd }), workflow.remoteStatus({ cwd })], + [workflow.localStatus({ cwd }), workflow.remoteStatus(input)], { concurrency: "unbounded" }, ); - return yield* updateCachedStatus(cwd, local, remote, { publish: true }); + return yield* updateCachedStatus(input, local, remote, { publish: true }); }); const makeRemoteRefreshLoop = ( - cwd: string, + input: VcsStatusInput, automaticRemoteRefreshInterval: Effect.Effect, refreshImmediately: boolean, ) => { @@ -390,7 +479,7 @@ export const make = Effect.gen(function* () { return activeInterval; } - const exit = yield* refreshRemoteStatus(cwd, { + const exit = yield* refreshRemoteStatus(input, { refreshUpstream: !Duration.isZero(configuredInterval), }).pipe(Effect.exit); if (Exit.isSuccess(exit)) { @@ -410,7 +499,8 @@ export const make = Effect.gen(function* () { ); const nextDelay = remoteRefreshFailureDelay(consecutiveFailures, activeInterval); yield* Effect.logWarning("VCS remote status refresh failed", { - cwdLength: cwd.length, + cwdLength: input.cwd.length, + hasExplicitChangeRequest: input.changeRequest !== undefined, ...remoteRefreshFailureDiagnostics(exit.cause), consecutiveFailures, nextDelayMs: Duration.toMillis(nextDelay), @@ -439,26 +529,27 @@ export const make = Effect.gen(function* () { }; const retainRemotePoller = Effect.fn("VcsStatusBroadcaster.retainRemotePoller")(function* ( - cwd: string, + input: VcsStatusInput, automaticRemoteRefreshInterval: Effect.Effect, refreshImmediately: boolean, ) { + const statusKey = statusInputKey(input); yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { - const existing = activePollers.get(cwd); + const existing = activePollers.get(statusKey); if (existing) { const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { + nextPollers.set(statusKey, { ...existing, subscriberCount: existing.subscriberCount + 1, }); return Effect.succeed([undefined, nextPollers] as const); } - return makeRemoteRefreshLoop(cwd, automaticRemoteRefreshInterval, refreshImmediately).pipe( + return makeRemoteRefreshLoop(input, automaticRemoteRefreshInterval, refreshImmediately).pipe( Effect.forkIn(broadcasterScope), Effect.map((fiber) => { const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { + nextPollers.set(statusKey, { fiber, subscriberCount: 1, }); @@ -469,17 +560,18 @@ export const make = Effect.gen(function* () { }); const releaseRemotePoller = Effect.fn("VcsStatusBroadcaster.releaseRemotePoller")(function* ( - cwd: string, + input: VcsStatusInput, ) { + const statusKey = statusInputKey(input); const pollerToInterrupt = yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => { - const existing = activePollers.get(cwd); + const existing = activePollers.get(statusKey); if (!existing) { return Effect.succeed([null, activePollers] as const); } if (existing.subscriberCount > 1) { const nextPollers = new Map(activePollers); - nextPollers.set(cwd, { + nextPollers.set(statusKey, { ...existing, subscriberCount: existing.subscriberCount - 1, }); @@ -487,18 +579,18 @@ export const make = Effect.gen(function* () { } const nextPollers = new Map(activePollers); - nextPollers.delete(cwd); - // Drop the cached status for this cwd in the same critical section that + nextPollers.delete(statusKey); + // Drop the cached status for this subscription key in the same critical section that // removes the poller, so a concurrent retainRemotePoller (which reloads // the cache and installs a fresh poller) cannot have its new entry wiped // by this release. Otherwise the cache grows one entry per cwd for the // broadcaster's lifetime; a future subscriber re-loads and re-seeds it. return Ref.update(cacheRef, (cache) => { - if (!cache.has(cwd)) { + if (!cache.has(statusKey)) { return cache; } const nextCache = new Map(cache); - nextCache.delete(cwd); + nextCache.delete(statusKey); return nextCache; }).pipe(Effect.as([existing.fiber, nextPollers] as const)); }); @@ -512,18 +604,20 @@ export const make = Effect.gen(function* () { Stream.unwrap( Effect.gen(function* () { const cwd = yield* withFileSystem(normalizeCwd(input.cwd)); + const normalizedInput = { ...input, cwd }; + const statusKey = statusInputKey(normalizedInput); const subscription = yield* PubSub.subscribe(changesPubSub); - const initialLocal = yield* getOrLoadLocalStatus(cwd); - const cachedStatus = yield* getCachedStatus(cwd); + const initialLocal = yield* getOrLoadLocalStatus(normalizedInput); + const cachedStatus = yield* getCachedStatus(normalizedInput); const initialRemote = cachedStatus?.remote?.value ?? null; yield* retainRemotePoller( - cwd, + normalizedInput, options?.automaticRemoteRefreshInterval ?? Effect.succeed(DEFAULT_VCS_STATUS_REFRESH_INTERVAL), cachedStatus?.remote === null || cachedStatus?.remote === undefined, ); - const release = releaseRemotePoller(cwd).pipe(Effect.ignore, Effect.asVoid); + const release = releaseRemotePoller(normalizedInput).pipe(Effect.ignore, Effect.asVoid); return Stream.concat( Stream.make({ @@ -533,7 +627,10 @@ export const make = Effect.gen(function* () { localGeneration: cachedStatus?.localGeneration, }), Stream.fromSubscription(subscription).pipe( - Stream.filter((event) => event.cwd === cwd), + Stream.filter( + (event) => + event.cwd === cwd && (event.statusKey === null || event.statusKey === statusKey), + ), Stream.map((event) => event.event), ), ).pipe(Stream.ensuring(release)); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8ab94103047..98b9c83278e 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1005,6 +1005,9 @@ const makeWsRpcLayer = ( interactionMode: bootstrap.createThread.interactionMode, branch: bootstrap.createThread.branch, worktreePath: bootstrap.createThread.worktreePath, + ...(bootstrap.createThread.changeRequest !== undefined + ? { changeRequest: bootstrap.createThread.changeRequest } + : {}), createdAt: bootstrap.createThread.createdAt, }); createdThread = true; @@ -1882,7 +1885,7 @@ const makeWsRpcLayer = ( [WS_METHODS.vcsRefreshStatus]: (input) => observeRpcEffect( WS_METHODS.vcsRefreshStatus, - vcsStatusBroadcaster.refreshStatus(input.cwd), + vcsStatusBroadcaster.refreshStatus(input.cwd, input.changeRequest), { "rpc.aggregate": "vcs", }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7f8161f357f..e25aabbfe44 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { type ApprovalRequestId, + type ChangeRequestAssociation, DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, @@ -1656,7 +1657,12 @@ function ChatViewContent(props: ChatViewProps) { }, []); const openOrReuseProjectDraftThread = useCallback( - async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { + async (input: { + branch: string; + worktreePath: string | null; + envMode: DraftThreadEnvMode; + changeRequest?: ChangeRequestAssociation; + }) => { if (!activeProject) { throw new Error("No active project is available for this pull request."); } @@ -1733,14 +1739,21 @@ function ChatViewContent(props: ChatViewProps) { ); const handlePreparedPullRequestThread = useCallback( - async (input: { branch: string; worktreePath: string | null }) => { + async (input: { + branch: string; + worktreePath: string | null; + changeRequest: ChangeRequestAssociation; + }) => { await openOrReuseProjectDraftThread({ branch: input.branch, worktreePath: input.worktreePath, envMode: input.worktreePath ? "worktree" : "local", + ...(settings.enableDurableChangeRequestStatus + ? { changeRequest: input.changeRequest } + : {}), }); }, - [openOrReuseProjectDraftThread], + [openOrReuseProjectDraftThread, settings.enableDurableChangeRequestStatus], ); useEffect(() => { @@ -4510,6 +4523,9 @@ function ChatViewContent(props: ChatViewProps) { interactionMode, branch: activeThreadBranch, worktreePath: activeWorktreePath, + ...(activeThread.changeRequest + ? { changeRequest: activeThread.changeRequest } + : {}), createdAt: activeThread.createdAt, }, } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index f71e855a18f..c16616e0b27 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -5,6 +5,7 @@ import { squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; import type { + ChangeRequestAssociation, GitActionProgressEvent, GitRunStackedActionResult, GitStackedAction, @@ -13,6 +14,7 @@ import type { SourceControlProviderKind, SourceControlPublishRepositoryResult, SourceControlRepositoryVisibility, + VcsStatusInput, VcsStatusResult, } from "@t3tools/contracts"; import { useNavigate } from "@tanstack/react-router"; @@ -136,22 +138,71 @@ interface RunGitActionWithToastInput { filePaths?: string[]; } +function changeRequestAssociationsEqual( + left: ChangeRequestAssociation | undefined, + right: ChangeRequestAssociation | undefined, +): boolean { + return ( + left?.provider === right?.provider && + left?.number === right?.number && + left?.title === right?.title && + left?.url === right?.url && + left?.baseRefName === right?.baseRefName && + left?.headRefName === right?.headRefName && + left?.state === right?.state + ); +} + +function refreshedChangeRequestAssociation(input: { + current: ChangeRequestAssociation | undefined; + status: VcsStatusResult | null | undefined; + enabled: boolean | undefined; +}): ChangeRequestAssociation | undefined { + const provider = input.status?.sourceControlProvider?.kind; + const pr = input.status?.pr; + if ( + !input.enabled || + !input.current || + !pr || + pr.stale || + !provider || + provider === "unknown" || + provider !== input.current.provider || + pr.number !== input.current.number + ) { + return undefined; + } + return { + provider, + number: pr.number, + title: pr.title, + url: pr.url, + baseRefName: pr.baseRef, + headRefName: pr.headRef, + state: pr.state, + }; +} + const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; type RefreshVcsStatus = (target: { readonly environmentId: ScopedThreadRef["environmentId"]; - readonly input: { readonly cwd: string }; + readonly input: VcsStatusInput; }) => Promise; function requestVcsStatusRefresh( refresh: RefreshVcsStatus, environmentId: ScopedThreadRef["environmentId"] | null, cwd: string | null, + changeRequest?: ChangeRequestAssociation, ): void { if (environmentId === null || cwd === null) { return; } - void refresh({ environmentId, input: { cwd } }); + void refresh({ + environmentId, + input: { cwd, ...(changeRequest ? { changeRequest } : {}) }, + }); } const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; @@ -1023,14 +1074,19 @@ export default function GitActionsControl({ }); }, []); - const persistThreadBranchSync = useCallback( - (branch: string | null) => { + const persistThreadMetadataSync = useCallback( + (input: { branch?: string | null; changeRequest?: ChangeRequestAssociation }) => { if (!activeThreadRef) { return; } if (activeServerThread) { - if (activeServerThread.branch === branch) { + const branchChanged = + input.branch !== undefined && activeServerThread.branch !== input.branch; + const changeRequestChanged = + input.changeRequest !== undefined && + !changeRequestAssociationsEqual(activeServerThread.changeRequest, input.changeRequest); + if (!branchChanged && !changeRequestChanged) { return; } @@ -1038,20 +1094,32 @@ export default function GitActionsControl({ environmentId: activeThreadRef.environmentId, input: { threadId: activeThreadRef.threadId, - ...resolveThreadBranchMetadataPatch(branch, activeServerThread.branch), + ...(branchChanged + ? resolveThreadBranchMetadataPatch(input.branch ?? null, activeServerThread.branch) + : {}), + ...(input.changeRequest ? { changeRequest: input.changeRequest } : {}), }, }); return; } - if (!activeDraftThread || activeDraftThread.branch === branch) { + if (!activeDraftThread) { + return; + } + + const branchChanged = input.branch !== undefined && activeDraftThread.branch !== input.branch; + const changeRequestChanged = + input.changeRequest !== undefined && + !changeRequestAssociationsEqual(activeDraftThread.changeRequest, input.changeRequest); + if (!branchChanged && !changeRequestChanged) { return; } setDraftThreadContext(draftId ?? activeThreadRef, { - branch, + ...(input.branch !== undefined ? { branch: input.branch } : {}), worktreePath: activeDraftThread.worktreePath, + ...(input.changeRequest ? { changeRequest: input.changeRequest } : {}), }); }, [ @@ -1064,23 +1132,49 @@ export default function GitActionsControl({ ], ); - const syncThreadBranchAfterGitAction = useCallback( - (result: GitRunStackedActionResult) => { + const syncThreadMetadataAfterGitAction = useCallback( + (result: GitRunStackedActionResult, provider: SourceControlProviderKind | undefined) => { const branchUpdate = resolveThreadBranchUpdate(result); - if (!branchUpdate) { + const changeRequest = + serverConfig?.settings.enableDurableChangeRequestStatus && + provider && + provider !== "unknown" && + result.pr.number && + result.pr.url && + result.pr.title && + result.pr.baseBranch && + result.pr.headBranch + ? { + provider, + number: result.pr.number, + title: result.pr.title, + url: result.pr.url, + baseRefName: result.pr.baseBranch, + headRefName: result.pr.headBranch, + state: "open" as const, + } + : undefined; + if (!branchUpdate && !changeRequest) { return; } - persistThreadBranchSync(branchUpdate.branch); + persistThreadMetadataSync({ + ...(branchUpdate ? { branch: branchUpdate.branch } : {}), + ...(changeRequest ? { changeRequest } : {}), + }); }, - [persistThreadBranchSync], + [persistThreadMetadataSync, serverConfig?.settings.enableDurableChangeRequestStatus], ); + const activeChangeRequest = activeServerThread?.changeRequest ?? activeDraftThread?.changeRequest; const gitStatusQuery = useEnvironmentQuery( activeEnvironmentId !== null && gitCwd !== null ? vcsEnvironment.status({ environmentId: activeEnvironmentId, - input: { cwd: gitCwd }, + input: { + cwd: gitCwd, + ...(activeChangeRequest ? { changeRequest: activeChangeRequest } : {}), + }, }) : null, ); @@ -1125,18 +1219,32 @@ export default function GitActionsControl({ threadBranch: activeServerThread?.branch ?? activeDraftThread?.branch ?? null, gitStatus: gitStatusForActions, }); - if (!branchUpdate) { + const refreshedChangeRequest = refreshedChangeRequestAssociation({ + current: activeChangeRequest, + status: gitStatusForActions, + enabled: serverConfig?.settings.enableDurableChangeRequestStatus, + }); + if (!branchUpdate && !refreshedChangeRequest) { return; } - persistThreadBranchSync(branchUpdate.branch); + const branchStillMatchesChangeRequest = + !branchUpdate || refreshedChangeRequest?.headRefName === branchUpdate.branch; + persistThreadMetadataSync({ + ...(branchUpdate ? { branch: branchUpdate.branch } : {}), + ...(refreshedChangeRequest && branchStillMatchesChangeRequest + ? { changeRequest: refreshedChangeRequest } + : {}), + }); }, [ + activeChangeRequest, activeServerThread?.branch, activeDraftThread?.branch, gitStatusForActions, isGitActionRunning, isSelectingWorktreeBase, - persistThreadBranchSync, + persistThreadMetadataSync, + serverConfig?.settings.enableDurableChangeRequestStatus, ]); const isDefaultRef = useMemo(() => { @@ -1189,7 +1297,7 @@ export default function GitActionsControl({ } refreshTimeout = window.setTimeout(() => { refreshTimeout = null; - requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); + requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd, activeChangeRequest); }, GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS); }; const handleVisibilityChange = () => { @@ -1208,7 +1316,7 @@ export default function GitActionsControl({ window.removeEventListener("focus", scheduleRefreshCurrentGitStatus); document.removeEventListener("visibilitychange", handleVisibilityChange); }; - }, [activeEnvironmentId, gitCwd, refreshVcsStatus]); + }, [activeChangeRequest, activeEnvironmentId, gitCwd, refreshVcsStatus]); const openExistingPr = useCallback(async () => { const api = readLocalApi(); @@ -1416,7 +1524,7 @@ export default function GitActionsControl({ } const actionResult = result.value; - syncThreadBranchAfterGitAction(actionResult); + syncThreadMetadataAfterGitAction(actionResult, gitStatus?.sourceControlProvider?.kind); const closeResultToast = () => { toastManager.close(resolvedProgressToastId); }; @@ -1726,7 +1834,12 @@ export default function GitActionsControl({ { if (open) { - requestVcsStatusRefresh(refreshVcsStatus, activeEnvironmentId, gitCwd); + requestVcsStatusRefresh( + refreshVcsStatus, + activeEnvironmentId, + gitCwd, + activeChangeRequest, + ); } }} > diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 4004b4930c2..dec2eae2480 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -1,4 +1,4 @@ -import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { ChangeRequestAssociation, EnvironmentId, ThreadId } from "@t3tools/contracts"; import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { useDebouncedValue } from "@tanstack/react-pacer"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -33,7 +33,11 @@ interface PullRequestThreadDialogProps { cwd: string | null; initialReference: string | null; onOpenChange: (open: boolean) => void; - onPrepared: (input: { branch: string; worktreePath: string | null }) => Promise | void; + onPrepared: (input: { + branch: string; + worktreePath: string | null; + changeRequest: ChangeRequestAssociation; + }) => Promise | void; } export function PullRequestThreadDialog({ @@ -154,6 +158,7 @@ export function PullRequestThreadDialog({ await onPrepared({ branch: result.value.branch, worktreePath: result.value.worktreePath, + changeRequest: result.value.changeRequest, }); onOpenChange(false); }, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index d85f19332b4..1e3ac967212 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -81,6 +81,7 @@ import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useThreadChangeRequestStatus } from "../hooks/useThreadChangeRequestStatus"; import { useThreadActions } from "../hooks/useThreadActions"; import { resolveShortcutCommand, @@ -115,7 +116,6 @@ import { import { useDesktopUpdateState } from "../state/desktopUpdate"; import { readThreadShell, - useProject, useProjects, useThreadShells, useThreadShellsForProjectRefs, @@ -123,7 +123,6 @@ import { import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { previewEnvironment } from "../state/preview"; import { projectEnvironment } from "../state/projects"; -import { useEnvironmentQuery } from "../state/query"; import { primaryServerConfigAtom, primaryServerKeybindingsAtom } from "../state/server"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; @@ -184,7 +183,6 @@ import { SidebarUpdatePill } from "./sidebar/SidebarUpdatePill"; import { ChangeRequestStatusIcon, prStatusIndicator, - resolveThreadPr, ThreadStatusLabel, terminalStatusFromRunningIds, } from "./ThreadStatusIndicators"; @@ -448,23 +446,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr // For grouped projects, the thread may belong to a different environment // than the representative project. Look up the thread's own project cwd // so git status (and thus PR detection) queries the correct path. - const threadProject = useProject( - useMemo( - () => - thread.projectId === null ? null : scopeProjectRef(thread.environmentId, thread.projectId), - [thread.environmentId, thread.projectId], - ), - ); - const threadProjectCwd = threadProject?.workspaceRoot ?? null; - const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; - const gitStatus = useEnvironmentQuery( - thread.branch != null && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd }, - }) - : null, - ); + const { pr, providerKind } = useThreadChangeRequestStatus(thread, props.projectCwd); const isHighlighted = isActive || isSelected; const handleOpenDiscoveredPort = useCallback( (event: React.MouseEvent) => { @@ -503,8 +485,7 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr lastVisitedAt, }, }); - const pr = resolveThreadPr(thread.branch, gitStatus.data); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const prStatus = prStatusIndicator(pr, providerKind); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 022469b919c..ae448bd3db6 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,16 +1,15 @@ -import { - scopeProjectRef, - scopedThreadKey, - scopeThreadRef, -} from "@t3tools/client-runtime/environment"; -import type { VcsStatusResult } from "@t3tools/contracts"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { + ChangeRequestAssociation, + SourceControlProviderInfo, + SourceControlProviderKind, + VcsStatusResult, +} from "@t3tools/contracts"; +import { resolveThreadChangeRequestStatus } from "@t3tools/shared/sourceControl"; import { CloudIcon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; -import { useMemo } from "react"; +import { useThreadChangeRequestStatus } from "../hooks/useThreadChangeRequestStatus"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; -import { useProject } from "../state/entities"; -import { useEnvironmentQuery } from "../state/query"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; @@ -34,7 +33,7 @@ export type ThreadPr = VcsStatusResult["pr"]; export function prStatusIndicator( pr: ThreadPr, - provider: VcsStatusResult["sourceControlProvider"] | null | undefined, + provider: SourceControlProviderInfo | SourceControlProviderKind | null | undefined, ): PrStatusIndicator | null { if (!pr) return null; const presentation = resolveChangeRequestPresentation(provider); @@ -43,7 +42,7 @@ export function prStatusIndicator( return { label: `${presentation.shortName} open`, colorClass: "text-emerald-600 dark:text-emerald-300/90", - tooltip: `#${pr.number} ${presentation.shortName} open: ${pr.title}`, + tooltip: `#${pr.number} ${presentation.shortName} open${pr.stale ? " (last known)" : ""}: ${pr.title}`, url: pr.url, }; } @@ -51,7 +50,7 @@ export function prStatusIndicator( return { label: `${presentation.shortName} closed`, colorClass: "text-zinc-500 dark:text-zinc-400/80", - tooltip: `#${pr.number} ${presentation.shortName} closed: ${pr.title}`, + tooltip: `#${pr.number} ${presentation.shortName} closed${pr.stale ? " (last known)" : ""}: ${pr.title}`, url: pr.url, }; } @@ -59,7 +58,7 @@ export function prStatusIndicator( return { label: `${presentation.shortName} merged`, colorClass: "text-violet-600 dark:text-violet-300/90", - tooltip: `#${pr.number} ${presentation.shortName} merged: ${pr.title}`, + tooltip: `#${pr.number} ${presentation.shortName} merged${pr.stale ? " (last known)" : ""}: ${pr.title}`, url: pr.url, }; } @@ -73,12 +72,15 @@ export function ChangeRequestStatusIcon({ className }: { className?: string }) { export function resolveThreadPr( threadBranch: string | null, gitStatus: VcsStatusResult | null, + changeRequest?: ChangeRequestAssociation, + durableChangeRequestStatusEnabled = false, ): ThreadPr | null { - if (threadBranch === null || gitStatus === null || gitStatus.refName !== threadBranch) { - return null; - } - - return gitStatus.pr ?? null; + return resolveThreadChangeRequestStatus({ + threadBranch, + ...(changeRequest ? { changeRequest } : {}), + gitStatus, + durableChangeRequestStatusEnabled, + }); } export function terminalStatusFromRunningIds( @@ -157,25 +159,8 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar const lastVisitedAt = useUiStateStore( (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], ); - const threadProject = useProject( - useMemo( - () => - thread.projectId === null ? null : scopeProjectRef(thread.environmentId, thread.projectId), - [thread.environmentId, thread.projectId], - ), - ); - const threadProjectCwd = threadProject?.workspaceRoot ?? null; - const gitCwd = thread.worktreePath ?? threadProjectCwd; - const gitStatus = useEnvironmentQuery( - thread.branch != null && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd }, - }) - : null, - ); - const pr = resolveThreadPr(thread.branch, gitStatus.data); - const prStatus = prStatusIndicator(pr, gitStatus.data?.sourceControlProvider); + const { pr, providerKind } = useThreadChangeRequestStatus(thread); + const prStatus = prStatusIndicator(pr, providerKind); const threadStatus = resolveThreadStatusPill({ thread: { ...thread, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 31ff27f8105..17438b80889 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -120,6 +120,7 @@ type PersonalFeatureFlagName = Extract< | "enableCheckoutAwareThreadCreation" | "enableCompletionSounds" | "enableForkPullRequests" + | "enableDurableChangeRequestStatus" | "enableProviderSkillDiscovery" | "enableTextFileAttachments" | "enableGeneratedImageRendering" @@ -167,6 +168,12 @@ const PERSONAL_FEATURE_SETTINGS = [ title: "Fork-aware pull requests", description: "Target the upstream repository when working from a fork.", }, + { + key: "enableDurableChangeRequestStatus", + title: "Durable pull request status", + description: + "Keep resolved PR identity and last-known state attached to its thread with rate-limited polling.", + }, { key: "enableProviderSkillDiscovery", title: "Project provider skills", diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 8af188f386d..dd75a3c8178 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1057,6 +1057,43 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("keeps PR identity with its draft branch and clears it when that context changes", () => { + const store = useComposerDraftStore.getState(); + const changeRequest = { + provider: "github" as const, + number: 42, + title: "Durable association", + url: "https://github.com/acme/repo/pull/42", + baseRefName: "main", + headRefName: "feature/durable", + state: "open" as const, + }; + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + branch: "feature/durable", + worktreePath: "/tmp/feature-durable", + changeRequest, + }); + + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.changeRequest).toEqual( + changeRequest, + ); + + store.setDraftThreadContext(draftId, { branch: "feature/replacement" }); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).not.toHaveProperty( + "changeRequest", + ); + + store.setDraftThreadContext(draftId, { + branch: "feature/durable", + changeRequest, + }); + store.setDraftThreadContext(draftId, { startFromOrigin: true }); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).not.toHaveProperty( + "changeRequest", + ); + }); + it("stores the start-from-origin choice with the draft thread", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 9d2b2f4a12a..13d4b0da350 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1,6 +1,8 @@ import { DEFAULT_MODEL, DEFAULT_MODEL_BY_PROVIDER, + ChangeRequestAssociation, + type ChangeRequestAssociation as ChangeRequestAssociationType, defaultInstanceIdForDriver, type EnvironmentId, ModelSelection, @@ -55,6 +57,7 @@ import { ReviewCommentContextSchema, type ReviewCommentContext } from "./reviewC const isRuntimeMode = Schema.is(RuntimeMode); const isProviderDriverKind = Schema.is(ProviderDriverKind); const isReviewCommentContext = Schema.is(ReviewCommentContextSchema); +const isChangeRequestAssociation = Schema.is(ChangeRequestAssociation); export const COMPOSER_DRAFT_STORAGE_KEY = "t3code:composer-drafts:v1"; const COMPOSER_DRAFT_STORAGE_VERSION = 8; @@ -214,6 +217,7 @@ const PersistedDraftThreadState = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + changeRequest: Schema.optionalKey(ChangeRequestAssociation), envMode: DraftThreadEnvModeSchema, startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), promotedTo: Schema.optionalKey( @@ -293,6 +297,7 @@ export interface DraftSessionState { interactionMode: ProviderInteractionMode; branch: string | null; worktreePath: string | null; + changeRequest?: ChangeRequestAssociationType; envMode: DraftThreadEnvMode; startFromOrigin: boolean; promotedTo?: ScopedThreadRef | null; @@ -355,6 +360,7 @@ interface ComposerDraftStoreState { threadId?: ThreadId; branch?: string | null; worktreePath?: string | null; + changeRequest?: ChangeRequestAssociationType | null; createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; @@ -370,6 +376,7 @@ interface ComposerDraftStoreState { threadId?: ThreadId; branch?: string | null; worktreePath?: string | null; + changeRequest?: ChangeRequestAssociationType | null; createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; @@ -394,6 +401,7 @@ interface ComposerDraftStoreState { options: { branch?: string | null; worktreePath?: string | null; + changeRequest?: ChangeRequestAssociationType | null; projectRef?: ScopedProjectRef; createdAt?: string; envMode?: DraftThreadEnvMode; @@ -1333,6 +1341,7 @@ function createDraftThreadState( threadId?: ThreadId; branch?: string | null; worktreePath?: string | null; + changeRequest?: ChangeRequestAssociationType | null; createdAt?: string; envMode?: DraftThreadEnvMode; startFromOrigin?: boolean; @@ -1356,6 +1365,14 @@ function createDraftThreadState( ? null : (existingThread?.branch ?? null) : (options.branch ?? null); + const nextChangeRequest = + options?.changeRequest === undefined + ? projectChanged || + options?.startFromOrigin === true || + (options?.branch !== undefined && nextBranch !== existingThread?.branch) + ? undefined + : existingThread?.changeRequest + : (options.changeRequest ?? undefined); const nextStartFromOrigin = options?.startFromOrigin === undefined ? projectChanged @@ -1373,6 +1390,7 @@ function createDraftThreadState( options?.interactionMode ?? existingThread?.interactionMode ?? DEFAULT_INTERACTION_MODE, branch: nextBranch, worktreePath: nextWorktreePath, + ...(nextChangeRequest ? { changeRequest: nextChangeRequest } : {}), envMode: options?.envMode ?? (nextWorktreePath @@ -1411,6 +1429,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.interactionMode === right.interactionMode && left.branch === right.branch && left.worktreePath === right.worktreePath && + Equal.equals(left.changeRequest, right.changeRequest) && left.envMode === right.envMode && left.startFromOrigin === right.startFromOrigin && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) @@ -1533,6 +1552,9 @@ function normalizePersistedDraftThreads( } const normalizedEnvironmentId = environmentId as EnvironmentId; const normalizedProjectId = projectId === null ? null : (projectId as ProjectId); + const changeRequest = isChangeRequestAssociation(candidateDraftThread.changeRequest) + ? candidateDraftThread.changeRequest + : undefined; draftThreadsByThreadKey[threadKey] = { threadId, environmentId: normalizedEnvironmentId, @@ -1560,6 +1582,7 @@ function normalizePersistedDraftThreads( : DEFAULT_INTERACTION_MODE, branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, + ...(changeRequest ? { changeRequest } : {}), envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), startFromOrigin, promotedTo, @@ -2179,6 +2202,9 @@ function toHydratedDraftThreadState( interactionMode: persistedDraftThread.interactionMode, branch: persistedDraftThread.branch, worktreePath: persistedDraftThread.worktreePath, + ...(persistedDraftThread.changeRequest + ? { changeRequest: persistedDraftThread.changeRequest } + : {}), envMode: persistedDraftThread.envMode, startFromOrigin: persistedDraftThread.startFromOrigin, promotedTo: persistedDraftThread.promotedTo @@ -2395,6 +2421,14 @@ const composerDraftStore = create()( ? null : existing.branch : (options.branch ?? null); + const nextChangeRequest = + options.changeRequest === undefined + ? projectChanged || + options.startFromOrigin === true || + (options.branch !== undefined && nextBranch !== existing.branch) + ? undefined + : existing.changeRequest + : (options.changeRequest ?? undefined); const nextStartFromOrigin = options.startFromOrigin === undefined ? projectChanged @@ -2414,6 +2448,7 @@ const composerDraftStore = create()( interactionMode: options.interactionMode ?? existing.interactionMode, branch: nextBranch, worktreePath: nextWorktreePath, + ...(nextChangeRequest ? { changeRequest: nextChangeRequest } : {}), envMode: options.envMode ?? (nextWorktreePath @@ -2433,6 +2468,7 @@ const composerDraftStore = create()( nextDraftThread.interactionMode === existing.interactionMode && nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && + Equal.equals(nextDraftThread.changeRequest, existing.changeRequest) && nextDraftThread.envMode === existing.envMode && nextDraftThread.startFromOrigin === existing.startFromOrigin && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); diff --git a/apps/web/src/hooks/useThreadChangeRequestStatus.ts b/apps/web/src/hooks/useThreadChangeRequestStatus.ts new file mode 100644 index 00000000000..4f42aecde35 --- /dev/null +++ b/apps/web/src/hooks/useThreadChangeRequestStatus.ts @@ -0,0 +1,72 @@ +import { scopeProjectRef } from "@t3tools/client-runtime/environment"; +import type { SourceControlProviderKind, VcsStatusResult } from "@t3tools/contracts"; +import { + resolveThreadChangeRequestProviderKind, + resolveThreadChangeRequestStatus, + shouldQueryThreadVcsStatus, +} from "@t3tools/shared/sourceControl"; +import { useMemo } from "react"; + +import { useEnvironmentServerConfig, useProject } from "../state/entities"; +import { useEnvironmentQuery } from "../state/query"; +import { vcsEnvironment } from "../state/vcs"; +import type { SidebarThreadSummary } from "../types"; + +export interface ThreadChangeRequestStatus { + readonly pr: VcsStatusResult["pr"]; + readonly providerKind: SourceControlProviderKind | null; +} + +/** + * Resolve the durable or inferred change request for a thread row. + * + * Visible rows own their subscriptions, so list virtualization naturally + * limits polling. Explicit identities may be rendered without a branch or + * repository; inferred identities remain bound to the checked-out branch. + */ +export function useThreadChangeRequestStatus( + thread: SidebarThreadSummary, + fallbackProjectCwd: string | null = null, +): ThreadChangeRequestStatus { + const threadProject = useProject( + useMemo( + () => + thread.projectId === null ? null : scopeProjectRef(thread.environmentId, thread.projectId), + [thread.environmentId, thread.projectId], + ), + ); + const cwd = thread.worktreePath ?? threadProject?.workspaceRoot ?? fallbackProjectCwd; + const durableChangeRequestStatusEnabled = + useEnvironmentServerConfig(thread.environmentId)?.settings.enableDurableChangeRequestStatus ?? + false; + const changeRequest = durableChangeRequestStatusEnabled ? thread.changeRequest : undefined; + const gitStatus = useEnvironmentQuery( + cwd !== null && + shouldQueryThreadVcsStatus({ + threadBranch: thread.branch, + ...(changeRequest ? { changeRequest } : {}), + durableChangeRequestStatusEnabled, + }) + ? vcsEnvironment.status({ + environmentId: thread.environmentId, + input: { + cwd, + ...(changeRequest ? { changeRequest } : {}), + }, + }) + : null, + ); + const resolutionInput = { + ...(changeRequest ? { changeRequest } : {}), + gitStatus: gitStatus.data, + durableChangeRequestStatusEnabled, + }; + + return { + pr: resolveThreadChangeRequestStatus({ + threadBranch: thread.branch, + ...resolutionInput, + }), + providerKind: resolveThreadChangeRequestProviderKind(resolutionInput), + }; +} diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index db895e37c4a..d24d1a1d26f 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -19,7 +19,7 @@ import { Atom } from "effect/unstable/reactivity"; import { useMemo } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentProjects } from "./projects"; -import { environmentServerConfigsAtom } from "./server"; +import { environmentServerConfigsAtom, serverEnvironment } from "./server"; import { allEnvironmentShellsBootstrappedAtom } from "./shell"; import { environmentThreadDetails, environmentThreadShells } from "./threads"; @@ -56,6 +56,9 @@ const EMPTY_PROPOSED_PLANS_ATOM = Atom.make(EMPTY_PROPOSED_PLANS).pipe( const EMPTY_SESSION_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-session:empty"), ); +const EMPTY_SERVER_CONFIG_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-server-config:empty"), +); export const activeEnvironmentIdAtom = Atom.make(null).pipe( Atom.keepAlive, @@ -110,6 +113,16 @@ export function useServerConfigs(): ReadonlyMap { return useAtomValue(environmentServerConfigsAtom); } +export function useEnvironmentServerConfig( + environmentId: EnvironmentId | null, +): ServerConfig | null { + return useAtomValue( + environmentId === null + ? EMPTY_SERVER_CONFIG_ATOM + : serverEnvironment.configValueAtom(environmentId), + ); +} + export function useThreadShells(): ReadonlyArray { return useAtomValue(environmentThreadShells.threadShellsAtom); } diff --git a/docs/assets/pr-status-sidebar-preview.svg b/docs/assets/pr-status-sidebar-preview.svg new file mode 100644 index 00000000000..c4800d3310b --- /dev/null +++ b/docs/assets/pr-status-sidebar-preview.svg @@ -0,0 +1,48 @@ + + + + PR status icon demo + feature/pr-status-demo + + + + + No pull request + 30m ago + + + + + + + + + + Closed pull request + 31m ago + + + + + + + + + + Merged pull request + 32m ago + + + + + + + + + + Open pull request + 33m ago + + + Actual sidebar spacing and state colors + diff --git a/docs/personal-fork-changes.md b/docs/personal-fork-changes.md index cb2ad403e25..76a536e6acc 100644 --- a/docs/personal-fork-changes.md +++ b/docs/personal-fork-changes.md @@ -12,6 +12,7 @@ Turning a flag off preserves upstream behavior. | Checkout-aware thread creation | `enableCheckoutAwareThreadCreation` | On | | Completion and attention sounds | `enableCompletionSounds` | On | | Fork-aware pull requests | `enableForkPullRequests` | On | +| Durable pull request status | `enableDurableChangeRequestStatus` | On | | Project provider skill discovery | `enableProviderSkillDiscovery` | On | | Markdown and text file attachments | `enableTextFileAttachments` | On | | Inline generated-image rendering | `enableGeneratedImageRendering` | On | @@ -63,6 +64,29 @@ Upstream PRs integrated into the fork are listed in - Disabling the flag restores the previous worktree-label behavior, where selecting the label immediately creates a chat in that checkout. +## Durable pull request status + +- Threads created from a resolved pull request persist its provider, number, URL, refs, and + last-known display state. Sidebar status refreshes query that canonical identity instead of + rediscovering historical pull requests from a reused branch name. +- A persisted pull request remains visible when a thread no longer has a branch. It refreshes by + canonical identity when repository context is available and otherwise renders the stored state + as last-known; inferred pull requests still require an exact checked-out branch match. +- The sidebar keeps the compact icon-only treatment: open is green, merged is purple, and closed + is muted gray. Provider failures retain the latest cached result and mark last-known fallback + metadata as stale instead of making the icon disappear. +- Background change-request polling has a sustained budget of 30 provider requests per minute, a + burst limit of 10, and at most four concurrent provider calls. It is shared by canonical + provider/URL/number identity across worktrees. Open results refresh at most once per minute, + closed and merged results use progressively longer cache windows, and provider failures back off + exponentially up to 15 minutes. Throttled or failed inferred lookups preserve the last successful + icon as stale instead of clearing it, but only while the inferred branch identity still matches. +- GitHub durable refreshes use the repository-qualified pull request URL to derive the target + repository, so a shared cache entry cannot be populated from an unrelated checkout that happens + to contain the same pull request number. +- Turning the flag off preserves branch-name discovery and does not attach new pull request + associations to threads. + ## Projectless standalone chats - Creating a standalone chat opens a local draft immediately, matching project-thread creation; diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 379897870de..dc13916e4f7 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -205,6 +205,41 @@ describe("applyThreadDetailEvent", () => { expect(result.thread.modelSelection).toEqual(baseThread.modelSelection); } }); + + it("removes a durable PR association when metadata clears it", () => { + const associatedThread: OrchestrationThread = { + ...baseThread, + changeRequest: { + provider: "github", + number: 42, + title: "Durable association", + url: "https://github.com/acme/repo/pull/42", + baseRefName: "main", + headRefName: "feature/durable", + state: "open", + }, + }; + const result = applyThreadDetailEvent(associatedThread, { + ...baseEventFields, + sequence: 5, + occurredAt: "2026-04-01T05:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + branch: "feature/replaced", + changeRequest: null, + updatedAt: "2026-04-01T05:00:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.branch).toBe("feature/replaced"); + expect(result.thread).not.toHaveProperty("changeRequest"); + } + }); }); describe("thread.message-sent", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 37ae19b44d7..efa9e38fcd7 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -107,6 +107,9 @@ export function applyThreadDetailEvent( interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + ...(event.payload.changeRequest !== undefined + ? { changeRequest: event.payload.changeRequest } + : {}), latestTurn: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -140,11 +143,12 @@ export function applyThreadDetailEvent( }; // ── Thread metadata ───────────────────────────────────────────── - case "thread.meta-updated": + case "thread.meta-updated": { + const { changeRequest: _changeRequest, ...threadWithoutChangeRequest } = thread; return { kind: "updated", thread: { - ...thread, + ...(event.payload.changeRequest === null ? threadWithoutChangeRequest : thread), ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } @@ -153,9 +157,15 @@ export function applyThreadDetailEvent( ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.changeRequest !== undefined + ? event.payload.changeRequest === null + ? {} + : { changeRequest: event.payload.changeRequest } + : {}), updatedAt: event.payload.updatedAt, }, }; + } case "thread.runtime-mode-set": return { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 3aea720f846..1bff2c025a7 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,6 +1,10 @@ import * as Schema from "effect/Schema"; import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; -import { SourceControlProviderError, SourceControlProviderInfo } from "./sourceControl.ts"; +import { + ChangeRequestAssociation, + SourceControlProviderError, + SourceControlProviderInfo, +} from "./sourceControl.ts"; import { VcsDriverKind } from "./vcs.ts"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -101,6 +105,7 @@ export type GitResolvedPullRequest = typeof GitResolvedPullRequest.Type; export const VcsStatusInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, + changeRequest: Schema.optionalKey(ChangeRequestAssociation), }); export type VcsStatusInput = typeof VcsStatusInput.Type; @@ -214,6 +219,7 @@ const VcsStatusChangeRequest = Schema.Struct({ baseRef: TrimmedNonEmptyStringSchema, headRef: TrimmedNonEmptyStringSchema, state: VcsStatusChangeRequestState, + stale: Schema.optionalKey(Schema.Boolean), }); const VcsStatusLocalShape = { @@ -244,6 +250,8 @@ const VcsStatusRemoteShape = { behindCount: NonNegativeInt, aheadOfDefaultCount: Schema.optional(NonNegativeInt), remoteRefHash: Schema.optional(TrimmedNonEmptyStringSchema), + changeRequestRefreshState: Schema.optionalKey(Schema.Literals(["fresh", "stale"])), + changeRequestRefName: Schema.optionalKey(TrimmedNonEmptyStringSchema), pr: Schema.NullOr(VcsStatusChangeRequest), }; @@ -298,6 +306,7 @@ export type GitResolvePullRequestResult = typeof GitResolvePullRequestResult.Typ export const GitPreparePullRequestThreadResult = Schema.Struct({ pullRequest: GitResolvedPullRequest, + changeRequest: ChangeRequestAssociation, branch: TrimmedNonEmptyStringSchema, worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), }); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 26c3b3cb8d7..2cddf00feaa 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -21,6 +21,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { ChangeRequestAssociation } from "./sourceControl.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -378,6 +379,7 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + changeRequest: Schema.optionalKey(ChangeRequestAssociation), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -429,6 +431,7 @@ export const OrchestrationThreadShell = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + changeRequest: Schema.optionalKey(ChangeRequestAssociation), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -569,6 +572,7 @@ const ThreadCreateCommand = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + changeRequest: Schema.optionalKey(ChangeRequestAssociation), createdAt: IsoDateTime, }); @@ -599,6 +603,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + changeRequest: Schema.optional(Schema.NullOr(ChangeRequestAssociation)), }); const ThreadRuntimeModeSetCommand = Schema.Struct({ @@ -626,6 +631,7 @@ const ThreadTurnStartBootstrapCreateThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + changeRequest: Schema.optionalKey(ChangeRequestAssociation), createdAt: IsoDateTime, }); @@ -916,6 +922,7 @@ export const ThreadCreatedPayload = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + changeRequest: Schema.optionalKey(ChangeRequestAssociation), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -942,6 +949,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + changeRequest: Schema.optional(Schema.NullOr(ChangeRequestAssociation)), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index a084abd4232..a21cd6dc7da 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -52,6 +52,7 @@ describe("personal fork feature flags", () => { "enableWorktreeSourceControl", "enableCheckoutAwareThreadCreation", "enableForkPullRequests", + "enableDurableChangeRequestStatus", "enableProviderSkillDiscovery", "enableTextFileAttachments", "enableGeneratedImageRendering", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 02565584a46..7ca5d656483 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -396,6 +396,9 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(true)), ), enableForkPullRequests: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + enableDurableChangeRequestStatus: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + ), enableProviderSkillDiscovery: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index 104aadd9161..9e17a7fcbf1 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -21,6 +21,26 @@ export type SourceControlProviderInfo = typeof SourceControlProviderInfo.Type; export const ChangeRequestState = Schema.Literals(["open", "closed", "merged"]); export type ChangeRequestState = typeof ChangeRequestState.Type; +/** + * Durable identity plus the last-known display snapshot for a change request. + * + * Threads persist this when T3 resolves, checks out, or creates a change + * request. The provider and repository-qualified URL are the canonical + * identity; the number enables direct lookup when repository context exists. + * The remaining fields keep the sidebar useful while the provider is + * temporarily unavailable. + */ +export const ChangeRequestAssociation = Schema.Struct({ + provider: SourceControlProviderKind, + number: PositiveInt, + title: TrimmedNonEmptyString, + url: Schema.String, + baseRefName: TrimmedNonEmptyString, + headRefName: TrimmedNonEmptyString, + state: ChangeRequestState, +}); +export type ChangeRequestAssociation = typeof ChangeRequestAssociation.Type; + export const ChangeRequest = Schema.Struct({ provider: SourceControlProviderKind, number: PositiveInt, diff --git a/packages/shared/src/sourceControl.test.ts b/packages/shared/src/sourceControl.test.ts index 368e8387ee6..a564b8a6462 100644 --- a/packages/shared/src/sourceControl.test.ts +++ b/packages/shared/src/sourceControl.test.ts @@ -1,11 +1,47 @@ +import type { ChangeRequestAssociation, VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { detectSourceControlProviderFromRemoteUrl, getChangeRequestTerminologyForKind, resolveChangeRequestPresentation, + resolveThreadChangeRequestProviderKind, + resolveThreadChangeRequestStatus, + shouldQueryThreadVcsStatus, } from "./sourceControl.ts"; +const changeRequest: ChangeRequestAssociation = { + provider: "github", + number: 42, + title: "Durable pull request", + url: "https://github.com/acme/repo/pull/42", + baseRefName: "main", + headRefName: "feature/original", + state: "open", +}; + +function status(overrides: Partial = {}): VcsStatusResult { + return { + isRepo: true, + sourceControlProvider: { + kind: "github", + name: "GitHub", + baseUrl: "https://github.com", + }, + hasPrimaryRemote: true, + isDefaultRef: false, + refName: "feature/current", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 1, + pr: null, + ...overrides, + }; +} + describe("source control presentation", () => { it("uses merge request terminology for GitLab", () => { expect(getChangeRequestTerminologyForKind("gitlab")).toEqual({ @@ -39,6 +75,169 @@ describe("source control presentation", () => { }), ); }); + + it("accepts a persisted provider kind without live provider metadata", () => { + expect(resolveChangeRequestPresentation("gitlab")).toEqual( + expect.objectContaining({ shortName: "MR", longName: "merge request" }), + ); + }); +}); + +describe("thread change request status", () => { + it("queries branchless threads only when they have an enabled durable association", () => { + expect( + shouldQueryThreadVcsStatus({ + threadBranch: null, + changeRequest, + durableChangeRequestStatusEnabled: true, + }), + ).toBe(true); + expect( + shouldQueryThreadVcsStatus({ + threadBranch: null, + changeRequest, + durableChangeRequestStatusEnabled: false, + }), + ).toBe(false); + expect( + shouldQueryThreadVcsStatus({ + threadBranch: null, + durableChangeRequestStatusEnabled: true, + }), + ).toBe(false); + }); + + it("uses matching live status for a branchless durable association", () => { + const livePr = { + number: 42, + title: "Updated title", + url: "https://github.com/acme/repo/pull/42/", + baseRef: "main", + headRef: "feature/original", + state: "merged" as const, + }; + expect( + resolveThreadChangeRequestStatus({ + threadBranch: null, + changeRequest, + gitStatus: status({ refName: null, pr: livePr }), + durableChangeRequestStatusEnabled: true, + }), + ).toEqual(livePr); + }); + + it("uses the stored snapshot as stale without repository status", () => { + expect( + resolveThreadChangeRequestStatus({ + threadBranch: null, + changeRequest, + gitStatus: null, + durableChangeRequestStatusEnabled: true, + }), + ).toEqual({ + number: 42, + title: "Durable pull request", + url: "https://github.com/acme/repo/pull/42", + baseRef: "main", + headRef: "feature/original", + state: "open", + stale: true, + }); + }); + + it("does not replace a durable association with status from another repository", () => { + expect( + resolveThreadChangeRequestStatus({ + threadBranch: null, + changeRequest, + gitStatus: status({ + refName: null, + pr: { + number: 42, + title: "Unrelated pull request", + url: "https://github.com/other/repo/pull/42/", + baseRef: "main", + headRef: "feature/unrelated", + state: "closed", + }, + }), + durableChangeRequestStatusEnabled: true, + }), + ).toMatchObject({ title: "Durable pull request", state: "open", stale: true }); + }); + + it("preserves branch-bound inference and flag-off behavior", () => { + const inferredPr = { + number: 7, + title: "Inferred pull request", + url: "https://github.com/acme/repo/pull/7", + baseRef: "main", + headRef: "feature/current", + state: "open" as const, + }; + const gitStatus = status({ pr: inferredPr }); + + expect( + resolveThreadChangeRequestStatus({ + threadBranch: "feature/current", + changeRequest, + gitStatus, + durableChangeRequestStatusEnabled: false, + }), + ).toEqual(inferredPr); + expect( + resolveThreadChangeRequestStatus({ + threadBranch: null, + changeRequest, + gitStatus, + durableChangeRequestStatusEnabled: false, + }), + ).toBeNull(); + expect( + resolveThreadChangeRequestStatus({ + threadBranch: "feature/other", + gitStatus, + durableChangeRequestStatusEnabled: true, + }), + ).toBeNull(); + expect( + resolveThreadChangeRequestStatus({ + threadBranch: "feature/current", + gitStatus: status({ + pr: inferredPr, + changeRequestRefName: "feature/previous", + }), + durableChangeRequestStatusEnabled: true, + }), + ).toBeNull(); + }); + + it("uses the persisted provider when live repository metadata is unavailable", () => { + expect( + resolveThreadChangeRequestProviderKind({ + changeRequest: { ...changeRequest, provider: "gitlab" }, + gitStatus: null, + durableChangeRequestStatusEnabled: true, + }), + ).toBe("gitlab"); + expect( + resolveThreadChangeRequestProviderKind({ + changeRequest: { ...changeRequest, provider: "gitlab" }, + gitStatus: null, + durableChangeRequestStatusEnabled: false, + }), + ).toBeNull(); + }); + + it("keeps the persisted provider when unrelated live metadata is present", () => { + expect( + resolveThreadChangeRequestProviderKind({ + changeRequest: { ...changeRequest, provider: "gitlab" }, + gitStatus: status(), + durableChangeRequestStatusEnabled: true, + }), + ).toBe("gitlab"); + }); }); describe("detectSourceControlProviderFromRemoteUrl", () => { diff --git a/packages/shared/src/sourceControl.ts b/packages/shared/src/sourceControl.ts index afbbc74b100..21be1067148 100644 --- a/packages/shared/src/sourceControl.ts +++ b/packages/shared/src/sourceControl.ts @@ -1,4 +1,9 @@ -import type { SourceControlProviderInfo, SourceControlProviderKind } from "@t3tools/contracts"; +import type { + ChangeRequestAssociation, + SourceControlProviderInfo, + SourceControlProviderKind, + VcsStatusResult, +} from "@t3tools/contracts"; const GITHUB_PULL_REQUEST_URL_PATTERN = /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; @@ -49,6 +54,106 @@ export function parsePullRequestReference(input: string): string | null { return PULL_REQUEST_NUMBER_PATTERN.exec(normalizedInput)?.[1] ?? null; } +export interface ThreadChangeRequestStatusInput { + readonly threadBranch: string | null; + readonly changeRequest?: ChangeRequestAssociation; + readonly gitStatus: VcsStatusResult | null; + readonly durableChangeRequestStatusEnabled: boolean; +} + +function normalizeChangeRequestUrl(url: string): string { + return url.trim().replace(/\/+$/u, ""); +} + +function statusMatchesChangeRequest( + status: NonNullable, + changeRequest: ChangeRequestAssociation, +): boolean { + return ( + status.number === changeRequest.number && + normalizeChangeRequestUrl(status.url) === normalizeChangeRequestUrl(changeRequest.url) + ); +} + +function storedChangeRequestStatus( + changeRequest: ChangeRequestAssociation, +): NonNullable { + return { + number: changeRequest.number, + title: changeRequest.title, + url: changeRequest.url, + baseRef: changeRequest.baseRefName, + headRef: changeRequest.headRefName, + state: changeRequest.state, + stale: true, + }; +} + +/** + * Whether a thread has enough identity to request source-control status. + * + * Inferred status remains branch-bound. A durable association is sufficient + * without a branch, but only while the fork-specific feature is enabled. + */ +export function shouldQueryThreadVcsStatus( + input: Pick< + ThreadChangeRequestStatusInput, + "threadBranch" | "changeRequest" | "durableChangeRequestStatusEnabled" + >, +): boolean { + return ( + input.threadBranch !== null || + (input.durableChangeRequestStatusEnabled && input.changeRequest !== undefined) + ); +} + +/** + * Resolve the PR/MR shown for a thread without conflating durable identity + * with the currently checked-out branch. + * + * Explicit associations may render from their persisted snapshot before a + * refresh completes or when no local repository is available. Inferred + * results still require an exact checked-out branch match. + */ +export function resolveThreadChangeRequestStatus( + input: ThreadChangeRequestStatusInput, +): VcsStatusResult["pr"] { + const explicitChangeRequest = input.durableChangeRequestStatusEnabled + ? input.changeRequest + : undefined; + if (explicitChangeRequest) { + const refreshed = input.gitStatus?.pr; + return refreshed && statusMatchesChangeRequest(refreshed, explicitChangeRequest) + ? refreshed + : storedChangeRequestStatus(explicitChangeRequest); + } + + if ( + input.threadBranch === null || + input.gitStatus === null || + input.gitStatus.refName !== input.threadBranch || + (input.gitStatus.changeRequestRefName !== undefined && + input.gitStatus.changeRequestRefName !== input.threadBranch) + ) { + return null; + } + + return input.gitStatus.pr ?? null; +} + +export function resolveThreadChangeRequestProviderKind( + input: Pick< + ThreadChangeRequestStatusInput, + "changeRequest" | "gitStatus" | "durableChangeRequestStatusEnabled" + >, +): SourceControlProviderKind | null { + return ( + (input.durableChangeRequestStatusEnabled ? input.changeRequest?.provider : undefined) ?? + input.gitStatus?.sourceControlProvider?.kind ?? + null + ); +} + export interface ChangeRequestPresentation { readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request"; readonly providerName: string; @@ -124,9 +229,9 @@ const GENERIC_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { }; export function resolveChangeRequestPresentation( - provider: SourceControlProviderInfo | null | undefined, + provider: SourceControlProviderInfo | SourceControlProviderKind | null | undefined, ): ChangeRequestPresentation { - switch (provider?.kind) { + switch (typeof provider === "string" ? provider : provider?.kind) { case "github": case undefined: return GITHUB_CHANGE_REQUEST_PRESENTATION;