From 10005f87b25cedd28255c1751f396f6e8fd4f44e Mon Sep 17 00:00:00 2001 From: Kalven Schraut Date: Fri, 4 Sep 2026 23:37:59 -0500 Subject: [PATCH] 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"), }), ), ),