diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 6a1a8c1510fa..9b52e943bf0d 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -29,6 +29,7 @@ import { GitCommandError, ProviderDriverKind, ProviderInstanceId, + type SourceControlProviderKind, TextGenerationError, } from "@t3tools/contracts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; @@ -637,6 +638,7 @@ function makeManager(input?: { serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; gitConfigReads?: string[]; + sourceControlProviderKind?: SourceControlProviderKind | (() => SourceControlProviderKind); }) { const { service: gitHubCli, ghCalls } = createGitHubCliWithFakeGh(input?.ghScenario); const textGeneration = createTextGeneration(input?.textGeneration); @@ -676,15 +678,23 @@ function makeManager(input?: { ? GitHubSourceControlProvider.make : Effect.succeed(input.sourceControlProvider) ).pipe( - Effect.map((provider) => - SourceControlProviderRegistry.SourceControlProviderRegistry.of({ + Effect.map((provider) => { + const sourceControlProvider = () => ({ + ...provider, + kind: + typeof input?.sourceControlProviderKind === "function" + ? input.sourceControlProviderKind() + : (input?.sourceControlProviderKind ?? provider.kind), + }); + return SourceControlProviderRegistry.SourceControlProviderRegistry.of({ resolveLink: (input) => provider.resolveLink?.(input), - get: () => Effect.succeed(provider), - resolveHandle: () => Effect.succeed({ provider, context: null }), - resolve: () => Effect.succeed(provider), + get: () => Effect.sync(sourceControlProvider), + resolveHandle: () => + Effect.sync(() => ({ provider: sourceControlProvider(), context: null })), + resolve: () => Effect.sync(sourceControlProvider), discover: Effect.succeed([]), - }), - ), + }); + }), Effect.provide(Layer.succeed(GitHubCli.GitHubCli, gitHubCli)), ), ); @@ -965,6 +975,199 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status skips PR lookup when the source-control provider is unknown", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-unknown-provider-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/unknown-provider"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/unknown-provider"]); + + const { manager, ghCalls } = yield* makeManager({ + sourceControlProviderKind: "unknown", + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr).toBeNull(); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(0); + }), + ); + + it.effect("status retries an unknown provider on the short PR lookup cadence", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-provider-refinement-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/provider-refinement"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/provider-refinement"]); + + let providerKind: SourceControlProviderKind = "unknown"; + const existingPr = { + number: 411, + title: "Provider refinement PR", + url: "https://github.com/pingdotgg/t3code/pull/411", + baseRefName: "main", + headRefName: "feature/provider-refinement", + }; + const { manager, ghCalls } = yield* makeManager({ + sourceControlProviderKind: () => providerKind, + ghScenario: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr).toBeNull(); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(0); + + providerKind = "github"; + yield* manager.invalidateRemoteStatus(repoDir); + + const cached = yield* manager.status({ cwd: repoDir }); + expect(cached.pr).toBeNull(); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(0); + + yield* TestClock.adjust(Duration.seconds(20)); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(411); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + }), + ); + + it.effect("status keeps the last known PR while the provider is temporarily unknown", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-provider-hiccup-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/provider-hiccup"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/provider-hiccup"]); + + let providerKind: SourceControlProviderKind = "github"; + const existingPr = { + number: 412, + title: "Provider hiccup PR", + url: "https://github.com/pingdotgg/t3code/pull/412", + baseRefName: "main", + headRefName: "feature/provider-hiccup", + }; + const { manager } = yield* makeManager({ + sourceControlProviderKind: () => providerKind, + ghScenario: { + // Fake gh returns raw JSON stdout, matching the CLI boundary under test. + // @effect-diagnostics-next-line preferSchemaOverJson:off + prListSequence: [JSON.stringify([existingPr])], + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + expect(first.pr?.number).toBe(412); + + providerKind = "unknown"; + yield* manager.invalidateStatus(repoDir); + + const second = yield* manager.status({ cwd: repoDir }); + expect(second.pr?.number).toBe(412); + }), + ); + + it.effect("branch PR lookup returns null when the source-control provider is unknown", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-unknown-branch-provider-"); + yield* initRepo(repoDir); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature/unknown-branch-provider"]); + yield* runGit(repoDir, ["push", "-u", "origin", "feature/unknown-branch-provider"]); + + const { manager, ghCalls } = yield* makeManager({ + sourceControlProviderKind: "unknown", + }); + + const pullRequest = yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/unknown-branch-provider", + }); + + expect(pullRequest).toBeNull(); + + yield* runGit(repoDir, ["config", "--unset", "remote.origin.url"]); + + expect( + yield* manager.branchPullRequest({ + cwd: repoDir, + branch: "feature/unknown-branch-provider", + }), + ).toBeNull(); + + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(0); + }), + ); + + it.effect("branch PR refresh preserves unknown-provider backoff and retries after expiry", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-unknown-provider-refresh-"); + yield* initRepo(repoDir); + const branch = "feature/unknown-provider-refresh"; + yield* runGit(repoDir, ["checkout", "-b", branch]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "origin", branch]); + + let providerKind: SourceControlProviderKind = "unknown"; + let providerLookups = 0; + const { manager, ghCalls } = yield* makeManager({ + sourceControlProviderKind: () => { + providerLookups += 1; + return providerKind; + }, + ghScenario: { + prListSequence: [ + encodeCliJson([ + { + number: 413, + title: "Recovered provider PR", + url: "https://github.com/pingdotgg/t3code/pull/413", + baseRefName: "main", + headRefName: branch, + }, + ]), + ], + }, + }); + const input = { cwd: repoDir, branch }; + + expect(yield* manager.branchPullRequest(input)).toBeNull(); + expect(yield* manager.branchPullRequest(input, { refresh: true })).toBeNull(); + expect(yield* manager.branchPullRequest(input, { refresh: true })).toBeNull(); + expect(providerLookups).toBe(1); + + yield* TestClock.adjust(Duration.seconds(20)); + expect(yield* manager.branchPullRequest(input, { refresh: true })).toBeNull(); + expect(providerLookups).toBe(2); + + providerKind = "github"; + yield* TestClock.adjust(Duration.seconds(39)); + expect(yield* manager.branchPullRequest(input, { refresh: true })).toBeNull(); + expect(providerLookups).toBe(2); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(0); + + yield* TestClock.adjust(Duration.seconds(1)); + expect(yield* manager.branchPullRequest(input, { refresh: true })).toMatchObject({ + number: 413, + state: "open", + }); + expect(providerLookups).toBe(3); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + }), + ); + it.effect("status briefly caches repeated lookups for the same cwd", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("t3code-git-manager-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f57afeb2a419..eb120c41a01c 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -195,6 +195,10 @@ interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { updatedAt: Option.Option; } +type PrLookupOutcome = + | { readonly _tag: "Complete"; readonly latest: PullRequestInfo | null } + | { readonly _tag: "ProviderUnknown" }; + const pullRequestUpdatedAtDescOrder: Order.Order = Order.mapInput( Order.flip(Option.makeOrder(DateTime.Order)), (pullRequest) => pullRequest.updatedAt, @@ -1041,11 +1045,12 @@ export const make = Effect.gen(function* () { normalizeStatusCacheKey(cwd).pipe( Effect.flatMap((cacheKey) => Cache.invalidate(localStatusResultCache, cacheKey)), ); - // PR lookups hit the hosting provider's API (gh/glab/...), so they refresh - // on their own, slower cadence: ahead/behind counts stay fresh on every - // status poll while the PR association is re-fetched at most once per - // PR_LOOKUP_CACHE_TTL per branch. Git actions and user-driven refreshes bump - // the epoch (invalidateStatus) to bypass the cache immediately. + // PR lookups hit the hosting provider's API (gh/glab/...), so definitive + // results refresh on their slower cadence, with a longer TTL without an open + // PR. An unresolved provider starts at the shorter failure cadence and backs + // off while it stays unresolved, capped at the open-PR cadence. Git actions and + // user-driven refreshes bump the epoch (invalidateStatus) to bypass the cache + // immediately. const prLookupEpochByCwd = new Map(); const prLookupEpoch = (cwd: string) => prLookupEpochByCwd.get(cwd) ?? 0; const bumpPrLookupEpoch = (cwd: string) => @@ -1075,8 +1080,9 @@ export const make = Effect.gen(function* () { details.remoteName ?? "", String(prLookupEpoch(cwd)), ].join("\u0000"); - // Consecutive failures per cache key, so a branch that keeps failing waits - // longer before the next attempt. Cleared as soon as a lookup succeeds. + // Consecutive failed or non-definitive attempts per cache key, so a branch + // that keeps failing waits longer before the next attempt. Cleared as soon + // as the lookup produces a definitive result. const prLookupFailureStreakByKey = new Map(); const nextPrLookupFailureTtl = (key: string) => { if ( @@ -1112,7 +1118,10 @@ export const make = Effect.gen(function* () { return Effect.gen(function* () { const { headContext, lookup } = yield* resolveLookupHeadContext(cwd, details); if (!lookup) { - return { latest: null, headContext }; + return { + outcome: { _tag: "Complete", latest: null } satisfies PrLookupOutcome, + headContext, + }; } // Only skip when the branch is untracked as well: anything carrying an // upstream keeps the old behaviour. @@ -1121,18 +1130,24 @@ export const make = Effect.gen(function* () { details.upstreamRef === null && (yield* isUnpublishedBranch(cwd, headContext)) ) { - return { latest: null, headContext }; + return { + outcome: { _tag: "Complete", latest: null } satisfies PrLookupOutcome, + headContext, + }; } - const latest = yield* findLatestPrForHeadContext(cwd, headContext); - return { latest, headContext }; + const outcome = yield* findLatestPrForHeadContext(cwd, headContext); + return { outcome, headContext }; }); }, { capacity: PR_LOOKUP_CACHE_CAPACITY, timeToLive: (exit, key) => { if (Exit.isSuccess(exit)) { + if (exit.value.outcome._tag === "ProviderUnknown") { + return Duration.min(nextPrLookupFailureTtl(key), PR_LOOKUP_CACHE_TTL); + } prLookupFailureStreakByKey.delete(key); - return exit.value.latest?.state === "open" + return exit.value.outcome.latest?.state === "open" ? PR_LOOKUP_CACHE_TTL : PR_LOOKUP_NO_OPEN_PR_CACHE_TTL; } @@ -1216,32 +1231,38 @@ export const make = Effect.gen(function* () { const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( Effect.orElseSucceed(() => Option.none()), ); - if (Option.isSome(cached) && cached.value.latest === null) { + if ( + Option.isSome(cached) && + cached.value.outcome._tag === "Complete" && + cached.value.outcome.latest === null + ) { yield* Cache.invalidate(prLookupCache, cacheKey); } } return yield* Cache.get(prLookupCache, cacheKey).pipe( - Effect.map(({ latest, headContext }) => { - if (!latest) return { pr: null, headContext }; - // 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 { pr: null, headContext }; + Effect.flatMap(({ outcome, headContext }) => { + const lastKnownContext = { + upstreamRef: details.upstreamRef, + headBranch: headContext.headBranch, + remoteName: headContext.remoteName, + headRemoteUrlKey: headContext.headRemoteUrlKey, + }; + if (outcome._tag === "ProviderUnknown") { + return Effect.succeed(resolveLastKnownPr(branchKey, lastKnownContext)); } - return { pr: toStatusPr(latest), headContext }; + + const pr = + outcome.latest === null || + // On the default branch, only surface open PRs. Merged/closed + // matches are usually reverse-merge history, not the thread's PR. + (details.isDefaultBranch && outcome.latest.state !== "open") + ? null + : toStatusPr(outcome.latest); + return Effect.sync(() => { + rememberLastKnownPr(branchKey, { pr, ...lastKnownContext }); + return pr; + }); }), - Effect.tap(({ pr, headContext }) => - Effect.sync(() => - rememberLastKnownPr(branchKey, { - pr, - upstreamRef: details.upstreamRef, - headBranch: headContext.headBranch, - remoteName: headContext.remoteName, - headRemoteUrlKey: headContext.headRemoteUrlKey, - }), - ), - ), - Effect.map(({ pr }) => pr), Effect.catch((error) => Effect.logWarning("PR lookup failed; keeping last known PR state.").pipe( Effect.annotateLogs({ @@ -1655,9 +1676,12 @@ export const make = Effect.gen(function* () { cwd: string, headContext: BranchHeadContext, ) { + const provider = yield* sourceControlProvider(cwd); + if (provider.kind === "unknown") { + return { _tag: "ProviderUnknown" } satisfies PrLookupOutcome; + } const parsedByNumber = new Map(); - const provider = yield* sourceControlProvider(cwd); for (const headSelector of probeableHeadSelectors(provider.kind, headContext.headSelectors)) { const pullRequests = yield* provider.listChangeRequests({ cwd, @@ -1678,9 +1702,9 @@ export const make = Effect.gen(function* () { const latestOpenPr = parsed.find((pr) => pr.state === "open"); if (latestOpenPr) { - return latestOpenPr; + return { _tag: "Complete", latest: latestOpenPr } satisfies PrLookupOutcome; } - return parsed[0] ?? null; + return { _tag: "Complete", latest: parsed[0] ?? null } satisfies PrLookupOutcome; }); const buildCompletionToast = Effect.fn("buildCompletionToast")(function* ( cwd: string, @@ -2224,13 +2248,16 @@ export const make = Effect.gen(function* () { }); if (options?.refresh) { // A completed turn can create a PR or reuse a merged PR's branch. - // Refresh successful answers, but keep failed lookups' retry backoff. + // Refresh definitive answers, but keep failed or unknown lookups' retry backoff. const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( Effect.orElseSucceed(() => Option.none()), ); - if (Option.isSome(cached)) yield* Cache.invalidate(prLookupCache, cacheKey); + if (Option.isSome(cached) && cached.value.outcome._tag === "Complete") { + yield* Cache.invalidate(prLookupCache, cacheKey); + } } let cached = yield* Cache.get(prLookupCache, cacheKey); + if (cached.outcome._tag === "ProviderUnknown") return null; // The cached head context may have resolved on a different remote than // the saved upstream: a branch tracking origin/main but pushed to a fork // is looked up on the fork. Verify against the remote the lookup used. @@ -2259,6 +2286,7 @@ export const make = Effect.gen(function* () { if (!hasSameIdentity(cached.headContext, currentIdentity)) { yield* Cache.invalidate(prLookupCache, cacheKey); cached = yield* Cache.get(prLookupCache, cacheKey); + if (cached.outcome._tag === "ProviderUnknown") return null; const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity( cacheCwd, branch, @@ -2275,7 +2303,7 @@ export const make = Effect.gen(function* () { }); } } - const { latest } = cached; + const { latest } = cached.outcome; if (latest === null) return null; if ( (branch === defaultBranch ||