Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,10 @@ import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.t
import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as GitManager from "./GitManager.ts";
import { driver as forgejoDriver } from "@t3tools/source-control-forgejo/server/driver";
import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
import * as HttpClient from "effect/http/HttpClient";

/** A change request the fake host reports. Omitted fields read as an open, undated request. */
type FakeChangeRequest = Omit<ChangeRequest, "provider" | "state" | "updatedAt"> & {
Expand Down Expand Up @@ -2232,6 +2236,110 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
}),
);

it.effect("shares Gitea branch scans while preferring the latest open PR across bases", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
yield* initRepo(repoDir);
const remoteDir = yield* createBareRemote();
yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
yield* runGit(repoDir, ["push", "-u", "origin", "main"]);
for (const branch of ["missing", "feature"]) {
yield* runGit(repoDir, ["checkout", "-b", branch]);
yield* runGit(repoDir, ["push", "-u", "origin", branch]);
}
yield* configureVisibleRemoteUrlWithLocalRewrite(
repoDir,
"origin",
"https://gitea.test/acme/web.git",
remoteDir,
);
const repo = { full_name: "acme/web", owner: { login: "acme" } };
const pulls = Array.from({ length: 75 }, (_, index) => ({
number: index + 1,
title: `PR ${index + 1}`,
html_url: `https://gitea.test/acme/web/pulls/${index + 1}`,
state: index === 1 ? "closed" : "open",
merged: index === 1,
updated_at: index < 2 ? "2026-10-25T00:00:00Z" : "2026-10-24T00:00:00Z",
head: {
ref: [1, 70, 71].includes(index) ? "feature" : `other-${index}`,
sha: "head",
repo,
},
base: { ref: index === 1 ? "main" : "release/arbitrary", sha: "base", repo },
}));
const paths: string[] = [];
const { sourceControl } = yield* forgejoDriver.make.pipe(
Effect.provideService(
FileSystem.FileSystem,
FileSystem.makeNoop({ exists: () => Effect.succeed(false) }),
),
Effect.provideService(
HttpClient.HttpClient,
HttpClient.make(() => Effect.die("tea owns HTTP in this fixture")),
),
Effect.provide(
Layer.mergeAll(
NodeServices.layer,
Layer.mock(SourceControlRateLimit.SourceControlRateLimit)({}),
TestSourceControlHost.layer({
process: {
run: (input) =>
Effect.sync(() => {
const output = TestSourceControlHost.processOutput;
if (input.command === "git") return output("https://gitea.test/acme/web.git");
if (input.args[0] === "login")
return output(
JSON.stringify([
{
name: "fixture",
url: "https://gitea.test",
user: "acme",
default: "true",
},
]),
);
expect(input.args[0]).toBe("api");
const url = new URL(input.args.at(-1)!);
if (url.pathname === "/api/v1/repos/acme/web")
return output(
JSON.stringify({
...repo,
clone_url: "https://gitea.test/acme/web.git",
ssh_url: "git@gitea.test:acme/web.git",
default_branch: "main",
}),
{ stderr: "HTTP/1.1 200 OK" },
);
expect(url.pathname).toBe("/api/v1/repos/acme/web/pulls");
paths.push(url.pathname + url.search);
const page = Number(url.searchParams.get("page"));
return output(JSON.stringify(pulls.slice((page - 1) * 50, page * 50)), {
stderr: "HTTP/1.1 200 OK",
});
}),
},
}),
),
),
);
const { manager } = yield* makeManager({ sourceControlProvider: sourceControl });
expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "missing" })).toBeNull();
expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject({
number: 71,
state: "open",
baseRef: "release/arbitrary",
});
expect(paths).toHaveLength(4);
pulls[70] = { ...pulls[70]!, state: "closed", merged: true };
pulls[71] = { ...pulls[71]!, state: "closed", merged: true };
pulls.push({ ...pulls[70]!, number: 76, state: "open", merged: false });
expect(
yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" }, { refresh: true }),
).toMatchObject({ number: 76, state: "open" });
}),
);

it.effect("status prefers open PR when merged PR has newer updatedAt", () =>
Effect.gen(function* () {
const repoDir = yield* makeTempDir("t3code-git-manager-");
Expand Down
16 changes: 12 additions & 4 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,9 @@ const PR_LOOKUP_NO_OPEN_PR_CACHE_TTL = Duration.minutes(5);
const PR_LOOKUP_FAILURE_BASE_TTL = Duration.seconds(20);
const PR_LOOKUP_FAILURE_MAX_TTL = Duration.minutes(15);
const PR_LOOKUP_CACHE_CAPACITY = 2_048;
const AllowCachedPrLookup = Context.Reference<boolean>("t3/git/GitManager/AllowCachedPrLookup", {
defaultValue: () => true,
});
const PR_LOOKUP_GIT_STATE_TTL = Duration.minutes(5);
const PR_THREAD_CONTEXT_BYTES = 16_000;
const PR_THREAD_COMMAND_OUTPUT_CHARS = 2_000;
Expand Down Expand Up @@ -1242,7 +1245,10 @@ export const make = Effect.gen(function* () {
},
},
);
const getPrLookup = (key: string) => detachStackFrame(Cache.get(prLookupCache, key));
const getPrLookup = (key: string, allowCached = true) =>
detachStackFrame(Cache.get(prLookupCache, key)).pipe(
Effect.provideService(AllowCachedPrLookup, allowCached),
);
interface LastKnownPr {
readonly pr: ReturnType<typeof toStatusPr> | null;
readonly upstreamRef: string | null;
Expand Down Expand Up @@ -1309,7 +1315,7 @@ export const make = Effect.gen(function* () {
yield* Cache.invalidate(prLookupCache, cacheKey);
}
}
return yield* getPrLookup(cacheKey).pipe(
return yield* getPrLookup(cacheKey, !refreshMissingPullRequest).pipe(
Effect.map(({ latest, headContext }) => {
if (!latest) return { pr: null, headContext };
// Merged/closed matches are usually reverse-merge history, not the thread's PR context.
Expand Down Expand Up @@ -1850,6 +1856,7 @@ export const make = Effect.gen(function* () {
headContext: BranchHeadContext,
) {
const parsedByNumber = new Map<number, PullRequestInfo>();
const allowCached = yield* AllowCachedPrLookup;

const provider = yield* sourceControlProvider(cwd);
const probe = headBranchProbe(provider, headContext.headSelectors, "all");
Expand All @@ -1859,6 +1866,7 @@ export const make = Effect.gen(function* () {
headSelector,
state: "all",
limit: probe.limit,
allowCached,
});

for (const pr of pullRequests.map(toPullRequestInfo)) {
Expand Down Expand Up @@ -2562,7 +2570,7 @@ export const make = Effect.gen(function* () {
);
if (Option.isSome(cached)) yield* Cache.invalidate(prLookupCache, cacheKey);
}
let cached = yield* getPrLookup(cacheKey);
let cached = yield* getPrLookup(cacheKey, !options.refresh);
// Lookup may use a fork instead of the saved upstream; verify the remote actually queried.
const identityRemoteName = (headContext: BranchHeadContext) =>
headContext.remoteName ?? remoteName ?? undefined;
Expand All @@ -2589,7 +2597,7 @@ export const make = Effect.gen(function* () {
}
if (!hasSameIdentity(cached.headContext, currentIdentity)) {
yield* Cache.invalidate(prLookupCache, cacheKey);
cached = yield* getPrLookup(cacheKey);
cached = yield* getPrLookup(cacheKey, false);
const refreshedIdentity = yield* resolvePrLookupRepositoryIdentity(
cacheCwd,
branch,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ export class SourceControlProvider extends Context.Service<
readonly headSelector: string;
readonly state: ChangeRequestState | "all";
readonly limit?: number;
/** Background reads may reuse complete results for up to one minute. */
readonly allowCached?: boolean;
}) => Effect.Effect<ReadonlyArray<ChangeRequest>, SourceControlProviderError>;
readonly getChangeRequest: (input: {
readonly cwd: string;
Expand Down
Loading
Loading