From c5e06e7dceb8fe452907c0cede7cda0a6a79c1b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFs=20Postula?= Date: Thu, 17 Sep 2026 10:56:01 +0200 Subject: [PATCH 1/3] fix(source-control): stop Forgejo status refresh from scanning every pull request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forgejo listed pull requests unfiltered and matched the head branch in JS, so a branch with one pull request or none walked the repository's entire history 50 rows at a time until a page came back empty. Status refreshes run that under the remote write lock, which is why vcs.refreshStatus hung past 15s on repositories with any real pull request history. Forgejo's pulls endpoint accepts a head parameter, so narrow server-side. The client-side branch match stays: a server too old for the filter ignores it and answers unfiltered. Signed-off-by: Loïs Postula --- .../ForgejoSourceControlProvider.test.ts | 79 +++++++++++++++++++ .../server/ForgejoSourceControlProvider.ts | 2 +- 2 files changed, 80 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..5d518abced07 --- /dev/null +++ b/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts @@ -0,0 +1,79 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { ChildProcessSpawner } from "effect/unstable/process"; +import * as FileSystem from "effect/FileSystem"; + +import * as ForgejoCli from "./ForgejoCli.ts"; +import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; + +const output = (stdout: string) => ({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +const pull = (number: number, headRef: string) => ({ + number, + title: `PR ${number}`, + html_url: `https://forge.test/owner/repo/pulls/${number}`, + state: "open", + merged: false, + base: { ref: "main", sha: "base", repo: { full_name: "owner/repo", owner: { login: "owner" } } }, + head: { ref: headRef, sha: "head", repo: { full_name: "owner/repo", owner: { login: "owner" } } }, +}); + +function makeProvider(cli: Partial) { + return ForgejoSourceControlProvider.make.pipe( + Effect.provide( + Layer.mergeAll( + Layer.mock(ForgejoCli.ForgejoCli)({ + resolveRepository: () => + Effect.succeed({ + login: "forge", + repository: "owner/repo", + baseUrl: "https://forge.test", + }), + ...cli, + }), + Layer.mock(VcsProcess.VcsProcess)({}), + FileSystem.layerNoop({}), + ), + ), + ); +} + +it.effect("narrows the pull request listing to the head branch server-side", () => + Effect.gen(function* () { + const paths: string[] = []; + const provider = yield* makeProvider({ + api: (input) => { + paths.push(input.path); + return Effect.succeed( + output(JSON.stringify(paths.length === 1 ? [pull(7, "feature/branch")] : [])), + ); + }, + }); + + const results = yield* provider.listChangeRequests({ + cwd: "/repo", + headSelector: "feature/branch", + state: "all", + limit: 20, + }); + + assert.deepStrictEqual( + results.map((result) => result.number), + [7], + ); + // The scan stops once the server answers an empty page, which only stays cheap + // while the server does the branch filtering. + assert.strictEqual(paths.length, 2); + for (const path of paths) { + assert.include(path, "head=feature%2Fbranch"); + } + }), +); diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts index fbcc5fbee99d..ab20a027f777 100644 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts @@ -286,7 +286,7 @@ export const make = Effect.gen(function* () { const items = yield* request( { ...input, - path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&sort=recentupdate&limit=50&page=${page}`, + path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&head=${encodeURIComponent(branch)}&sort=recentupdate&limit=50&page=${page}`, }, Schema.Array(ForgejoPullRequestSchema), ); From eeaceb27dc9b62bbc5b38858d8aae3ea2c4634f1 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:09:24 +0000 Subject: [PATCH 2/3] test(forgejo): adapt head-filter regression test to provider package Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../server/ForgejoSourceControlProvider.head-filter.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts => packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.head-filter.test.ts (92%) diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.head-filter.test.ts similarity index 92% rename from apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts rename to packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.head-filter.test.ts index 5d518abced07..ece2a2bae606 100644 --- a/apps/server/src/sourceControl/ForgejoSourceControlProvider.test.ts +++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.head-filter.test.ts @@ -1,12 +1,12 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { ChildProcessSpawner } from "effect/unstable/process"; +import { ChildProcessSpawner } from "effect/process"; import * as FileSystem from "effect/FileSystem"; import * as ForgejoCli from "./ForgejoCli.ts"; import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; -import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; const output = (stdout: string) => ({ exitCode: ChildProcessSpawner.ExitCode(0), @@ -39,7 +39,7 @@ function makeProvider(cli: Partial) { }), ...cli, }), - Layer.mock(VcsProcess.VcsProcess)({}), + TestSourceControlHost.layer({}), FileSystem.layerNoop({}), ), ), From 62a3b564438f30904f2285e0c33f4cca6c606d92 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 10 Oct 2026 11:10:49 -0700 Subject: [PATCH 3/3] Delete packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.head-filter.test.ts --- ...oSourceControlProvider.head-filter.test.ts | 79 ------------------- 1 file changed, 79 deletions(-) delete mode 100644 packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.head-filter.test.ts diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.head-filter.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.head-filter.test.ts deleted file mode 100644 index ece2a2bae606..000000000000 --- a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.head-filter.test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; -import { ChildProcessSpawner } from "effect/process"; -import * as FileSystem from "effect/FileSystem"; - -import * as ForgejoCli from "./ForgejoCli.ts"; -import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; -import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost"; - -const output = (stdout: string) => ({ - exitCode: ChildProcessSpawner.ExitCode(0), - stdout, - stderr: "", - stdoutTruncated: false, - stderrTruncated: false, -}); - -const pull = (number: number, headRef: string) => ({ - number, - title: `PR ${number}`, - html_url: `https://forge.test/owner/repo/pulls/${number}`, - state: "open", - merged: false, - base: { ref: "main", sha: "base", repo: { full_name: "owner/repo", owner: { login: "owner" } } }, - head: { ref: headRef, sha: "head", repo: { full_name: "owner/repo", owner: { login: "owner" } } }, -}); - -function makeProvider(cli: Partial) { - return ForgejoSourceControlProvider.make.pipe( - Effect.provide( - Layer.mergeAll( - Layer.mock(ForgejoCli.ForgejoCli)({ - resolveRepository: () => - Effect.succeed({ - login: "forge", - repository: "owner/repo", - baseUrl: "https://forge.test", - }), - ...cli, - }), - TestSourceControlHost.layer({}), - FileSystem.layerNoop({}), - ), - ), - ); -} - -it.effect("narrows the pull request listing to the head branch server-side", () => - Effect.gen(function* () { - const paths: string[] = []; - const provider = yield* makeProvider({ - api: (input) => { - paths.push(input.path); - return Effect.succeed( - output(JSON.stringify(paths.length === 1 ? [pull(7, "feature/branch")] : [])), - ); - }, - }); - - const results = yield* provider.listChangeRequests({ - cwd: "/repo", - headSelector: "feature/branch", - state: "all", - limit: 20, - }); - - assert.deepStrictEqual( - results.map((result) => result.number), - [7], - ); - // The scan stops once the server answers an empty page, which only stays cheap - // while the server does the branch filtering. - assert.strictEqual(paths.length, 2); - for (const path of paths) { - assert.include(path, "head=feature%2Fbranch"); - } - }), -);