From 5013f1123a6937ff29872029d8d6d4ca47eb3b3e Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 12 Apr 2026 23:47:54 -0500 Subject: [PATCH] Cache PR lookups and tighten project matching - 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 --- apps/server/src/git/Layers/GitManager.test.ts | 32 +++++++++++++++++++ apps/server/src/git/Layers/GitManager.ts | 24 +++++++++++--- apps/web/src/pullRequestProjectMatch.test.ts | 22 +++++++++++++ apps/web/src/pullRequestProjectMatch.ts | 12 ++++--- 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 98cc1079b..e6efcb950 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -758,6 +758,38 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { }), ); + it.effect("status reuses cached PR lookup results for the same branch context", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("okcode-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-cache"]); + + const { manager, ghCalls } = yield* makeManager({ + ghScenario: { + prListSequence: [ + JSON.stringify([ + { + number: 91, + title: "Cached PR", + url: "https://github.com/pingdotgg/codething-mvp/pull/91", + baseRefName: "main", + headRefName: "feature/status-cache", + state: "OPEN", + updatedAt: "2026-03-10T07:00:00Z", + }, + ]), + ], + }, + }); + + const first = yield* manager.status({ cwd: repoDir }); + const second = yield* manager.status({ cwd: repoDir }); + + expect(first.pr).toEqual(second.pr); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + }), + ); + it.effect("creates a commit when working tree is dirty", () => Effect.gen(function* () { const repoDir = yield* makeTempDir("okcode-git-manager-"); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index 85fbb6605..4d737e76a 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -45,6 +45,11 @@ interface PullRequestInfo extends OpenPrInfo { updatedAt: string | null; } +interface LatestPrLookupCacheEntry { + expiresAtMs: number; + pr: PullRequestInfo | null; +} + interface ResolvedPullRequest { number: number; title: string; @@ -369,6 +374,8 @@ export const makeGitManager = Effect.gen(function* () { const gitCore = yield* GitCore; const gitHubCli = yield* GitHubCli; const textGeneration = yield* TextGeneration; + const latestPrLookupCache = new Map(); + const LATEST_PR_LOOKUP_CACHE_TTL_MS = 15_000; const createProgressEmitter = ( input: { cwd: string; action: "commit" | "commit_push" | "commit_push_pr" }, @@ -619,6 +626,13 @@ export const makeGitManager = Effect.gen(function* () { const findLatestPr = (cwd: string, details: { branch: string; upstreamRef: string | null }) => Effect.gen(function* () { const headContext = yield* resolveBranchHeadContext(cwd, details); + const cacheKey = [cwd, headContext.headSelectors.join("\u0000")].join("\u0001"); + const now = Date.now(); + const cached = latestPrLookupCache.get(cacheKey); + if (cached && cached.expiresAtMs > now) { + return cached.pr; + } + const parsedByNumber = new Map(); for (const headSelector of headContext.headSelectors) { @@ -663,10 +677,12 @@ export const makeGitManager = Effect.gen(function* () { }); const latestOpenPr = parsed.find((pr) => pr.state === "open"); - if (latestOpenPr) { - return latestOpenPr; - } - return parsed[0] ?? null; + const resolved = latestOpenPr ?? parsed[0] ?? null; + latestPrLookupCache.set(cacheKey, { + expiresAtMs: now + LATEST_PR_LOOKUP_CACHE_TTL_MS, + pr: resolved, + }); + return resolved; }); const resolveBaseBranch = ( diff --git a/apps/web/src/pullRequestProjectMatch.test.ts b/apps/web/src/pullRequestProjectMatch.test.ts index f3bfe994f..c684064e8 100644 --- a/apps/web/src/pullRequestProjectMatch.test.ts +++ b/apps/web/src/pullRequestProjectMatch.test.ts @@ -64,4 +64,26 @@ describe("findProjectMatchingPullRequestReference", () => { ), ).toBeNull(); }); + + it("returns null when multiple projects match the same repository slug", () => { + const projects = [ + makeProject({ + id: "project-1" as Project["id"], + name: "Psi Claw", + cwd: "/Users/buns/projects/psi-claw", + }), + makeProject({ + id: "project-2" as Project["id"], + name: "Psi Claw", + cwd: "/Users/buns/projects/psi-claw-copy", + }), + ]; + + expect( + findProjectMatchingPullRequestReference( + projects, + "https://github.com/OpenKnots/psi-claw/pull/137", + ), + ).toBeNull(); + }); }); diff --git a/apps/web/src/pullRequestProjectMatch.ts b/apps/web/src/pullRequestProjectMatch.ts index e36c42729..141fb4323 100644 --- a/apps/web/src/pullRequestProjectMatch.ts +++ b/apps/web/src/pullRequestProjectMatch.ts @@ -16,7 +16,7 @@ function normalizeRepositorySlug(input: string): string { } function projectRepositoryCandidates(project: Project): string[] { - const candidates = [project.name, lastPathSegment(project.cwd)] + const candidates = [lastPathSegment(project.cwd), project.name] .map(normalizeRepositorySlug) .filter((candidate) => candidate.length > 0); @@ -37,8 +37,12 @@ export function findProjectMatchingPullRequestReference( return null; } - return ( - projects.find((project) => projectRepositoryCandidates(project).includes(targetRepository)) ?? - null + const matches = projects.filter((project) => + projectRepositoryCandidates(project).includes(targetRepository), ); + if (matches.length !== 1) { + return null; + } + + return matches[0] ?? null; }