Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions apps/server/src/pullRequest/GiteaForkCapabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ const base: PullRequestCapabilities = {
};

describe("GiteaForkCapabilities", () => {
it("offers fork-only actions only when the server advertises them", () => {
const available: PullRequestCapabilities = {
...base,
actions: ["merge", "approve-workflows", "revert"],
};
expect(giteaForkCapabilities(available, []).actions).toEqual(["merge"]);
expect(giteaForkCapabilities(available, ["actions-run-approve"]).actions).toEqual([
"merge",
"approve-workflows",
]);
expect(giteaForkCapabilities(available, ["pull-revert"]).actions).toEqual(["merge", "revert"]);
expect(available.actions).toEqual(["merge", "approve-workflows", "revert"]);
});
it("enables review-summary reactions only for an advertising server", () => {
expect(giteaForkCapabilities(base, []).reactionSubjects?.review).toBe(false);
expect(giteaForkCapabilities(base, ["pull-review-reactions"]).reactionSubjects?.review).toBe(
Expand Down
17 changes: 13 additions & 4 deletions apps/server/src/pullRequest/GiteaForkCapabilities.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import type { PullRequestCapabilities } from "@t3tools/contracts";

/** Capabilities added by the companion Gitea API extension, discovered lazily per server. */
export function giteaForkCapabilities(
base: PullRequestCapabilities,
features: ReadonlyArray<string>,
): PullRequestCapabilities {
return features.includes("pull-review-reactions")
? { ...base, reactionSubjects: { ...base.reactionSubjects!, review: true } }
: base;
return {
...base,
actions: base.actions.filter((action) =>
action === "approve-workflows"
? features.includes("actions-run-approve")
: action === "revert"
? features.includes("pull-revert")
: true,
),
...(features.includes("pull-review-reactions")
? { reactionSubjects: { ...base.reactionSubjects!, review: true } }
: {}),
};
}

export function giteaHasFeature(features: ReadonlyArray<string>, feature: string): boolean {
Expand Down
127 changes: 117 additions & 10 deletions apps/server/src/pullRequest/GiteaPullRequestApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@ const mockedRequest = vi.fn<GiteaApi.GiteaApi["Service"]["request"]>();
const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown));

const layer = it.layer(
Layer.succeed(GiteaApi.GiteaApi, GiteaApi.GiteaApi.of({
baseUrl: Option.some("https://forge.example.test/gitea"),
sshHosts: ["work-forge"],
request: mockedRequest,
probeAuth: Effect.die("not used"),
})),
Layer.succeed(
GiteaApi.GiteaApi,
GiteaApi.GiteaApi.of({
baseUrl: Option.some("https://forge.example.test/gitea"),
sshHosts: ["work-forge"],
request: mockedRequest,
probeAuth: Effect.die("not used"),
}),
),
);

function response(value: unknown, headers: Readonly<Record<string, string>> = {}) {
Expand Down Expand Up @@ -147,6 +150,110 @@ 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("approves only the current pull request's waiting workflow runs", () =>
Effect.gen(function* () {
const pull = rawPullRequest(7);
mockedRequest
.mockReturnValueOnce(Effect.succeed(response(pull)))
.mockReturnValueOnce(Effect.succeed(response({ features: ["actions-run-approve"] })))
.mockReturnValueOnce(Effect.succeed(response(pull)))
.mockReturnValueOnce(
Effect.succeed(
response({
total_count: 1,
workflow_runs: [
{
id: 42,
needs_approval: true,
pull_request_head_sha: "head-sha",
head_sha: "merge-sha",
event: "pull_request",
html_url: "https://forge.example.test/run/42",
pull_requests: [{ number: 7 }],
},
],
}),
),
)
.mockReturnValueOnce(Effect.succeed(response(pull)))
.mockReturnValueOnce(Effect.succeed(response({})));
const api = yield* GiteaPullRequestApi.make;
yield* api.runAction({
host: "forge.example.test",
repository: "acme/web",
number: 7,
action: "approve-workflows",
});
expect(
mockedRequest.mock.calls
.filter(([call]) => call.method === "POST")
.map(([call]) => call.path),
).toEqual(["/repos/acme/web/actions/runs/42/approve"]);
}),
);

it.effect("rejects workflow approval on servers without native approval metadata", () =>
Effect.gen(function* () {
mockedRequest
.mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7))))
.mockReturnValueOnce(Effect.succeed(response({})));
const api = yield* GiteaPullRequestApi.make;
const error = yield* api
.runAction({
host: "forge.example.test",
repository: "acme/web",
number: 7,
action: "approve-workflows",
})
.pipe(Effect.flip);
expect(error.detail).toContain("does not expose workflow approval metadata");
expect(mockedRequest.mock.calls.every(([call]) => call.method === "GET")).toBe(true);
}),
);

it.effect("never approves a workflow after the pull request head changes", () =>
Effect.gen(function* () {
mockedRequest.mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7))));
mockedRequest.mockReturnValueOnce(
Effect.succeed(response({ features: ["actions-run-approve"] })),
);
mockedRequest.mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7))));
mockedRequest.mockReturnValueOnce(
Effect.succeed(
response({
total_count: 1,
workflow_runs: [
{
id: 42,
needs_approval: true,
pull_request_head_sha: "head-sha",
head_sha: "merge-sha",
event: "pull_request",
html_url: "https://forge.example.test/run/42",
pull_requests: [{ number: 7 }],
},
],
}),
),
);
mockedRequest.mockReturnValueOnce(
Effect.succeed(
response(rawPullRequest(7, { head: { ref: "feature", sha: "changed-head" } })),
),
);
const api = yield* GiteaPullRequestApi.make;
const error = yield* api
.runAction({
host: "forge.example.test",
repository: "acme/web",
number: 7,
action: "approve-workflows",
})
.pipe(Effect.flip);
expect(error.detail).toContain("head changed");
expect(mockedRequest.mock.calls.every(([call]) => call.method === "GET")).toBe(true);
}),
);

it.effect("validates the requested host before making an HTTP request", () =>
Effect.gen(function* () {
Expand Down Expand Up @@ -1211,7 +1318,7 @@ layer("GiteaPullRequestApi", (it) => {

it.effect("reports Gitea's missing review-summary reaction route", () =>
Effect.gen(function* () {
mockedRequest.mockReturnValueOnce(Effect.succeed(response({features: []})));
mockedRequest.mockReturnValueOnce(Effect.succeed(response({ features: [] })));
const api = yield* GiteaPullRequestApi.make;
const error = yield* api
.setReaction({
Expand Down Expand Up @@ -1304,7 +1411,7 @@ layer("GiteaPullRequestApi", (it) => {
Effect.gen(function* () {
mockedRequest
.mockReturnValueOnce(Effect.succeed(response({})))
.mockReturnValueOnce(Effect.succeed(response({features: []})))
.mockReturnValueOnce(Effect.succeed(response({ features: [] })))
.mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7))))
.mockReturnValueOnce(Effect.succeed(response({})))
.mockReturnValueOnce(
Expand Down Expand Up @@ -1364,7 +1471,7 @@ layer("GiteaPullRequestApi", (it) => {
it.effect("restores the title when Gitea does not recognize the configured draft prefix", () =>
Effect.gen(function* () {
mockedRequest
.mockReturnValueOnce(Effect.succeed(response({features: []})))
.mockReturnValueOnce(Effect.succeed(response({ features: [] })))
.mockReturnValueOnce(Effect.succeed(response(rawPullRequest(7))))
.mockReturnValueOnce(Effect.succeed(response({})))
.mockReturnValueOnce(
Expand Down Expand Up @@ -1452,7 +1559,7 @@ layer("GiteaPullRequestApi", (it) => {

it.effect("honors a server timeline page-size cap before reading the final merge state", () =>
Effect.gen(function* () {
mockedRequest.mockReturnValueOnce(Effect.succeed(response({features: []})));
mockedRequest.mockReturnValueOnce(Effect.succeed(response({ features: [] })));
mockedRequest.mockReturnValueOnce(
Effect.succeed(
response([{ id: 1, type: "pull_scheduled_merge" }], { "x-total-count": "2" }),
Expand Down
76 changes: 73 additions & 3 deletions apps/server/src/pullRequest/GiteaPullRequestApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {

import * as GiteaApi from "../sourceControl/GiteaApi.ts";
import * as GiteaLifecycle from "./GiteaLifecycle.ts";
import * as GiteaWorkflows from "./GiteaWorkflows.ts";
import {
editableCommentId,
type GiteaConversationReactionTarget,
Expand Down Expand Up @@ -445,6 +446,14 @@ function nextPagePath(input: {
export class GiteaPullRequestApi extends Context.Service<
GiteaPullRequestApi,
{
readonly getWorkflowApprovals: (input: {
host: string;
repository: string;
number: number;
}) => Effect.Effect<
{ supported: boolean; runs: ReadonlyArray<GiteaWorkflows.GiteaWorkflowRun> },
GiteaPullRequestApiError
>;
readonly getViewer: () => Effect.Effect<string, GiteaPullRequestApiError>;
readonly getFeatures: () => Effect.Effect<ReadonlyArray<string>, GiteaPullRequestApiError>;
readonly listPullRequests: (input: {
Expand Down Expand Up @@ -741,6 +750,58 @@ export const make = Effect.gen(function* () {
},
);

const getWorkflowApprovals = Effect.fn("GiteaPullRequestApi.getWorkflowApprovals")(
function* (input: { host: string; repository: string; number: number }) {
yield* validateHost(input.host);
if (!(yield* getFeatures).includes("actions-run-approve"))
return { supported: false, runs: [] };
const pull = yield* getPullRequest(input);
if (pull.state !== "open") return { supported: true, runs: [] };
const runs = yield* GiteaWorkflows.list(gitea, { ...input, headSha: pull.headSha }).pipe(
Effect.mapError((error) => failure("getWorkflowApprovals", error)),
);
return { supported: true, runs };
},
);

const approveWorkflows = Effect.fn("GiteaPullRequestApi.approveWorkflows")(function* (input: {
host: string;
repository: string;
number: number;
}) {
const before = yield* getPullRequest(input);
const approvals = yield* getWorkflowApprovals(input);
if (!approvals.supported)
return yield* new GiteaPullRequestApiError({
operation: "approveWorkflows",
reason: "failed",
detail: "This Gitea server does not expose workflow approval metadata.",
});
for (const run of approvals.runs) {
const current = yield* getPullRequest(input);
if (
current.state !== "open" ||
current.headSha !== before.headSha ||
!GiteaWorkflows.isCurrentPullWorkflow(run, {
number: input.number,
headSha: current.headSha,
})
) {
return yield* new GiteaPullRequestApiError({
operation: "approveWorkflows",
reason: "failed",
detail: "The pull request head changed; refresh before approving workflows.",
});
}
yield* request({
operation: "approveWorkflows",
...input,
method: "POST",
path: `${basePath(input.repository)}/actions/runs/${run.id}/approve`,
});
}
});

const readUnknownPage = Effect.fn("GiteaPullRequestApi.readUnknownPage")(function* (input: {
operation: string;
host: string;
Expand Down Expand Up @@ -854,7 +915,9 @@ export const make = Effect.gen(function* () {
);

const getFeatures = yield* Effect.cachedWithTTL(
Effect.suspend(() => gitea.request({ operation: "getFeatures", method: "GET", path: "/settings/api" })).pipe(
Effect.suspend(() =>
gitea.request({ operation: "getFeatures", method: "GET", path: "/settings/api" }),
).pipe(
Effect.mapError((error) => failure("getFeatures", error)),
Effect.flatMap((response) =>
decode(
Expand Down Expand Up @@ -1558,6 +1621,7 @@ export const make = Effect.gen(function* () {

return GiteaPullRequestApi.of({
getFeatures: () => getFeatures,
getWorkflowApprovals,
getViewer: Effect.fn("GiteaPullRequestApi.getViewer")(function* () {
const response = yield* gitea
.request({
Expand Down Expand Up @@ -1747,8 +1811,9 @@ export const make = Effect.gen(function* () {
number: input.number,
action: input.action,
});
case "revert":
case "approve-workflows":
return approveWorkflows(input);
case "revert":
return Effect.fail(unsupportedAction(input.action));
}
},
Expand Down Expand Up @@ -1954,7 +2019,12 @@ export const make = Effect.gen(function* () {
);
}
return Effect.gen(function* () {
if (target.kind === "review" && !(yield* getFeatures.pipe(Effect.orElseSucceed(() => []))).includes("pull-review-reactions")) {
if (
target.kind === "review" &&
!(yield* getFeatures.pipe(Effect.orElseSucceed(() => []))).includes(
"pull-review-reactions",
)
) {
return yield* new GiteaPullRequestApiError({
operation: "setReaction",
reason: "failed",
Expand Down
18 changes: 18 additions & 0 deletions apps/server/src/pullRequest/GiteaPullRequestProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,24 @@ import {
import { GiteaPullRequestApiError } from "./GiteaPullRequestApi.ts";

describe("giteaViewerPermissions", () => {
it("offers workflow approval only when the server supports it and the viewer can write", () => {
expect(
giteaViewerPermissions({
canWrite: true,
ownsPullRequest: false,
updateMethods: [],
workflowApprovalSupported: true,
}).actions,
).toContain("approve-workflows");
expect(
giteaViewerPermissions({
canWrite: false,
ownsPullRequest: true,
updateMethods: [],
workflowApprovalSupported: true,
}).actions,
).not.toContain("approve-workflows");
});
it("offers repository writes and only the configured branch update strategies", () => {
expect(
giteaViewerPermissions({
Expand Down
Loading