diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 1e4d08cff438..a0cc182a06dd 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -74,6 +74,79 @@ function callAt(index: number) { afterEach(() => mockedRequest.mockReset()); +it.effect("skips an invalid hydrated search pull request without dropping later valid rows", () => + Effect.gen(function* () { + mockedRequest.mockImplementation((input) => { + if (input.path.startsWith("/repos/acme/web/issues?")) + return Effect.succeed(response([{ number: 1 }, { number: 2 }])); + if (input.path === "/repos/acme/web/pulls/1") return Effect.succeed(response({ number: 1 })); + if (input.path === "/repos/acme/web/pulls/2") + return Effect.succeed(response(rawPullRequest(2))); + return Effect.die(`unexpected request: ${input.path}`); + }); + const api = yield* GiteaPullRequestApi.make.pipe( + Effect.provideService( + GiteaApi.GiteaApi, + GiteaApi.GiteaApi.of({ + baseUrl: Option.some("https://forge.example.test/gitea"), + sshHosts: ["work-forge"], + request: mockedRequest, + probeAuth: Effect.die("not used"), + }), + ), + ); + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "reader", + limit: 10, + query: "bug", + }); + expect(page.items.map((pullRequest) => pullRequest.number)).toEqual([2]); + }), +); + +it.effect("keeps a search hydration transport failure fatal", () => + Effect.gen(function* () { + mockedRequest.mockImplementation((input) => { + if (input.path.startsWith("/repos/acme/web/issues?")) + return Effect.succeed(response([{ number: 1 }])); + return Effect.fail( + new GiteaApi.GiteaApiError({ + operation: "getPullRequest", + reason: "unauthenticated", + detail: "expired", + }), + ); + }); + const api = yield* GiteaPullRequestApi.make.pipe( + Effect.provideService( + GiteaApi.GiteaApi, + GiteaApi.GiteaApi.of({ + baseUrl: Option.some("https://forge.example.test/gitea"), + sshHosts: ["work-forge"], + request: mockedRequest, + probeAuth: Effect.die("not used"), + }), + ), + ); + const error = yield* api + .listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "reader", + limit: 10, + query: "bug", + }) + .pipe(Effect.flip); + assert.strictEqual(error.reason, "unauthenticated"); + }), +); + layer("GiteaPullRequestApi", (it) => { it.effect("validates the requested host before making an HTTP request", () => Effect.gen(function* () { @@ -222,6 +295,181 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("uses native issue search and hydrates its pull request summaries", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response([{ number: 7 }, { number: 8 }]))) + .mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7)))) + .mockReturnValueOnce(Effect.succeed(response(rawPullRequest(8)))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "reviewer", + limit: 1, + query: "needs review", + }); + + expect(page.items.map((item) => item.number)).toEqual([7]); + assert.strictEqual(page.consumed, 1); + assert.isTrue(page.truncated); + expect(callAt(0).path).toContain("/repos/acme/web/issues?"); + expect(callAt(0).path).toContain("type=pulls"); + expect(callAt(0).path).toContain("q=needs+review"); + expect(callAt(0).path).not.toContain("/pulls?"); + expect( + mockedRequest.mock.calls + .slice(1) + .map(([request]) => request.path) + .toSorted(), + ).toEqual(["/repos/acme/web/pulls/7", "/repos/acme/web/pulls/8"]); + }), + ); + + it.effect("returns a page-boundary search match without requesting the page after the cap", () => + Effect.gen(function* () { + mockedRequest.mockImplementation((request) => { + if (request.path.includes("/pulls/100")) + return Effect.succeed(response(rawPullRequest(100))); + const url = new URL(request.path, "https://forge.example.test"); + const page = Number(url.searchParams.get("page")); + return Effect.succeed( + response(page === 100 ? [{ number: 100 }] : [{ number: "malformed" }], { + "x-total-count": "5000", + link: `; rel="next"`, + }), + ); + }); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "reviewer", + limit: 1, + query: "match", + }); + const searchPages = mockedRequest.mock.calls + .map(([request]) => request.path) + .filter((path) => path.includes("/issues?")) + .map((path) => + Number(new URL(path, "https://forge.example.test").searchParams.get("page")), + ); + + expect(page.items.map((item) => item.number)).toEqual([100]); + assert.strictEqual(page.consumed, 100); + assert.isTrue(page.truncated); + expect(searchPages).toEqual(Array.from({ length: 100 }, (_, index) => index + 1)); + assert.strictEqual(mockedRequest.mock.calls.length, 101); + }), + ); + + it.effect("post-filters merged state and keeps authored search case-insensitive", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response([{ number: 1 }, { number: 2 }]))) + .mockReturnValueOnce( + Effect.succeed( + response( + rawPullRequest(1, { + state: "closed", + merged: true, + user: { login: "AUTHOR" }, + }), + ), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response( + rawPullRequest(2, { + state: "closed", + merged: true, + user: { login: "other" }, + }), + ), + ), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "merged", + involvement: "authored", + viewer: "author", + limit: 1, + query: "release", + }); + + expect(page.items.map((item) => item.number)).toEqual([1]); + assert.strictEqual(page.consumed, 1); + assert.isTrue(page.truncated); + expect(callAt(0).path).toContain("state=closed"); + expect(callAt(0).path).toContain("created_by=author"); + }), + ); + + it.effect("carries a search cursor as a raw-row offset without an updated-time filter", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed(response([{ number: 1 }, { number: 2 }], { "x-total-count": "4" })), + ) + .mockReturnValueOnce( + Effect.succeed(response([{ number: 3 }, { number: 4 }], { "x-total-count": "4" })), + ) + .mockReturnValueOnce(Effect.succeed(response(rawPullRequest(3)))) + .mockReturnValueOnce(Effect.succeed(response(rawPullRequest(4)))); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "reviewer", + limit: 1, + query: "cursor", + cursor: { + updatedBefore: "2026-09-02T10:10:00.000Z", + delivered: 2, + }, + }); + + expect(page.items.map((item) => item.number)).toEqual([3]); + assert.strictEqual(page.consumed, 1); + assert.isTrue(page.truncated); + expect(callAt(0).path).toContain("page=1"); + expect(callAt(0).path).not.toContain("updatedBefore"); + expect(callAt(1).path).toContain("page=2"); + }), + ); + + it.effect("fails at the pagination cap when native search has no matching pull request", () => + Effect.gen(function* () { + mockedRequest.mockImplementation(() => + Effect.succeed(response([{ number: "malformed" }], { "x-total-count": "101" })), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const error = yield* api + .listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "all", + viewer: "reviewer", + limit: 1, + query: "does-not-exist", + }) + .pipe(Effect.flip); + + expect(error.detail).toContain("safe page limit"); + assert.strictEqual(mockedRequest.mock.calls.length, 100); + }), + ); + it.effect("rescans capped Gitea pages to apply a raw-row cursor without gaps", () => Effect.gen(function* () { mockedRequest diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 0c4140a63d62..6f42568a2a15 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -37,12 +37,16 @@ import { reactionTarget, } from "./GiteaConversation.ts"; import type { ProviderListCursor } from "./PullRequestProvider.ts"; +import * as GiteaSearch from "./GiteaSearch.ts"; import { dedupeChecks } from "./pullRequestChecks.ts"; const PAGE_SIZE = 50; const CONVERSATION_PAGES = 4; const MAX_PAGINATION_PAGES = 100; const DIFF_MAX_BYTES = 8 * 1024 * 1024; +// Issue search rows do not carry branches or full state, so hydrate them without opening an +// unbounded fan-out against the pull endpoint. +const SEARCH_HYDRATION_CONCURRENCY = 8; const RawUser = Schema.Struct({ id: Schema.optional(Schema.Int), @@ -317,6 +321,23 @@ function pullRequest(value: RawPullRequest): GiteaPullRequest | null { }; } +function matchesPullRequest( + value: GiteaPullRequest, + state: PullRequestListState, + involvement: PullRequestInvolvement, + viewer: string, +): boolean { + if (state !== "all" && value.state !== state) return false; + if (involvement === "authored" && value.author?.login.toLowerCase() !== viewer.toLowerCase()) + return false; + if ( + involvement === "reviewing" && + !value.reviewRequestLogins.some((login) => login.toLowerCase() === viewer.toLowerCase()) + ) + return false; + return true; +} + function query( path: string, params: Readonly>, @@ -422,6 +443,7 @@ export class GiteaPullRequestApi extends Context.Service< readonly involvement: PullRequestInvolvement; readonly viewer: string; readonly limit: number; + readonly query?: string; readonly cursor?: ProviderListCursor; }) => Effect.Effect< { @@ -684,6 +706,30 @@ export const make = Effect.gen(function* () { return mapped; }); + const getSearchPullRequest = Effect.fn("GiteaPullRequestApi.getSearchPullRequest")( + function* (input: { + host: string; + repository: string; + number: number; + includeTracking?: boolean; + }) { + const response = yield* request({ + operation: "getPullRequest", + host: input.host, + repository: input.repository, + method: "GET", + path: query(`${basePath(input.repository)}/pulls/${input.number}`, { + include_tracking: input.includeTracking ? "true" : undefined, + }), + }); + const raw = yield* decode("getPullRequest", RawPullRequest, response).pipe(Effect.option); + return Option.match(raw, { + onNone: () => null, + onSome: pullRequest, + }); + }, + ); + const readUnknownPage = Effect.fn("GiteaPullRequestApi.readUnknownPage")(function* (input: { operation: string; host: string; @@ -695,6 +741,102 @@ export const make = Effect.gen(function* () { return { rows, headers: response.headers } satisfies UnknownPage; }); + const listSearchPullRequests = Effect.fn("GiteaPullRequestApi.listSearchPullRequests")( + function* (input: { + readonly host: string; + readonly repository: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly limit: number; + readonly query: string; + readonly cursor?: ProviderListCursor; + }) { + const wanted = Math.max(1, input.limit); + const delivered = input.cursor?.delivered ?? 0; + let page = 1; + let path = GiteaSearch.giteaSearchPath({ + repositoryPath: basePath(input.repository), + query: input.query, + state: input.state, + involvement: input.involvement, + viewer: input.viewer, + page, + limit: PAGE_SIZE, + }); + let rowsSeen = 0; + let rowsSkipped = 0; + let consumed = 0; + const collected: Array = []; + + while (page <= MAX_PAGINATION_PAGES) { + const result = yield* readUnknownPage({ + operation: "listPullRequests", + host: input.host, + repository: input.repository, + path, + }); + rowsSeen += result.rows.length; + const toSkip = Math.min(Math.max(0, delivered - rowsSkipped), result.rows.length); + rowsSkipped += toSkip; + const pageRows = result.rows.slice(toSkip); + const next = nextPagePath({ + path, + page, + pageRows: result.rows.length, + rowsSeen, + headers: result.headers, + }); + const hydrated = yield* Effect.forEach( + pageRows, + (row) => { + const number = GiteaSearch.giteaSearchIssueNumber(row); + return number === null + ? Effect.succeed(null) + : getSearchPullRequest({ + host: input.host, + repository: input.repository, + number, + }); + }, + { concurrency: SEARCH_HYDRATION_CONCURRENCY }, + ); + + for (const [index, pullRequest] of hydrated.entries()) { + consumed += 1; + if (pullRequest === null) continue; + if (!matchesPullRequest(pullRequest, input.state, input.involvement, input.viewer)) + continue; + collected.push(pullRequest); + if (collected.length === wanted) { + // A raw-row offset can safely continue even when this is the last allowed page; a + // search that cannot fill its requested slice reaches the bounded failure below. + return { + items: collected, + truncated: index < pageRows.length - 1 || next !== null, + consumed, + }; + } + } + if (next === null) break; + path = next; + page += 1; + } + if (page > MAX_PAGINATION_PAGES) { + return yield* new GiteaPullRequestApiError({ + operation: "listPullRequests", + reason: "failed", + detail: "Gitea pull request pagination exceeded the safe page limit.", + }); + } + return { + items: collected, + truncated: false, + consumed, + }; + }, + ); + const readUnknownArray = Effect.fn("GiteaPullRequestApi.readUnknownArray")( (input: { operation: string; host: string; repository: string; path: string }) => readUnknownPage(input).pipe(Effect.map((page) => page.rows)), @@ -742,6 +884,10 @@ export const make = Effect.gen(function* () { const listPullRequests: GiteaPullRequestApi["Service"]["listPullRequests"] = Effect.fn( "GiteaPullRequestApi.listPullRequests", )(function* (input) { + const search = input.query?.trim(); + if (search !== undefined && search !== "") { + return yield* listSearchPullRequests({ ...input, query: search }); + } const wanted = Math.max(1, input.limit); const delivered = input.cursor?.delivered ?? 0; const endpointState = @@ -782,19 +928,7 @@ export const make = Effect.gen(function* () { if (Option.isNone(decoded)) continue; const pr = pullRequest(decoded.value); if (pr === null) continue; - if (input.state !== "all" && pr.state !== input.state) continue; - if ( - input.involvement === "authored" && - pr.author?.login.toLowerCase() !== input.viewer.toLowerCase() - ) - continue; - if ( - input.involvement === "reviewing" && - !pr.reviewRequestLogins.some( - (login) => login.toLowerCase() === input.viewer.toLowerCase(), - ) - ) - continue; + if (!matchesPullRequest(pr, input.state, input.involvement, input.viewer)) continue; collected.push(pr); if (collected.length === wanted) { if (page === MAX_PAGINATION_PAGES && next !== null) { diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index b28fd4443d0c..6e7c8d65b285 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -26,9 +26,7 @@ const CAPABILITIES: PullRequestCapabilities = { ], mergeMethods: ["merge", "squash", "rebase"], updateMethods: ["merge", "rebase"], - // Gitea's repository pull listing has no text parameter. Returning an unfiltered page keeps - // narrowing correct at the service boundary without claiming host-side search. - search: false, + search: true, // Review summaries have no Gitea reaction route. Conversation rows carry reactions only for // target kinds the host supports; the provider must not claim the legacy all-remarks flag. reactions: false, @@ -150,6 +148,7 @@ export const make = Effect.gen(function* () { involvement: input.involvement, viewer: input.viewer, limit: input.limit, + ...(input.query === undefined ? {} : { query: input.query }), ...(input.cursor === undefined ? {} : { cursor: input.cursor }), }) .pipe( diff --git a/apps/server/src/pullRequest/GiteaSearch.test.ts b/apps/server/src/pullRequest/GiteaSearch.test.ts new file mode 100644 index 000000000000..946a3f10353a --- /dev/null +++ b/apps/server/src/pullRequest/GiteaSearch.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Option from "effect/Option"; + +import { decodeGiteaSearchIssue, giteaSearchIssueNumber, giteaSearchPath } from "./GiteaSearch.ts"; + +describe("giteaSearchPath", () => { + it("encodes pull-only text search and authored involvement", () => { + const path = giteaSearchPath({ + repositoryPath: "/repos/acme/web", + query: "needs review & fixes", + state: "merged", + involvement: "authored", + viewer: "Reviewer", + page: 2, + limit: 50, + }); + const url = new URL(path, "https://forge.example.test"); + + expect([...url.searchParams.entries()]).toEqual([ + ["type", "pulls"], + ["q", "needs review & fixes"], + ["state", "closed"], + ["page", "2"], + ["limit", "50"], + ["created_by", "Reviewer"], + ]); + }); + + it("does not add review involvement to the repository endpoint", () => { + const path = giteaSearchPath({ + repositoryPath: "/repos/acme/web", + query: "review", + state: "all", + involvement: "reviewing", + viewer: "Reviewer", + page: 1, + limit: 50, + }); + + expect(new URL(path, "https://forge.example.test").searchParams.get("created_by")).toBeNull(); + }); +}); + +describe("giteaSearchIssueNumber", () => { + it("keeps only positive integer issue numbers", () => { + expect(giteaSearchIssueNumber({ number: 12 })).toBe(12); + expect(giteaSearchIssueNumber({ number: 0 })).toBeNull(); + expect(giteaSearchIssueNumber({ number: "12" })).toBeNull(); + expect(Option.isSome(decodeGiteaSearchIssue({ number: 12 }))).toBe(true); + }); +}); diff --git a/apps/server/src/pullRequest/GiteaSearch.ts b/apps/server/src/pullRequest/GiteaSearch.ts new file mode 100644 index 000000000000..949ffe4be7ff --- /dev/null +++ b/apps/server/src/pullRequest/GiteaSearch.ts @@ -0,0 +1,50 @@ +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import { + PositiveInt, + type PullRequestInvolvement, + type PullRequestListState, +} from "@t3tools/contracts"; + +/** + * The repository issue search returns issue summaries. Pull request branches and the full state + * live behind the pull endpoint, so the caller hydrates each number before exposing a row. + */ +export const GiteaSearchIssue = Schema.Struct({ number: PositiveInt }); +export type GiteaSearchIssue = typeof GiteaSearchIssue.Type; + +export const decodeGiteaSearchIssue = Schema.decodeUnknownOption(GiteaSearchIssue); + +function endpointState(state: PullRequestListState): "open" | "closed" | "all" { + return state === "open" ? "open" : state === "all" ? "all" : "closed"; +} + +/** + * Gitea orders issue search by creation time. The service cursor is therefore carried as the + * delivered raw-row offset, just like the ordinary pull listing; an updated-time boundary would + * be unsafe for this endpoint's order. + */ +export function giteaSearchPath(input: { + readonly repositoryPath: string; + readonly query: string; + readonly state: PullRequestListState; + readonly involvement: PullRequestInvolvement; + readonly viewer: string; + readonly page: number; + readonly limit: number; +}): string { + const search = new URLSearchParams({ + type: "pulls", + q: input.query, + state: endpointState(input.state), + page: String(input.page), + limit: String(input.limit), + ...(input.involvement === "authored" ? { created_by: input.viewer } : {}), + }); + return `${input.repositoryPath}/issues?${search}`; +} + +export function giteaSearchIssueNumber(value: unknown): number | null { + const decoded = decodeGiteaSearchIssue(value); + return Option.isSome(decoded) ? decoded.value.number : null; +}