Skip to content

Commit 8368407

Browse files
authored
Cache PR lookups and tighten project matching (#429)
- Reuse recent PR lookup results during git status - Return null when a PR reference matches multiple projects - Add coverage for cached lookups and ambiguous matches
1 parent d988a63 commit 8368407

4 files changed

Lines changed: 82 additions & 8 deletions

File tree

apps/server/src/git/Layers/GitManager.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,38 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
758758
}),
759759
);
760760

761+
it.effect("status reuses cached PR lookup results for the same branch context", () =>
762+
Effect.gen(function* () {
763+
const repoDir = yield* makeTempDir("okcode-git-manager-");
764+
yield* initRepo(repoDir);
765+
yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]);
766+
767+
const { manager, ghCalls } = yield* makeManager({
768+
ghScenario: {
769+
prListSequence: [
770+
JSON.stringify([
771+
{
772+
number: 91,
773+
title: "Cached PR",
774+
url: "https://github.com/pingdotgg/codething-mvp/pull/91",
775+
baseRefName: "main",
776+
headRefName: "feature/status-cache",
777+
state: "OPEN",
778+
updatedAt: "2026-03-10T07:00:00Z",
779+
},
780+
]),
781+
],
782+
},
783+
});
784+
785+
const first = yield* manager.status({ cwd: repoDir });
786+
const second = yield* manager.status({ cwd: repoDir });
787+
788+
expect(first.pr).toEqual(second.pr);
789+
expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1);
790+
}),
791+
);
792+
761793
it.effect("creates a commit when working tree is dirty", () =>
762794
Effect.gen(function* () {
763795
const repoDir = yield* makeTempDir("okcode-git-manager-");

apps/server/src/git/Layers/GitManager.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,11 @@ interface PullRequestInfo extends OpenPrInfo {
4545
updatedAt: string | null;
4646
}
4747

48+
interface LatestPrLookupCacheEntry {
49+
expiresAtMs: number;
50+
pr: PullRequestInfo | null;
51+
}
52+
4853
interface ResolvedPullRequest {
4954
number: number;
5055
title: string;
@@ -369,6 +374,8 @@ export const makeGitManager = Effect.gen(function* () {
369374
const gitCore = yield* GitCore;
370375
const gitHubCli = yield* GitHubCli;
371376
const textGeneration = yield* TextGeneration;
377+
const latestPrLookupCache = new Map<string, LatestPrLookupCacheEntry>();
378+
const LATEST_PR_LOOKUP_CACHE_TTL_MS = 15_000;
372379

373380
const createProgressEmitter = (
374381
input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" },
@@ -619,6 +626,13 @@ export const makeGitManager = Effect.gen(function* () {
619626
const findLatestPr = (cwd: string, details: { branch: string; upstreamRef: string | null }) =>
620627
Effect.gen(function* () {
621628
const headContext = yield* resolveBranchHeadContext(cwd, details);
629+
const cacheKey = [cwd, headContext.headSelectors.join("\u0000")].join("\u0001");
630+
const now = Date.now();
631+
const cached = latestPrLookupCache.get(cacheKey);
632+
if (cached && cached.expiresAtMs > now) {
633+
return cached.pr;
634+
}
635+
622636
const parsedByNumber = new Map<number, PullRequestInfo>();
623637

624638
for (const headSelector of headContext.headSelectors) {
@@ -663,10 +677,12 @@ export const makeGitManager = Effect.gen(function* () {
663677
});
664678

665679
const latestOpenPr = parsed.find((pr) => pr.state === "open");
666-
if (latestOpenPr) {
667-
return latestOpenPr;
668-
}
669-
return parsed[0] ?? null;
680+
const resolved = latestOpenPr ?? parsed[0] ?? null;
681+
latestPrLookupCache.set(cacheKey, {
682+
expiresAtMs: now + LATEST_PR_LOOKUP_CACHE_TTL_MS,
683+
pr: resolved,
684+
});
685+
return resolved;
670686
});
671687

672688
const resolveBaseBranch = (

apps/web/src/pullRequestProjectMatch.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,26 @@ describe("findProjectMatchingPullRequestReference", () => {
6464
),
6565
).toBeNull();
6666
});
67+
68+
it("returns null when multiple projects match the same repository slug", () => {
69+
const projects = [
70+
makeProject({
71+
id: "project-1" as Project["id"],
72+
name: "Psi Claw",
73+
cwd: "/Users/buns/projects/psi-claw",
74+
}),
75+
makeProject({
76+
id: "project-2" as Project["id"],
77+
name: "Psi Claw",
78+
cwd: "/Users/buns/projects/psi-claw-copy",
79+
}),
80+
];
81+
82+
expect(
83+
findProjectMatchingPullRequestReference(
84+
projects,
85+
"https://github.com/OpenKnots/psi-claw/pull/137",
86+
),
87+
).toBeNull();
88+
});
6789
});

apps/web/src/pullRequestProjectMatch.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ function normalizeRepositorySlug(input: string): string {
1616
}
1717

1818
function projectRepositoryCandidates(project: Project): string[] {
19-
const candidates = [project.name, lastPathSegment(project.cwd)]
19+
const candidates = [lastPathSegment(project.cwd), project.name]
2020
.map(normalizeRepositorySlug)
2121
.filter((candidate) => candidate.length > 0);
2222

@@ -37,8 +37,12 @@ export function findProjectMatchingPullRequestReference(
3737
return null;
3838
}
3939

40-
return (
41-
projects.find((project) => projectRepositoryCandidates(project).includes(targetRepository)) ??
42-
null
40+
const matches = projects.filter((project) =>
41+
projectRepositoryCandidates(project).includes(targetRepository),
4342
);
43+
if (matches.length !== 1) {
44+
return null;
45+
}
46+
47+
return matches[0] ?? null;
4448
}

0 commit comments

Comments
 (0)