Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ import {
forgejoChecks,
forgejoReactions,
} from "./forgejoPullRequestJson.ts";
import { ForgejoBranchStatusChecks, markRequiredForgejoChecks } from "./forgejoRequiredChecks.ts";
import { definition } from "../client/definition.ts";

const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError);

const CAPABILITIES: PullRequestCapabilities = {
diff: true,
viewedFiles: "environment",
Expand Down Expand Up @@ -202,6 +205,44 @@ export const make = Effect.gen(function* () {
);
return permissions(repo, pr, viewer);
});
const readChecks = Effect.fn("ForgejoPullRequestProvider.readChecks")(function* (
input: ProviderRepositoryRef,
pr: typeof ForgejoPullRequest.Type,
) {
const branch = read(
{
...input,
path: `${repoPath(input)}/branches/${pr.base.ref.split("/").map(encodeURIComponent).join("/")}`,
},
ForgejoBranchStatusChecks,
).pipe(
Effect.catchTags({
PullRequestProviderError: (error) => {
if (
isForgejoCliError(error.cause) &&
(error.cause.reason === "forbidden" || error.cause.reason === "not-found")
) {
return Effect.succeed({});
}
return Effect.fail(error);
},
}),
);
const [statuses, policy] = yield* Effect.all(
[
page(
{
...input,
path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`,
},
ForgejoStatus,
),
branch,
],
{ concurrency: 2 },
);
return markRequiredForgejoChecks(forgejoChecks(statuses.items, pr.html_url), policy);
});
const unsupported = (operation: string) =>
Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`));
const provider: PullRequestProviderApi = {
Expand Down Expand Up @@ -244,31 +285,15 @@ export const make = Effect.gen(function* () {
getChangeRequestChecks: Effect.fn("ForgejoPullRequestProvider.getChangeRequestChecks")(
function* (input) {
const pr = yield* getPull(input);
const statuses = yield* page(
{
...input,
path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`,
},
ForgejoStatus,
);
return {
state: forgejoChangeRequest(pr).state,
checks: forgejoChecks(statuses.items, pr.html_url),
};
return { state: forgejoChangeRequest(pr).state, checks: yield* readChecks(input, pr) };
},
),
getChangeRequest: Effect.fn("ForgejoPullRequestProvider.getChangeRequest")(function* (input) {
const [pr, repo, viewer] = yield* Effect.all(
[getPull(input), getRepo(input), getViewer(input)],
{ concurrency: 3 },
);
const statuses = yield* page(
{
...input,
path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`,
},
ForgejoStatus,
);
const checks = yield* readChecks(input, pr);
return {
...forgejoChangeRequest(pr),
headSha: pr.head.sha,
Expand All @@ -278,7 +303,7 @@ export const make = Effect.gen(function* () {
const actor = forgejoActor(user);
return actor ? [actor] : [];
}),
checks: forgejoChecks(statuses.items, pr.html_url),
checks,
baseComparison: !pr.merge_base
? "unknown"
: pr.merge_base === pr.base.sha
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,8 @@ it.effect("reads the newest Forgejo checks without repository or viewer requests
});
assert.strictEqual(result.state, "open");
assert.strictEqual(result.checks[0]?.status, "failure");
assert.deepStrictEqual(paths, [
assert.deepStrictEqual(paths.toSorted(), [
"repos/acme/web/branches/main",
"repos/acme/web/pulls/1",
"repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=1",
"repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=2",
Expand All @@ -146,16 +147,18 @@ it.effect("reads the newest Forgejo checks without repository or viewer requests
Layer.mock(ForgejoCli.ForgejoCli)({
api: (input) => {
paths.push(input.path);
assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head)/);
assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head|branches\/main)/);
return Effect.succeed(
processOutput(
input.path.endsWith("pulls/1")
? `{"number":1,"title":"Checks","body":"","html_url":"https://forgejo.test/acme/web/pulls/1", "user":null,"state":"open","merged":false,
input.path.endsWith("branches/main")
? `{"name":"main","protected":false,"enable_status_check":false,"status_check_contexts":[]}`
: input.path.endsWith("pulls/1")
? `{"number":1,"title":"Checks","body":"","html_url":"https://forgejo.test/acme/web/pulls/1", "user":null,"state":"open","merged":false,
"head":{"ref":"feature","sha":"head","repo":null},"base":{"ref":"main","sha":"base","repo":null},
"created_at":"2026-09-16T00:00:00Z","updated_at":"2026-09-16T00:00:00Z","closed_at":null,"merged_at":null,"labels":[]}`
: input.path.endsWith("page=1")
? `[{"context":"build","status":"failure","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00.500Z"},{"context":"build","status":"success","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00Z"}]`
: "[]",
: input.path.endsWith("page=1")
? `[{"context":"build","status":"failure","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00.500Z"},{"context":"build","status":"success","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00Z"}]`
: "[]",
),
);
},
Expand All @@ -164,6 +167,164 @@ it.effect("reads the newest Forgejo checks without repository or viewer requests
);
});

const requiredChecksPullRequest = {
number: 7,
title: "Required checks",
body: "",
html_url: "https://forgejo.test/acme/web/pulls/7",
user: { login: "maria" },
state: "open",
merged: false,
head: { ref: "feature", sha: "head", repo: null },
base: { ref: "release/1.x", sha: "base", repo: null },
created_at: "2026-10-12T00:00:00Z",
updated_at: "2026-10-12T00:00:00Z",
closed_at: null,
merged_at: null,
labels: [],
};
const requiredChecksStatuses = [
["ci/required-build", "success"],
["ci/optional-lint", "failure"],
].map(([context, status]) => ({
context,
status,
description: null,
target_url: null,
updated_at: "2026-10-12T00:00:00Z",
}));

const requiredChecksInput = {
cwd: "/repo",
repository: "acme/web",
host: "forgejo.test",
number: 7,
};
const requiredChecksLayer = (
branch: Effect.Effect<unknown, ForgejoCli.ForgejoCliError>,
statuses = requiredChecksStatuses,
) =>
Layer.mock(ForgejoCli.ForgejoCli)({
api: (input) => {
const [path, query] = input.path.split("?");
const respond = (response: unknown) =>
encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput));
switch (path) {
case "repos/acme/web/pulls/7":
return respond(requiredChecksPullRequest);
case "repos/acme/web/statuses/head":
return respond(new URLSearchParams(query).get("page") === "1" ? statuses : []);
case "repos/acme/web/branches/release/1.x":
return Effect.flatMap(branch, respond);
case "repos/acme/web":
return respond({ full_name: "acme/web", permissions: { push: true, admin: false } });
case "user":
return respond({ login: "maria" });
default:
return Effect.die(`Unexpected Forgejo request: ${input.path}`);
}
},
});

it.effect.each([
["ci/required-deploy", true],
["test-*", true],
["ci/[ab]", false],
] as const)("keeps missing required pattern %s pending (supported: %s)", ([missing, supported]) =>
Effect.gen(function* () {
const provider = yield* ForgejoPullRequestProvider.make;
const readChecks = provider.getChangeRequestChecks;
if (readChecks === undefined) return yield* Effect.die("checks read missing");
const summary = yield* readChecks(requiredChecksInput);
const detail = yield* provider.getChangeRequest(requiredChecksInput);
for (const checks of [summary.checks, detail.checks]) {
assert.deepStrictEqual(
checks.map((check) => [check.name, check.status, check.required]),
[
["ci/required-build", "success", supported ? true : undefined],
["ci/optional-lint", supported ? "failure" : "success", undefined],
[missing, "pending", supported ? true : undefined],
],
);
if (!supported) {
assert.strictEqual(
checks.at(-1)?.description,
"Required check pattern cannot be evaluated.",
);
}
}
}).pipe(
Effect.provide(
requiredChecksLayer(
Effect.succeed({
name: "release/1.x",
protected: true,
enable_status_check: true,
status_check_contexts: ["ci/required-*", missing, missing],
}),
supported
? requiredChecksStatuses
: requiredChecksStatuses.map((status) => ({ ...status, status: "success" })),
),
),
),
);

it.effect.each([
["older branch metadata", Effect.succeed({ name: "release/1.x", protected: true })],
...(["not-found", "forbidden"] as const).map(
(reason) =>
[
reason,
Effect.fail(
new ForgejoCli.ForgejoCliError({
command: "fj",
cwd: "/repo",
reason,
httpStatus: reason === "not-found" ? 404 : 403,
detail: "Branch metadata unavailable.",
}),
),
] as const,
),
] as const)("keeps all statuses gating with %s", ([, branch]) =>
Effect.gen(function* () {
const provider = yield* ForgejoPullRequestProvider.make;
const readChecks = provider.getChangeRequestChecks;
if (readChecks === undefined) return yield* Effect.die("checks read missing");
const summary = yield* readChecks(requiredChecksInput);
const detail = yield* provider.getChangeRequest(requiredChecksInput);
for (const checks of [summary.checks, detail.checks]) {
assert.deepStrictEqual(
checks.map((check) => [check.name, check.status, check.required]),
[
["ci/required-build", "success", undefined],
["ci/optional-lint", "failure", undefined],
],
);
}
}).pipe(Effect.provide(requiredChecksLayer(branch))),
);

it.effect.each([
["rate limit", { reason: "rate-limit", httpStatus: 429 }, "rate-limited"],
["authentication failure", { reason: "authentication", httpStatus: 401 }, "unauthenticated"],
["server failure", { httpStatus: 503 }, "failed"],
["network failure", {}, "failed"],
] as const)("propagates branch metadata %s", ([detail, fields, reason]) => {
const cause = new ForgejoCli.ForgejoCliError({ command: "fj", cwd: "/repo", detail, ...fields });
return Effect.gen(function* () {
const provider = yield* ForgejoPullRequestProvider.make;
const readChecks = provider.getChangeRequestChecks;
if (readChecks === undefined) return yield* Effect.die("checks read missing");
for (const read of [readChecks, provider.getChangeRequest]) {
const error = yield* read(requiredChecksInput).pipe(Effect.flip);
assert.strictEqual(error.reason, reason);
assert.strictEqual(error.cause, cause);
}
}).pipe(Effect.provide(requiredChecksLayer(Effect.fail(cause))));
});

it.effect("opens Forgejo Actions check links on the pull request's server", () =>
Effect.gen(function* () {
const provider = yield* ForgejoPullRequestProvider.make;
Expand Down Expand Up @@ -194,31 +355,33 @@ it.effect("opens Forgejo Actions check links on the pull request's server", () =
target_url,
updated_at: "2026-10-11T00:00:00Z",
});
const body = input.path.endsWith("pulls/1")
? {
number: 1,
title: "Checks",
body: "",
html_url: "https://forgejo.test/git/acme/web/pulls/1",
user: null,
state: "open",
merged: false,
head: { ref: "feature", sha: "head", repo: null },
base: { ref: "main", sha: "base", repo: null },
created_at: "2026-10-11T00:00:00Z",
updated_at: "2026-10-11T00:00:00Z",
closed_at: null,
merged_at: null,
labels: [],
}
: input.path.endsWith("page=1")
? [
// Forgejo includes any sub-path mount in the relative links it reports.
status("actions", "/git/acme/web/actions/runs/7/jobs/2"),
status("external", "https://ci.example/build/9"),
status("none", null),
]
: [];
const body = input.path.endsWith("branches/main")
? {}
: input.path.endsWith("pulls/1")
? {
number: 1,
title: "Checks",
body: "",
html_url: "https://forgejo.test/git/acme/web/pulls/1",
user: null,
state: "open",
merged: false,
head: { ref: "feature", sha: "head", repo: null },
base: { ref: "main", sha: "base", repo: null },
created_at: "2026-10-11T00:00:00Z",
updated_at: "2026-10-11T00:00:00Z",
closed_at: null,
merged_at: null,
labels: [],
}
: input.path.endsWith("page=1")
? [
// Forgejo includes any sub-path mount in the relative links it reports.
status("actions", "/git/acme/web/actions/runs/7/jobs/2"),
status("external", "https://ci.example/build/9"),
status("none", null),
]
: [];
return encodeJsonEffect(body).pipe(Effect.orDie, Effect.map(processOutput));
},
}),
Expand Down Expand Up @@ -297,6 +460,9 @@ it.effect.each([
case "repos/maria/project/statuses/head":
response = [];
break;
case "repos/maria/project/branches/main":
response = { name: "main", protected: false };
break;
default:
return Effect.die(`Unexpected Forgejo request: ${input.path}`);
}
Expand Down
Loading
Loading