From 80fc4ce670043ce78ff4cde824c284d78076d422 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 26 Oct 2026 16:52:48 +0000 Subject: [PATCH] fix(forgejo): share complete Gitea PR scans across branches Co-authored-by: Tin Zeljar <35663757+Zheruel@users.noreply.github.com> --- apps/server/src/git/GitManager.test.ts | 108 +++++++ apps/server/src/git/GitManager.ts | 16 +- .../src/server/SourceControlProvider.ts | 2 + .../ForgejoSourceControlProvider.test.ts | 296 ++++++++++++++++++ .../server/ForgejoSourceControlProvider.ts | 71 ++++- 5 files changed, 482 insertions(+), 11 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 8e6a42c2271d2..a0564287a3c96 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -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 & { @@ -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-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 53d0b4f5853ff..7d443fe8d1c11 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -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("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; @@ -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 | null; readonly upstreamRef: string | null; @@ -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. @@ -1850,6 +1856,7 @@ export const make = Effect.gen(function* () { headContext: BranchHeadContext, ) { const parsedByNumber = new Map(); + const allowCached = yield* AllowCachedPrLookup; const provider = yield* sourceControlProvider(cwd); const probe = headBranchProbe(provider, headContext.headSelectors, "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)) { @@ -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; @@ -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, diff --git a/packages/source-control-core/src/server/SourceControlProvider.ts b/packages/source-control-core/src/server/SourceControlProvider.ts index 76eb90cfd8c0e..1286a541ee965 100644 --- a/packages/source-control-core/src/server/SourceControlProvider.ts +++ b/packages/source-control-core/src/server/SourceControlProvider.ts @@ -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, SourceControlProviderError>; readonly getChangeRequest: (input: { readonly cwd: string; diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts index 9dd678d35f773..73857186aa6bd 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts @@ -1,6 +1,9 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -31,6 +34,299 @@ const requestJson = (request: HttpClientRequest.HttpClientRequest): unknown => { }; const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const branchPull = (number: number, head = `unrelated-${number}`, state = "open") => ({ + number, + title: `Pull request ${number}`, + html_url: `https://gitea.test/acme/web/pulls/${number}`, + state: state === "merged" ? "closed" : state, + merged: state === "merged", + head: { + ref: head, + sha: `head-${number}`, + repo: { full_name: "acme/web", owner: { login: "acme" } }, + }, + base: { + ref: "release/arbitrary", + sha: "base", + repo: { full_name: "acme/web", owner: { login: "acme" } }, + }, + updated_at: "2026-10-25T00:00:00Z", +}); + +const branchLookupFixture = ( + pulls: ReadonlyArray>, + honorsHead = false, +) => { + const paths: string[] = []; + const repository: ForgejoCli.ForgejoRepository = { + command: "tea", + login: "work", + repository: "acme/web", + baseUrl: "https://gitea.test", + }; + let beforeRequest: ( + input: ForgejoCli.ForgejoApiInput, + ) => Effect.Effect = () => Effect.void; + const provider = ForgejoSourceControlProvider.make.pipe( + Effect.provide( + Layer.mock(ForgejoCli.ForgejoCli)({ + resolveRepository: () => Effect.succeed(repository), + api: (input) => + Effect.gen(function* () { + paths.push(input.path); + yield* beforeRequest(input); + const [path, query] = input.path.split("?"); + assert.strictEqual(path, `repos/${repository.repository}/pulls`); + const params = new URLSearchParams(query); + const page = Number(params.get("page")); + const rows = pulls.filter( + (pull) => + (params.get("state") === "all" || pull.state === params.get("state")) && + (!honorsHead || !params.has("head") || pull.head.ref === params.get("head")), + ); + return processOutput(encodeJson(rows.slice((page - 1) * 50, page * 50))); + }), + }), + ), + Effect.provideService(FileSystem.FileSystem, FileSystem.makeNoop({})), + Effect.provide(TestSourceControlHost.layer()), + ); + return { + provider, + paths, + repository, + setBeforeRequest: (hook: typeof beforeRequest) => { + beforeRequest = hook; + }, + }; +}; + +it.effect("shares a complete ignored-head scan across background branch lookups", () => + Effect.gen(function* () { + const pulls = Array.from({ length: 75 }, (_, index) => branchPull(index + 1)); + pulls[70] = branchPull(71, "old-feature", "merged"); + const fixture = branchLookupFixture(pulls); + const provider = yield* fixture.provider; + const input = { + cwd: "/repo", + headSelector: "no-such-branch", + state: "all" as const, + allowCached: true, + }; + assert.deepStrictEqual(yield* provider.listChangeRequests(input), []); + assert.deepStrictEqual( + (yield* provider.listChangeRequests({ + ...input, + cwd: "/another-worktree", + headSelector: "old-feature", + })).map((pr) => [pr.number, pr.state, pr.baseRefName]), + [[71, "merged", "release/arbitrary"]], + ); + assert.strictEqual(fixture.paths.length, 4); + }), +); + +it.effect("keeps source identities, arbitrary bases and PR states in a shared scan", () => + Effect.gen(function* () { + const foreign = branchPull(2, "feature"); + const pulls = [ + branchPull(1), + { + ...foreign, + head: { + ...foreign.head, + repo: { full_name: "other/renamed-fork", owner: { login: "other" } }, + }, + }, + branchPull(3, "feature", "closed"), + branchPull(4, "feature", "open"), + branchPull(5, "feature", "merged"), + ]; + const fixture = branchLookupFixture(pulls); + const provider = yield* fixture.provider; + const input = { + cwd: "/repo", + headSelector: "acme:feature", + state: "all" as const, + allowCached: true, + }; + assert.deepStrictEqual( + (yield* provider.listChangeRequests(input)).map((pr) => [pr.number, pr.state]), + [ + [3, "closed"], + [4, "open"], + [5, "merged"], + ], + ); + for (const [state, number] of [ + ["open", 4], + ["closed", 3], + ["merged", 5], + ] as const) { + assert.deepStrictEqual( + (yield* provider.listChangeRequests({ ...input, state, limit: 1 })).map((pr) => pr.number), + [number], + ); + } + assert.deepStrictEqual( + (yield* provider.listChangeRequests({ + ...input, + source: { refName: "feature", repository: "other/renamed-fork" }, + })).map((pr) => [pr.number, pr.isCrossRepository]), + [[2, true]], + ); + assert.strictEqual(fixture.paths.length, 3); + }), +); + +it.effect("keeps filtered Forgejo lookups on their existing path", () => + Effect.gen(function* () { + const fixture = branchLookupFixture([branchPull(1), branchPull(2, "feature")], true); + const provider = yield* fixture.provider; + const input = { + cwd: "/repo", + headSelector: "feature", + state: "all" as const, + allowCached: true, + }; + assert.deepStrictEqual( + (yield* provider.listChangeRequests(input)).map((pr) => pr.number), + [2], + ); + assert.deepStrictEqual( + yield* provider.listChangeRequests({ ...input, headSelector: "missing" }), + [], + ); + assert.strictEqual(fixture.paths.length, 3); + assert.isTrue( + fixture.paths.every((path) => new URLSearchParams(path.split("?")[1]).has("head")), + ); + }), +); + +it.effect("expires complete scans and bypasses them for authoritative reads", () => + Effect.gen(function* () { + const pulls = [branchPull(1), branchPull(2, "feature", "closed")]; + const fixture = branchLookupFixture(pulls); + const provider = yield* fixture.provider; + const input = { + cwd: "/repo", + headSelector: "feature", + state: "all" as const, + allowCached: true, + }; + assert.strictEqual((yield* provider.listChangeRequests(input))[0]?.state, "closed"); + pulls[1] = branchPull(2, "feature"); + assert.strictEqual((yield* provider.listChangeRequests(input))[0]?.state, "closed"); + assert.strictEqual(fixture.paths.length, 3); + assert.strictEqual( + (yield* provider.listChangeRequests({ ...input, allowCached: false }))[0]?.state, + "open", + ); + assert.strictEqual(fixture.paths.length, 5); + assert.strictEqual((yield* provider.listChangeRequests(input))[0]?.state, "open"); + assert.strictEqual(fixture.paths.length, 8); + pulls[1] = branchPull(2, "feature", "merged"); + yield* TestClock.adjust("1 minute"); + assert.strictEqual((yield* provider.listChangeRequests(input))[0]?.state, "merged"); + assert.strictEqual(fixture.paths.length, 11); + }), +); + +it.effect("isolates complete scans by resolved server, login, CLI and repository", () => + Effect.gen(function* () { + const fixture = branchLookupFixture([branchPull(1)]); + const provider = yield* fixture.provider; + const input = { + cwd: "/repo", + headSelector: "missing", + state: "all" as const, + allowCached: true, + }; + for (const repository of [ + {}, + { baseUrl: "https://gitea.test/mount" }, + { login: "another-account" }, + { command: "fj" as const }, + { repository: "acme/another-repo" }, + ]) { + Object.assign(fixture.repository, repository); + const before = fixture.paths.length; + assert.deepStrictEqual(yield* provider.listChangeRequests(input), []); + assert.strictEqual(fixture.paths.length - before, 3); + } + }), +); + +it.effect("never caches a partial scan after an API failure", () => + Effect.gen(function* () { + const pulls = Array.from({ length: 75 }, (_, index) => branchPull(index + 1)); + pulls[70] = branchPull(71, "feature"); + const fixture = branchLookupFixture(pulls); + const provider = yield* fixture.provider; + fixture.setBeforeRequest((input) => + input.path.endsWith("page=2") + ? Effect.fail( + new ForgejoCli.ForgejoCliError({ + command: "tea", + cwd: input.cwd, + detail: "Synthetic API failure.", + }), + ) + : Effect.void, + ); + const input = { + cwd: "/repo", + headSelector: "feature", + state: "all" as const, + allowCached: true, + }; + assert.isTrue(Exit.isFailure(yield* Effect.exit(provider.listChangeRequests(input)))); + assert.strictEqual(fixture.paths.length, 3); + fixture.setBeforeRequest(() => Effect.void); + assert.deepStrictEqual( + (yield* provider.listChangeRequests(input)).map((pr) => pr.number), + [71], + ); + assert.strictEqual(fixture.paths.length, 7); + }), +); + +it.effect("joins an in-flight complete scan for another branch", () => + Effect.gen(function* () { + const fixture = branchLookupFixture([branchPull(1), branchPull(2, "feature")]); + const started = yield* Deferred.make(); + const release = yield* Deferred.make(); + fixture.setBeforeRequest((input) => + input.path.includes("&head=") + ? Effect.void + : Deferred.succeed(started, undefined).pipe(Effect.andThen(Deferred.await(release))), + ); + const provider = yield* fixture.provider; + const input = { + cwd: "/repo", + headSelector: "missing", + state: "all" as const, + allowCached: true, + }; + const first = yield* provider + .listChangeRequests(input) + .pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(started); + const second = yield* provider + .listChangeRequests({ ...input, headSelector: "feature", cwd: "/another-worktree" }) + .pipe(Effect.forkChild({ startImmediately: true })); + assert.strictEqual(fixture.paths.length, 2); + yield* Deferred.succeed(release, undefined); + assert.deepStrictEqual(yield* Fiber.join(first), []); + assert.deepStrictEqual( + (yield* Fiber.join(second)).map((pr) => pr.number), + [2], + ); + assert.strictEqual(fixture.paths.length, 3); + }), +); + const requiredChecksPullRequest = { number: 7, title: "Required checks", diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts index 3557a5c92eb24..c50ae75320d7c 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts @@ -1,5 +1,11 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as Cache from "effect/Cache"; +import * as Duration from "effect/Duration"; +import * as Equal from "effect/Equal"; +import * as Exit from "effect/Exit"; +import * as Hash from "effect/Hash"; +import * as Option from "effect/Option"; import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import * as Result from "effect/Result"; @@ -266,6 +272,26 @@ const cloneUrls = (raw: typeof RepositorySchema.Type) => ({ const repositoryPath = (repository: string) => `repos/${repository.split("/").map(encodeURIComponent).join("/")}`; +type Pulls = ReadonlyArray>; + +class RepositoryPullsLookup { + readonly key: string; + readonly lookup: Effect.Effect; + constructor( + repo: ForgejoCli.ForgejoRepository, + lookup: Effect.Effect, + ) { + this.key = JSON.stringify([repo.command, repo.baseUrl, repo.login, repo.repository]); + this.lookup = lookup; + } + [Equal.symbol](that: unknown): boolean { + return that instanceof RepositoryPullsLookup && this.key === that.key; + } + [Hash.symbol](): number { + return Hash.string(this.key); + } +} + export const make = Effect.gen(function* () { const cli = yield* ForgejoCli.ForgejoCli; const fs = yield* FileSystem.FileSystem; @@ -320,6 +346,27 @@ export const make = Effect.gen(function* () { ForgejoPullRequestSchema, ); }); + const repositoryPulls = yield* Cache.makeWith((key: RepositoryPullsLookup) => key.lookup, { + capacity: 16, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(1) : Duration.zero), + }); + const scanPulls = Effect.fn("ForgejoSourceControlProvider.scanPulls")(function* ( + input: ForgejoCli.ForgejoRepositoryInput, + repo: ForgejoCli.ForgejoRepository, + ) { + const pulls: Array> = []; + for (let page = 1; ; page++) { + const items = yield* request( + { + ...input, + path: `${repositoryPath(repo.repository)}/pulls?state=all&sort=recentupdate&limit=50&page=${page}`, + }, + Schema.Array(ForgejoPullRequestSchema), + ); + if (items.length === 0) return pulls; + pulls.push(...items.map(toForgejoChangeRequest)); + } + }); return SourceControlProvider.SourceControlProvider.of({ kind: SourceControlProviderKind.make("forgejo"), repositoryNameFromRemoteUrl, @@ -332,6 +379,19 @@ export const make = Effect.gen(function* () { const branch = SourceControlProvider.sourceBranch(input); const results: ReturnType[] = []; const limit = input.limit ?? 20; + if (limit <= 0) return results; + const matches = (pr: ReturnType) => + pr.headRefName === branch && + (!source?.repository || pr.headRepositoryNameWithOwner === source.repository) && + (!source?.owner || pr.headRepositoryOwnerLogin === source.owner) && + (input.state === "all" || pr.state === input.state); + const lookup = new RepositoryPullsLookup(repo, scanPulls(input, repo)); + if (input.allowCached) { + const cached = yield* Cache.getOption(repositoryPulls, lookup); + if (Option.isSome(cached)) return cached.value.filter(matches).slice(0, limit); + } else { + yield* Cache.invalidate(repositoryPulls, lookup); + } for (let page = 1; results.length < limit; page++) { const items = yield* request( { @@ -340,15 +400,12 @@ export const make = Effect.gen(function* () { }, Schema.Array(ForgejoPullRequestSchema), ); + if (input.allowCached && items.some((item) => item.head.ref !== branch)) { + return (yield* Cache.get(repositoryPulls, lookup)).filter(matches).slice(0, limit); + } for (const item of items) { - if ( - item.head.ref !== branch || - (source?.repository && item.head.repo?.full_name !== source.repository) || - (source?.owner && item.head.repo?.owner.login !== source.owner) - ) - continue; const normalized = toForgejoChangeRequest(item); - if (input.state === "all" || normalized.state === input.state) results.push(normalized); + if (matches(normalized)) results.push(normalized); } if (items.length === 0) break; }