From ee08c2327ddc7d99a929831eca44138c292d5116 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Fri, 4 Sep 2026 23:32:39 -0500 Subject: [PATCH 01/19] feat(pull-requests): surface Gitea tracking summaries Assisted-by: Codex:GPT-6 --- .../pullRequest/GiteaPullRequestApi.test.ts | 82 +++++++++++++++++++ .../src/pullRequest/GiteaPullRequestApi.ts | 21 ++++- .../GiteaPullRequestProvider.test.ts | 47 ++++++++++- .../pullRequest/GiteaPullRequestProvider.ts | 21 +++-- apps/server/src/pullRequest/GiteaSearch.ts | 2 + 5 files changed, 164 insertions(+), 9 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 81c201979066..5f150c432a1c 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -300,6 +300,51 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("decodes nullable tracking summaries when explicitly requested", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + rawPullRequest(7, { + review_decision: "approved", + checks_state: "passing", + }), + ), + ), + ); + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + rawPullRequest(8, { + review_decision: null, + checks_state: null, + }), + ), + ), + ); + const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const pullRequest = yield* api.getPullRequest({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + includeTracking: true, + }); + + expect(pullRequest.reviewDecision).toBe("approved"); + expect(pullRequest.checksState).toBe("passing"); + expect(callAt(0).path).toBe("/repos/acme/web/pulls/7?include_tracking=true"); + + const nullablePullRequest = yield* api.getPullRequest({ + host: "forge.example.test", + repository: "acme/web", + number: 8, + includeTracking: true, + }); + expect(nullablePullRequest.reviewDecision).toBeNull(); + expect(nullablePullRequest.checksState).toBeNull(); + }), + ); + it.effect("keeps merged and closed pull requests distinct and counts malformed rows", () => Effect.gen(function* () { mockedRequest.mockReturnValueOnce( @@ -329,6 +374,7 @@ layer("GiteaPullRequestApi", (it) => { involvement: "all", viewer: "reviewer", limit: 2, + includeTracking: true, }); expect(page.items.map((item) => [item.number, item.state])).toEqual([ @@ -339,6 +385,7 @@ layer("GiteaPullRequestApi", (it) => { assert.isFalse(page.truncated); expect(callAt(0).path).toContain("state=closed"); expect(callAt(0).path).toContain("sort=recentupdate"); + expect(callAt(0).path).toContain("include_tracking=true"); }), ); @@ -435,6 +482,41 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("passes tracking opt-in through native search and pull hydration", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response([{ number: 7 }]))) + .mockReturnValueOnce( + Effect.succeed( + response( + rawPullRequest(7, { + review_decision: "review-required", + checks_state: "failing", + }), + ), + ), + ); + 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", + includeTracking: true, + }); + + expect(page.items[0]).toMatchObject({ + reviewDecision: "review-required", + checksState: "failing", + }); + expect(callAt(0).path).toContain("include_tracking=true"); + expect(callAt(1).path).toBe("/repos/acme/web/pulls/7?include_tracking=true"); + }), + ); + it.effect("returns a page-boundary search match without requesting the page after the cap", () => Effect.gen(function* () { mockedRequest.mockImplementation((request) => { diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 9885b7f44879..77b8546a3c1b 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -19,7 +19,9 @@ import type { PullRequestMergeability, PullRequestReaction, PullRequestReactionContent, + PullRequestChecksState, PullRequestReviewCommentDraft, + PullRequestReviewDecision, PullRequestReviewThread, PullRequestReviewVerdict, PullRequestReviewerCandidateList, @@ -91,6 +93,10 @@ const RawPullRequest = Schema.Struct({ merged: Schema.optional(Schema.Boolean), mergeable: Schema.optional(Schema.NullOr(Schema.Boolean)), draft: Schema.optional(Schema.Boolean), + review_decision: Schema.optional( + Schema.NullOr(Schema.Literals(["approved", "changes-requested", "review-required"])), + ), + checks_state: Schema.optional(Schema.NullOr(Schema.Literals(["passing", "failing", "pending"]))), auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), auto_merge_method: Schema.optional(Schema.NullOr(Schema.String)), html_url: Schema.String, @@ -236,6 +242,8 @@ export interface GiteaPullRequest { readonly commentCount: number; readonly autoMergeEnabled?: boolean; readonly autoMergeMethod?: PullRequestMergeMethod; + readonly reviewDecision?: PullRequestReviewDecision | null; + readonly checksState?: PullRequestChecksState | null; } export interface GiteaRepositoryAccess { @@ -329,6 +337,8 @@ function pullRequest(value: RawPullRequest): GiteaPullRequest | null { ...(["merge", "squash", "rebase"].includes(value.auto_merge_method ?? "") ? { autoMergeMethod: value.auto_merge_method as PullRequestMergeMethod } : {}), + ...(value.review_decision === undefined ? {} : { reviewDecision: value.review_decision }), + ...(value.checks_state === undefined ? {} : { checksState: value.checks_state }), }; } @@ -465,6 +475,7 @@ export class GiteaPullRequestApi extends Context.Service< readonly limit: number; readonly query?: string; readonly cursor?: ProviderListCursor; + readonly includeTracking?: boolean; }) => Effect.Effect< { items: ReadonlyArray; @@ -477,6 +488,7 @@ export class GiteaPullRequestApi extends Context.Service< host: string; repository: string; number: number; + includeTracking?: boolean; }) => Effect.Effect; readonly getRepositoryAccess: (input: { host: string; @@ -706,6 +718,7 @@ export const make = Effect.gen(function* () { host: string; repository: string; number: number; + includeTracking?: boolean; }) { const operation = "getPullRequest"; const response = yield* request({ @@ -713,7 +726,9 @@ export const make = Effect.gen(function* () { host: input.host, repository: input.repository, method: "GET", - path: `${basePath(input.repository)}/pulls/${input.number}`, + path: query(`${basePath(input.repository)}/pulls/${input.number}`, { + include_tracking: input.includeTracking === true ? "true" : undefined, + }), }); const raw = yield* decode(operation, RawPullRequest, response); const mapped = pullRequest(raw); @@ -823,6 +838,7 @@ export const make = Effect.gen(function* () { readonly limit: number; readonly query: string; readonly cursor?: ProviderListCursor; + readonly includeTracking?: boolean; }) { const wanted = Math.max(1, input.limit); const delivered = input.cursor?.delivered ?? 0; @@ -835,6 +851,7 @@ export const make = Effect.gen(function* () { viewer: input.viewer, page, limit: PAGE_SIZE, + includeTracking: input.includeTracking, }); let rowsSeen = 0; let rowsSkipped = 0; @@ -869,6 +886,7 @@ export const make = Effect.gen(function* () { host: input.host, repository: input.repository, number, + includeTracking: input.includeTracking, }); }, { concurrency: SEARCH_HYDRATION_CONCURRENCY }, @@ -987,6 +1005,7 @@ export const make = Effect.gen(function* () { sort: "recentupdate", page, limit: PAGE_SIZE, + include_tracking: input.includeTracking === true ? "true" : undefined, ...(input.involvement === "authored" ? { poster: input.viewer } : {}), }); let rowsSeen = 0; diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts index 33493f55a7f5..d4fc6095d9b5 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts @@ -2,10 +2,55 @@ import { describe, expect, it } from "@effect/vitest"; import { giteaBaseComparison, + giteaToChangeRequest, giteaProviderFailure, giteaViewerPermissions, } from "./GiteaPullRequestProvider.ts"; -import { GiteaPullRequestApiError } from "./GiteaPullRequestApi.ts"; +import { GiteaPullRequestApiError, type GiteaPullRequest } from "./GiteaPullRequestApi.ts"; + +const trackedPullRequest: GiteaPullRequest = { + number: 7, + title: "Tracking summary", + body: "", + url: "https://forge.example.test/acme/web/pulls/7", + author: null, + headBranch: "feature", + headSha: "head-sha", + headRepositoryNameWithOwner: "acme/web", + baseBranch: "main", + baseSha: "base-sha", + mergeBaseSha: "base-sha", + state: "open", + isDraft: false, + mergeability: "mergeable", + additions: 1, + deletions: 1, + changedFiles: 1, + createdAt: "2026-09-04T00:00:00.000Z", + updatedAt: "2026-09-04T00:00:00.000Z", + mergedAt: null, + closedAt: null, + reviewRequestLogins: [], + reviewers: [], + labels: [], + commentCount: 0, + reviewDecision: "approved", + checksState: "failing", +}; + +it("maps Gitea tracking summaries into the neutral change request", () => { + expect(giteaToChangeRequest(trackedPullRequest)).toMatchObject({ + reviewDecision: "approved", + checksState: "failing", + }); + expect( + giteaToChangeRequest({ + ...trackedPullRequest, + reviewDecision: null, + checksState: null, + }), + ).toMatchObject({ reviewDecision: null, checksState: null }); +}); describe("giteaViewerPermissions", () => { it("offers workflow approval only when the server supports it and the viewer can write", () => { diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index 108aa3ab9022..e94af4d80fb5 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -93,7 +93,9 @@ export function giteaBaseComparison( return pullRequest.baseSha === pullRequest.mergeBaseSha ? "up-to-date" : "behind"; } -function toChangeRequest(pullRequest: GiteaPullRequestApi.GiteaPullRequest): ProviderChangeRequest { +export function giteaToChangeRequest( + pullRequest: GiteaPullRequestApi.GiteaPullRequest, +): ProviderChangeRequest { return { number: pullRequest.number, title: pullRequest.title, @@ -111,6 +113,10 @@ function toChangeRequest(pullRequest: GiteaPullRequestApi.GiteaPullRequest): Pro updatedAt: pullRequest.updatedAt, reviewRequestLogins: pullRequest.reviewRequestLogins, labels: pullRequest.labels, + ...(pullRequest.reviewDecision === undefined + ? {} + : { reviewDecision: pullRequest.reviewDecision }), + ...(pullRequest.checksState === undefined ? {} : { checksState: pullRequest.checksState }), }; } @@ -160,13 +166,14 @@ export const make = Effect.gen(function* () { involvement: input.involvement, viewer: input.viewer, limit: input.limit, + includeTracking: true, ...(input.query === undefined ? {} : { query: input.query }), ...(input.cursor === undefined ? {} : { cursor: input.cursor }), }) .pipe( Effect.mapError(fail("listChangeRequests")), Effect.map((page) => ({ - items: page.items.map(toChangeRequest), + items: page.items.map(giteaToChangeRequest), truncated: page.truncated, cursorAdvance: page.consumed, continues: true, @@ -176,7 +183,7 @@ export const make = Effect.gen(function* () { getChangeRequest: (input) => Effect.all( [ - api.getPullRequest(input), + api.getPullRequest({ ...input, includeTracking: true }), api.getRepositoryAccess(input), api.getViewer(), api.getAutoMergeEnabled(input), @@ -190,7 +197,7 @@ export const make = Effect.gen(function* () { api.listChecks({ ...input, sha: pullRequest.headSha }).pipe( Effect.orElseSucceed(() => []), Effect.map((checks): ProviderChangeRequestDetail => ({ - ...toChangeRequest(pullRequest), + ...giteaToChangeRequest(pullRequest), body: pullRequest.body, changedFiles: pullRequest.changedFiles, mergedAt: pullRequest.mergedAt, @@ -225,7 +232,7 @@ export const make = Effect.gen(function* () { ), getChangeRequestSummary: (input) => - api.getPullRequest(input).pipe( + api.getPullRequest({ ...input, includeTracking: true }).pipe( Effect.mapError(fail("getChangeRequestSummary")), Effect.map((pullRequest) => ({ number: pullRequest.number, @@ -242,7 +249,7 @@ export const make = Effect.gen(function* () { getChangeRequestActivity: (input) => Effect.all( [ - api.getPullRequest(input), + api.getPullRequest({ ...input, includeTracking: true }), api .listComments(input) .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), @@ -309,7 +316,7 @@ export const make = Effect.gen(function* () { getViewerPermissions: (input) => Effect.all( [ - api.getPullRequest(input), + api.getPullRequest({ ...input, includeTracking: true }), api.getRepositoryAccess(input), api.getViewer(), api.getFeatures().pipe(Effect.orElseSucceed(() => [])), diff --git a/apps/server/src/pullRequest/GiteaSearch.ts b/apps/server/src/pullRequest/GiteaSearch.ts index 949ffe4be7ff..f9459e667fa4 100644 --- a/apps/server/src/pullRequest/GiteaSearch.ts +++ b/apps/server/src/pullRequest/GiteaSearch.ts @@ -32,6 +32,7 @@ export function giteaSearchPath(input: { readonly viewer: string; readonly page: number; readonly limit: number; + readonly includeTracking?: boolean; }): string { const search = new URLSearchParams({ type: "pulls", @@ -39,6 +40,7 @@ export function giteaSearchPath(input: { state: endpointState(input.state), page: String(input.page), limit: String(input.limit), + ...(input.includeTracking === true ? { include_tracking: "true" } : {}), ...(input.involvement === "authored" ? { created_by: input.viewer } : {}), }); return `${input.repositoryPath}/issues?${search}`; From 911169f7118984b739bfcf41bca680b64d954ceb Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Fri, 4 Sep 2026 23:38:02 -0500 Subject: [PATCH 02/19] fix(pull-requests): limit Gitea tracking reads to displayed summaries --- apps/server/src/pullRequest/GiteaPullRequestApi.test.ts | 4 ++-- apps/server/src/pullRequest/GiteaPullRequestProvider.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 5f150c432a1c..b3e5d519cbb2 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -322,7 +322,7 @@ layer("GiteaPullRequestApi", (it) => { ), ), ); - const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const api = yield* GiteaPullRequestApi.make; const pullRequest = yield* api.getPullRequest({ host: "forge.example.test", repository: "acme/web", @@ -496,7 +496,7 @@ layer("GiteaPullRequestApi", (it) => { ), ), ); - const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; + const api = yield* GiteaPullRequestApi.make; const page = yield* api.listPullRequests({ host: "forge.example.test", repository: "acme/web", diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index e94af4d80fb5..fbe4b0d0dc42 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -232,7 +232,7 @@ export const make = Effect.gen(function* () { ), getChangeRequestSummary: (input) => - api.getPullRequest({ ...input, includeTracking: true }).pipe( + api.getPullRequest(input).pipe( Effect.mapError(fail("getChangeRequestSummary")), Effect.map((pullRequest) => ({ number: pullRequest.number, @@ -249,7 +249,7 @@ export const make = Effect.gen(function* () { getChangeRequestActivity: (input) => Effect.all( [ - api.getPullRequest({ ...input, includeTracking: true }), + api.getPullRequest(input), api .listComments(input) .pipe(Effect.orElseSucceed(() => ({ comments: [], truncated: true }))), @@ -316,7 +316,7 @@ export const make = Effect.gen(function* () { getViewerPermissions: (input) => Effect.all( [ - api.getPullRequest({ ...input, includeTracking: true }), + api.getPullRequest(input), api.getRepositoryAccess(input), api.getViewer(), api.getFeatures().pipe(Effect.orElseSucceed(() => [])), From 10005f87b25cedd28255c1751f396f6e8fd4f44e Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Fri, 4 Sep 2026 23:37:59 -0500 Subject: [PATCH 03/19] feat(pull-requests): open native Gitea revert pull requests --- .../pullRequest/GiteaPullRequestApi.test.ts | 33 +++++++++++++++++++ .../src/pullRequest/GiteaPullRequestApi.ts | 25 ++++++++++---- .../GiteaPullRequestProvider.test.ts | 16 +++++++++ .../pullRequest/GiteaPullRequestProvider.ts | 10 +++++- 4 files changed, 77 insertions(+), 7 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index b3e5d519cbb2..85c5c61c2ea6 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -150,6 +150,39 @@ layer("GiteaPullRequestApi", (it) => { expect(yield* api.getAutoMergeEnabled({host: "forge.example.test", repository: "acme/web", number: 7})).toBe(true); expect(callAt(1).path).toContain("/timeline?"); })); + it.effect("opens a native revert PR only on an advertising Gitea server", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response({ features: ["pull-revert"] }))) + .mockReturnValueOnce(Effect.succeed(response(rawPullRequest(8)))); + const api = yield* GiteaPullRequestApi.make; + yield* api.runAction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + action: "revert", + }); + expect(callAt(1)).toMatchObject({ method: "POST", path: "/repos/acme/web/pulls/7/revert" }); + expect(mockedRequest.mock.calls).toHaveLength(2); + }), + ); + it.effect("does not attempt a revert on stock Gitea", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce(Effect.succeed(response({ features: [] }))); + const api = yield* GiteaPullRequestApi.make; + const error = yield* api + .runAction({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + action: "revert", + }) + .pipe(Effect.flip); + expect(error.detail).toContain("does not expose native pull request reverts"); + expect(mockedRequest.mock.calls.every(([call]) => call.method === "GET")).toBe(true); + }), + ); + it.effect("approves only the current pull request's waiting workflow runs", () => Effect.gen(function* () { const pull = rawPullRequest(7); diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 77b8546a3c1b..3307ee7c70a6 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1631,12 +1631,25 @@ export const make = Effect.gen(function* () { }, ); - const unsupportedAction = (action: string) => - new GiteaPullRequestApiError({ - operation: "runAction", - reason: "failed", - detail: `Gitea does not expose a reliable ${action} operation through this API.`, + const revertPullRequest = Effect.fn("GiteaPullRequestApi.revertPullRequest")(function* (input: { + host: string; + repository: string; + number: number; + }) { + yield* validateHost(input.host); + if (!(yield* getFeatures).includes("pull-revert")) + return yield* new GiteaPullRequestApiError({ + operation: "revertPullRequest", + reason: "failed", + detail: "This Gitea server does not expose native pull request reverts.", + }); + return yield* write({ + operation: "revertPullRequest", + ...input, + method: "POST", + path: `${basePath(input.repository)}/pulls/${input.number}/revert`, }); + }); return GiteaPullRequestApi.of({ getFeatures: () => getFeatures, @@ -1833,7 +1846,7 @@ export const make = Effect.gen(function* () { case "approve-workflows": return approveWorkflows(input); case "revert": - return Effect.fail(unsupportedAction(input.action)); + return revertPullRequest(input); } }, updatePullRequest: (input) => diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts index d4fc6095d9b5..1d9de88ff71d 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts @@ -166,3 +166,19 @@ describe("giteaProviderFailure", () => { ).toEqual({ reason: "rate-limited", retryAt: 1234 }); }); }); + +describe("native revert permission", () => { + it("requires write access and the advertised native endpoint", () => { + const input = { canWrite: true, ownsPullRequest: false, updateMethods: [] as const }; + expect(giteaViewerPermissions(input).actions).not.toContain("revert"); + expect(giteaViewerPermissions({ ...input, revertSupported: true }).actions).toContain("revert"); + expect( + giteaViewerPermissions({ + ...input, + canWrite: false, + ownsPullRequest: true, + revertSupported: true, + }).actions, + ).not.toContain("revert"); + }); +}); diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index fbe4b0d0dc42..1e1b2b7ecd92 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -25,6 +25,7 @@ const CAPABILITIES: PullRequestCapabilities = { "enable-auto-merge", "disable-auto-merge", "approve-workflows", + "revert", ], mergeMethods: ["merge", "squash", "rebase"], updateMethods: ["merge", "rebase"], @@ -66,11 +67,13 @@ export function giteaProviderFailure( export function giteaViewerPermissions(input: { readonly canWrite: boolean; readonly workflowApprovalSupported?: boolean; + readonly revertSupported?: boolean; readonly ownsPullRequest: boolean; readonly updateMethods: ReadonlyArray<"merge" | "rebase">; }): PullRequestViewerPermissions { return { actions: CAPABILITIES.actions.filter((action) => { + if (action === "revert") return input.canWrite && input.revertSupported === true; if (action === "approve-workflows") return input.canWrite && input.workflowApprovalSupported === true; if (action === "ready" || action === "draft" || action === "close" || action === "reopen") @@ -137,10 +140,12 @@ export const make = Effect.gen(function* () { readonly viewer: string; readonly author: string | undefined; readonly workflowApprovalSupported?: boolean; + readonly revertSupported?: boolean; }) => giteaViewerPermissions({ canWrite: input.access.canWrite, workflowApprovalSupported: input.workflowApprovalSupported, + revertSupported: input.revertSupported, ownsPullRequest: input.author !== undefined && input.author.toLowerCase() === input.viewer.toLowerCase(), updateMethods: input.access.updateMethods, @@ -190,10 +195,11 @@ export const make = Effect.gen(function* () { api .getWorkflowApprovals(input) .pipe(Effect.orElseSucceed(() => ({ supported: false, runs: [] }))), + api.getFeatures().pipe(Effect.orElseSucceed(() => [])), ], { concurrency: 4 }, ).pipe( - Effect.flatMap(([pullRequest, access, viewer, autoMergeEnabled, workflows]) => + Effect.flatMap(([pullRequest, access, viewer, autoMergeEnabled, workflows, features]) => api.listChecks({ ...input, sha: pullRequest.headSha }).pipe( Effect.orElseSucceed(() => []), Effect.map((checks): ProviderChangeRequestDetail => ({ @@ -224,6 +230,7 @@ export const make = Effect.gen(function* () { viewer, author: pullRequest.author?.login, workflowApprovalSupported: workflows.supported, + revertSupported: features.includes("pull-revert"), }), })), ), @@ -332,6 +339,7 @@ export const make = Effect.gen(function* () { viewer, author: pullRequest.author?.login, workflowApprovalSupported: features.includes("actions-run-approve"), + revertSupported: features.includes("pull-revert"), }), ), ), From dfd06aec5894829f715dac7d8584f62bfe7588c4 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 00:02:02 -0500 Subject: [PATCH 04/19] fix(pull-requests): keep incomplete Gitea access conservative --- .../pullRequest/GiteaPullRequestApi.test.ts | 52 ++++++++++++++----- .../src/pullRequest/GiteaPullRequestApi.ts | 18 +++---- 2 files changed, 47 insertions(+), 23 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 85c5c61c2ea6..6d5bdc394c27 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -144,12 +144,30 @@ it.effect("keeps a search hydration transport failure fatal", () => ); layer("GiteaPullRequestApi", (it) => { - it.effect("reconstructs auto-merge from the timeline when discovery is unavailable", () => Effect.gen(function* () { - mockedRequest.mockReturnValueOnce(Effect.fail(new GiteaApi.GiteaApiError({operation: "getFeatures", reason: "failed", detail: "temporarily unavailable"}))).mockReturnValueOnce(Effect.succeed(response([{id: 1, type: "pull_scheduled_merge"}]))); - const api = yield* GiteaPullRequestApi.make; - expect(yield* api.getAutoMergeEnabled({host: "forge.example.test", repository: "acme/web", number: 7})).toBe(true); - expect(callAt(1).path).toContain("/timeline?"); - })); + it.effect("reconstructs auto-merge from the timeline when discovery is unavailable", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.fail( + new GiteaApi.GiteaApiError({ + operation: "getFeatures", + reason: "failed", + detail: "temporarily unavailable", + }), + ), + ) + .mockReturnValueOnce(Effect.succeed(response([{ id: 1, type: "pull_scheduled_merge" }]))); + const api = yield* GiteaPullRequestApi.make; + expect( + yield* api.getAutoMergeEnabled({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }), + ).toBe(true); + expect(callAt(1).path).toContain("/timeline?"); + }), + ); it.effect("opens a native revert PR only on an advertising Gitea server", () => Effect.gen(function* () { mockedRequest @@ -821,7 +839,7 @@ layer("GiteaPullRequestApi", (it) => { }), ); - it.effect("does not turn omitted repository permissions into a denial", () => + it.effect("does not advertise writes or merge methods from incomplete repository settings", () => Effect.gen(function* () { mockedRequest.mockReturnValueOnce(Effect.succeed(response({}))); const api = yield* GiteaPullRequestApi.make; @@ -831,13 +849,13 @@ layer("GiteaPullRequestApi", (it) => { }); expect(access).toEqual({ - canWrite: true, + canWrite: false, mergeCapabilities: { - merge: true, - squash: true, - rebase: true, + merge: false, + squash: false, + rebase: false, }, - updateMethods: ["merge", "rebase"], + updateMethods: [], }); }), ); @@ -1042,6 +1060,16 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("does not request commit statuses without a head revision", () => + Effect.gen(function* () { + const api = yield* GiteaPullRequestApi.make; + expect( + yield* api.listChecks({ host: "forge.example.test", repository: "acme/web", sha: "" }), + ).toEqual([]); + expect(mockedRequest).not.toHaveBeenCalled(); + }), + ); + it.effect("reads every capped page of commit statuses and keeps the newest context", () => Effect.gen(function* () { mockedRequest diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 3307ee7c70a6..5a0c6fb6fda7 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1082,20 +1082,15 @@ export const make = Effect.gen(function* () { }); const repo = yield* decode(operation, RawRepository, response); return { - // An omitted permission block is unknown rather than a denial. Gitea will still enforce - // the write, while hiding it here would leave an entitled viewer with no route to try. - canWrite: - repo.permissions == null || - repo.permissions.push === true || - repo.permissions.admin === true, + canWrite: repo.permissions?.push === true || repo.permissions?.admin === true, mergeCapabilities: { - merge: repo.allow_merge_commits ?? true, - squash: repo.allow_squash_merge ?? true, - rebase: repo.allow_rebase ?? true, + merge: repo.allow_merge_commits === true, + squash: repo.allow_squash_merge === true, + rebase: repo.allow_rebase === true, }, updateMethods: [ - ...(repo.allow_merge_update !== false ? (["merge"] as const) : []), - ...(repo.allow_rebase_update !== false ? (["rebase"] as const) : []), + ...(repo.allow_merge_update === true ? (["merge"] as const) : []), + ...(repo.allow_rebase_update === true ? (["rebase"] as const) : []), ], }; }, @@ -1337,6 +1332,7 @@ export const make = Effect.gen(function* () { sha: string; }) { const operation = "listChecks"; + if (input.sha.trim() === "") return []; const statuses: Array = []; let path = query( `${basePath(input.repository)}/commits/${encodeURIComponent(input.sha)}/status`, From d41838efd1fe8341cc3a8a9216e6f14ab150f6dd Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 00:09:10 -0500 Subject: [PATCH 05/19] fix(pull-requests): apply available tracking summaries to filters --- .../src/pullRequest/GiteaPullRequestApi.ts | 10 ++-- .../pullRequest/PullRequestService.test.ts | 47 +++++++++++++++++++ .../src/pullRequest/PullRequestService.ts | 8 +++- packages/contracts/src/pullRequest.ts | 5 +- 4 files changed, 62 insertions(+), 8 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 5a0c6fb6fda7..b000063652bc 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -4,6 +4,10 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import { + PullRequestChecksState as PullRequestChecksStateSchema, + PullRequestReviewDecision as PullRequestReviewDecisionSchema, +} from "@t3tools/contracts"; import type { PullRequestAction, PullRequestActor, @@ -93,10 +97,8 @@ const RawPullRequest = Schema.Struct({ merged: Schema.optional(Schema.Boolean), mergeable: Schema.optional(Schema.NullOr(Schema.Boolean)), draft: Schema.optional(Schema.Boolean), - review_decision: Schema.optional( - Schema.NullOr(Schema.Literals(["approved", "changes-requested", "review-required"])), - ), - checks_state: Schema.optional(Schema.NullOr(Schema.Literals(["passing", "failing", "pending"]))), + review_decision: Schema.optional(Schema.NullOr(PullRequestReviewDecisionSchema)), + checks_state: Schema.optional(Schema.NullOr(PullRequestChecksStateSchema)), auto_merge_enabled: Schema.optional(Schema.NullOr(Schema.Boolean)), auto_merge_method: Schema.optional(Schema.NullOr(Schema.String)), html_url: Schema.String, diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts index 0dd5a30fb396..53cd8676b640 100644 --- a/apps/server/src/pullRequest/PullRequestService.test.ts +++ b/apps/server/src/pullRequest/PullRequestService.test.ts @@ -3840,6 +3840,53 @@ it.effect("judges the review filter only on a host that summarises its reviews", }), ); +it.effect("judges checks from provider rows while preserving rows without a check summary", () => + Effect.gen(function* () { + const service = yield* makeService({ + projects: [ + project({ + id: "p1", + title: "web", + workspaceRoot: "/a", + repository: "acme/web", + provider: "gitea", + host: "forge.example.test", + }), + ], + providers: [ + // Gitea can carry its check rollup on a row, but older or unconfigured hosts may leave it + // undefined. A null rollup is different: it says the host checked and found no checks. + fakeProvider("gitea", { + listChangeRequests: () => + Effect.succeed({ + items: [ + { ...changeRequest(1, "2026-07-04T00:00:00Z"), checksState: "passing" }, + { ...changeRequest(2, "2026-07-03T00:00:00Z"), checksState: "failing" }, + { ...changeRequest(3, "2026-07-02T00:00:00Z"), checksState: "pending" }, + { ...changeRequest(4, "2026-07-01T00:00:00Z"), checksState: null }, + changeRequest(5, "2026-06-30T00:00:00Z"), + ], + truncated: false, + continues: false, + }), + }), + ], + }); + + const passing = yield* service.list({ state: "open", filters: { checks: "passing" } }); + assert.deepStrictEqual( + passing.entries.map((entry) => entry.number), + [1, 5], + ); + + const failing = yield* service.list({ state: "open", filters: { checks: "failing" } }); + assert.deepStrictEqual( + failing.entries.map((entry) => entry.number), + [2, 5], + ); + }), +); + it.effect("sends only the words a rewrite carries", () => Effect.gen(function* () { const received: Array<{ title?: string | undefined; body?: string | undefined }> = []; diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index f6a0405ec9b2..60ac6ed6d0e5 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -833,8 +833,9 @@ export const make = Effect.gen(function* () { * answers unnarrowed, and without this pass a draft filter or a label filter would be sent, * accepted and quietly ignored. Idempotent for the hosts that did narrow. * - * `checks` is absent because no listed row carries its check state: that one filter is the - * host's alone, and a row nobody narrowed stays rather than being guessed at. + * `checks` is judged when a row carries its check state. A host that leaves the field undefined + * cannot be judged, so that row stays rather than being guessed at; null means the host answered + * that no checks are present and therefore matches neither passing nor failing. */ const matchesRowFilters = ( item: ProviderChangeRequest, @@ -856,6 +857,9 @@ export const make = Effect.gen(function* () { (filters.review === "none" ? item.reviewDecision === null : item.reviewDecision === filters.review)) && + (filters.checks === undefined || + item.checksState === undefined || + item.checksState === filters.checks) && (filters.labels === undefined || filters.labels.every((group) => group.some(holds))) && (filters.excludedLabels === undefined || !filters.excludedLabels.some(holds)) && (filters.author === undefined || diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index 74ea49ceb9e2..449c2a4c1824 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -44,8 +44,9 @@ const PullRequestQualifierValues = Schema.Array(PullRequestQualifierValue).check * nothing, which is what every listing did before there were any. Optional as a whole so a page * and a server of different ages still speak to each other. * - * `checks` is host-side only: no row carries its own check state, so a host that cannot match it - * answers unnarrowed rather than the page pretending to know. + * `checks` is host-side where supported, and is also judged from a row's own state when the + * provider carries one. A host that cannot match it answers unnarrowed rather than the page + * pretending to know. */ export const PullRequestListFilters = Schema.Struct({ draft: Schema.optional(Schema.Literals(["only", "hide"])), From 010e69b9c7cf866baae51f2255090eb559a61060 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 00:09:12 -0500 Subject: [PATCH 06/19] fix(source-control): recognize encoded Gitea repository URLs --- apps/server/src/sourceControl/giteaRepository.test.ts | 4 ++++ apps/server/src/sourceControl/giteaRepository.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/server/src/sourceControl/giteaRepository.test.ts b/apps/server/src/sourceControl/giteaRepository.test.ts index 48412b6c1cc8..5015416281e6 100644 --- a/apps/server/src/sourceControl/giteaRepository.test.ts +++ b/apps/server/src/sourceControl/giteaRepository.test.ts @@ -49,6 +49,10 @@ it("accepts PR numbers and URLs only from the selected repository", () => { giteaPullRequestNumber(`${base}/TEAM/REPO/pulls/42/files`, "team/repo", base), 42, ); + assert.strictEqual( + giteaPullRequestNumber(`${base}/%C3%A9quipe/r%C3%A9po/pulls/43/files`, "équipe/répo", base), + 43, + ); for (const ref of [ "0", "-1", diff --git a/apps/server/src/sourceControl/giteaRepository.ts b/apps/server/src/sourceControl/giteaRepository.ts index 12596e76689b..0d449e76f4a3 100644 --- a/apps/server/src/sourceControl/giteaRepository.ts +++ b/apps/server/src/sourceControl/giteaRepository.ts @@ -59,7 +59,8 @@ export function giteaPullRequestNumber( try { const url = new URL(reference); const base = new URL(baseUrl); - const expected = `${base.pathname.replace(/\/+$/u, "")}/${repository}/pulls/`; + const encodedRepository = repository.split("/").map(encodeURIComponent).join("/"); + const expected = `${base.pathname.replace(/\/+$/u, "")}/${encodedRepository}/pulls/`; if ( url.origin !== base.origin || !url.pathname.toLowerCase().startsWith(expected.toLowerCase()) From 23da5a27bd986c3ecbab856b3dc8a4465bb4953a Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 00:17:18 -0500 Subject: [PATCH 07/19] fix(pull-requests): preserve native Gitea review pagination --- .../pullRequest/GiteaPullRequestApi.test.ts | 142 +++++++++++++++++- .../src/pullRequest/GiteaPullRequestApi.ts | 25 ++- .../GiteaPullRequestProvider.activity.test.ts | 6 +- 3 files changed, 163 insertions(+), 10 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 6d5bdc394c27..f2c10b36c44f 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -942,7 +942,147 @@ layer("GiteaPullRequestApi", (it) => { ], }), ]); - expect(callAt(1).path).toBe("/repos/acme/web/pulls/7/reviews/21/comments"); + expect(callAt(1).path).toBe("/repos/acme/web/pulls/7/reviews/21/comments?page=1&limit=50"); + }), + ); + + it.effect( + "marks review activity truncated when nested review comments exceed the conversation bound", + () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response([ + { + id: 21, + body: "Review", + state: "COMMENT", + submitted_at: "2026-09-03T11:00:00Z", + }, + ]), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response( + [ + { + id: 31, + body: "First", + path: "src/a.ts", + position: 1, + created_at: "2026-09-03T11:01:00Z", + }, + ], + { "x-total-count": "501" }, + ), + ), + ) + .mockReturnValueOnce(Effect.succeed(response([], { "x-total-count": "501" }))) + .mockReturnValueOnce(Effect.succeed(response([], { "x-total-count": "501" }))) + .mockReturnValueOnce(Effect.succeed(response([], { "x-total-count": "501" }))) + .mockReturnValueOnce(Effect.succeed(response([], { "x-total-count": "501" }))); + const api = yield* GiteaPullRequestApi.make; + const result = yield* api.listReviews({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + assert.isTrue(result.truncated); + expect(result.comments).toContainEqual( + expect.objectContaining({ id: "review-comment:31" }), + ); + expect(callAt(5).path).toContain("page=5"); + }), + ); + + it.effect("does not repeat an unpaginated native review-comment response at the page size", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response([ + { + id: 21, + body: "Review", + state: "COMMENT", + submitted_at: "2026-09-03T11:00:00Z", + }, + ]), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response( + Array.from({ length: 51 }, (_, index) => ({ + id: index + 31, + body: `Comment ${index + 1}`, + path: "src/a.ts", + position: index + 1, + created_at: "2026-09-03T11:01:00Z", + })), + ), + ), + ); + const api = yield* GiteaPullRequestApi.make; + const result = yield* api.listReviews({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + assert.isFalse(result.truncated); + assert.strictEqual( + result.comments.filter((comment) => comment.kind === "review-comment").length, + 51, + ); + assert.strictEqual(mockedRequest.mock.calls.length, 2); + }), + ); + + it.effect("does not mark an exact unpaginated review-comment safety bound as truncated", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response([ + { + id: 21, + body: "Review", + state: "COMMENT", + submitted_at: "2026-09-03T11:00:00Z", + }, + ]), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response( + Array.from({ length: 200 }, (_, index) => ({ + id: index + 31, + body: `Comment ${index + 1}`, + path: "src/a.ts", + position: index + 1, + created_at: "2026-09-03T11:01:00Z", + })), + ), + ), + ); + const api = yield* GiteaPullRequestApi.make; + const result = yield* api.listReviews({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + assert.isFalse(result.truncated); + assert.strictEqual( + result.comments.filter((comment) => comment.kind === "review-comment").length, + 200, + ); + assert.strictEqual(mockedRequest.mock.calls.length, 2); }), ); diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index b000063652bc..364ad6cefcf5 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -957,6 +957,7 @@ export const make = Effect.gen(function* () { repository: string; path: string; limit: number; + requirePaginationEvidence?: boolean; }) { const rows: Array = []; let path = input.path; @@ -978,14 +979,18 @@ export const make = Effect.gen(function* () { rowsSeen, headers: result.headers, }); + const hasPaginationEvidence = + nextLink(result.headers) !== null || totalCount(result.headers) !== null; + const paginationNext = + input.requirePaginationEvidence && !hasPaginationEvidence ? null : next; if (result.rows.length > remaining || rows.length >= input.limit) { return { rows, - truncated: result.rows.length > remaining || next !== null, + truncated: result.rows.length > remaining || paginationNext !== null, }; } - if (next === null) return { rows, truncated: false }; - path = next; + if (paginationNext === null) return { rows, truncated: false }; + path = paginationNext; } return { rows, truncated: true }; }); @@ -1183,7 +1188,7 @@ export const make = Effect.gen(function* () { } const comments: Array = []; const threads: Array = []; - const commentsTruncated = reviewsTruncated; + let commentsTruncated = reviewsTruncated; for (const row of reviewRows) { const review = decodeReview(row); if (Option.isNone(review)) continue; @@ -1200,11 +1205,17 @@ export const make = Effect.gen(function* () { reviewState: review.value.state?.toLowerCase().replaceAll("_", " ") ?? null, }); } - const codeRows = yield* readUnknownArray({ + const codeRows = yield* readUnknownSlice({ operation: "listReviewComments", ...input, - path: `${basePath(input.repository)}/pulls/${input.number}/reviews/${review.value.id}/comments`, + path: query( + `${basePath(input.repository)}/pulls/${input.number}/reviews/${review.value.id}/comments`, + { page: 1, limit: PAGE_SIZE }, + ), + limit: PAGE_SIZE * CONVERSATION_PAGES, + requirePaginationEvidence: true, }); + commentsTruncated ||= codeRows.truncated; const grouped = new Map< string, Array<{ @@ -1216,7 +1227,7 @@ export const make = Effect.gen(function* () { readonly comment: PullRequestReviewThread["comments"][number]; }> >(); - for (const codeRow of codeRows) { + for (const codeRow of codeRows.rows) { const decoded = decodeReviewComment(codeRow); if (Option.isNone(decoded)) continue; const mapped = decoded.value; diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts index 333def121618..b9b7d70dc1c5 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.activity.test.ts @@ -15,7 +15,9 @@ const response = (value: unknown) => ({ headers: {}, }); const failure = () => - Effect.fail(new GiteaApi.GiteaApiError({ operation: "test", reason: "failed", detail: "offline" })); + Effect.fail( + new GiteaApi.GiteaApiError({ operation: "test", reason: "failed", detail: "offline" }), + ); const pull = { number: 7, @@ -58,7 +60,7 @@ function route(viewerFails: boolean, reactionsFail: boolean) { return Effect.succeed( response([{ id: 2, body: "summary", submitted_at: "2026-01-01T00:00:00Z" }]), ); - if (input.path === "/repos/acme/web/pulls/7/reviews/2/comments") + if (input.path === "/repos/acme/web/pulls/7/reviews/2/comments?page=1&limit=50") return Effect.succeed( response([ { id: 3, body: "inline", created_at: "2026-01-01T00:00:00Z", path: "a.ts", position: 1 }, From a09a525142247cd748df67026c1b374ba2d2888f Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 00:21:46 -0500 Subject: [PATCH 08/19] test(pull-requests): fill bounded Gitea comment pages --- .../pullRequest/GiteaPullRequestApi.test.ts | 51 ++++++++----------- 1 file changed, 21 insertions(+), 30 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index f2c10b36c44f..2b89fa03e514 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -950,51 +950,42 @@ layer("GiteaPullRequestApi", (it) => { "marks review activity truncated when nested review comments exceed the conversation bound", () => Effect.gen(function* () { - mockedRequest - .mockReturnValueOnce( - Effect.succeed( - response([ - { - id: 21, - body: "Review", - state: "COMMENT", - submitted_at: "2026-09-03T11:00:00Z", - }, - ]), - ), - ) - .mockReturnValueOnce( + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response([ + { id: 21, body: "Review", state: "COMMENT", submitted_at: "2026-09-03T11:00:00Z" }, + ]), + ), + ); + for (let page = 0; page < 4; page += 1) { + mockedRequest.mockReturnValueOnce( Effect.succeed( response( - [ - { - id: 31, - body: "First", - path: "src/a.ts", - position: 1, - created_at: "2026-09-03T11:01:00Z", - }, - ], + Array.from({ length: 50 }, (_, index) => ({ + id: 31 + page * 50 + index, + body: "Comment", + path: "src/a.ts", + position: 1, + created_at: "2026-09-03T11:01:00Z", + })), { "x-total-count": "501" }, ), ), - ) - .mockReturnValueOnce(Effect.succeed(response([], { "x-total-count": "501" }))) - .mockReturnValueOnce(Effect.succeed(response([], { "x-total-count": "501" }))) - .mockReturnValueOnce(Effect.succeed(response([], { "x-total-count": "501" }))) - .mockReturnValueOnce(Effect.succeed(response([], { "x-total-count": "501" }))); + ); + } const api = yield* GiteaPullRequestApi.make; const result = yield* api.listReviews({ host: "forge.example.test", repository: "acme/web", number: 7, }); - assert.isTrue(result.truncated); expect(result.comments).toContainEqual( expect.objectContaining({ id: "review-comment:31" }), ); - expect(callAt(5).path).toContain("page=5"); + expect(result.comments).toHaveLength(201); + expect(callAt(4).path).toContain("page=4"); + expect(mockedRequest).toHaveBeenCalledTimes(5); }), ); From de4d24c535f7bd6a73ae358b099c5ce3419fda91 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 00:17:25 -0500 Subject: [PATCH 09/19] feat(pull-requests): support native Gitea team reviewers --- .../pullRequest/GiteaPullRequestApi.test.ts | 130 +++++++++++++++- .../src/pullRequest/GiteaPullRequestApi.ts | 140 +++++++++++++++--- .../GiteaPullRequestProvider.test.ts | 2 + 3 files changed, 252 insertions(+), 20 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 2b89fa03e514..2cd4b76f1df5 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -443,6 +443,7 @@ layer("GiteaPullRequestApi", (it) => { it.effect("walks later pages until involvement filtering fills the requested slice", () => Effect.gen(function* () { mockedRequest + .mockReturnValueOnce(Effect.succeed(response([]))) .mockReturnValueOnce( Effect.succeed( response( @@ -468,7 +469,7 @@ layer("GiteaPullRequestApi", (it) => { expect(page.items.map((item) => item.number)).toEqual([51]); assert.strictEqual(page.consumed, 51); assert.isFalse(page.truncated); - expect(callAt(1).path).toContain("page=2"); + expect(callAt(2).path).toContain("page=2"); }), ); @@ -1856,6 +1857,133 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("includes requested native teams in reviewer candidates and sends their names", () => + Effect.gen(function* () { + mockedRequest.mockImplementation((input) => { + if (input.path === "/repos/acme/web/pulls/7") + return Effect.succeed( + response( + rawPullRequest(7, { + requested_reviewers_teams: [ + { id: 41, name: "maintainers", organization: { username: "acme" } }, + ], + }), + ), + ); + if (input.path.startsWith("/repos/acme/web/reviewers?")) + return Effect.succeed(response([{ id: 2, login: "reviewer" }])); + if (input.path === "/repos/acme/web/teams") + return Effect.succeed( + response([{ id: 41, name: "maintainers", organization: { username: "acme" } }]), + ); + if (input.path === "/repos/acme/web/pulls/7/requested_reviewers") + return Effect.succeed(response({})); + return Effect.die(`unexpected request: ${input.path}`); + }); + const api = yield* GiteaPullRequestApi.make; + const candidates = yield* api.listReviewerCandidates({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect(candidates).toEqual({ + candidates: [ + expect.objectContaining({ id: "reviewer", kind: "user", isRequested: true }), + expect.objectContaining({ + id: "maintainers", + kind: "team", + login: "maintainers", + name: "acme", + isRequested: true, + }), + ], + truncated: false, + }); + yield* api.setReviewerRequest({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + requested: true, + reviewers: [candidates.candidates[1]!], + }); + const request = callAt(3); + expect(decodeJson(request.body ?? "{}")).toEqual({ + reviewers: [], + team_reviewers: ["maintainers"], + }); + }), + ); + + it.effect("treats a native repository team 405 as a personal repository", () => + Effect.gen(function* () { + mockedRequest.mockImplementation((input) => { + if (input.path === "/repos/acme/web/pulls/7") + return Effect.succeed(response(rawPullRequest(7))); + if (input.path.startsWith("/repos/acme/web/reviewers?")) + return Effect.succeed(response([{ id: 2, login: "reviewer" }])); + if (input.path === "/repos/acme/web/teams") + return Effect.fail( + new GiteaApi.GiteaApiError({ + operation: "listTeamReviewerCandidates", + reason: "failed", + detail: "Gitea returned HTTP 405.", + status: 405, + }), + ); + return Effect.die(`unexpected request: ${input.path}`); + }); + const api = yield* GiteaPullRequestApi.make; + const candidates = yield* api.listReviewerCandidates({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect(candidates.candidates).toEqual([ + expect.objectContaining({ id: "reviewer", kind: "user" }), + ]); + }), + ); + + it.effect("includes pull requests requested from a viewer team in reviewing listings", () => + Effect.gen(function* () { + mockedRequest.mockImplementation((input) => { + if (input.path === "/user/teams?page=1&limit=50") + return Effect.succeed(response([{ id: 4, name: "first" }], { "x-total-count": "2" })); + if (input.path === "/user/teams?page=2&limit=50") + return Effect.succeed( + response([{ id: 9, name: "maintainers" }], { "x-total-count": "2" }), + ); + if (input.path.startsWith("/repos/acme/web/pulls?")) + return Effect.succeed( + response( + [ + rawPullRequest(7, { + requested_reviewers: [], + requested_reviewers_teams: [{ id: 9, name: "maintainers" }], + }), + ], + { "x-total-count": "1" }, + ), + ); + return Effect.die(`unexpected request: ${input.path}`); + }); + const api = yield* GiteaPullRequestApi.make; + const page = yield* api.listPullRequests({ + host: "forge.example.test", + repository: "acme/web", + state: "open", + involvement: "reviewing", + viewer: "viewer", + limit: 10, + }); + + expect(page.items.map((pullRequest) => pullRequest.number)).toEqual([7]); + assert.strictEqual(mockedRequest.mock.calls.length, 3); + }), + ); + it.effect("arms and cancels Gitea auto-merge through the native merge route", () => Effect.gen(function* () { mockedRequest diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 364ad6cefcf5..894c5cdfcd61 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -89,6 +89,13 @@ const RawLabel = Schema.Struct({ color: Schema.optional(Schema.NullOr(Schema.String)), description: Schema.optional(Schema.NullOr(Schema.String)), }); +const RawTeam = Schema.Struct({ + id: Schema.optional(Schema.Int), + name: Schema.optional(Schema.String), + organization: Schema.optional( + Schema.NullOr(Schema.Struct({ username: Schema.optional(Schema.String) })), + ), +}); const RawPullRequest = Schema.Struct({ number: Schema.Int, title: Schema.String, @@ -115,6 +122,7 @@ const RawPullRequest = Schema.Struct({ base: RawBranch, head: RawBranch, requested_reviewers: Schema.optional(Schema.NullOr(Schema.Array(RawUser))), + requested_reviewers_teams: Schema.optional(Schema.NullOr(Schema.Array(RawTeam))), labels: Schema.optional(Schema.NullOr(Schema.Array(RawLabel))), merge_base: Schema.optional(Schema.String), }); @@ -206,6 +214,7 @@ type RawCommitStatus = NonNullable<(typeof RawCombinedStatus.Type)["statuses"]>[ const decodeRow = Schema.decodeUnknownOption(RawPullRequest); const decodeUser = Schema.decodeUnknownOption(RawUser); +const decodeTeam = Schema.decodeUnknownOption(RawTeam); const decodeComment = Schema.decodeUnknownOption(RawComment); const decodeReview = Schema.decodeUnknownOption(RawReview); const decodeReviewComment = Schema.decodeUnknownOption(RawReviewComment); @@ -239,6 +248,8 @@ export interface GiteaPullRequest { readonly mergedAt: string | null; readonly closedAt: string | null; readonly reviewRequestLogins: ReadonlyArray; + readonly reviewRequestTeamIDs: ReadonlyArray; + readonly reviewRequestTeamNames: ReadonlyArray; readonly reviewers: ReadonlyArray; readonly labels: ReadonlyArray; readonly commentCount: number; @@ -327,6 +338,13 @@ function pullRequest(value: RawPullRequest): GiteaPullRequest | null { mergedAt: iso(value.merged_at), closedAt: iso(value.closed_at), reviewRequestLogins: reviewers.map((reviewer) => reviewer.login), + reviewRequestTeamIDs: (value.requested_reviewers_teams ?? []).flatMap((team) => + team.id === undefined ? [] : [team.id], + ), + reviewRequestTeamNames: (value.requested_reviewers_teams ?? []).flatMap((team) => { + const name = team.name?.trim(); + return name ? [name] : []; + }), reviewers, labels: (value.labels ?? []).flatMap((label) => { const name = label.name?.trim(); @@ -349,13 +367,15 @@ function matchesPullRequest( state: PullRequestListState, involvement: PullRequestInvolvement, viewer: string, + viewerTeamIDs: ReadonlySet = new Set(), ): 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()) + !value.reviewRequestLogins.some((login) => login.toLowerCase() === viewer.toLowerCase()) && + !value.reviewRequestTeamIDs.some((id) => viewerTeamIDs.has(id)) ) return false; return true; @@ -859,6 +879,8 @@ export const make = Effect.gen(function* () { let rowsSkipped = 0; let consumed = 0; const collected: Array = []; + const viewerTeamIDs = + input.involvement === "reviewing" ? yield* getViewerTeamIDs : new Set(); while (page <= MAX_PAGINATION_PAGES) { const result = yield* readUnknownPage({ @@ -897,7 +919,15 @@ export const make = Effect.gen(function* () { for (const [index, pullRequest] of hydrated.entries()) { consumed += 1; if (pullRequest === null) continue; - if (!matchesPullRequest(pullRequest, input.state, input.involvement, input.viewer)) + if ( + !matchesPullRequest( + pullRequest, + input.state, + input.involvement, + input.viewer, + viewerTeamIDs, + ) + ) continue; collected.push(pullRequest); if (collected.length === wanted) { @@ -951,6 +981,39 @@ export const make = Effect.gen(function* () { "1 minute", ); + const getViewerTeamIDs = yield* Effect.cachedWithTTL( + Effect.suspend(() => + Effect.gen(function* () { + const teamIDs = new Set(); + let path = query("/user/teams", { page: 1, limit: PAGE_SIZE }); + let rowsSeen = 0; + for (let page = 1; page <= MAX_PAGINATION_PAGES; page += 1) { + const response = yield* gitea + .request({ operation: "getViewerTeams", method: "GET", path }) + .pipe(Effect.mapError((error) => failure("getViewerTeams", error))); + const teams = yield* decode("getViewerTeams", Schema.Array(RawTeam), response); + for (const team of teams) if (team.id !== undefined) teamIDs.add(team.id); + rowsSeen += teams.length; + const next = nextPagePath({ + path, + page, + pageRows: teams.length, + rowsSeen, + headers: response.headers, + }); + if (next === null) return teamIDs; + path = next; + } + return yield* new GiteaPullRequestApiError({ + operation: "getViewerTeams", + reason: "failed", + detail: "Gitea viewer team pagination exceeded the safe page limit.", + }); + }), + ), + "1 minute", + ); + const readUnknownSlice = Effect.fn("GiteaPullRequestApi.readUnknownSlice")(function* (input: { operation: string; host: string; @@ -1019,6 +1082,8 @@ export const make = Effect.gen(function* () { let rowsSkipped = 0; let consumed = 0; const collected: Array = []; + const viewerTeamIDs = + input.involvement === "reviewing" ? yield* getViewerTeamIDs : new Set(); while (page <= MAX_PAGINATION_PAGES) { const result = yield* readUnknownPage({ operation: "listPullRequests", @@ -1043,7 +1108,8 @@ export const make = Effect.gen(function* () { if (Option.isNone(decoded)) continue; const pr = pullRequest(decoded.value); if (pr === null) continue; - if (!matchesPullRequest(pr, input.state, input.involvement, input.viewer)) continue; + if (!matchesPullRequest(pr, input.state, input.involvement, input.viewer, viewerTeamIDs)) + continue; collected.push(pr); if (collected.length === wanted) { if (page === MAX_PAGINATION_PAGES && next !== null) { @@ -1936,26 +2002,62 @@ export const make = Effect.gen(function* () { }), limit: PAGE_SIZE, }), + readUnknownArray({ + operation: "listTeamReviewerCandidates", + ...input, + path: `${basePath(input.repository)}/teams`, + }).pipe( + Effect.catchTag("GiteaPullRequestApiError", (error) => + error.cause instanceof GiteaApi.GiteaApiError && error.cause.status === 405 + ? Effect.succeed([]) + : Effect.fail(error), + ), + ), ], - { concurrency: 2 }, + { concurrency: 3 }, ).pipe( - Effect.map(([pr, result]) => { + Effect.map(([pr, result, teamRows]) => { const requested = new Set(pr.reviewRequestLogins.map((login) => login.toLowerCase())); + const requestedTeams = new Set( + pr.reviewRequestTeamNames.map((name) => name.toLowerCase()), + ); return { - candidates: result.rows.flatMap((row) => { - const raw = decodeUser(row); - if (Option.isNone(raw)) return []; - const mapped = actor(raw.value); - if (mapped === null || mapped.login === pr.author?.login) return []; - return [ - { - ...mapped, - id: mapped.login, - kind: "user" as const, - isRequested: requested.has(mapped.login.toLowerCase()), - }, - ]; - }), + candidates: [ + ...result.rows.flatMap((row) => { + const raw = decodeUser(row); + if (Option.isNone(raw)) return []; + const mapped = actor(raw.value); + if ( + mapped === null || + mapped.login.toLowerCase() === pr.author?.login.toLowerCase() + ) + return []; + return [ + { + ...mapped, + id: mapped.login, + kind: "user" as const, + isRequested: requested.has(mapped.login.toLowerCase()), + }, + ]; + }), + ...teamRows.flatMap((row) => { + const raw = decodeTeam(row); + if (Option.isNone(raw)) return []; + const name = raw.value.name?.trim(); + if (!name) return []; + return [ + { + id: name, + kind: "team" as const, + login: name, + name: raw.value.organization?.username?.trim() || null, + avatarUrl: null, + isRequested: requestedTeams.has(name.toLowerCase()), + }, + ]; + }), + ], truncated: result.truncated, }; }), diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts index 1d9de88ff71d..0c0d2e7c8d1c 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts @@ -31,6 +31,8 @@ const trackedPullRequest: GiteaPullRequest = { mergedAt: null, closedAt: null, reviewRequestLogins: [], + reviewRequestTeamIDs: [], + reviewRequestTeamNames: [], reviewers: [], labels: [], commentCount: 0, From bf2bdc7622c2f328af84409e8d4ca6df2e8c1750 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 00:21:37 -0500 Subject: [PATCH 10/19] fix(pull-requests): decode native Gitea team errors --- apps/server/src/pullRequest/GiteaPullRequestApi.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 894c5cdfcd61..2871ab9e15b0 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -221,6 +221,7 @@ const decodeReviewComment = Schema.decodeUnknownOption(RawReviewComment); const decodeCommit = Schema.decodeUnknownOption(RawCommit); const decodeLabel = Schema.decodeUnknownOption(RawLabel); const decodeReaction = Schema.decodeUnknownOption(RawGiteaReaction); +const isGiteaApiError = Schema.is(GiteaApi.GiteaApiError); const encodeObject = Schema.encodeSync( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); @@ -2007,8 +2008,8 @@ export const make = Effect.gen(function* () { ...input, path: `${basePath(input.repository)}/teams`, }).pipe( - Effect.catchTag("GiteaPullRequestApiError", (error) => - error.cause instanceof GiteaApi.GiteaApiError && error.cause.status === 405 + Effect.catchTag("GiteaPullRequestApiError", (error: GiteaPullRequestApiError) => + isGiteaApiError(error.cause) && error.cause.status === 405 ? Effect.succeed([]) : Effect.fail(error), ), From 169019e9990a7c0a4cee8c1c5be898817c83d14f Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 00:22:58 -0500 Subject: [PATCH 11/19] fix(pull-requests): preserve Gitea schema inference --- .../src/pullRequest/GiteaPullRequestApi.ts | 18 ++++++++++-------- .../pullRequest/GiteaPullRequestProvider.ts | 8 ++++---- .../src/pullRequest/GiteaWorkflows.test.ts | 9 ++++++--- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 2871ab9e15b0..795e9083d056 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -874,7 +874,7 @@ export const make = Effect.gen(function* () { viewer: input.viewer, page, limit: PAGE_SIZE, - includeTracking: input.includeTracking, + includeTracking: input.includeTracking === true, }); let rowsSeen = 0; let rowsSkipped = 0; @@ -911,7 +911,7 @@ export const make = Effect.gen(function* () { host: input.host, repository: input.repository, number, - includeTracking: input.includeTracking, + includeTracking: input.includeTracking === true, }); }, { concurrency: SEARCH_HYDRATION_CONCURRENCY }, @@ -1525,7 +1525,9 @@ export const make = Effect.gen(function* () { const getAutoMergeEnabled = Effect.fn("GiteaPullRequestApi.getAutoMergeEnabled")( function* (input: { host: string; repository: string; number: number }) { - const features = yield* getFeatures.pipe(Effect.orElseSucceed(() => [])); + const features = yield* getFeatures.pipe( + Effect.orElseSucceed((): ReadonlyArray => []), + ); if (features.includes("pull-auto-merge-state")) { return (yield* getPullRequest(input)).autoMergeEnabled; } @@ -1573,7 +1575,7 @@ export const make = Effect.gen(function* () { number: number; action: Extract; }) { - const features = yield* getFeatures.pipe(Effect.orElseSucceed(() => [])); + const features = yield* getFeatures.pipe(Effect.orElseSucceed((): ReadonlyArray => [])); if (features.includes("pull-draft")) { return yield* write({ operation: "runAction", @@ -1643,7 +1645,7 @@ export const make = Effect.gen(function* () { subjectIds: ReadonlyArray; }) { const supportsReviewReactions = (yield* getFeatures.pipe( - Effect.orElseSucceed(() => []), + Effect.orElseSucceed((): ReadonlyArray => []), )).includes("pull-review-reactions"); const targets: Array<{ readonly subjectId: string | undefined; @@ -2165,9 +2167,9 @@ export const make = Effect.gen(function* () { return Effect.gen(function* () { if ( target.kind === "review" && - !(yield* getFeatures.pipe(Effect.orElseSucceed(() => []))).includes( - "pull-review-reactions", - ) + !(yield* getFeatures.pipe( + Effect.orElseSucceed((): ReadonlyArray => []), + )).includes("pull-review-reactions") ) { return yield* new GiteaPullRequestApiError({ operation: "setReaction", diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index 1e1b2b7ecd92..35c4e59fd23e 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -144,8 +144,8 @@ export const make = Effect.gen(function* () { }) => giteaViewerPermissions({ canWrite: input.access.canWrite, - workflowApprovalSupported: input.workflowApprovalSupported, - revertSupported: input.revertSupported, + workflowApprovalSupported: input.workflowApprovalSupported === true, + revertSupported: input.revertSupported === true, ownsPullRequest: input.author !== undefined && input.author.toLowerCase() === input.viewer.toLowerCase(), updateMethods: input.access.updateMethods, @@ -195,7 +195,7 @@ export const make = Effect.gen(function* () { api .getWorkflowApprovals(input) .pipe(Effect.orElseSucceed(() => ({ supported: false, runs: [] }))), - api.getFeatures().pipe(Effect.orElseSucceed(() => [])), + api.getFeatures().pipe(Effect.orElseSucceed((): ReadonlyArray => [])), ], { concurrency: 4 }, ).pipe( @@ -326,7 +326,7 @@ export const make = Effect.gen(function* () { api.getPullRequest(input), api.getRepositoryAccess(input), api.getViewer(), - api.getFeatures().pipe(Effect.orElseSucceed(() => [])), + api.getFeatures().pipe(Effect.orElseSucceed((): ReadonlyArray => [])), ], { concurrency: 3, diff --git a/apps/server/src/pullRequest/GiteaWorkflows.test.ts b/apps/server/src/pullRequest/GiteaWorkflows.test.ts index 594ec5435eba..1ca1b9a0230d 100644 --- a/apps/server/src/pullRequest/GiteaWorkflows.test.ts +++ b/apps/server/src/pullRequest/GiteaWorkflows.test.ts @@ -1,10 +1,13 @@ import { assert, expect, it, vi } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import * as GiteaApi from "../sourceControl/GiteaApi.ts"; import { isCurrentPullWorkflow, list } from "./GiteaWorkflows.ts"; +const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + const run = { id: 7, needs_approval: true, @@ -33,7 +36,7 @@ it.effect("reads capped pages completely and selects only this PR's current bloc const request = vi.fn(); request.mockReturnValueOnce( Effect.succeed({ - body: JSON.stringify({ + body: encodeJson({ total_count: 2, workflow_runs: [{ ...run, pull_request_head_sha: "old" }], }), @@ -43,7 +46,7 @@ it.effect("reads capped pages completely and selects only this PR's current bloc ); request.mockReturnValueOnce( Effect.succeed({ - body: JSON.stringify({ total_count: 2, workflow_runs: [run] }), + body: encodeJson({ total_count: 2, workflow_runs: [run] }), truncated: false, headers: {}, }), @@ -62,7 +65,7 @@ it.effect("fails incomplete pagination instead of reporting no approvals", () => Effect.gen(function* () { const request = vi.fn(() => Effect.succeed({ - body: JSON.stringify({ total_count: 1, workflow_runs: [] }), + body: encodeJson({ total_count: 1, workflow_runs: [] }), truncated: false, headers: {}, }), From 256fd6fe51ce3e481eaed824fa585becd6bb9e2f Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 00:30:07 -0500 Subject: [PATCH 12/19] fix(pull-requests): retain typed Gitea team recovery --- apps/server/src/pullRequest/GiteaPullRequestApi.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 795e9083d056..a2091367d57a 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -2010,7 +2010,7 @@ export const make = Effect.gen(function* () { ...input, path: `${basePath(input.repository)}/teams`, }).pipe( - Effect.catchTag("GiteaPullRequestApiError", (error: GiteaPullRequestApiError) => + Effect.catch((error) => isGiteaApiError(error.cause) && error.cause.status === 405 ? Effect.succeed([]) : Effect.fail(error), From 93580c016f2f39f79ccc96d3b8abc44d0892d249 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 00:39:54 -0500 Subject: [PATCH 13/19] fix(pull-requests): match native Gitea warning status semantics --- .../pullRequest/GiteaPullRequestApi.test.ts | 20 +++++++++++++++++++ .../src/pullRequest/GiteaPullRequestApi.ts | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 2cd4b76f1df5..2967f4b534d7 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -1192,6 +1192,26 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("maps native warning statuses to failing checks", () => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response({ + total_count: 1, + statuses: [{ context: "scan", status: "warning", updated_at: "2026-09-03T11:00:00Z" }], + }), + ), + ); + const api = yield* GiteaPullRequestApi.make; + const checks = yield* api.listChecks({ + host: "forge.example.test", + repository: "acme/web", + sha: "head-sha", + }); + expect(checks).toEqual([expect.objectContaining({ name: "scan", status: "failure" })]); + }), + ); + it.effect("does not request commit statuses without a head revision", () => Effect.gen(function* () { const api = yield* GiteaPullRequestApi.make; diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index a2091367d57a..9efcc25cee36 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1458,7 +1458,7 @@ export const make = Effect.gen(function* () { ? "success" : state === "pending" ? "pending" - : state === "failure" || state === "error" + : state === "failure" || state === "error" || state === "warning" ? "failure" : state === "skipped" ? "skipped" From c4fed16aecd1b7411f55e37a3d332ffa22f148e6 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 01:16:53 -0500 Subject: [PATCH 14/19] fix(pull-requests): decode native Gitea reactions --- .../src/pullRequest/GiteaConversation.test.ts | 22 +++++++++++++++---- .../src/pullRequest/GiteaConversation.ts | 4 ++-- .../pullRequest/GiteaPullRequestApi.test.ts | 14 +++++++----- 3 files changed, 29 insertions(+), 11 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaConversation.test.ts b/apps/server/src/pullRequest/GiteaConversation.test.ts index c36ceecf1d94..23afa88ba6b3 100644 --- a/apps/server/src/pullRequest/GiteaConversation.test.ts +++ b/apps/server/src/pullRequest/GiteaConversation.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; import { editableCommentId, @@ -23,10 +24,10 @@ describe("GiteaConversation", () => { it("groups supported Gitea reactions and names the signed-in viewer separately", () => { const rows: ReadonlyArray = [ - { reaction: "+1", user: { login: "Reader" } }, - { reaction: "+1", user: { login: "teammate" } }, - { reaction: "heart", user: { login: "friend" } }, - { reaction: "party", user: { login: "ignored" } }, + { content: "+1", user: { login: "Reader" } }, + { content: "+1", user: { login: "teammate" } }, + { content: "heart", user: { login: "friend" } }, + { content: "party", user: { login: "ignored" } }, ]; expect(reactionsForViewer(rows, "reader")).toEqual([ @@ -35,6 +36,19 @@ describe("GiteaConversation", () => { ]); }); + it("decodes and groups the native Gitea reaction response shape", () => { + const decodeReaction = Schema.decodeUnknownSync(RawGiteaReaction); + const row = decodeReaction({ + content: "+1", + created_at: "2026-09-05T00:00:00Z", + user: { id: 7, login: "kalvens", full_name: "Kalven" }, + }); + + expect(reactionsForViewer([row], "Kalvens")).toEqual([ + { content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }, + ]); + }); + it("uses Gitea's reaction spelling on writes", () => { expect(nativeReactionContent("thumbs-up")).toBe("+1"); expect(nativeReactionContent("heart")).toBe("heart"); diff --git a/apps/server/src/pullRequest/GiteaConversation.ts b/apps/server/src/pullRequest/GiteaConversation.ts index 2831c6cdd992..9fc61dad9d96 100644 --- a/apps/server/src/pullRequest/GiteaConversation.ts +++ b/apps/server/src/pullRequest/GiteaConversation.ts @@ -7,7 +7,7 @@ const RawReactionUser = Schema.Struct({ /** The shape returned by Gitea's issue and issue-comment reaction endpoints. */ export const RawGiteaReaction = Schema.Struct({ - reaction: Schema.optional(Schema.String), + content: Schema.String, user: Schema.optional(Schema.NullOr(RawReactionUser)), }); @@ -71,7 +71,7 @@ export function reactionsForViewer( { count: number; actors: Array; viewerHasReacted: boolean } >(); for (const row of rows) { - const content = row.reaction === undefined ? undefined : reactionContent.get(row.reaction); + const content = reactionContent.get(row.content); if (content === undefined) continue; const group = groups.get(content) ?? { count: 0, actors: [], viewerHasReacted: false }; group.count += 1; diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 2967f4b534d7..da1f83e0a716 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -1547,13 +1547,17 @@ layer("GiteaPullRequestApi", (it) => { .mockReturnValueOnce( Effect.succeed( response([ - { reaction: "+1", user: { login: "reader" } }, - { reaction: "+1", user: { login: "teammate" } }, + { + content: "+1", + created_at: "2026-09-05T00:00:00Z", + user: { login: "reader" }, + }, + { content: "+1", user: { login: "teammate" } }, ]), ), ) .mockReturnValueOnce( - Effect.succeed(response([{ reaction: "heart", user: { login: "friend" } }])), + Effect.succeed(response([{ content: "heart", user: { login: "friend" } }])), ) .mockReturnValueOnce(Effect.succeed(response([]))); const api = yield* GiteaPullRequestApi.make; @@ -1588,11 +1592,11 @@ layer("GiteaPullRequestApi", (it) => { if (input.path === "/settings/api") return Effect.succeed(response({ features: [] })); if (input.path === "/repos/acme/web/issues/7/reactions?page=1&limit=50") return Effect.succeed( - response([{ reaction: "heart", user: { login: "one" } }], { "x-total-count": "2" }), + response([{ content: "heart", user: { login: "one" } }], { "x-total-count": "2" }), ); if (input.path === "/repos/acme/web/issues/7/reactions?page=2&limit=50") return Effect.succeed( - response([{ reaction: "eyes", user: { login: "two" } }], { "x-total-count": "2" }), + response([{ content: "eyes", user: { login: "two" } }], { "x-total-count": "2" }), ); return Effect.die(`unexpected request: ${input.path}`); }); From 5a889ffeb11c176e2c2ac3556bb2065336296e40 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 01:29:37 -0500 Subject: [PATCH 15/19] fix(pull-requests): accept empty Gitea reaction lists --- .../pullRequest/GiteaPullRequestApi.test.ts | 24 +++++++++++++++++++ .../src/pullRequest/GiteaPullRequestApi.ts | 21 ++++++++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index da1f83e0a716..fde03eb034fe 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -1586,6 +1586,30 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("treats a native null reaction list as empty without dropping other subjects", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce(Effect.succeed(response({ features: [] }))) + .mockReturnValueOnce(Effect.succeed(response(null))) + .mockReturnValueOnce( + Effect.succeed(response([{ content: "heart", user: { login: "friend" } }])), + ); + const api = yield* GiteaPullRequestApi.make; + const reactions = yield* api.listConversationReactions({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + viewer: "Reader", + subjectIds: ["issue:12"], + }); + + expect(reactions.pullRequest).toEqual([]); + expect(reactions.bySubjectId.get("issue:12")).toEqual([ + { content: "heart", count: 1, actors: ["friend"], viewerHasReacted: false }, + ]); + }), + ); + it.effect("follows a reaction list when Gitea caps a requested page below its limit", () => Effect.gen(function* () { mockedRequest.mockImplementation((input) => { diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 9efcc25cee36..33c133749443 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -845,9 +845,23 @@ export const make = Effect.gen(function* () { host: string; repository: string; path: string; + nullAsEmpty?: boolean; }) { - const response = yield* request({ ...input, method: "GET" }); - const rows = yield* decode(input.operation, Schema.Array(Schema.Unknown), response); + const response = yield* request({ + operation: input.operation, + host: input.host, + repository: input.repository, + path: input.path, + method: "GET", + }); + const decoded = yield* decode( + input.operation, + input.nullAsEmpty + ? Schema.NullOr(Schema.Array(Schema.Unknown)) + : Schema.Array(Schema.Unknown), + response, + ); + const rows = decoded ?? []; return { rows, headers: response.headers } satisfies UnknownPage; }); @@ -1022,6 +1036,7 @@ export const make = Effect.gen(function* () { path: string; limit: number; requirePaginationEvidence?: boolean; + nullAsEmpty?: boolean; }) { const rows: Array = []; let path = input.path; @@ -1032,6 +1047,7 @@ export const make = Effect.gen(function* () { host: input.host, repository: input.repository, path, + ...(input.nullAsEmpty === undefined ? {} : { nullAsEmpty: input.nullAsEmpty }), }); rowsSeen += result.rows.length; const remaining = Math.max(0, input.limit - rows.length); @@ -1681,6 +1697,7 @@ export const make = Effect.gen(function* () { }, ), limit: PAGE_SIZE * MAX_PAGINATION_PAGES, + nullAsEmpty: true, }).pipe( Effect.map((result) => ({ subjectId: entry.subjectId, From 3c866b6f6b26ebfc9f044ab8ba31c634dc4b516b Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 01:57:40 -0500 Subject: [PATCH 16/19] fix(pull-requests): paginate Gitea timelines correctly --- .../src/pullRequest/GiteaPullRequestApi.test.ts | 12 ++++++++---- apps/server/src/pullRequest/GiteaPullRequestApi.ts | 12 +++++------- docs/user/source-control.md | 6 +++--- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 81751a5ca1f4..a9db186f6c53 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -998,6 +998,7 @@ layer("GiteaPullRequestApi", (it) => { id, type: "comment", })), + { "x-total-count": "50" }, ), ), ) @@ -1015,16 +1016,19 @@ layer("GiteaPullRequestApi", (it) => { }), ); - it.effect("honors a server timeline page-size cap before reading the final merge state", () => + it.effect("follows a timeline next link before reading the final merge state", () => Effect.gen(function* () { mockedRequest.mockReturnValueOnce( Effect.succeed( - response([{ id: 1, type: "pull_scheduled_merge" }], { "x-total-count": "2" }), + response([{ id: 1, type: "pull_scheduled_merge" }], { + link: '; rel="next"', + "x-total-count": "1", + }), ), ); mockedRequest.mockReturnValueOnce( Effect.succeed( - response([{ id: 2, type: "pull_cancel_scheduled_merge" }], { "x-total-count": "2" }), + response([{ id: 2, type: "pull_cancel_scheduled_merge" }], { "x-total-count": "1" }), ), ); const api = yield* GiteaPullRequestApi.GiteaPullRequestApi; @@ -1035,7 +1039,7 @@ layer("GiteaPullRequestApi", (it) => { number: 7, }), ); - expect(callAt(1).path).toContain("page=2"); + expect(callAt(1).path).toBe("/repos/acme/web/issues/7/timeline?page=2&limit=1"); }), ); diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index c9ae5268ab2c..d1dfeda5626d 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1203,13 +1203,11 @@ export const make = Effect.gen(function* () { response, ); events.push(...pageEvents); - const next = nextPagePath({ - path, - page, - pageRows: pageEvents.length, - rowsSeen: events.length, - headers: response.headers, - }); + // Gitea's timeline route reports the current page length in X-Total-Count rather than the + // total number of events, so that header cannot prove the timeline is complete. + const next = + nextLink(response.headers) ?? + (pageEvents.length >= PAGE_SIZE ? pathAtPage(path, page + 1) : null); if (next === null) return GiteaLifecycle.autoMergeEnabled(events); path = next; } diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 853ef9a63689..f2713cba2b1a 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -114,9 +114,9 @@ For Azure DevOps, use the host website to view diffs or change comments. Bitbuck reopening a declined pull request. Gitea supports PR tracking, comments, reviews, diffs, reviewer and label updates, merge methods, -branch updates, and close/reopen. Draft status is shown when Gitea reports it, but draft/ready -changes, auto-merge controls, reactions, comment editing, workflow approval, and revert PRs are -not currently available in T3. Use your Gitea website for those tasks. +branch updates, close/reopen, draft/ready changes, and auto-merge controls. Reactions, comment +editing, workflow approval, and revert PRs are not currently available in T3. Use your Gitea +website for those tasks. ## Troubleshooting From 92761803ad9ee6d548af49b2965323ae28806018 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 02:10:36 -0500 Subject: [PATCH 17/19] fix(pull-requests): tolerate Gitea timeline failures --- .../GiteaPullRequestProvider.test.ts | 91 ++++++++++++++++++- .../pullRequest/GiteaPullRequestProvider.ts | 4 +- 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts index df0867a7c2ed..60596816bab9 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts @@ -1,12 +1,101 @@ -import { describe, expect, it } from "@effect/vitest"; +import { describe, expect, it, vi } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as GiteaApi from "../sourceControl/GiteaApi.ts"; import { giteaBaseComparison, giteaProviderFailure, giteaViewerPermissions, + make as makeGiteaPullRequestProvider, } from "./GiteaPullRequestProvider.ts"; +import * as GiteaPullRequestApi from "./GiteaPullRequestApi.ts"; import { GiteaPullRequestApiError } from "./GiteaPullRequestApi.ts"; +function response(value: unknown) { + return { body: JSON.stringify(value), truncated: false, headers: {} }; +} + +function rawPullRequest() { + return { + number: 7, + title: "Pull request 7", + body: "Body", + state: "open", + merged: false, + mergeable: true, + draft: false, + html_url: "https://forge.example.test/gitea/acme/web/pulls/7", + created_at: "2026-09-01T10:00:00Z", + updated_at: "2026-09-02T10:00:00Z", + additions: 4, + deletions: 2, + changed_files: 1, + comments: 1, + review_comments: 2, + merge_base: "base-sha", + user: { id: 1, login: "author", full_name: "Author" }, + base: { ref: "main", sha: "base-sha", repo: { full_name: "acme/web" } }, + head: { ref: "feature", sha: "head-sha", repo: { full_name: "fork/web" } }, + requested_reviewers: [], + labels: [], + }; +} + +describe("GiteaPullRequestProvider", () => { + it.effect("keeps pull request detail when auto-merge state cannot be read", () => + Effect.gen(function* () { + const request = vi.fn((input) => { + switch (input.path) { + case "/repos/acme/web/pulls/7": + return Effect.succeed(response(rawPullRequest())); + case "/repos/acme/web": + return Effect.succeed(response({ permissions: { push: true } })); + case "/user": + return Effect.succeed(response({ login: "reader" })); + case "/repos/acme/web/issues/7/timeline?page=1&limit=50": + return Effect.fail( + new GiteaApi.GiteaApiError({ + operation: "getAutoMergeEnabled", + reason: "failed", + detail: "timeline unavailable", + }), + ); + case "/repos/acme/web/commits/head-sha/status?page=1&limit=50": + return Effect.succeed(response({ statuses: [], total_count: 0 })); + default: + return Effect.die(`Unexpected Gitea request: ${input.path}`); + } + }); + const apiLayer = GiteaPullRequestApi.layer.pipe( + Layer.provide( + Layer.succeed( + GiteaApi.GiteaApi, + GiteaApi.GiteaApi.of({ + baseUrl: Option.some("https://forge.example.test/gitea"), + sshHosts: [], + request, + probeAuth: Effect.die("not used"), + }), + ), + ), + ); + const provider = yield* makeGiteaPullRequestProvider.pipe(Effect.provide(apiLayer)); + + const detail = yield* provider.getChangeRequest({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect(detail.number).toBe(7); + expect(detail.checks).toEqual([]); + expect("autoMergeEnabled" in detail).toBe(false); + }), + ); +}); + describe("giteaViewerPermissions", () => { it("offers repository writes and only the configured branch update strategies", () => { expect( diff --git a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts index dc882dbe7b8b..e798da895b9c 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestProvider.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestProvider.ts @@ -162,7 +162,7 @@ export const make = Effect.gen(function* () { api.getPullRequest(input), api.getRepositoryAccess(input), api.getViewer(), - api.getAutoMergeEnabled(input), + api.getAutoMergeEnabled(input).pipe(Effect.orElseSucceed(() => undefined)), ], { concurrency: 4 }, ).pipe( @@ -179,7 +179,7 @@ export const make = Effect.gen(function* () { checks, mergeCapabilities: access.mergeCapabilities, baseComparison: giteaBaseComparison(pullRequest), - autoMergeEnabled, + ...(autoMergeEnabled === undefined ? {} : { autoMergeEnabled }), viewerPermissions: permissions({ access, viewer, From 32f0bff07f7f7369a5a6bd1632dc52578ef854ee Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 16:26:17 -0500 Subject: [PATCH 18/19] fix(pull-requests): retain uncertain Gitea comment pagination --- .../pullRequest/GiteaPullRequestApi.test.ts | 42 +++++++++++++++++++ .../src/pullRequest/GiteaPullRequestApi.ts | 11 ++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 06b2a9904034..5da1e39c707f 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -1337,6 +1337,48 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect.each([1, 4])( + "marks inline comments incomplete when page %i has no pagination evidence", + (pageCount) => + Effect.gen(function* () { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response([ + { id: 21, body: "Review", state: "COMMENT", submitted_at: "2026-09-03T11:00:00Z" }, + ]), + ), + ); + for (let page = 1; page <= pageCount; page += 1) { + mockedRequest.mockReturnValueOnce( + Effect.succeed( + response( + Array.from({ length: 50 }, (_, index) => ({ + id: page * 50 + index, + body: `Comment ${index + 1}`, + path: "src/a.ts", + position: index + 1, + created_at: "2026-09-03T11:01:00Z", + })), + page === pageCount ? {} : { "x-total-count": "201" }, + ), + ), + ); + } + const api = yield* GiteaPullRequestApi.make; + const result = yield* api.listReviews({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + assert.isTrue(result.truncated); + expect(result.comments.filter((comment) => comment.kind === "review-comment")).toHaveLength( + 50 * pageCount, + ); + expect(mockedRequest).toHaveBeenCalledTimes(1 + pageCount); + }), + ); + it.effect("does not repeat an unpaginated native review-comment response at the page size", () => Effect.gen(function* () { mockedRequest diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index eb4bc15fd7dd..4e33b5011273 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1079,13 +1079,20 @@ export const make = Effect.gen(function* () { nextLink(result.headers) !== null || totalCount(result.headers) !== null; const paginationNext = input.requirePaginationEvidence && !hasPaginationEvidence ? null : next; + // Native unpaginated endpoints can return more than the requested page size. Exactly + // one requested page is ambiguous when headers do not establish whether more rows exist. + const paginationUncertain = + input.requirePaginationEvidence === true && + !hasPaginationEvidence && + result.rows.length === PAGE_SIZE; if (result.rows.length > remaining || rows.length >= input.limit) { return { rows, - truncated: result.rows.length > remaining || paginationNext !== null, + truncated: + result.rows.length > remaining || paginationNext !== null || paginationUncertain, }; } - if (paginationNext === null) return { rows, truncated: false }; + if (paginationNext === null) return { rows, truncated: paginationUncertain }; path = paginationNext; } return { rows, truncated: true }; From 6114cf39dac017bd665908effd538e5f4f8d5764 Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Sat, 5 Sep 2026 16:35:04 -0500 Subject: [PATCH 19/19] fix(pull-requests): share the inline comment budget across reviews --- .../pullRequest/GiteaPullRequestApi.test.ts | 45 +++++++++++++++++++ .../src/pullRequest/GiteaPullRequestApi.ts | 8 +++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts index 5da1e39c707f..49540f2078ad 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.test.ts @@ -1467,6 +1467,51 @@ layer("GiteaPullRequestApi", (it) => { }), ); + it.effect("shares the raw inline-comment budget across reviews", () => + Effect.gen(function* () { + mockedRequest + .mockReturnValueOnce( + Effect.succeed( + response([ + { id: 21, body: "First review", submitted_at: "2026-09-03T11:00:00Z" }, + { id: 22, body: "Second review", submitted_at: "2026-09-03T12:00:00Z" }, + { id: 23, body: "Third review", submitted_at: "2026-09-03T13:00:00Z" }, + ]), + ), + ) + .mockReturnValueOnce( + Effect.succeed( + response([ + ...Array.from({ length: 199 }, (_, index) => ({ + id: index + 31, + body: `Comment ${index + 1}`, + path: "src/a.ts", + position: index + 1, + created_at: "2026-09-03T11:01:00Z", + })), + { id: "malformed" }, + ]), + ), + ); + const api = yield* GiteaPullRequestApi.make; + const result = yield* api.listReviews({ + host: "forge.example.test", + repository: "acme/web", + number: 7, + }); + + expect( + result.comments.filter((comment) => comment.kind === "review").map((comment) => comment.id), + ).toEqual(["review:21", "review:22", "review:23"]); + expect(result.comments.filter((comment) => comment.kind === "review-comment")).toHaveLength( + 199, + ); + assert.isTrue(result.truncated); + expect(mockedRequest).toHaveBeenCalledTimes(2); + expect(callAt(1).path).toContain("/reviews/21/comments?"); + }), + ); + it.effect("follows pagination links when Gitea caps comment pages below the limit", () => Effect.gen(function* () { mockedRequest diff --git a/apps/server/src/pullRequest/GiteaPullRequestApi.ts b/apps/server/src/pullRequest/GiteaPullRequestApi.ts index 4e33b5011273..260b63d9cf8f 100644 --- a/apps/server/src/pullRequest/GiteaPullRequestApi.ts +++ b/apps/server/src/pullRequest/GiteaPullRequestApi.ts @@ -1375,6 +1375,7 @@ export const make = Effect.gen(function* () { const comments: Array = []; const threads: Array = []; let commentsTruncated = reviewsTruncated; + let remainingReviewCommentRows = PAGE_SIZE * CONVERSATION_PAGES; for (const row of reviewRows) { const review = decodeReview(row); if (Option.isNone(review)) continue; @@ -1391,6 +1392,10 @@ export const make = Effect.gen(function* () { reviewState: review.value.state?.toLowerCase().replaceAll("_", " ") ?? null, }); } + if (remainingReviewCommentRows === 0) { + commentsTruncated = true; + continue; + } const codeRows = yield* readUnknownSlice({ operation: "listReviewComments", ...input, @@ -1398,9 +1403,10 @@ export const make = Effect.gen(function* () { `${basePath(input.repository)}/pulls/${input.number}/reviews/${review.value.id}/comments`, { page: 1, limit: PAGE_SIZE }, ), - limit: PAGE_SIZE * CONVERSATION_PAGES, + limit: remainingReviewCommentRows, requirePaginationEvidence: true, }); + remainingReviewCommentRows -= codeRows.rows.length; commentsTruncated ||= codeRows.truncated; const grouped = new Map< string,