From e7bb2ccdd59ef5221e37f395f7a373764f33c201 Mon Sep 17 00:00:00 2001 From: Ahmad El Mir Date: Thu, 8 Oct 2026 15:05:55 +0200 Subject: [PATCH] fix(server): Forgejo branch status no longer pages through every pull request Forgejo cannot filter pulls by head branch, so a branch without a PR scanned the whole repository on every status refresh. Search only the two most recently updated pages. Co-Authored-By: Claude Opus 5.5 --- .../ForgejoSourceControlProvider.test.ts | 64 +++++++++++++++++++ .../ForgejoSourceControlProvider.ts | 5 +- 2 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts new file mode 100644 index 000000000000..0a18e8c3590a --- /dev/null +++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts @@ -0,0 +1,64 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/process"; + +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as ForgejoCli from "./ForgejoCli.ts"; +import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; + +const pull = (number: number, branch: string) => ({ + number, + title: `PR ${number}`, + html_url: `https://forgejo.example/owner/repo/pulls/${number}`, + state: "open", + merged: false, + base: { ref: "main", sha: "base", repo: null }, + head: { ref: branch, sha: "head", repo: null }, +}); + +it.effect("stops searching for a branch's pull request after the most recent pages", () => + Effect.gen(function* () { + const paths: Array = []; + const provider = yield* ForgejoSourceControlProvider.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ForgejoCli.ForgejoCli)({ + resolveRepository: () => + Effect.succeed({ + login: "forgejo", + repository: "owner/repo", + baseUrl: "https://forgejo.example", + }), + api: (input) => { + paths.push(input.path); + const page = Number(new URL(input.path, "https://x").searchParams.get("page")); + const items = Array.from({ length: page <= 5 ? 50 : 0 }, (_, index) => + pull(page * 50 + index, "other"), + ); + return Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: JSON.stringify(items), + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + }, + }), + FileSystem.layerNoop({}), + Layer.mock(VcsProcess.VcsProcess)({}), + ), + ), + ); + + const changeRequests = yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/no-pr", + state: "all", + }); + + assert.deepStrictEqual(changeRequests, []); + assert.strictEqual(paths.length, 2); + }), +); diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts index 5a501d96b235..bd302b3c595e 100644 --- a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts +++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts @@ -171,6 +171,9 @@ const cloneUrls = (raw: typeof RepositorySchema.Type) => ({ }); const repositoryPath = (repository: string) => `repos/${repository.split("/").map(encodeURIComponent).join("/")}`; +// Forgejo cannot filter pulls by head branch, so a branch without a PR would otherwise page +// through every PR in the repository. Only the most recently updated ones are searched. +const HEAD_BRANCH_LOOKUP_MAX_PAGES = 2; export const make = Effect.gen(function* () { const cli = yield* ForgejoCli.ForgejoCli; @@ -235,7 +238,7 @@ export const make = Effect.gen(function* () { const branch = SourceControlProvider.sourceBranch(input); const results: ReturnType[] = []; const limit = input.limit ?? 20; - for (let page = 1; results.length < limit; page++) { + for (let page = 1; page <= HEAD_BRANCH_LOOKUP_MAX_PAGES && results.length < limit; page++) { const items = yield* request( { ...input,