diff --git a/apps/server/package.json b/apps/server/package.json
index ea7b9019e41524976f105e6248587af1872518b5..17b77a22244a07cc958d6adf464502c5a2844133 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -46,6 +46,7 @@
"@t3tools/provider-opencode": "workspace:*",
"@t3tools/provider-pi": "workspace:*",
"@t3tools/source-control-core": "workspace:*",
+ "@t3tools/source-control-forgejo": "workspace:*",
"diff": "8.0.3",
"effect": "catalog:",
"jose": "catalog:",
diff --git a/apps/server/scripts/evaluate-thread-titles.ts b/apps/server/scripts/evaluate-thread-titles.ts
index 196bcbdfbcaaa6169b8a5088cde6ba2697387f9e..3ebd7cbaf8d5e5e77049fcd8f6cba6073a13cd63 100644
--- a/apps/server/scripts/evaluate-thread-titles.ts
+++ b/apps/server/scripts/evaluate-thread-titles.ts
@@ -23,10 +23,9 @@ import {
} from "@t3tools/provider-core/server/threadTitleContext";
import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";
import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";
-import * as ServerSourceControlHost from "../src/sourceControl/ServerSourceControlHost.ts";
import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";
-import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";
+import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts";
import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";
import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";
import * as ServerSettings from "../src/serverSettings.ts";
@@ -155,11 +154,10 @@ await Effect.runPromise(
Layer.mergeAll(
GitHubApi.layerWithDependencies,
GitLabCli.layer,
- ForgejoCli.layer,
+ SourceControlBuiltInDrivers.layer,
AzureDevOpsCli.layer,
// No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.
BitbucketApi.layer,
- ServerSourceControlHost.layer,
),
),
// Default settings: no saved Bitbucket token, gh's own GitHub account choice.
diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts
index c7e76f5e71ab5011eada6a7541ac7cb8817d9a1b..9df266d56c8f58675cd310b246b4e38057c86879 100644
--- a/apps/server/src/git/GitManager.test.ts
+++ b/apps/server/src/git/GitManager.test.ts
@@ -57,7 +57,7 @@ import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceContr
import {
ForgejoPullRequestSchema,
toForgejoChangeRequest,
-} from "../sourceControl/forgejoPullRequests.ts";
+} from "@t3tools/source-control-forgejo/server/forgejoPullRequests";
import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as ServerConfig from "../config.ts";
diff --git a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts
index f5e108b05d452941b3135a0b07e88f1518b65c9b..b62b86ffe48f9c11e9688b00b0a15616b4f5e94a 100644
--- a/apps/server/src/pullRequest/BitbucketPullRequestApi.ts
+++ b/apps/server/src/pullRequest/BitbucketPullRequestApi.ts
@@ -22,7 +22,7 @@ import type {
} from "@t3tools/contracts";
import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
-import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts";
+import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions";
import {
buildReviewThreads,
decodeCommentsJson,
diff --git a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts b/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts
deleted file mode 100644
index a9337fdbe8fd46c2b5b67051aaca746a35f8fad2..0000000000000000000000000000000000000000
--- a/apps/server/src/pullRequest/ForgejoPullRequestProvider.ts
+++ /dev/null
@@ -1,614 +0,0 @@
-import * as Effect from "effect/Effect";
-import * as Schema from "effect/Schema";
-import * as Result from "effect/Result";
-import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
-import { decodeJsonResult } from "@t3tools/shared/schemaJson";
-import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
-import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts";
-import {
- PullRequestProviderError,
- type ProviderChangeRequestDetail,
- type ProviderRepositoryRef,
- type PullRequestProviderApi,
-} from "@t3tools/source-control-core/server/PullRequestProvider";
-import {
- ForgejoPullRequest,
- ForgejoRepository,
- ForgejoUser,
- ForgejoComment,
- ForgejoReview,
- ForgejoReviewComment,
- ForgejoCommit,
- ForgejoStatus,
- ForgejoLabel,
- ForgejoReaction,
- FORGEJO_REACTIONS,
- forgejoChangeRequest,
- forgejoActor,
- forgejoComment,
- forgejoReview,
- forgejoReviewThread,
- forgejoCommit,
- forgejoChecks,
- forgejoReactions,
-} from "./forgejoPullRequestJson.ts";
-
-const CAPABILITIES: PullRequestCapabilities = {
- diff: true,
- viewedFiles: "environment",
- comment: true,
- actions: ["merge", "close", "reopen", "update-branch"],
- mergeMethods: ["merge", "squash", "rebase"],
- updateMethods: ["merge", "rebase"],
- search: false,
- reactions: true,
- labels: true,
- // Forgejo's public API has no thread replies/resolution or draft conversion endpoint.
- review: {
- inlineComment: true,
- reply: false,
- resolve: false,
- verdicts: ["comment", "approve", "request-changes"],
- },
- reviewers: { request: true, listCandidates: true },
- edit: { changeRequest: true, comment: true },
-};
-const repoPath = (input: ProviderRepositoryRef) =>
- `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`;
-const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) =>
- `${repoPath(input)}/pulls/${input.number}`;
-const issuePath = (input: ProviderRepositoryRef & { readonly number: number }) =>
- `${repoPath(input)}/issues/${input.number}`;
-// Review IDs differ from the issue-comment IDs used by Forgejo's reactions API.
-const reviewCommentId = (review: typeof ForgejoReview.Type) =>
- /#issuecomment-([1-9]\d*)$/.exec(review.html_url ?? "")?.[1];
-
-export const make = Effect.gen(function* () {
- const cli = yield* ForgejoCli.ForgejoCli;
- const failure = (operation: string, detail: string, cause?: unknown) =>
- new PullRequestProviderError({
- provider: "forgejo",
- operation,
- reason: "failed",
- detail,
- ...(cause === undefined ? {} : { cause }),
- });
- const request = (input: ForgejoCli.ForgejoApiInput) =>
- cli.api(input).pipe(
- Effect.mapError(
- (error) =>
- new PullRequestProviderError({
- provider: "forgejo",
- operation: input.path,
- detail: error.detail,
- cause: error,
- reason:
- error.reason === "missing-cli"
- ? "missing-tool"
- : error.reason === "authentication"
- ? "unauthenticated"
- : error.reason === "rate-limit"
- ? "rate-limited"
- : error.reason === "not-found"
- ? "not-found"
- : "failed",
- }),
- ),
- );
- const read = Effect.fn("ForgejoPullRequestProvider.read")(function* (
- input: ForgejoCli.ForgejoApiInput,
- schema: Schema.Codec,
- ) {
- const result = yield* request(input);
- if (result.stdoutTruncated)
- return yield* failure(input.path, "Forgejo response exceeded the output limit.");
- const decoded = decodeJsonResult(schema)(result.stdout);
- return Result.isSuccess(decoded)
- ? decoded.success
- : yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure);
- });
- const readArray = (
- input: ForgejoCli.ForgejoApiInput,
- schema: Schema.Codec,
- ) => read(input, Schema.NullOr(Schema.Array(schema))).pipe(Effect.map((rows) => rows ?? []));
- const readPage = Effect.fn("ForgejoPullRequestProvider.readPage")(function* (
- input: ForgejoCli.ForgejoApiInput,
- schema: Schema.Codec,
- index: number,
- ) {
- const result = yield* request({
- ...input,
- path: `${input.path}${input.path.includes("?") ? "&" : "?"}limit=50&page=${index}`,
- });
- if (result.stdoutTruncated)
- return yield* failure(input.path, "Forgejo response exceeded the output limit.");
- const decoded = decodeJsonResult(Schema.NullOr(Schema.Array(schema)))(result.stdout);
- if (Result.isFailure(decoded))
- return yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure);
- const rows = decoded.success ?? [];
- const links = /^link:\s*(.*)$/im.exec(result.stderr)?.[1];
- return {
- rows,
- more: rows.length > 0 && (links === undefined || /rel="?next"?/i.test(links)),
- };
- });
- // Use a stable, small page size that also works with Forgejo's default maximum of 50.
- const page = Effect.fn("ForgejoPullRequestProvider.page")(function* (
- input: ForgejoCli.ForgejoApiInput,
- schema: Schema.Codec,
- limit = 500,
- ) {
- const items: A[] = [];
- for (let index = 1; items.length < limit; index++) {
- const { rows, more } = yield* readPage(input, schema, index);
- items.push(...rows);
- if (!more) return { items, truncated: false };
- }
- return { items, truncated: true };
- });
- const write = (input: ForgejoCli.ForgejoApiInput) => request(input).pipe(Effect.asVoid);
- const getPull = (input: ProviderRepositoryRef & { readonly number: number }) =>
- read(
- {
- cwd: input.cwd,
- repository: input.repository,
- host: input.host,
- path: pullPath(input),
- },
- ForgejoPullRequest,
- );
- const getRepo = (input: ProviderRepositoryRef) =>
- read({ ...input, path: repoPath(input) }, ForgejoRepository);
- const getViewer = (input: {
- readonly cwd: string;
- readonly repository?: string;
- readonly host?: string;
- }) => read({ ...input, path: "user" }, ForgejoUser).pipe(Effect.map((user) => user.login));
- const permissions = (
- repo: typeof ForgejoRepository.Type,
- pr: typeof ForgejoPullRequest.Type,
- viewer: string,
- ): PullRequestViewerPermissions => {
- const canWrite = repo.permissions?.push ?? false;
- const canEdit = canWrite || pr.user?.login === viewer;
- const active = !repo.archived;
- return {
- actions: active
- ? CAPABILITIES.actions.filter((action) =>
- action === "merge" || action === "update-branch" ? canWrite : canEdit,
- )
- : [],
- comment: active && (!pr.is_locked || canWrite),
- resolve: false,
- verdicts: active
- ? pr.user?.login === viewer
- ? ["comment"]
- : CAPABILITIES.review.verdicts
- : [],
- requestReviewers: active && canEdit,
- labels: active && canWrite,
- updateMethods:
- active && canWrite ? (repo.allow_rebase_update ? ["merge", "rebase"] : ["merge"]) : [],
- };
- };
- const getPermissions = Effect.fn("ForgejoPullRequestProvider.getPermissions")(function* (
- input: ProviderRepositoryRef & { readonly number: number },
- ) {
- const [repo, pr, viewer] = yield* Effect.all(
- [getRepo(input), getPull(input), getViewer(input)],
- { concurrency: 3 },
- );
- return permissions(repo, pr, viewer);
- });
- const unsupported = (operation: string) =>
- Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`));
- const provider: PullRequestProviderApi = {
- kind: "forgejo",
- capabilities: CAPABILITIES,
- getViewer,
- listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")(
- function* (input) {
- const offset = input.cursor?.delivered ?? 0;
- const items: ReturnType[] = [];
- const state = input.state === "merged" ? "closed" : input.state;
- const query = {
- ...input,
- path: `${repoPath(input)}/pulls?state=${state}&sort=recentupdate`,
- };
- // Self-hosted servers may cap pages below 50. Establish their actual page size before
- // translating the service's row offset into an API page number.
- const first = yield* readPage(query, Schema.NullOr(ForgejoPullRequest), 1);
- const pageSize = first.rows.length || 50;
- const firstIndex = Math.floor(offset / pageSize) + 1;
- let consumed = 0;
- let more = true;
- for (let index = firstIndex; more && consumed < input.limit; index++) {
- const result =
- index === 1 ? first : yield* readPage(query, Schema.NullOr(ForgejoPullRequest), index);
- const rows = result.rows;
- const start = index === firstIndex ? offset % pageSize : 0;
- const countBefore = consumed;
- for (const row of rows.slice(start)) {
- if (consumed >= input.limit) break;
- consumed++;
- if (row) items.push(forgejoChangeRequest(row));
- }
- more = result.more || rows.length - start > consumed - countBefore;
- }
- return { items, truncated: more, continues: true, cursorAdvance: consumed };
- },
- ),
- getChangeRequestSummary: (input) => getPull(input).pipe(Effect.map(forgejoChangeRequest)),
- 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) };
- },
- ),
- 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,
- );
- return {
- ...forgejoChangeRequest(pr),
- body: pr.body ?? "",
- changedFiles: pr.changed_files ?? 0,
- reviewers: (pr.requested_reviewers ?? []).flatMap((user) => {
- const actor = forgejoActor(user);
- return actor ? [actor] : [];
- }),
- checks: forgejoChecks(statuses.items),
- baseComparison: !pr.merge_base
- ? "unknown"
- : pr.merge_base === pr.base.sha
- ? "up-to-date"
- : "behind",
- viewerPermissions: permissions(repo, pr, viewer),
- mergeCapabilities: {
- merge: repo.allow_merge_commits ?? true,
- squash: repo.allow_squash_merge ?? true,
- rebase: repo.allow_rebase ?? true,
- },
- } satisfies ProviderChangeRequestDetail;
- }),
- getViewerPermissions: getPermissions,
- getChangeRequestActivity: Effect.fn("ForgejoPullRequestProvider.getChangeRequestActivity")(
- function* (input) {
- const [comments, reviews, commits, reactions, viewer] = yield* Effect.all(
- [
- // Issue comments ignore page/limit; fetch this unpaginated endpoint once.
- readArray({ ...input, path: `${issuePath(input)}/comments` }, ForgejoComment).pipe(
- Effect.map((items) => ({
- items: items.slice(0, 500),
- truncated: items.length > 500,
- })),
- ),
- page({ ...input, path: `${pullPath(input)}/reviews` }, ForgejoReview),
- page({ ...input, path: `${pullPath(input)}/commits` }, ForgejoCommit),
- page({ ...input, path: `${issuePath(input)}/reactions` }, ForgejoReaction),
- getViewer(input),
- ],
- { concurrency: 5 },
- );
- const reviewComments = yield* Effect.forEach(
- reviews.items.filter((review) => review.comments_count > 0 && review.state !== "PENDING"),
- (review) =>
- readArray(
- { ...input, path: `${pullPath(input)}/reviews/${review.id}/comments` },
- ForgejoReviewComment,
- ),
- { concurrency: 4 },
- );
- const allInline = reviewComments.flat();
- const inline = allInline.slice(0, 500);
- const entries = [
- ...comments.items.map((comment) => ({
- comment: forgejoComment(comment),
- reactionId: String(comment.id),
- })),
- ...reviews.items
- .filter((review) => review.state !== "PENDING" && review.state !== "REQUEST_REVIEW")
- .map((review) => ({
- comment: forgejoReview(review),
- reactionId: reviewCommentId(review),
- })),
- ...inline.map((comment) => ({
- comment: {
- ...forgejoComment(comment),
- kind: "review-comment" as const,
- path: comment.path,
- },
- reactionId: String(comment.id),
- })),
- ];
- const enriched = yield* Effect.forEach(
- entries,
- ({ comment, reactionId }) =>
- (reactionId === undefined
- ? Effect.succeed([])
- : readArray(
- { ...input, path: `${repoPath(input)}/issues/comments/${reactionId}/reactions` },
- ForgejoReaction,
- )
- ).pipe(
- Effect.map((rows) => ({ ...comment, reactions: forgejoReactions(rows, viewer) })),
- ),
- { concurrency: 4 },
- );
- const byId = new Map(enriched.map((comment) => [comment.id, comment]));
- const timeline = enriched.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
- return {
- comments: timeline,
- commentCount: timeline.length,
- commentsTruncated:
- comments.truncated || reviews.truncated || allInline.length > inline.length,
- reviewThreads: inline.map((comment) => ({
- ...forgejoReviewThread(comment),
- comments: [byId.get(String(comment.id)) ?? forgejoComment(comment)],
- })),
- commits: commits.items.map(forgejoCommit),
- reactions: forgejoReactions(reactions.items, viewer),
- };
- },
- ),
- getFileRevisions: Effect.fn("ForgejoPullRequestProvider.getFileRevisions")(function* (input) {
- const result = yield* request({ ...input, path: `${pullPath(input)}.diff` });
- if (result.stdoutTruncated) {
- return yield* failure("getFileRevisions", "Forgejo diff exceeded the output limit.");
- }
- const revisions = new Map(parseDiffFileRevisions(result.stdout));
- for (const path of input.paths) {
- if (!revisions.has(path)) revisions.set(path, "");
- }
- return { revisions, complete: true };
- }),
- getDiff: (input) =>
- request({
- ...input,
- path: input.commit
- ? `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}.diff`
- : `${pullPath(input)}.diff`,
- }).pipe(
- Effect.map((result) => ({
- patch: result.stdout,
- truncated: result.stdoutTruncated,
- nextCursor: null,
- })),
- ),
- getDiffFileContents: Effect.fn("ForgejoPullRequestProvider.getDiffFileContents")(
- function* (input) {
- const pr = yield* getPull(input);
- const commit = input.commit
- ? yield* read(
- {
- ...input,
- path: `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}`,
- },
- ForgejoCommit,
- )
- : null;
- const oldRef = commit ? commit.parents[0]?.sha : pr.merge_base || pr.base.sha;
- const newRef = input.commit ?? pr.head.sha;
- const content = (repository: string, ref: string, path: string) =>
- read(
- {
- ...input,
- repository,
- path: `${repoPath({ ...input, repository })}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`,
- },
- Schema.Struct({ content: Schema.String, encoding: Schema.Literal("base64") }),
- ).pipe(Effect.map((file) => Buffer.from(file.content, "base64").toString("utf8")));
- const [oldContents, newContents] = yield* Effect.all(
- [
- input.changeType === "new" || !oldRef
- ? Effect.succeed("")
- : content(input.repository, oldRef, input.oldPath),
- input.changeType === "deleted"
- ? Effect.succeed("")
- : content(pr.head.repo?.full_name ?? input.repository, newRef, input.newPath),
- ],
- { concurrency: 2 },
- );
- return { oldContents, newContents };
- },
- ),
- runAction: (input) => {
- switch (input.action) {
- case "merge":
- return write({
- ...input,
- path: `${pullPath(input)}/merge`,
- method: "POST",
- body: { Do: input.mergeMethod ?? "merge" },
- });
- case "close":
- case "reopen":
- return write({
- ...input,
- path: pullPath(input),
- method: "PATCH",
- body: { state: input.action === "close" ? "closed" : "open" },
- });
- case "update-branch":
- return write({
- ...input,
- path: `${pullPath(input)}/update?style=${input.updateMethod ?? "merge"}`,
- method: "POST",
- });
- default:
- return unsupported(input.action);
- }
- },
- updateChangeRequest: (input) =>
- write({
- ...input,
- path: pullPath(input),
- method: "PATCH",
- body: {
- ...(input.title === undefined ? {} : { title: input.title }),
- ...(input.body === undefined ? {} : { body: input.body }),
- },
- }),
- comment: (input) =>
- write({
- ...input,
- path: `${issuePath(input)}/comments`,
- method: "POST",
- body: { body: input.body },
- }),
- updateComment: (input) =>
- write({
- ...input,
- path: `${repoPath(input)}/issues/comments/${encodeURIComponent(input.commentId)}`,
- method: "PATCH",
- body: { body: input.body },
- }),
- submitReview: Effect.fn("ForgejoPullRequestProvider.submitReview")(function* (input) {
- const pr = yield* getPull(input);
- yield* write({
- ...input,
- path: `${pullPath(input)}/reviews`,
- method: "POST",
- body: {
- event:
- input.verdict === "approve"
- ? "APPROVED"
- : input.verdict === "request-changes"
- ? "REQUEST_CHANGES"
- : "COMMENT",
- body: input.body,
- commit_id: pr.head.sha,
- comments: input.comments.map((comment) => {
- const position = comment.position;
- const old =
- position.kind === "deleted" ||
- (position.kind === "context" && position.side === "left");
- return {
- path: old ? (comment.oldPath ?? comment.path) : comment.path,
- body: comment.body,
- old_position: old ? position.oldLine : 0,
- new_position: old ? 0 : position.newLine,
- };
- }),
- },
- });
- }),
- listReviewerCandidates: Effect.fn("ForgejoPullRequestProvider.listReviewerCandidates")(
- function* (input) {
- const [pr, users] = yield* Effect.all(
- [getPull(input), page({ ...input, path: `${repoPath(input)}/assignees` }, ForgejoUser)],
- { concurrency: 2 },
- );
- return {
- candidates: users.items
- .filter((user) => user.login !== pr.user?.login)
- .flatMap((user) => {
- const actor = forgejoActor(user);
- return actor
- ? [
- {
- ...actor,
- id: user.login,
- kind: "user" as const,
- isRequested:
- pr.requested_reviewers?.some((reviewer) => reviewer.login === user.login) ??
- false,
- },
- ]
- : [];
- }),
- truncated: users.truncated,
- };
- },
- ),
- setReviewerRequest: (input) =>
- write({
- ...input,
- path: `${pullPath(input)}/requested_reviewers`,
- method: input.requested ? "POST" : "DELETE",
- body: { reviewers: input.reviewers.map((reviewer) => reviewer.id) },
- }),
- listLabelCandidates: Effect.fn("ForgejoPullRequestProvider.listLabelCandidates")(
- function* (input) {
- const [pr, labels] = yield* Effect.all(
- [getPull(input), page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel)],
- { concurrency: 2 },
- );
- return {
- candidates: labels.items.map((label) => ({
- name: label.name,
- color: label.color ?? null,
- description: label.description ?? null,
- isApplied: pr.labels?.some((applied) => applied.id === label.id) ?? false,
- })),
- truncated: labels.truncated,
- };
- },
- ),
- setLabels: Effect.fn("ForgejoPullRequestProvider.setLabels")(function* (input) {
- const labels = yield* page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel);
- const selected = labels.items.filter((label) => input.labels.includes(label.name));
- if (selected.length !== input.labels.length)
- return yield* failure("setLabels", "One or more requested labels could not be found.");
- if (input.applied)
- yield* write({
- ...input,
- path: `${issuePath(input)}/labels`,
- method: "POST",
- body: { labels: selected.map((label) => label.id) },
- });
- else
- yield* Effect.forEach(
- selected,
- (label) =>
- write({ ...input, path: `${issuePath(input)}/labels/${label.id}`, method: "DELETE" }),
- { concurrency: 1 },
- );
- }),
- setReaction: Effect.fn("ForgejoPullRequestProvider.setReaction")(function* (input) {
- let commentId = input.subjectId;
- if (commentId?.startsWith("review:")) {
- const reviewId = /^review:([1-9]\d*)$/.exec(commentId)?.[1];
- if (!reviewId) return yield* failure("setReaction", "Invalid Forgejo review ID.");
- const review = yield* read(
- { ...input, path: `${pullPath(input)}/reviews/${reviewId}` },
- ForgejoReview,
- );
- commentId = reviewCommentId(review);
- if (!commentId)
- return yield* failure(
- "setReaction",
- "Forgejo did not return a comment ID for this review.",
- );
- }
- if (commentId && !/^[1-9]\d*$/.test(commentId))
- return yield* failure("setReaction", "Invalid Forgejo comment ID.");
- yield* write({
- ...input,
- path: commentId
- ? `${repoPath(input)}/issues/comments/${commentId}/reactions`
- : `${issuePath(input)}/reactions`,
- method: input.reacted ? "POST" : "DELETE",
- body: { content: FORGEJO_REACTIONS[input.content] },
- });
- }),
- replyToThread: () => unsupported("thread replies"),
- setThreadResolution: () => unsupported("thread resolution"),
- };
- return provider;
-});
diff --git a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts
index 8e5a1d9cfd0b008d3e3e4290c3bb8358f576afb4..3c2ad367d4463a7fb14c4275c794dad6a2537eaf 100644
--- a/apps/server/src/pullRequest/PullRequestProviderRegistry.ts
+++ b/apps/server/src/pullRequest/PullRequestProviderRegistry.ts
@@ -7,8 +7,6 @@ import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
import * as GitHubApi from "../sourceControl/GitHubApi.ts";
import * as GitLabCli from "../sourceControl/GitLabCli.ts";
-import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
-import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";
import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
@@ -17,6 +15,7 @@ import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";
+import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts";
import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider";
export class PullRequestProviderRegistry extends Context.Service<
@@ -45,16 +44,21 @@ export function fromProviders(
*
* @public Service construction is part of the canonical Effect module API.
*/
-export const make = Effect.map(
- Effect.all([
+export const make = Effect.gen(function* () {
+ const providers = yield* Effect.all([
GitHubPullRequestProvider.make,
GitLabPullRequestProvider.make,
- ForgejoPullRequestProvider.make,
BitbucketPullRequestProvider.make,
AzureDevOpsPullRequestProvider.make,
- ]),
- fromProviders,
-);
+ ]);
+ const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
+ driver.make.pipe(Effect.map((instance) => instance.pullRequests)),
+ );
+ return fromProviders([
+ ...providers,
+ ...drivers.filter((provider): provider is PullRequestProviderApi => provider !== null),
+ ]);
+});
export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
Layer.provide(
@@ -66,7 +70,7 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
),
),
Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),
- Layer.provide(ForgejoCli.layer),
+ Layer.provide(BuiltInDrivers.layer),
Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),
Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))),
);
diff --git a/apps/server/src/pullRequest/PullRequestService.test.ts b/apps/server/src/pullRequest/PullRequestService.test.ts
index ef84de264ec63216f4ff63c6beec602c5099319a..83f362fa20a06b6e1d6fdff8bfd6b45d44a34a17 100644
--- a/apps/server/src/pullRequest/PullRequestService.test.ts
+++ b/apps/server/src/pullRequest/PullRequestService.test.ts
@@ -30,8 +30,8 @@ import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.t
import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
-import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
-import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
+import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
+import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
import {
PullRequestProviderError,
type ProviderChangeRequest,
diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts
deleted file mode 100644
index 1426a67d29dae6bc194da4ff84f5e07e05742935..0000000000000000000000000000000000000000
--- a/apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts
+++ /dev/null
@@ -1,280 +0,0 @@
-import { assert, describe, it } from "@effect/vitest";
-
-import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts";
-
-function patchOf(...lines: ReadonlyArray): string {
- return `${lines.join("\n")}\n`;
-}
-
-describe("parseDiffFileRevisions", () => {
- it("reads the head id of a changed file off its index line", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- "diff --git a/src/a.ts b/src/a.ts",
- "index 7f2aa0ab6..b4a2a7c9a 100644",
- "--- a/src/a.ts",
- "+++ b/src/a.ts",
- "@@ -1 +1 @@",
- "-a",
- "+b",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["src/a.ts", "b4a2a7c9a"]]);
- });
-
- it("names a deletion by the path it had, which is the one still on screen", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- "diff --git a/gone.ts b/gone.ts",
- "deleted file mode 100644",
- "index 1111111..0000000",
- "--- a/gone.ts",
- "+++ /dev/null",
- "@@ -1 +0,0 @@",
- "-x",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["gone.ts", "0000000"]]);
- });
-
- it("names a rename by where it moved to", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- "diff --git a/old.ts b/new.ts",
- "similarity index 90%",
- "rename from old.ts",
- "rename to new.ts",
- "index 2222222..3333333 100644",
- "--- a/old.ts",
- "+++ b/new.ts",
- "@@ -1 +1 @@",
- "-a",
- "+b",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["new.ts", "3333333"]]);
- });
-
- it("names a rename that changed nothing, which states no paths of its own", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- "diff --git a/old.ts b/new.ts",
- "similarity index 100%",
- "rename from old.ts",
- "rename to new.ts",
- "index 2222222..2222222 100644",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["new.ts", "2222222"]]);
- });
-
- it("leaves out a file Bitbucket excluded, which it gives no index line for", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- "diff --git a/package.json b/package.json",
- "index 7f2aa0ab6..b4a2a7c9a 100644",
- "--- a/package.json",
- "+++ b/package.json",
- "@@ -1 +1 @@",
- '- "x": "1"',
- '+ "x": "2"',
- "diff --git a/yarn.lock b/yarn.lock",
- 'File excluded by pattern "yarn.lock"',
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["package.json", "b4a2a7c9a"]]);
- });
-
- it("stops reading headers at the first hunk, so content cannot pose as one", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- "diff --git a/notes.md b/notes.md",
- "index aaaaaaa..bbbbbbb 100644",
- "--- a/notes.md",
- "+++ b/notes.md",
- "@@ -1,2 +1,2 @@",
- "--- a/decoy.ts",
- "+++ b/decoy.ts",
- "+index ccccccc..ddddddd 100644",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["notes.md", "bbbbbbb"]]);
- });
-
- it("splits a header whose paths contain the separator, by the sides agreeing", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- "diff --git a/one b/two.ts b/one b/two.ts",
- "index eeeeeee..fffffff 100644",
- "@@ -1 +1 @@",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["one b/two.ts", "fffffff"]]);
- });
-
- it("leaves out an added file that Bitbucket sent no index line for", () => {
- const revisions = parseDiffFileRevisions(
- patchOf("diff --git a/added.ts b/added.ts", "new file mode 100644", "--- /dev/null"),
- );
-
- assert.strictEqual(revisions.size, 0);
- });
-
- it("reads nothing out of an empty patch", () => {
- assert.strictEqual(parseDiffFileRevisions("").size, 0);
- });
-
- it("reads a name git had to quote, here one holding a tab", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- 'diff --git "a/we\\tird.ts" "b/we\\tird.ts"',
- "index 4444444..5555555 100644",
- '--- "a/we\\tird.ts"',
- '+++ "b/we\\tird.ts"',
- "@@ -1 +1 @@",
- "-a",
- "+b",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["we\tird.ts", "5555555"]]);
- });
-
- it("rejoins the octal bytes git writes for a name outside ASCII", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- 'diff --git "a/caf\\303\\251/r\\303\\251sum\\303\\251.ts" "b/caf\\303\\251/r\\303\\251sum\\303\\251.ts"',
- "index 6666666..7777777 100644",
- "@@ -1 +1 @@",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["café/résumé.ts", "7777777"]]);
- });
-
- it("splits a rename header where git quoted only the side that needed it", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- 'diff --git a/old.ts "b/new\\tname.ts"',
- "similarity index 90%",
- "rename from old.ts",
- 'rename to "new\\tname.ts"',
- "index 8888888..9999999 100644",
- "--- a/old.ts",
- '+++ "b/new\\tname.ts"',
- "@@ -1 +1 @@",
- "-a",
- "+b",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["new\tname.ts", "9999999"]]);
- });
-
- it("names a quoted rename that changed nothing, which states no paths of its own", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- 'diff --git "a/old\\tname.ts" "b/new\\tname.ts"',
- "similarity index 100%",
- 'rename from "old\\tname.ts"',
- 'rename to "new\\tname.ts"',
- "index abcabca..abcabca 100644",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["new\tname.ts", "abcabca"]]);
- });
-
- it("keeps a character from outside the basic plane that git left unescaped", () => {
- // `core.quotePath` off leaves the name's own bytes in place, and git still quotes the header
- // for the tab. Encoding what it left one unit at a time would split the pair into two halves.
- const revisions = parseDiffFileRevisions(
- patchOf(
- 'diff --git "a/we\\tird-\u{1f680}.ts" "b/we\\tird-\u{1f680}.ts"',
- "index aaaaaaa..bbbbbbb 100644",
- "@@ -1 +1 @@",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["we\tird-\u{1f680}.ts", "bbbbbbb"]]);
- });
-
- it("drops the tab git ends a name holding a space with", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- "diff --git a/with space.txt b/with space.txt",
- "index 1111111..6178079 100644",
- "--- a/with space.txt\t",
- "+++ b/with space.txt\t",
- "@@ -1 +1 @@",
- "-a",
- "+b",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["with space.txt", "6178079"]]);
- });
-
- it("drops that tab from a quoted name too, where it lands past the closing quote", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- 'diff --git "a/we ird\\tname.ts" "b/we ird\\tname.ts"',
- "index 2222222..7777777 100644",
- '--- "a/we ird\\tname.ts"\t',
- '+++ "b/we ird\\tname.ts"\t',
- "@@ -1 +1 @@",
- "-a",
- "+b",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["we ird\tname.ts", "7777777"]]);
- });
-
- it("reads a deletion whose name holds a space, tab and all", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- "diff --git a/with space.txt b/with space.txt",
- "deleted file mode 100644",
- "index 3333333..0000000",
- "--- a/with space.txt\t",
- "+++ /dev/null",
- "@@ -1 +0,0 @@",
- "-a",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["with space.txt", "0000000"]]);
- });
-
- it("drops the timestamp other producers of the format write past that tab", () => {
- const revisions = parseDiffFileRevisions(
- patchOf(
- "diff --git a/stamped.ts b/stamped.ts",
- "index 4444444..5555555 100644",
- "--- a/stamped.ts\t2024-01-01 00:00:00.000000000 +0000",
- "+++ b/stamped.ts\t2024-01-02 00:00:00.000000000 +0000",
- "@@ -1 +1 @@",
- "-a",
- "+b",
- ),
- );
-
- assert.deepStrictEqual([...revisions], [["stamped.ts", "5555555"]]);
- });
-
- it("splits an unquoted header whose names hold a space, by the sides agreeing", () => {
- const revisions = parseDiffFileRevisions(
- patchOf("diff --git a/one two b/one two", "index ddddddd..eeeeeee 100644", "@@ -1 +1 @@"),
- );
-
- assert.deepStrictEqual([...revisions], [["one two", "eeeeeee"]]);
- });
-});
diff --git a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts b/apps/server/src/pullRequest/bitbucketDiffRevisions.ts
deleted file mode 100644
index 135a23dfdff6fdc4593dd5148c6c0e2165366aa6..0000000000000000000000000000000000000000
--- a/apps/server/src/pullRequest/bitbucketDiffRevisions.ts
+++ /dev/null
@@ -1,129 +0,0 @@
-import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
-
-const ENTRY = "diff --git ";
-const QUOTE = '"';
-
-interface Entry {
- oldPath: string | null;
- newPath: string | null;
- deleted: boolean;
- revision: string | null;
- /** Past the first hunk header every line is content, and content can start like a header. */
- inBody: boolean;
-}
-
-/** Where a quoted name closes, given git escapes every quote the name itself holds. */
-function quotedEnd(rest: string): number {
- for (let at = 1; at < rest.length; at += 1) {
- const char = rest.charAt(at);
- if (char === "\\") {
- at += 1;
- continue;
- }
- if (char === QUOTE) return at;
- }
- return -1;
-}
-
-/**
- * `a/x`/`b/x` on a `---`/`+++` line; `/dev/null` marks the side that has no file. Git ends the
- * name with a tab when it holds a space, and a name with a tab of its own arrives quoted with
- * that tab escaped, so the first literal tab is never part of what the file is called.
- */
-function sidePath(rest: string, prefix: string): string | null {
- const tab = rest.indexOf("\t");
- const token = tab === -1 ? rest : rest.slice(0, tab);
- if (token === "/dev/null") return null;
- const path = unquoteGitPatchPath(token);
- return path.startsWith(prefix) ? path.slice(prefix.length) : path;
-}
-
-function headerSide(token: string, prefix: string): string | null {
- const path = unquoteGitPatchPath(token);
- return path.startsWith(prefix) ? path.slice(prefix.length) : null;
-}
-
-/**
- * The two names on a `diff --git` line, written with no delimiter between them. `a/one two b/one
- * two` can split in more than one place, so the split leaving both sides equal wins; a rename
- * (the only case where sides differ) states its names on separate lines instead. A quoted name
- * ends at its own closing quote and needs none of that guessing.
- */
-function headerPaths(rest: string): readonly [string | null, string | null] {
- if (rest.startsWith(QUOTE)) {
- const end = quotedEnd(rest);
- if (end === -1 || rest.charAt(end + 1) !== " ") return [null, null];
- return [headerSide(rest.slice(0, end + 1), "a/"), headerSide(rest.slice(end + 2), "b/")];
- }
- if (rest.endsWith(QUOTE)) {
- const opens = rest.indexOf(QUOTE);
- if (opens < 1 || rest.charAt(opens - 1) !== " ") return [null, null];
- return [headerSide(rest.slice(0, opens - 1), "a/"), headerSide(rest.slice(opens), "b/")];
- }
- if (!rest.startsWith("a/")) return [null, null];
- const splits: Array = [];
- for (let at = rest.indexOf(" b/"); at !== -1; at = rest.indexOf(" b/", at + 1)) splits.push(at);
- const chosen =
- splits.find((at) => rest.slice(2, at) === rest.slice(at + 3)) ??
- (splits.length === 1 ? splits[0] : undefined);
- return chosen === undefined ? [null, null] : [rest.slice(2, chosen), rest.slice(chosen + 3)];
-}
-
-/** The right-hand id of `index .. `. */
-function headRevision(rest: string): string | null {
- const gap = rest.indexOf("..");
- if (gap === -1) return null;
- const after = rest.slice(gap + 2);
- const end = after.indexOf(" ");
- const head = end === -1 ? after : after.slice(0, end);
- return head.length === 0 ? null : head;
-}
-
-/**
- * What the head has of each file, as the blob ids from a unified patch's `index ..`
- * line: Bitbucket exposes no blob id for a file anywhere else. Keyed by the head's name, except
- * for a deletion where only the old name exists. An entry with no `index` line (most often one
- * Bitbucket excluded by pattern) is left out.
- */
-export function parseDiffFileRevisions(patch: string): ReadonlyMap {
- const revisions = new Map();
- let entry: Entry | null = null;
-
- const close = () => {
- if (entry === null) return;
- const path = entry.deleted ? entry.oldPath : (entry.newPath ?? entry.oldPath);
- if (path !== null && path.length > 0 && entry.revision !== null) {
- revisions.set(path, entry.revision);
- }
- entry = null;
- };
-
- for (const line of patch.split("\n")) {
- if (line.startsWith(ENTRY)) {
- close();
- const [oldPath, newPath] = headerPaths(line.slice(ENTRY.length));
- entry = { oldPath, newPath, deleted: false, revision: null, inBody: false };
- continue;
- }
- if (entry === null || entry.inBody) continue;
- if (line.startsWith("@@")) {
- entry.inBody = true;
- } else if (line.startsWith("index ")) {
- entry.revision = headRevision(line.slice("index ".length));
- } else if (line.startsWith("deleted file mode")) {
- entry.deleted = true;
- } else if (line.startsWith("rename from ")) {
- entry.oldPath = unquoteGitPatchPath(line.slice("rename from ".length));
- } else if (line.startsWith("rename to ")) {
- entry.newPath = unquoteGitPatchPath(line.slice("rename to ".length));
- } else if (line.startsWith("--- ")) {
- entry.oldPath = sidePath(line.slice(4), "a/");
- } else if (line.startsWith("+++ ")) {
- const side = sidePath(line.slice(4), "b/");
- entry.newPath = side;
- if (side === null) entry.deleted = true;
- }
- }
- close();
- return revisions;
-}
diff --git a/apps/server/src/pullRequest/forgejoPullRequestJson.ts b/apps/server/src/pullRequest/forgejoPullRequestJson.ts
deleted file mode 100644
index a937c00c2ef842a273f0b20d70c0bd1caded6e0b..0000000000000000000000000000000000000000
--- a/apps/server/src/pullRequest/forgejoPullRequestJson.ts
+++ /dev/null
@@ -1,262 +0,0 @@
-import * as Schema from "effect/Schema";
-import * as DateTime from "effect/DateTime";
-import * as Option from "effect/Option";
-import type {
- PullRequestActor,
- PullRequestCheck,
- PullRequestComment,
- PullRequestCommit,
- PullRequestReaction,
- PullRequestReactionContent,
- PullRequestReviewThread,
-} from "@t3tools/contracts";
-import type { ProviderChangeRequest } from "@t3tools/source-control-core/server/PullRequestProvider";
-import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
-
-export const ForgejoUser = Schema.Struct({
- login: Schema.String,
- full_name: Schema.optional(Schema.NullOr(Schema.String)),
- avatar_url: Schema.optional(Schema.NullOr(Schema.String)),
-});
-export const ForgejoLabel = Schema.Struct({
- id: Schema.Int,
- name: Schema.String,
- color: Schema.optional(Schema.NullOr(Schema.String)),
- description: Schema.optional(Schema.NullOr(Schema.String)),
-});
-export const ForgejoRepository = Schema.Struct({
- full_name: Schema.String,
- permissions: Schema.optional(Schema.Struct({ push: Schema.Boolean, admin: Schema.Boolean })),
- archived: Schema.optional(Schema.Boolean),
- allow_merge_commits: Schema.optional(Schema.Boolean),
- allow_squash_merge: Schema.optional(Schema.Boolean),
- allow_rebase: Schema.optional(Schema.Boolean),
- allow_rebase_update: Schema.optional(Schema.Boolean),
-});
-const Branch = Schema.Struct({
- ref: Schema.String,
- sha: Schema.String,
- repo: Schema.NullOr(ForgejoRepository),
-});
-export const ForgejoPullRequest = Schema.Struct({
- number: Schema.Int,
- title: Schema.String,
- body: Schema.NullOr(Schema.String),
- html_url: Schema.String,
- user: Schema.NullOr(ForgejoUser),
- state: Schema.String,
- draft: Schema.optional(Schema.Boolean),
- merged: Schema.Boolean,
- mergeable: Schema.optional(Schema.Boolean),
- is_locked: Schema.optional(Schema.Boolean),
- head: Branch,
- base: Branch,
- merge_base: Schema.optional(Schema.String),
- created_at: Schema.String,
- updated_at: Schema.String,
- closed_at: Schema.NullOr(Schema.String),
- merged_at: Schema.NullOr(Schema.String),
- additions: Schema.optional(Schema.NullOr(Schema.Int)),
- deletions: Schema.optional(Schema.NullOr(Schema.Int)),
- changed_files: Schema.optional(Schema.NullOr(Schema.Int)),
- comments: Schema.optional(Schema.Int),
- labels: Schema.NullOr(Schema.Array(ForgejoLabel)),
- requested_reviewers: Schema.optional(Schema.NullOr(Schema.Array(ForgejoUser))),
-});
-export const ForgejoComment = Schema.Struct({
- id: Schema.Int,
- body: Schema.String,
- user: Schema.NullOr(ForgejoUser),
- created_at: Schema.String,
- html_url: Schema.optional(Schema.String),
-});
-export const ForgejoReview = Schema.Struct({
- id: Schema.Int,
- body: Schema.String,
- user: Schema.NullOr(ForgejoUser),
- state: Schema.String,
- submitted_at: Schema.String,
- html_url: Schema.optional(Schema.String),
- comments_count: Schema.Int,
-});
-export const ForgejoReviewComment = Schema.Struct({
- ...ForgejoComment.fields,
- path: Schema.String,
- position: Schema.Int,
- original_position: Schema.Int,
- commit_id: Schema.String,
- original_commit_id: Schema.String,
- resolver: Schema.NullOr(ForgejoUser),
-});
-export const ForgejoCommit = Schema.Struct({
- sha: Schema.String,
- author: Schema.NullOr(ForgejoUser),
- commit: Schema.Struct({
- message: Schema.String,
- committer: Schema.Struct({ date: Schema.String }),
- }),
- parents: Schema.Array(Schema.Struct({ sha: Schema.String })),
- stats: Schema.optional(
- Schema.NullOr(Schema.Struct({ additions: Schema.Int, deletions: Schema.Int })),
- ),
-});
-export const ForgejoStatus = Schema.Struct({
- context: Schema.String,
- status: Schema.String,
- description: Schema.NullOr(Schema.String),
- target_url: Schema.NullOr(Schema.String),
- updated_at: Schema.String,
-});
-export const ForgejoReaction = Schema.Struct({
- content: Schema.String,
- user: Schema.NullOr(ForgejoUser),
-});
-
-export function forgejoActor(
- user: typeof ForgejoUser.Type | null | undefined,
-): PullRequestActor | null {
- return user?.login
- ? { login: user.login, name: user.full_name || null, avatarUrl: user.avatar_url || null }
- : null;
-}
-
-function toIsoUtc(value: string): string {
- return Option.match(DateTime.make(value), { onNone: () => value, onSome: DateTime.formatIso });
-}
-
-export function forgejoChangeRequest(pr: typeof ForgejoPullRequest.Type) {
- return {
- number: pr.number,
- title: pr.title,
- url: pr.html_url,
- author: forgejoActor(pr.user),
- headBranch: pr.head.ref,
- baseBranch: pr.base.ref,
- headRepositoryNameWithOwner: pr.head.repo?.full_name ?? null,
- state: pr.merged ? "merged" : pr.state === "closed" ? "closed" : "open",
- isDraft: pr.draft ?? /^(?:\[WIP\]|WIP:)/i.test(pr.title),
- // A false value also covers drafts and unfinished or failed conflict checks.
- mergeability: pr.mergeable === true ? "mergeable" : "unknown",
- additions: pr.additions ?? 0,
- deletions: pr.deletions ?? 0,
- createdAt: toIsoUtc(pr.created_at),
- updatedAt: toIsoUtc(pr.updated_at),
- closedAt: pr.closed_at === null ? null : toIsoUtc(pr.closed_at),
- mergedAt: pr.merged_at === null ? null : toIsoUtc(pr.merged_at),
- reviewRequestLogins: (pr.requested_reviewers ?? []).map((user) => user.login),
- labels: (pr.labels ?? []).map((label) => ({ name: label.name, color: label.color ?? null })),
- } satisfies ProviderChangeRequest;
-}
-
-export function forgejoComment(comment: typeof ForgejoComment.Type): PullRequestComment {
- return {
- id: String(comment.id),
- kind: "issue-comment",
- author: forgejoActor(comment.user),
- body: comment.body,
- createdAt: toIsoUtc(comment.created_at),
- url: comment.html_url || null,
- path: null,
- reviewState: null,
- };
-}
-
-export function forgejoReview(review: typeof ForgejoReview.Type): PullRequestComment {
- return {
- id: `review:${review.id}`,
- kind: "review",
- author: forgejoActor(review.user),
- body: review.body,
- createdAt: toIsoUtc(review.submitted_at),
- url: review.html_url || null,
- path: null,
- reviewState:
- review.state === "REQUEST_CHANGES"
- ? "CHANGES_REQUESTED"
- : review.state === "COMMENT"
- ? "COMMENTED"
- : review.state,
- };
-}
-
-export function forgejoReviewThread(
- comment: typeof ForgejoReviewComment.Type,
-): PullRequestReviewThread {
- const oldSide = comment.position === 0 && comment.original_position > 0;
- const line = oldSide ? comment.original_position : comment.position;
- return {
- id: String(comment.id),
- path: comment.path,
- line: line > 0 ? line : null,
- side: oldSide ? "left" : "right",
- isResolved: comment.resolver !== null,
- isOutdated: false,
- comments: [forgejoComment(comment)],
- };
-}
-
-export function forgejoCommit(commit: typeof ForgejoCommit.Type): PullRequestCommit {
- const author = forgejoActor(commit.author);
- return {
- oid: commit.sha,
- messageHeadline: commit.commit.message.split("\n")[0] ?? "",
- committedDate: toIsoUtc(commit.commit.committer.date),
- authors: author ? [author] : [],
- ...(commit.stats
- ? { additions: commit.stats.additions, deletions: commit.stats.deletions }
- : {}),
- };
-}
-
-export function forgejoChecks(
- statuses: ReadonlyArray,
-): ReadonlyArray {
- return dedupeChecks(
- statuses.map((status) => ({
- workflowName: null,
- at: status.updated_at,
- check: {
- name: status.context || "check",
- description: status.description || null,
- url: status.target_url || null,
- status:
- status.status === "success"
- ? "success"
- : status.status === "failure" || status.status === "error"
- ? "failure"
- : "pending",
- },
- })),
- );
-}
-
-export const FORGEJO_REACTIONS: Record = {
- "thumbs-up": "+1",
- "thumbs-down": "-1",
- laugh: "laugh",
- hooray: "hooray",
- confused: "confused",
- heart: "heart",
- rocket: "rocket",
- eyes: "eyes",
-};
-export function forgejoReactions(
- reactions: ReadonlyArray,
- viewer: string,
-): ReadonlyArray {
- return Object.entries(FORGEJO_REACTIONS).flatMap(([content, emoji]) => {
- const matching = reactions.filter((reaction) => reaction.content === emoji);
- return matching.length === 0
- ? []
- : [
- {
- content: content as PullRequestReactionContent,
- count: matching.length,
- actors: matching.flatMap((reaction) =>
- reaction.user?.login && reaction.user.login !== viewer ? [reaction.user.login] : [],
- ),
- viewerHasReacted: matching.some((reaction) => reaction.user?.login === viewer),
- },
- ];
- });
-}
diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts
index 913f8067d250df07aeb2a73fb281488edf74f941..b1ac0b12dbd33c198d8ebdd0bbae538673244e37 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -56,7 +56,8 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
import * as GitHubApi from "./sourceControl/GitHubApi.ts";
import * as GitLabCli from "./sourceControl/GitLabCli.ts";
-import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
+import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
+import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
import * as TextGeneration from "./textGeneration/TextGeneration.ts";
import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts";
import * as TerminalManager from "./terminal/Manager.ts";
@@ -104,7 +105,6 @@ import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
import * as GitWorkflowService from "./git/GitWorkflowService.ts";
import * as ReviewService from "./review/ReviewService.ts";
import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
-import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts";
import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts";
import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
@@ -305,8 +305,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p
BitbucketApi.layer,
GitHubApi.layerWithDependencies,
GitLabCli.layer,
- ForgejoCli.layer,
- ServerSourceControlHost.layer,
+ SourceControlBuiltInDrivers.layer,
),
),
Layer.provideMerge(GitVcsDriver.layer),
diff --git a/apps/server/src/sourceControl/ForgejoCli.ts b/apps/server/src/sourceControl/ForgejoCli.ts
deleted file mode 100644
index 573c081e5a42ae3174d0b0bc17ca11057febc931..0000000000000000000000000000000000000000
--- a/apps/server/src/sourceControl/ForgejoCli.ts
+++ /dev/null
@@ -1,724 +0,0 @@
-import * as Context from "effect/Context";
-import * as Effect from "effect/Effect";
-import * as Layer from "effect/Layer";
-import * as Schema from "effect/Schema";
-import * as Result from "effect/Result";
-import * as Clock from "effect/Clock";
-import * as FileSystem from "effect/FileSystem";
-import * as Semaphore from "effect/Semaphore";
-import * as NodeOS from "node:os";
-// @effect-diagnostics-next-line nodeBuiltinImport:off - fj storage paths use explicit Windows and POSIX layouts, independently of this process's platform.
-import * as NodePath from "node:path";
-import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
-import { ChildProcessSpawner } from "effect/process";
-import { decodeJsonResult } from "@t3tools/shared/schemaJson";
-import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
-import * as VcsProcess from "../vcs/VcsProcess.ts";
-import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
-import type { SourceControlProviderContext } from "@t3tools/source-control-core/server/SourceControlProvider";
-
-const encodeApiBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
-
-export class ForgejoCliError extends Schema.TaggedError()("ForgejoCliError", {
- command: Schema.Literals(["fj", "tea"]),
- cwd: Schema.String,
- detail: Schema.String,
- reason: Schema.optional(
- Schema.Literals([
- "missing-cli",
- "authentication",
- "forbidden",
- "not-found",
- "rate-limit",
- "invalid-response",
- ]),
- ),
- httpStatus: Schema.optional(Schema.Int),
- cause: Schema.optional(Schema.Defect()),
-}) {}
-
-export const ForgejoLoginSchema = Schema.Struct({
- name: Schema.String,
- url: Schema.String,
- ssh_host: Schema.optional(Schema.String),
- valid: Schema.optional(Schema.String),
- user: Schema.String,
- default: Schema.String,
-});
-
-export function parseForgejoLogins(raw: string) {
- const decoded = decodeJsonResult(Schema.Array(ForgejoLoginSchema))(raw);
- return Result.isSuccess(decoded) ? decoded.success : [];
-}
-
-export const ForgejoKeysSchema = Schema.Struct({
- hosts: Schema.Record(
- Schema.String,
- Schema.Struct({
- type: Schema.Literals(["Application", "OAuth"]),
- token: Schema.String,
- }),
- ),
- aliases: Schema.optional(Schema.Record(Schema.String, Schema.String)),
-});
-
-const parseForgejoKeys = decodeJsonResult(ForgejoKeysSchema);
-
-/** Matches fj's directories::ProjectDirs, including its pre-0.6 organization name. */
-function forgejoKeysPaths(input: {
- readonly platform: string;
- readonly home: string;
- readonly dataHome?: string;
- readonly appData?: string;
-}) {
- if (input.platform === "darwin")
- return ["forgejo-cli", "Cyborus"].map((organization) =>
- NodePath.join(
- input.home,
- "Library",
- "Application Support",
- `${organization}.forgejo-cli`,
- "keys.json",
- ),
- );
- if (input.platform === "win32")
- return ["forgejo-cli", "Cyborus"].map((organization) =>
- NodePath.win32.join(
- input.appData || NodePath.win32.join(input.home, "AppData", "Roaming"),
- organization,
- "forgejo-cli",
- "data",
- "keys.json",
- ),
- );
- return [
- NodePath.join(
- input.dataHome && NodePath.isAbsolute(input.dataHome)
- ? input.dataHome
- : NodePath.join(input.home, ".local", "share"),
- "forgejo-cli",
- "keys.json",
- ),
- ];
-}
-
-export interface ForgejoRepositoryInput {
- readonly cwd: string;
- readonly context?: SourceControlProviderContext;
- readonly repository?: string;
- readonly reference?: string;
- readonly host?: string;
-}
-
-export interface ForgejoRepository {
- readonly command?: "fj" | "tea";
- readonly login: string;
- readonly repository: string;
- readonly baseUrl: string;
-}
-
-export interface ForgejoApiInput extends ForgejoRepositoryInput {
- readonly path: string;
- readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
- readonly body?: unknown;
-}
-
-export class ForgejoCli extends Context.Service<
- ForgejoCli,
- {
- readonly execute: (input: {
- readonly command?: "fj" | "tea";
- readonly cwd: string;
- readonly args: ReadonlyArray;
- readonly stdin?: string;
- readonly timeoutMs?: number;
- readonly maxOutputBytes?: number;
- }) => Effect.Effect;
- readonly listLogins?: (input: {
- readonly cwd: string;
- readonly command: "fj" | "tea";
- readonly remoteUrl?: string;
- }) => Effect.Effect, ForgejoCliError>;
- readonly getAccount?: (input: {
- readonly cwd: string;
- readonly baseUrl: string;
- }) => Effect.Effect;
- readonly resolveRepository: (
- input: ForgejoRepositoryInput,
- ) => Effect.Effect;
- readonly api: (
- input: ForgejoApiInput,
- ) => Effect.Effect;
- }
->()("t3/sourceControl/ForgejoCli") {}
-
-export function parseForgejoRemote(value: string) {
- if (/^(?:https?|ssh):\/\//i.test(value)) {
- try {
- const url = new URL(value);
- return {
- host: url.host.toLowerCase(),
- hostname: url.hostname.toLowerCase(),
- ssh: url.protocol === "ssh:",
- path: url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""),
- };
- } catch {
- return null;
- }
- }
- // SCP remotes may omit the username; URL treats these as a custom scheme.
- const ssh = /^(?:[^@/]+@)?([^:/]+):([^/].*)$/.exec(value);
- return ssh?.[1] && ssh[2]
- ? {
- host: ssh[1].toLowerCase(),
- hostname: ssh[1].toLowerCase(),
- ssh: true,
- path: ssh[2].replace(/\.git$/, ""),
- }
- : null;
-}
-
-export function matchForgejoLogin(
- logins: ReturnType,
- remote: NonNullable>,
- requestedHost?: string,
- hostOnly = false,
-) {
- const matches = [
- ...new Map(
- logins
- .filter((login) => {
- const url = parseForgejoRemote(login.url);
- if (!url) return false;
- if (requestedHost !== undefined && url.host !== requestedHost.toLowerCase()) return false;
- return remote.ssh
- ? login.ssh_host?.toLowerCase() === remote.host ||
- login.ssh_host?.toLowerCase() === remote.hostname ||
- url.hostname === remote.hostname
- : url.host === remote.host &&
- ((hostOnly && !remote.path) ||
- !url.path ||
- remote.path === url.path ||
- remote.path.startsWith(`${url.path}/`));
- })
- .map((login) => [login.name, login]),
- ).values(),
- ];
- return matches.length === 1
- ? matches[0]
- : new Set(matches.map((login) => login.url)).size === 1
- ? matches.find((login) => login.default === "true")
- : undefined;
-}
-
-export const make = Effect.gen(function* () {
- const process = yield* VcsProcess.VcsProcess;
- const fileSystem = yield* FileSystem.FileSystem;
- const httpClient = yield* HttpClient.HttpClient;
- const authLock = yield* Semaphore.make(1);
- const authenticated = new Map();
- const execute: ForgejoCli["Service"]["execute"] = (input) =>
- process
- .run({
- ...input,
- operation: "ForgejoCli.execute",
- command: input.command ?? "tea",
- timeoutMs: input.timeoutMs ?? 30_000,
- })
- .pipe(
- Effect.mapError(
- (cause) =>
- new ForgejoCliError({
- command: input.command ?? "tea",
- cwd: input.cwd,
- ...(input.command === "fj" ? {} : { cause }),
- ...(cause._tag === "VcsProcessSpawnError"
- ? { reason: "missing-cli" as const }
- : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication"
- ? { reason: "authentication" as const }
- : {}),
- detail:
- cause._tag === "VcsProcessSpawnError"
- ? "Install Forgejo CLI (`fj` 0.6 or later) or Gitea CLI (`tea` 0.16 or later) and retry."
- : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication"
- ? "Authenticate this server with `fj auth login`, `fj auth add-token`, or `tea login add`."
- : "Forgejo CLI command failed.",
- }),
- ),
- );
-
- const readKeys = Effect.fn("ForgejoCli.readKeys")(function* (cwd: string) {
- for (const path of forgejoKeysPaths({
- platform: yield* HostProcessPlatform,
- home: NodeOS.homedir(),
- ...(globalThis.process.env.XDG_DATA_HOME
- ? { dataHome: globalThis.process.env.XDG_DATA_HOME }
- : {}),
- ...(globalThis.process.env.APPDATA ? { appData: globalThis.process.env.APPDATA } : {}),
- })) {
- const exists = yield* fileSystem.exists(path).pipe(
- Effect.mapError(
- () =>
- new ForgejoCliError({
- command: "fj",
- cwd,
- reason: "authentication",
- detail: "Could not read fj authentication storage.",
- }),
- ),
- );
- if (!exists) continue;
- const raw = yield* fileSystem.readFileString(path).pipe(
- Effect.mapError(
- () =>
- new ForgejoCliError({
- command: "fj",
- cwd,
- reason: "authentication",
- detail: "Could not read fj authentication storage.",
- }),
- ),
- );
- const decoded = parseForgejoKeys(raw);
- if (Result.isFailure(decoded))
- return yield* new ForgejoCliError({
- command: "fj",
- cwd,
- reason: "authentication",
- detail: "fj authentication storage is invalid. Authenticate again with fj.",
- });
- return decoded.success;
- }
- const empty: typeof ForgejoKeysSchema.Type = { hosts: {}, aliases: {} };
- return empty;
- });
-
- const publicLogins = (
- keys: typeof ForgejoKeysSchema.Type,
- remoteUrl?: string,
- ): ReturnType => {
- const remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null;
- return Object.keys(keys.hosts).flatMap((host) => {
- const url = parseForgejoRemote(`https://${host}`);
- // fj 0.6 drops URL mounts during whoami and OAuth renewal; tea supports them.
- if (!url || url.path) return [];
- // fj omits the scheme in storage. Only an explicit matching HTTP remote opts into HTTP.
- const scheme =
- remote && !remote.ssh && remote.host === url.host && /^http:\/\//i.test(remoteUrl ?? "")
- ? "http"
- : "https";
- const login = { name: host, url: `${scheme}://${host}`, user: "", default: "false" };
- const aliases = Object.entries(keys.aliases ?? {})
- .filter(([, target]) => target === host)
- .map(([alias]) => ({ ...login, ssh_host: alias }));
- return aliases.length ? aliases : [login];
- });
- };
-
- const listLogins: NonNullable = Effect.fn(
- "ForgejoCli.listLogins",
- )(function* (input) {
- if (input.command === "fj") {
- const keys = yield* readKeys(input.cwd).pipe(Effect.result);
- if (Result.isFailure(keys)) {
- // Stale credentials from an uninstalled fj must not disable an available tea login.
- const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe(
- Effect.result,
- );
- if (Result.isFailure(available) && available.failure.reason === "missing-cli") return [];
- return yield* keys.failure;
- }
- return publicLogins(keys.success, input.remoteUrl);
- }
- return parseForgejoLogins(
- (yield* execute({ cwd: input.cwd, args: ["login", "list", "--output", "json"] })).stdout,
- );
- });
-
- const requestFj = Effect.fn("ForgejoCli.requestFj")(
- function* (input: {
- readonly cwd: string;
- readonly baseUrl: string;
- readonly token: string;
- readonly path: string;
- readonly method?: ForgejoApiInput["method"];
- readonly body?: string;
- }) {
- const base = new URL(`${input.baseUrl}/api/v1/`);
- const url = new URL(input.path, base);
- if (
- url.origin !== base.origin ||
- !url.pathname.startsWith(base.pathname) ||
- url.username ||
- url.password
- )
- return yield* new ForgejoCliError({
- command: "fj",
- cwd: input.cwd,
- detail: "Invalid Forgejo API path.",
- });
- let request = HttpClientRequest.make(input.method ?? "GET")(url.toString()).pipe(
- HttpClientRequest.setHeader("Authorization", `token ${input.token}`),
- );
- if (input.body !== undefined)
- request = request.pipe(HttpClientRequest.bodyText(input.body, "application/json"));
- const response = yield* httpClient
- .execute(request)
- .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }));
- const status = response.status;
- const body =
- status === 204 || status === 205
- ? { text: "", truncated: false, invalidUtf8: false }
- : yield* collectUint8StreamText({
- stream: response.stream,
- maxBytes: 8 * 1024 * 1024,
- });
- if (body.truncated || body.invalidUtf8)
- return yield* new ForgejoCliError({
- command: "fj",
- cwd: input.cwd,
- reason: "invalid-response",
- detail: "Forgejo returned an oversized or invalid response.",
- });
- if (status < 200 || status >= 300) {
- const detail =
- status === 404
- ? "Forgejo repository or pull request was not found."
- : body.text
- ? `Forgejo API request failed (HTTP ${status}): ${body.text}`
- : `Forgejo API request failed (HTTP ${status}). Check this server's fj credentials and permissions.`;
- return yield* new ForgejoCliError({
- command: "fj",
- cwd: input.cwd,
- httpStatus: status,
- ...(status === 401
- ? { reason: "authentication" as const }
- : status === 403
- ? { reason: "forbidden" as const }
- : status === 404
- ? { reason: "not-found" as const }
- : status === 429
- ? { reason: "rate-limit" as const }
- : {}),
- detail,
- });
- }
- return {
- exitCode: ChildProcessSpawner.ExitCode(0),
- stdout: body.text,
- stderr: `HTTP/1.1 ${status}\n${response.headers.link ? `link: ${response.headers.link}\n` : ""}`,
- stdoutTruncated: false,
- stderrTruncated: false,
- };
- },
- (effect, input) =>
- effect.pipe(
- Effect.timeout(30_000),
- Effect.mapError((error) =>
- error._tag === "ForgejoCliError"
- ? error
- : new ForgejoCliError({
- command: "fj",
- cwd: input.cwd,
- detail: "Forgejo API request failed or timed out.",
- }),
- ),
- ),
- );
-
- const authenticateFj = Effect.fn("ForgejoCli.authenticateFj")(function* (
- cwd: string,
- login: typeof ForgejoLoginSchema.Type,
- ) {
- const keys = yield* readKeys(cwd);
- const token = keys.hosts[login.name]?.token;
- const now = yield* Clock.currentTimeMillis;
- const cached = authenticated.get(login.url);
- if (token && cached?.token === token && now - cached.time < 30_000) return token;
- yield* execute({ command: "fj", cwd, args: ["--host", login.url, "whoami"] });
- // fj owns OAuth renewal. Re-read the file after it has refreshed an expired token.
- const refreshed = (yield* readKeys(cwd)).hosts[login.name]?.token;
- if (!refreshed)
- return yield* new ForgejoCliError({
- command: "fj",
- cwd,
- reason: "authentication",
- detail: "fj has no credentials for this server. Authenticate again with fj.",
- });
- authenticated.set(login.url, { token: refreshed, time: now });
- return refreshed;
- }, authLock.withPermits(1));
-
- const getAccount: NonNullable = Effect.fn(
- "ForgejoCli.getAccount",
- )(function* (input) {
- const logins = yield* listLogins({ cwd: input.cwd, command: "fj", remoteUrl: input.baseUrl });
- const login = logins.find(
- (item) => item.url.replace(/\/+$/, "") === input.baseUrl.replace(/\/+$/, ""),
- );
- if (!login)
- return yield* new ForgejoCliError({
- command: "fj",
- cwd: input.cwd,
- reason: "authentication",
- detail: "fj has no credentials for this server.",
- });
- const token = yield* authenticateFj(input.cwd, login);
- const currentUser = yield* requestFj({
- cwd: input.cwd,
- baseUrl: login.url.replace(/\/+$/, ""),
- token,
- path: "user",
- });
- const user = decodeJsonResult(Schema.Struct({ login: Schema.String }))(currentUser.stdout);
- if (Result.isFailure(user) || !user.success.login.trim())
- return yield* new ForgejoCliError({
- command: "fj",
- cwd: input.cwd,
- reason: "invalid-response",
- detail: "Forgejo returned an invalid account response.",
- });
- return user.success.login;
- });
-
- const resolveTarget = Effect.fn("ForgejoCli.resolveTarget")(function* (
- input: ForgejoRepositoryInput,
- hostOnly = false,
- ) {
- const referenceRemote = input.reference ? parseForgejoRemote(input.reference) : null;
- let remoteUrl = [input.reference, input.repository, input.context?.remoteUrl].find(
- (value) => value && parseForgejoRemote(value),
- );
- let remote =
- referenceRemote ??
- (input.repository ? parseForgejoRemote(input.repository) : null) ??
- (input.context ? parseForgejoRemote(input.context.remoteUrl) : null);
- if (!remote && (!input.repository || input.host)) {
- const result = yield* process
- .run({
- operation: "ForgejoCli.remote",
- command: "git",
- args: input.host ? ["remote", "-v"] : ["remote", "get-url", "origin"],
- cwd: input.cwd,
- allowNonZeroExit: true,
- })
- .pipe(
- Effect.mapError(
- (cause) =>
- new ForgejoCliError({
- command: "tea",
- cwd: input.cwd,
- detail: "Could not resolve the Forgejo repository remote.",
- cause,
- }),
- ),
- );
- if (input.host) {
- const matchingUrls = [
- ...new Set(
- result.stdout.split("\n").flatMap((line) => {
- const url = /^\S+\s+(https?:\/\/\S+)\s+\(fetch\)$/.exec(line.trim())?.[1];
- return url && parseForgejoRemote(url)?.host === input.host?.toLowerCase()
- ? [url]
- : [];
- }),
- ),
- ];
- const origins = [...new Set(matchingUrls.map((url) => new URL(url).origin))];
- remoteUrl =
- matchingUrls.length === 1
- ? matchingUrls[0]
- : origins.length === 1
- ? origins[0]
- : undefined;
- } else {
- remoteUrl = result.stdout.trim();
- }
- remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null;
- }
- if (
- input.host &&
- !remote?.ssh &&
- remote?.host !== input.host.toLowerCase() &&
- remote?.hostname !== input.host.toLowerCase()
- )
- remote = {
- host: input.host.toLowerCase(),
- hostname: input.host.split(":")[0] ?? input.host,
- ssh: false,
- path: remote?.path ?? "",
- };
- const schemeRemoteUrl = remote?.ssh ? input.context?.provider.baseUrl : remoteUrl;
- const fjLogins = yield* listLogins({
- cwd: input.cwd,
- command: "fj",
- ...(schemeRemoteUrl ? { remoteUrl: schemeRemoteUrl } : {}),
- });
- const requestedHost = input.host ?? input.context?.requestedHost;
- const matchHostOnly = hostOnly || (!!input.host && !remote?.path);
- const selectLogin = (logins: ReturnType) =>
- remote
- ? matchForgejoLogin(logins, remote, remote.ssh ? requestedHost : undefined, matchHostOnly)
- : (logins.find((item) => item.default === "true") ??
- (new Set(logins.map((item) => item.name)).size === 1 ? logins[0] : undefined));
- let login = selectLogin(fjLogins);
- let command: "fj" | "tea" = "fj";
- if (
- !login &&
- fjLogins.some(
- (item) =>
- !remote ||
- matchForgejoLogin([item], remote, remote.ssh ? requestedHost : undefined, matchHostOnly),
- )
- ) {
- const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe(
- Effect.result,
- );
- if (Result.isSuccess(available))
- return yield* new ForgejoCliError({
- command: "fj",
- cwd: input.cwd,
- reason: "authentication",
- detail: "Multiple fj logins match this repository. Specify its full server URL.",
- });
- if (available.failure.reason !== "missing-cli") return yield* available.failure;
- }
- if (login) {
- const auth = yield* authenticateFj(input.cwd, login).pipe(Effect.result);
- if (Result.isFailure(auth)) {
- if (auth.failure.reason === "missing-cli") login = undefined;
- else return yield* auth.failure;
- }
- }
- if (!login) {
- command = "tea";
- login = selectLogin(yield* listLogins({ cwd: input.cwd, command: "tea" }));
- }
- if (!login)
- return yield* new ForgejoCliError({
- command: "tea",
- cwd: input.cwd,
- reason: "authentication",
- detail:
- "No matching Forgejo login. Use `fj auth login`, `fj auth add-token`, or `tea login add` for this server; choose a default when multiple tea accounts match.",
- });
- if (hostOnly)
- return { command, login: login.name, repository: "", baseUrl: login.url.replace(/\/+$/, "") };
- const path =
- referenceRemote?.path ??
- (input.repository && !parseForgejoRemote(input.repository)
- ? input.repository
- : remote?.path) ??
- "";
- const basePath = new URL(login.url).pathname.replace(/^\/+|\/+$/g, "");
- const relativePath =
- basePath && path.split("/").length > 2 && path.startsWith(`${basePath}/`)
- ? path.slice(basePath.length + 1)
- : path;
- const repositoryPath = relativePath.replace(/\/pulls\/\d+.*$/, "").replace(/\.git$/, "");
- if (command === "fj" && !repositoryPath.includes("/")) {
- login = { ...login, user: yield* getAccount({ cwd: input.cwd, baseUrl: login.url }) };
- }
- const repository = repositoryPath.includes("/")
- ? repositoryPath
- : `${login.user}/${repositoryPath}`;
- if (!/^[^/\s]+\/[^/\s]+$/.test(repository))
- return yield* new ForgejoCliError({
- command,
- cwd: input.cwd,
- detail: "Specify a Forgejo repository as owner/repository or its full server URL.",
- });
- return { command, login: login.name, repository, baseUrl: login.url.replace(/\/+$/, "") };
- });
- const resolveRepository = (input: ForgejoRepositoryInput) => resolveTarget(input);
- const api = Effect.fn("ForgejoCli.api")(function* (input: ForgejoApiInput) {
- const repository = yield* resolveTarget(
- input,
- input.path.replace(/^\/+/, "") === "user" && (!input.method || input.method === "GET"),
- );
- const stdin =
- input.body === undefined
- ? undefined
- : yield* encodeApiBody(input.body).pipe(
- Effect.mapError(
- (cause) =>
- new ForgejoCliError({
- command: "tea",
- cwd: input.cwd,
- detail: "Could not encode the Forgejo request body.",
- cause,
- }),
- ),
- );
- let path = input.path.replace(/^\/+/, "");
- if (input.repository && input.repository !== repository.repository) {
- // Repository identities retain the server mount path; API routes do not.
- const prefix = `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`;
- if (path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}?`)) {
- path = `repos/${repository.repository.split("/").map(encodeURIComponent).join("/")}${path.slice(prefix.length)}`;
- }
- }
- if (repository.command === "fj") {
- const token = (yield* readKeys(input.cwd)).hosts[repository.login]?.token;
- if (!token)
- return yield* new ForgejoCliError({
- command: "fj",
- cwd: input.cwd,
- reason: "authentication",
- detail: "fj has no credentials for this server.",
- });
- return yield* requestFj({
- cwd: input.cwd,
- baseUrl: repository.baseUrl,
- token,
- path,
- ...(input.method === undefined ? {} : { method: input.method }),
- ...(stdin === undefined ? {} : { body: stdin }),
- });
- }
- const result = yield* execute({
- cwd: input.cwd,
- args: [
- "api",
- "--include",
- "--login",
- repository.login,
- ...(repository.repository ? ["--repo", repository.repository] : []),
- "--method",
- input.method ?? "GET",
- ...(input.body === undefined ? [] : ["--data", "@-"]),
- `${repository.baseUrl}/api/v1/${path}`,
- ],
- ...(stdin === undefined ? {} : { stdin }),
- });
- // tea reports HTTP failures with exit code zero; use its response status.
- const status = Number(/^HTTP\/\S+ (\d{3})/m.exec(result.stderr)?.[1]);
- if (!status || status >= 400)
- return yield* new ForgejoCliError({
- command: "tea",
- cwd: input.cwd,
- ...(status ? { httpStatus: status } : {}),
- ...(status === 401
- ? { reason: "authentication" as const }
- : status === 403
- ? { reason: "forbidden" as const }
- : status === 404
- ? { reason: "not-found" as const }
- : status === 429
- ? { reason: "rate-limit" as const }
- : {}),
- detail:
- status === 401 || status === 403
- ? "Forgejo denied access. Check this server's `tea login` credentials and permissions."
- : status === 404
- ? "Forgejo repository or pull request was not found."
- : status === 429
- ? "Forgejo API rate limit exceeded."
- : `Forgejo API request failed${status ? ` (HTTP ${status})` : " without an HTTP status"}.`,
- });
- return result;
- });
- return ForgejoCli.of({ execute, listLogins, getAccount, resolveRepository, api });
-});
-
-export const layer = Layer.effect(ForgejoCli, make);
diff --git a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts b/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts
deleted file mode 100644
index 79a1ca6f2aa030006e104190f0dad5b11f40c049..0000000000000000000000000000000000000000
--- a/apps/server/src/sourceControl/ForgejoSourceControlProvider.ts
+++ /dev/null
@@ -1,387 +0,0 @@
-import * as Effect from "effect/Effect";
-import * as FileSystem from "effect/FileSystem";
-import * as Schema from "effect/Schema";
-import * as Result from "effect/Result";
-import { SourceControlProviderError } from "@t3tools/contracts";
-import * as VcsProcess from "../vcs/VcsProcess.ts";
-import * as ForgejoCli from "./ForgejoCli.ts";
-import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
-import {
- providerAuth,
- probeSourceControlProvider,
- type SourceControlCliDiscoverySpec,
- type SourceControlManagedCliDiscoverySpec,
-} from "@t3tools/source-control-core/server/discovery";
-import { ForgejoPullRequestSchema, toForgejoChangeRequest } from "./forgejoPullRequests.ts";
-
-const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError);
-
-export const discovery = {
- type: "cli",
- kind: "forgejo",
- label: "Forgejo / Gitea",
- executable: "tea",
- versionArgs: ["--version"],
- authArgs: ["login", "status", "--output", "json"],
- remoteRefinementArgs: ["login", "list", "--output", "json"],
- parseAuth: (input) => {
- const logins = ForgejoCli.parseForgejoLogins(input.stdout);
- const login = logins.find((entry) => entry.default === "true") ?? logins[0];
- return login
- ? providerAuth({
- status: login.valid === "true" ? "authenticated" : "unauthenticated",
- account: login.user,
- host: ForgejoCli.parseForgejoRemote(login.url)?.host,
- })
- : providerAuth({
- status: "unauthenticated",
- detail: "Run `tea login add` to authenticate a Forgejo or Gitea server.",
- });
- },
- refineUnknownRemote: (input) => {
- const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl);
- const login =
- remote &&
- ForgejoCli.matchForgejoLogin(
- ForgejoCli.parseForgejoLogins(input.auth.stdout),
- remote,
- input.context.requestedHost,
- );
- return login ? { kind: "forgejo", name: "Forgejo / Gitea", baseUrl: login.url } : null;
- },
- installHint:
- "Install `fj` 0.6 or later from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj --host auth add-token`, or install `tea` 0.16 or later from https://gitea.com/gitea/tea and run `tea login add` for each Forgejo or Gitea server.",
-} satisfies SourceControlCliDiscoverySpec;
-
-export const makeDiscovery = Effect.gen(function* () {
- const cli = yield* ForgejoCli.ForgejoCli;
- const process = yield* VcsProcess.VcsProcess;
- const listLogins = cli.listLogins;
- if (!listLogins) return discovery;
- return {
- type: "managed-cli",
- kind: "forgejo",
- label: discovery.label,
- installHint: discovery.installHint,
- probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) {
- const remoteUrl = yield* process
- .run({
- operation: "source-control.discovery.remote",
- command: "git",
- args: ["remote", "get-url", "origin"],
- cwd,
- allowNonZeroExit: true,
- timeoutMs: 5_000,
- maxOutputBytes: 8_000,
- })
- .pipe(
- Effect.map((result) => result.stdout.trim()),
- Effect.orElseSucceed(() => ""),
- );
- const credentials = yield* Effect.result(listLogins({ cwd, command: "fj", remoteUrl }));
- const logins = Result.isSuccess(credentials) ? credentials.success : [];
- const remote = ForgejoCli.parseForgejoRemote(remoteUrl);
- const login =
- (remote && ForgejoCli.matchForgejoLogin(logins, remote)) ||
- logins.find((entry) => entry.default === "true") ||
- logins[0];
- const fj = yield* probeSourceControlProvider({
- cwd,
- process,
- spec: {
- ...discovery,
- executable: "fj",
- versionArgs: ["version"],
- authArgs: login ? ["--host", login.url, "whoami"] : ["auth", "list"],
- parseAuth: (result) =>
- Result.isFailure(credentials)
- ? providerAuth({
- status: "unknown",
- detail: "Could not read fj authentication storage. Authenticate again with fj.",
- })
- : login && result.exitCode === 0
- ? providerAuth({
- status: "authenticated",
- account: login.user,
- host: ForgejoCli.parseForgejoRemote(login.url)?.host,
- })
- : providerAuth({
- status: "unauthenticated",
- detail:
- "Authenticate this server with `fj --host auth add-token`.",
- }),
- },
- });
- // A configured fj account owns its requests, including authentication errors.
- if (fj.status === "available" && (login || Result.isFailure(credentials))) {
- if (login && fj.auth.status === "authenticated" && cli.getAccount) {
- const account = yield* cli.getAccount({ cwd, baseUrl: login.url }).pipe(Effect.result);
- return {
- ...fj,
- auth: Result.isSuccess(account)
- ? providerAuth({
- status: "authenticated",
- account: account.success,
- host: ForgejoCli.parseForgejoRemote(login.url)?.host,
- })
- : providerAuth({
- status: "unknown",
- detail: account.failure.detail,
- host: ForgejoCli.parseForgejoRemote(login.url)?.host,
- }),
- };
- }
- return fj;
- }
- const tea = yield* probeSourceControlProvider({ cwd, process, spec: discovery });
- return tea.status === "available" || fj.status === "missing" ? tea : fj;
- }),
- refineUnknownRemote: Effect.fn("ForgejoSourceControlProvider.refineUnknownRemote")(
- function* (input: {
- readonly cwd: string;
- readonly context: SourceControlProvider.SourceControlProviderContext;
- }) {
- const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl);
- if (!remote) return null;
- for (const command of ["fj", "tea"] as const) {
- const logins = yield* listLogins({
- cwd: input.cwd,
- command,
- remoteUrl: input.context.remoteUrl,
- }).pipe(Effect.orElseSucceed(() => []));
- const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost);
- if (login) return { kind: "forgejo" as const, name: discovery.label, baseUrl: login.url };
- }
- return null;
- },
- ),
- } satisfies SourceControlManagedCliDiscoverySpec;
-});
-
-const RepositorySchema = Schema.Struct({
- full_name: Schema.String,
- clone_url: Schema.String,
- ssh_url: Schema.String,
- default_branch: Schema.optional(Schema.NullOr(Schema.String)),
-});
-const cloneUrls = (raw: typeof RepositorySchema.Type) => ({
- nameWithOwner: raw.full_name,
- url: raw.clone_url,
- sshUrl: raw.ssh_url,
-});
-const repositoryPath = (repository: string) =>
- `repos/${repository.split("/").map(encodeURIComponent).join("/")}`;
-
-export const make = Effect.gen(function* () {
- const cli = yield* ForgejoCli.ForgejoCli;
- const fs = yield* FileSystem.FileSystem;
- const process = yield* VcsProcess.VcsProcess;
- const request = >(
- input: ForgejoCli.ForgejoApiInput,
- schema: S,
- ) =>
- cli.api(input).pipe(
- Effect.flatMap((result) =>
- Schema.decodeEffect(Schema.fromJsonString(schema))(result.stdout).pipe(
- Effect.mapError(
- (cause) =>
- new ForgejoCli.ForgejoCliError({
- command: "tea",
- cwd: input.cwd,
- detail: "Forgejo API returned an invalid response.",
- reason: "invalid-response",
- cause,
- }),
- ),
- ),
- ),
- );
- const mapError = (operation: string, cwd: string) =>
- Effect.mapError(
- (cause: unknown) =>
- new SourceControlProviderError({
- provider: "forgejo",
- operation,
- cwd,
- ...(isForgejoCliError(cause) ? { command: cause.command } : {}),
- detail: isForgejoCliError(cause) ? cause.detail : "Forgejo operation failed.",
- cause,
- }),
- );
- const getPull = Effect.fn("ForgejoSourceControlProvider.getPull")(function* (
- input: Parameters<
- SourceControlProvider.SourceControlProvider["Service"]["getChangeRequest"]
- >[0],
- ) {
- const repo = yield* cli.resolveRepository(input);
- const number = /(?:^#?|\/pulls\/)(\d+)(?:\/[^?#]*)?(?:[?#].*)?$/.exec(input.reference)?.[1];
- if (!number)
- return yield* new ForgejoCli.ForgejoCliError({
- command: "tea",
- cwd: input.cwd,
- detail: "Specify a pull request number or Forgejo pull request URL.",
- });
- return yield* request(
- { ...input, path: `${repositoryPath(repo.repository)}/pulls/${number}` },
- ForgejoPullRequestSchema,
- );
- });
- return SourceControlProvider.SourceControlProvider.of({
- kind: "forgejo",
- listChangeRequests: (input) =>
- Effect.gen(function* () {
- const repo = yield* cli.resolveRepository(input);
- const source = SourceControlProvider.sourceControlRefFromInput(input);
- const branch = SourceControlProvider.sourceBranch(input);
- const results: ReturnType[] = [];
- const limit = input.limit ?? 20;
- for (let page = 1; results.length < limit; page++) {
- const items = yield* request(
- {
- ...input,
- path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&sort=recentupdate&limit=50&page=${page}`,
- },
- Schema.Array(ForgejoPullRequestSchema),
- );
- for (const item of items) {
- if (
- item.head.ref !== branch ||
- (source?.repository && item.head.repo?.full_name !== source.repository) ||
- (source?.owner && item.head.repo?.owner.login !== source.owner)
- )
- continue;
- const normalized = toForgejoChangeRequest(item);
- if (input.state === "all" || normalized.state === input.state) results.push(normalized);
- }
- if (items.length === 0) break;
- }
- return results.slice(0, limit);
- }).pipe(mapError("listChangeRequests", input.cwd)),
- getChangeRequest: (input) =>
- getPull(input).pipe(
- Effect.map(toForgejoChangeRequest),
- mapError("getChangeRequest", input.cwd),
- ),
- createChangeRequest: (input) =>
- Effect.gen(function* () {
- const repo = yield* cli.resolveRepository(input);
- const source = SourceControlProvider.sourceControlRefFromInput(input);
- const owner = source?.owner ?? source?.repository?.split("/")[0];
- const head = SourceControlProvider.sourceBranch(input);
- yield* cli.api({
- ...input,
- path: `${repositoryPath(input.target?.repository ?? repo.repository)}/pulls`,
- method: "POST",
- body: {
- base: input.target?.refName ?? input.baseRefName,
- head: owner ? `${owner}:${head}` : head,
- title: input.title,
- body: yield* fs.readFileString(input.bodyFile),
- },
- });
- }).pipe(mapError("createChangeRequest", input.cwd)),
- getRepositoryCloneUrls: (input) =>
- Effect.gen(function* () {
- const repo = yield* cli.resolveRepository(input);
- return cloneUrls(
- yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema),
- );
- }).pipe(mapError("getRepositoryCloneUrls", input.cwd)),
- createRepository: (input) =>
- Effect.gen(function* () {
- const repo = yield* cli.resolveRepository(input);
- const user = yield* request(
- { ...input, path: "user" },
- Schema.Struct({ login: Schema.String }),
- );
- const [owner, name] = repo.repository.split("/");
- return cloneUrls(
- yield* request(
- {
- ...input,
- path:
- owner === user.login
- ? "user/repos"
- : `orgs/${encodeURIComponent(owner ?? "")}/repos`,
- method: "POST",
- body: { name, private: input.visibility === "private", auto_init: false },
- },
- RepositorySchema,
- ),
- );
- }).pipe(mapError("createRepository", input.cwd)),
- getDefaultBranch: (input) =>
- Effect.gen(function* () {
- const repo = yield* cli.resolveRepository(input);
- return (
- (yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema))
- .default_branch ?? null
- );
- }).pipe(mapError("getDefaultBranch", input.cwd)),
- checkoutChangeRequest: (input) =>
- Effect.gen(function* () {
- const repo = yield* cli.resolveRepository(input);
- const pull = yield* getPull(input);
- if (repo.command === "fj") {
- // fj checkout cannot target a repository outside the local remotes.
- const urls = yield* request(
- { ...input, path: repositoryPath(repo.repository) },
- RepositorySchema,
- );
- const remote = input.context?.remoteUrl;
- const useSsh = remote && ForgejoCli.parseForgejoRemote(remote)?.ssh;
- yield* process.run({
- operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
- command: "git",
- cwd: input.cwd,
- args: [
- "fetch",
- "--",
- useSsh ? urls.ssh_url : urls.clone_url,
- `refs/pull/${pull.number}/head`,
- ],
- });
- const branch = `pulls/${pull.number}`;
- const existing = yield* process.run({
- operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
- command: "git",
- cwd: input.cwd,
- args: ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
- allowNonZeroExit: true,
- });
- yield* process.run({
- operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
- command: "git",
- cwd: input.cwd,
- args:
- existing.exitCode === 0
- ? ["checkout", branch]
- : ["checkout", "-b", branch, "FETCH_HEAD"],
- });
- } else
- yield* cli.execute({
- cwd: input.cwd,
- args: [
- "pulls",
- "checkout",
- "--login",
- repo.login,
- "--repo",
- repo.repository,
- "--branch",
- String(pull.number),
- ],
- });
- if (input.force) {
- // tea leaves an existing PR branch at its old tip. Keep dirty files safe
- // while bringing the selected branch to the PR revision we fetched.
- yield* process.run({
- operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
- command: "git",
- cwd: input.cwd,
- args: ["reset", "--keep", pull.head.sha],
- });
- }
- }).pipe(mapError("checkoutChangeRequest", input.cwd)),
- });
-});
diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts
index e0d1c3efca238fdb8a33920212a7fc21c7793308..fe7694cef5458b787eaa37e612f0e048339d367d 100644
--- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts
+++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts
@@ -20,13 +20,13 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
import * as BitbucketApi from "./BitbucketApi.ts";
import * as GitHubApi from "./GitHubApi.ts";
import * as GitLabCli from "./GitLabCli.ts";
-import * as ForgejoCli from "./ForgejoCli.ts";
-import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
-import * as ForgejoPullRequestProvider from "../pullRequest/ForgejoPullRequestProvider.ts";
+import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
+import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";
+import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
-import { firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery";
+import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
const layerSourceControlProviderRegistryTest = (input: {
readonly bitbucket: Partial;
@@ -76,434 +76,6 @@ const processOutput = (
const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
-it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => {
- const methods: string[] = [];
- const fetchReview = async (
- ...[input, init]: Parameters>
- ) => {
- const request = new Request(input instanceof Request ? input.url : String(input), {
- ...(init?.method === undefined ? {} : { method: init.method }),
- ...(init?.headers === undefined ? {} : { headers: init.headers }),
- ...(init?.body === undefined ? {} : { body: init.body }),
- });
- methods.push(request.method);
- if (request.method === "GET") {
- assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42");
- return new Response(
- encodeJson({
- number: 42,
- title: "Review target",
- body: "",
- html_url: "https://forgejo.test/maria/project/pulls/42",
- user: { login: "maria" },
- state: "open",
- merged: false,
- head: { ref: "feature", sha: "head", repo: null },
- base: { ref: "main", sha: "base", repo: null },
- created_at: "2026-09-13T00:00:00Z",
- updated_at: "2026-09-13T00:00:00Z",
- closed_at: null,
- merged_at: null,
- labels: [],
- }),
- );
- }
- assert.strictEqual(request.method, "POST");
- assert.strictEqual(
- request.url,
- "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews",
- );
- assert.deepStrictEqual(JSON.parse(await request.text()), {
- event: "COMMENT",
- body: "Review summary",
- commit_id: "head",
- comments: [],
- });
- return new Response('{"id":1}', { status: 200 });
- };
- return Effect.gen(function* () {
- const cli = yield* ForgejoCli.make;
- const provider = yield* ForgejoPullRequestProvider.make.pipe(
- Effect.provideService(ForgejoCli.ForgejoCli, cli),
- );
- yield* provider.submitReview({
- cwd: "/repo",
- repository: "maria/project",
- host: "forgejo.test",
- number: 42,
- verdict: "comment",
- body: "Review summary",
- comments: [],
- });
- assert.deepStrictEqual(methods, ["GET", "POST"]);
- }).pipe(
- Effect.provideService(
- FetchHttpClient.Fetch,
- Object.assign(fetchReview, { preconnect: () => undefined }),
- ),
- Effect.provide(FetchHttpClient.layer),
- Effect.provideService(
- FileSystem.FileSystem,
- FileSystem.makeNoop({
- exists: () => Effect.succeed(true),
- readFileString: () =>
- Effect.succeed(
- encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }),
- ),
- }),
- ),
- Effect.provide(
- Layer.mock(VcsProcess.VcsProcess)({
- run: (input) => {
- if (input.command === "git") {
- assert.deepStrictEqual(input.args, ["remote", "-v"]);
- return Effect.succeed(
- processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"),
- );
- }
- assert.strictEqual(input.command, "fj");
- assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]);
- return Effect.succeed(processOutput(""));
- },
- }),
- ),
- );
-});
-
-it.effect("reads Forgejo checks without repository or viewer requests", () => {
- const paths: string[] = [];
- return Effect.gen(function* () {
- const provider = yield* ForgejoPullRequestProvider.make;
- const read = provider.getChangeRequestChecks;
- if (read === undefined) return yield* Effect.die("checks read missing");
- const result = yield* read({
- cwd: "/repo",
- repository: "acme/web",
- host: "forgejo.test",
- number: 1,
- });
- assert.strictEqual(result.state, "open");
- assert.strictEqual(result.checks[0]?.status, "failure");
- assert.deepStrictEqual(paths, [
- "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",
- ]);
- }).pipe(
- Effect.provide(
- Layer.mock(ForgejoCli.ForgejoCli)({
- api: (input) => {
- paths.push(input.path);
- assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head)/);
- 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,
- "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:00Z"}]`
- : "[]",
- ),
- );
- },
- }),
- ),
- );
-});
-
-it.effect.each([
- ["Ready", false, true, false, "mergeable"],
- ["Draft", true, false, true, "unknown"],
- ["Blocked", false, false, false, "unknown"],
- ["Unchecked", false, undefined, false, "unknown"],
- ["WIP: Legacy draft", undefined, false, true, "unknown"],
- ["[WIP] Legacy draft", undefined, false, true, "unknown"],
- ["WIP: Explicitly ready", false, false, false, "unknown"],
-] as const)(
- "reads Forgejo mergeability for %s (draft=%s, mergeable=%s) across list, summary and detail",
- ([title, draft, mergeable, isDraft, mergeability]) => {
- const pr = {
- number: 42,
- title,
- body: "",
- html_url: "https://forgejo.test/maria/project/pulls/42",
- user: { login: "maria" },
- state: "open",
- merged: false,
- ...(draft === undefined ? {} : { draft }),
- ...(mergeable === undefined ? {} : { mergeable }),
- 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: [],
- };
- return Effect.gen(function* () {
- const provider = yield* ForgejoPullRequestProvider.make;
- const readSummary = provider.getChangeRequestSummary;
- if (readSummary === undefined) return yield* Effect.die("summary read missing");
- const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 42 };
- const list = yield* provider.listChangeRequests({
- ...input,
- state: "open",
- involvement: "all",
- viewer: "maria",
- limit: 10,
- });
- const summary = yield* readSummary(input);
- const detail = yield* provider.getChangeRequest(input);
- assert.strictEqual(list.items.length, 1);
- for (const result of [list.items[0]!, summary, detail]) {
- assert.strictEqual(result.mergeability, mergeability);
- assert.strictEqual(result.isDraft, isDraft);
- }
- }).pipe(
- Effect.provide(
- Layer.mock(ForgejoCli.ForgejoCli)({
- api: (input) => {
- const [path, query] = input.path.split("?");
- let response: unknown;
- switch (path) {
- case "repos/maria/project/pulls":
- response = new URLSearchParams(query).get("page") === "1" ? [pr] : [];
- break;
- case "repos/maria/project/pulls/42":
- response = pr;
- break;
- case "repos/maria/project":
- response = { full_name: "maria/project", permissions: { push: true, admin: true } };
- break;
- case "user":
- response = pr.user;
- break;
- case "repos/maria/project/statuses/head":
- response = [];
- break;
- default:
- return Effect.die(`Unexpected Forgejo request: ${input.path}`);
- }
- return encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput));
- },
- }),
- ),
- );
- },
-);
-
-it.effect("loads Forgejo pull request references from files and commits views", () =>
- Effect.gen(function* () {
- const provider = yield* ForgejoSourceControlProvider.make;
- for (const reference of [
- "42",
- "#42",
- "https://forgejo.test/maria/project/pulls/42",
- "https://forgejo.test/maria/project/pulls/42/",
- "https://forgejo.test/maria/project/pulls/42/files?w=1#diff-1",
- "http://forgejo.test:3000/git/maria/project/pulls/42/commits",
- ]) {
- const result = yield* provider.getChangeRequest({ cwd: "/repo", reference });
- assert.strictEqual(result.number, 42);
- assert.strictEqual(result.title, "Forgejo view reference");
- }
- const invalid = yield* provider
- .getChangeRequest({
- cwd: "/repo",
- reference: "https://forgejo.test/maria/project/pulls/42invalid/files",
- })
- .pipe(Effect.result);
- assert.strictEqual(invalid._tag, "Failure");
- }).pipe(
- Effect.provide(
- Layer.mergeAll(
- Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})),
- Layer.mock(VcsProcess.VcsProcess)({}),
- Layer.mock(ForgejoCli.ForgejoCli)({
- resolveRepository: () =>
- Effect.succeed({
- login: "work",
- repository: "maria/project",
- baseUrl: "https://forgejo.test",
- }),
- api: (input) => {
- assert.strictEqual(input.path, "repos/maria/project/pulls/42");
- return encodeJsonEffect({
- number: 42,
- title: "Forgejo view reference",
- html_url: "https://forgejo.test/maria/project/pulls/42",
- state: "open",
- merged: false,
- base: { ref: "main", sha: "base", repo: null },
- head: { ref: "feature", sha: "head", repo: null },
- }).pipe(Effect.orDie, Effect.map(processOutput));
- },
- }),
- ),
- ),
- ),
-);
-
-it.effect(
- "loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations",
- () => {
- const user = { login: "maria" };
- const review = {
- id: 8,
- body: "review body",
- user,
- state: "COMMENT",
- submitted_at: "2026-09-12T00:00:00Z",
- html_url: "https://forgejo.test/maria/project/pulls/2#issuecomment-37",
- comments_count: 1,
- };
- const comment = { id: 12, body: "ordinary", user, created_at: review.submitted_at };
- const responses: Record = {
- user,
- "repos/maria/project/issues/2/comments": [comment],
- "repos/maria/project/pulls/2/reviews": [review],
- "repos/maria/project/pulls/2/reviews/8": review,
- "repos/maria/project/pulls/2/reviews/9": { ...review, id: 9, html_url: "" },
- "repos/maria/project/pulls/2/commits": [],
- "repos/maria/project/issues/2/reactions": [],
- "repos/maria/project/pulls/2/reviews/8/comments": [
- {
- ...comment,
- id: 38,
- body: "inline",
- path: "file.ts",
- position: 1,
- original_position: 1,
- commit_id: "head",
- original_commit_id: "head",
- resolver: null,
- },
- ],
- "repos/maria/project/issues/comments/12/reactions": [{ content: "+1", user }],
- "repos/maria/project/issues/comments/37/reactions": [{ content: "heart", user }],
- "repos/maria/project/issues/comments/38/reactions": [
- { content: "rocket", user: { login: "reviewer" } },
- ],
- };
- const writes: ForgejoCli.ForgejoApiInput[] = [];
- let reactionReads = 0;
- return Effect.gen(function* () {
- const provider = yield* ForgejoPullRequestProvider.make;
- const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 2 };
- const activity = yield* provider.getChangeRequestActivity(input);
- assert.deepStrictEqual(
- activity.comments.map((entry) => ({
- id: entry.id,
- kind: entry.kind,
- reactions: entry.reactions,
- })),
- [
- {
- id: "12",
- kind: "issue-comment",
- reactions: [{ content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }],
- },
- {
- id: "review:8",
- kind: "review",
- reactions: [{ content: "heart", count: 1, actors: [], viewerHasReacted: true }],
- },
- {
- id: "38",
- kind: "review-comment",
- reactions: [
- { content: "rocket", count: 1, actors: ["reviewer"], viewerHasReacted: false },
- ],
- },
- ],
- );
- const inlineComment = activity.comments[2];
- assert.ok(inlineComment);
- assert.deepStrictEqual(activity.reviewThreads[0]?.comments, [inlineComment]);
- for (const reacted of [true, false]) {
- yield* provider.setReaction({ ...input, subjectId: "review:8", content: "heart", reacted });
- yield* provider.setReaction({ ...input, subjectId: "38", content: "rocket", reacted });
- }
- assert.deepStrictEqual(
- writes.map(({ path, method, body }) => ({ path, method, body })),
- [
- {
- path: "repos/maria/project/issues/comments/37/reactions",
- method: "POST",
- body: { content: "heart" },
- },
- {
- path: "repos/maria/project/issues/comments/38/reactions",
- method: "POST",
- body: { content: "rocket" },
- },
- {
- path: "repos/maria/project/issues/comments/37/reactions",
- method: "DELETE",
- body: { content: "heart" },
- },
- {
- path: "repos/maria/project/issues/comments/38/reactions",
- method: "DELETE",
- body: { content: "rocket" },
- },
- ],
- );
- const missing = yield* provider
- .setReaction({ ...input, subjectId: "review:9", content: "heart", reacted: true })
- .pipe(Effect.result);
- assert.strictEqual(missing._tag, "Failure");
- if (missing._tag === "Failure") assert.include(missing.failure.detail, "comment ID");
- assert.strictEqual(writes.length, 4);
- responses["repos/maria/project/pulls/2/reviews/8/comments"] = Array.from(
- { length: 501 },
- (_, index) => ({
- ...comment,
- id: 1000 + index,
- path: "file.ts",
- position: 1,
- original_position: 1,
- commit_id: "head",
- original_commit_id: "head",
- resolver: null,
- }),
- );
- for (let index = 0; index < 500; index++) {
- responses[`repos/maria/project/issues/comments/${1000 + index}/reactions`] = [];
- }
- reactionReads = 0;
- const bounded = yield* provider.getChangeRequestActivity(input);
- assert.strictEqual(bounded.reviewThreads.length, 500);
- assert.strictEqual(
- bounded.comments.filter((entry) => entry.kind === "review-comment").length,
- 500,
- );
- assert.strictEqual(bounded.commentsTruncated, true);
- assert.strictEqual(reactionReads, 502);
- }).pipe(
- Effect.provide(
- Layer.mock(ForgejoCli.ForgejoCli)({
- api: (input) => {
- if (input.method) {
- writes.push(input);
- return Effect.succeed(processOutput("{}"));
- }
- const path = input.path.split("?")[0]!;
- if (/\/issues\/comments\/\d+\/reactions$/.test(path)) reactionReads++;
- assert.ok(Object.hasOwn(responses, path), `Unexpected Forgejo request: ${path}`);
- const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page"));
- return encodeJsonEffect(page > 1 ? [] : responses[path]).pipe(
- Effect.orDie,
- Effect.map(processOutput),
- );
- },
- }),
- ),
- );
- },
-);
-
it.effect("reports implemented tools separately from locally available executables", () => {
const processMock = {
run: (input: VcsProcess.VcsProcessInput) => {
@@ -764,820 +336,6 @@ Logged in to gitlab.com as gitlab-user
}).pipe(Effect.provide(layerTest));
});
-it.effect("discovers Forgejo accounts and retains the server port", () =>
- Effect.gen(function* () {
- const auth = ForgejoSourceControlProvider.discovery.parseAuth(
- processOutput(
- yield* encodeJsonEffect([
- {
- name: "work",
- url: "http://forgejo.local:3000",
- ssh_host: "git.forgejo.local",
- user: "maria",
- default: "true",
- valid: "true",
- },
- ]),
- ),
- );
- assert.deepStrictEqual(
- firstNonEmptyLine("\u001b[1mtea version 0.16.0\u001b[0m\n"),
- Option.some("tea version 0.16.0"),
- );
- assert.strictEqual(auth.status, "authenticated");
- assert.deepStrictEqual(auth.account, Option.some("maria"));
- assert.deepStrictEqual(auth.host, Option.some("forgejo.local:3000"));
- const revoked = ForgejoSourceControlProvider.discovery.parseAuth(
- processOutput(
- encodeJson([
- {
- name: "work",
- url: "http://forgejo.local:3000",
- user: "maria",
- default: "true",
- valid: "false",
- },
- ]),
- ),
- );
- assert.strictEqual(revoked.status, "unauthenticated");
- const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
- cwd: "/repo",
- context: {
- provider: {
- kind: "unknown",
- name: "git.forgejo.local",
- baseUrl: "https://git.forgejo.local",
- },
- remoteName: "origin",
- remoteUrl: "git@git.forgejo.local:maria/project.git",
- },
- auth: processOutput(
- yield* encodeJsonEffect([
- {
- name: "work",
- url: "http://forgejo.local:3000",
- ssh_host: "git.forgejo.local",
- user: "maria",
- default: "true",
- },
- ]),
- ),
- });
- assert.deepStrictEqual(refined, {
- kind: "forgejo",
- name: "Forgejo / Gitea",
- baseUrl: "http://forgejo.local:3000",
- });
- }),
-);
-
-it.effect("does not choose a default Forgejo login across ambiguous SSH server ports", () =>
- Effect.gen(function* () {
- const logins = ForgejoCli.parseForgejoLogins(
- yield* encodeJsonEffect([
- {
- name: "one",
- url: "http://forgejo.local:3000",
- ssh_host: "forgejo.local",
- user: "maria",
- default: "true",
- },
- {
- name: "two",
- url: "http://forgejo.local:4000",
- ssh_host: "forgejo.local",
- user: "maria",
- default: "false",
- },
- ]),
- );
- const remote = ForgejoCli.parseForgejoRemote("git@forgejo.local:maria/project.git");
- assert.isNotNull(remote);
- assert.deepStrictEqual(
- ForgejoCli.parseForgejoRemote("forgejo.local:maria/project.git"),
- remote,
- );
- assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!));
- assert.strictEqual(
- ForgejoCli.matchForgejoLogin(logins, remote!, "forgejo.local:4000")?.name,
- "two",
- );
- assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!, "other.local:4000"));
- const alias = ForgejoCli.parseForgejoRemote("git@ssh.forgejo.local:maria/project.git");
- assert.isNotNull(alias);
- assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, alias!, "forgejo.local:4000"));
- const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
- cwd: "/repo",
- context: {
- provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" },
- remoteName: "origin",
- remoteUrl: "git@forgejo.local:maria/project.git",
- requestedHost: "forgejo.local:4000",
- },
- auth: processOutput(yield* encodeJsonEffect(logins)),
- });
- assert.strictEqual(refined?.baseUrl, "http://forgejo.local:4000");
- const https = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000/maria/project.git");
- assert.isNotNull(https);
- assert.strictEqual(ForgejoCli.matchForgejoLogin(logins, https!)?.name, "two");
- const hostOnly = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000");
- assert.strictEqual(
- ForgejoCli.matchForgejoLogin(logins, hostOnly!, undefined, true)?.name,
- "two",
- );
- const mounted = logins.map((login) => ({
- ...login,
- url: `http://forgejo.local:4000/${login.name}`,
- }));
- assert.isUndefined(ForgejoCli.matchForgejoLogin(mounted, hostOnly!, undefined, true));
- }),
-);
-
-it.effect("rejects HTTP failures even when tea exits successfully", () =>
- Effect.gen(function* () {
- const cli = yield* ForgejoCli.make;
- const result = yield* cli
- .api({
- cwd: "/repo",
- repository: "http://forgejo.local:3000/maria/project",
- path: "repos/maria/project/pulls/42",
- method: "PATCH",
- body: { state: "closed" },
- })
- .pipe(Effect.result);
- assert.strictEqual(result._tag, "Failure");
- if (result._tag === "Failure")
- assert.strictEqual(
- result.failure.detail,
- "Forgejo repository or pull request was not found.",
- );
- }).pipe(
- Effect.provideService(
- FileSystem.FileSystem,
- FileSystem.makeNoop({
- exists: () => Effect.succeed(false),
- }),
- ),
- Effect.provideService(
- HttpClient.HttpClient,
- HttpClient.make(() => {
- throw new Error("tea must handle its own HTTP request");
- }),
- ),
- Effect.provide(
- Layer.mock(VcsProcess.VcsProcess)({
- run: (input) => {
- if (input.args[0] === "api") {
- assert.strictEqual(input.stdin, '{"state":"closed"}');
- assert.include(input.args, "work");
- assert.include(
- input.args,
- "http://forgejo.local:3000/api/v1/repos/maria/project/pulls/42",
- );
- }
- return Effect.succeed(
- input.args[0] === "login"
- ? processOutput(
- encodeJson([
- {
- name: "work",
- url: "http://forgejo.local:3000",
- ssh_host: "forgejo.local",
- user: "maria",
- default: "true",
- },
- ]),
- )
- : processOutput('{"message":"not found"}', { stderr: "HTTP/1.1 404 Not Found\n" }),
- );
- },
- }),
- ),
- ),
-);
-
-it.effect("routes mounted Forgejo repositories without repeating the mount in API paths", () =>
- Effect.gen(function* () {
- const cli = yield* ForgejoCli.make;
- const viewer = yield* cli.api({ cwd: "/upstream-only", host: "code.test", path: "user" });
- assert.strictEqual(viewer.stdout, "[]");
- const mountedRepository = yield* cli.resolveRepository({
- cwd: "/upstream-only",
- host: "code.test",
- repository: "maria/project",
- });
- assert.strictEqual(mountedRepository.baseUrl, "https://code.test/forgejo");
- assert.strictEqual(mountedRepository.repository, "maria/project");
- for (const path of [
- "repos/forgejo/maria/project/pulls?state=open",
- "repos/forgejo/maria/project",
- "repos/reviewer/project/contents/file.ts",
- ]) {
- const result = yield* cli.api({
- cwd: "/repo",
- repository: "forgejo/maria/project",
- context: {
- provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" },
- remoteName: "origin",
- remoteUrl: "https://code.test/forgejo/maria/project.git",
- },
- path,
- });
- assert.strictEqual(result.stdout, "[]");
- }
- const sameOwnerAsMount = yield* cli.resolveRepository({
- cwd: "/repo",
- repository: "forgejo/project",
- context: {
- provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" },
- remoteName: "origin",
- remoteUrl: "ssh://git@code.test/forgejo/project.git",
- },
- });
- assert.strictEqual(sameOwnerAsMount.command, "tea");
- assert.strictEqual(sameOwnerAsMount.repository, "forgejo/project");
- }).pipe(
- Effect.provideService(
- FileSystem.FileSystem,
- FileSystem.makeNoop({
- exists: () => Effect.succeed(true),
- readFileString: () =>
- Effect.succeed(
- encodeJson({
- hosts: { "code.test/forgejo": { type: "Application", token: "test-token" } },
- }),
- ),
- }),
- ),
- Effect.provideService(
- HttpClient.HttpClient,
- HttpClient.make(() => {
- throw new Error("tea must handle its own HTTP request");
- }),
- ),
- Effect.provide(
- Layer.mock(VcsProcess.VcsProcess)({
- run: (input) => {
- if (input.command === "git")
- return Effect.succeed(processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }));
- if (input.args[0] === "login")
- return Effect.succeed(
- processOutput(
- encodeJson([
- {
- name: "mounted",
- url: "https://code.test/forgejo",
- ssh_host: "code.test",
- user: "maria",
- default: "true",
- },
- ]),
- ),
- );
- const supported = [
- "https://code.test/forgejo/api/v1/user",
- "https://code.test/forgejo/api/v1/repos/maria/project/pulls?state=open",
- "https://code.test/forgejo/api/v1/repos/maria/project",
- "https://code.test/forgejo/api/v1/repos/reviewer/project/contents/file.ts",
- ];
- if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo");
- assert.strictEqual(input.command, "tea");
- return Effect.succeed(
- supported.includes(input.args.at(-1) ?? "")
- ? processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" })
- : processOutput("{}", { stderr: "HTTP/1.1 404 Not Found\n" }),
- );
- },
- }),
- ),
- ),
-);
-
-it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => {
- const commands: string[] = [];
- const requests: string[] = [];
- return Effect.gen(function* () {
- const cli = yield* ForgejoCli.make;
- for (const remoteUrl of [
- "http://forgejo.local:3000/maria/project.git",
- "ssh://git@ssh.forgejo.local:2222/maria/project.git",
- "ssh://git@forgejo.local:2222/maria/project.git",
- ]) {
- const result = yield* cli.api({
- cwd: "/repo",
- repository: "maria/project",
- context: {
- provider: {
- kind: "forgejo",
- name: "Forgejo",
- baseUrl: "http://forgejo.local:3000",
- },
- remoteName: "origin",
- remoteUrl,
- requestedHost: "forgejo.local:3000",
- },
- path: "repos/maria/project/issues/42/comments",
- method: "POST",
- body: { body: "verified through fj" },
- });
- assert.strictEqual(result.stdout, '{"id":99}');
- }
- assert.deepStrictEqual(commands, ["fj"]);
- assert.deepStrictEqual(requests, [
- "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
- "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
- "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
- ]);
- const viewer = yield* cli.api({
- cwd: "/no-remotes",
- host: "forgejo.local:3000",
- path: "user",
- });
- assert.strictEqual(viewer.stdout, '{"login":"maria"}');
- assert.strictEqual(requests.at(-1), "https://forgejo.local:3000/api/v1/user");
- const upstreamViewer = yield* cli.api({
- cwd: "/upstream-only",
- host: "forgejo.local:3000",
- path: "user",
- });
- assert.strictEqual(upstreamViewer.stdout, '{"login":"maria"}');
- assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user");
- const upstreamRepository = yield* cli.resolveRepository({
- cwd: "/upstream-only",
- host: "forgejo.local:3000",
- repository: "maria/project",
- });
- assert.strictEqual(upstreamRepository.baseUrl, "http://forgejo.local:3000");
- assert.strictEqual(upstreamRepository.repository, "maria/project");
- const httpViewer = yield* cli.api({ cwd: "/repo", host: "forgejo.local:3000", path: "user" });
- assert.strictEqual(httpViewer.stdout, '{"login":"maria"}');
- assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user");
- }).pipe(
- Effect.provideService(
- FileSystem.FileSystem,
- FileSystem.makeNoop({
- exists: () => Effect.succeed(true),
- readFileString: () =>
- Effect.succeed(
- encodeJson({
- hosts: {
- "forgejo.local:3000": { type: "Application", token: "test-token" },
- "forgejo.local:4000": { type: "Application", token: "other-token" },
- },
- aliases: { "ssh.forgejo.local:2222": "forgejo.local:3000" },
- }),
- ),
- }),
- ),
- Effect.provideService(
- HttpClient.HttpClient,
- HttpClient.make((request) => {
- requests.push(request.url);
- if (request.url.endsWith("/user")) {
- assert.strictEqual(request.method, "GET");
- assert.strictEqual(request.headers.authorization, "token test-token");
- return Effect.succeed(
- HttpClientResponse.fromWeb(request, new Response('{"login":"maria"}')),
- );
- }
- assert.strictEqual(request.method, "POST");
- assert.strictEqual(request.headers.authorization, "token test-token");
- assert.strictEqual(request.body._tag, "Uint8Array");
- if (request.body._tag === "Uint8Array")
- assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), {
- body: "verified through fj",
- });
- return Effect.succeed(
- HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })),
- );
- }),
- ),
- Effect.provide(
- Layer.mock(VcsProcess.VcsProcess)({
- run: (input) => {
- commands.push(input.command);
- if (input.command === "git")
- return Effect.succeed(
- input.cwd === "/no-remotes"
- ? processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) })
- : processOutput(
- `${input.cwd === "/upstream-only" ? "upstream" : "origin"}\thttp://forgejo.local:3000/maria/project.git (fetch)\nother\thttp://forgejo.local:3000/maria/other.git (fetch)\nunrelated\thttp://other.local:3000/maria/project.git (fetch)`,
- ),
- );
- assert.strictEqual(input.command, "fj");
- assert.deepStrictEqual(input.args, [
- "--host",
- input.cwd === "/no-remotes"
- ? "https://forgejo.local:3000"
- : "http://forgejo.local:3000",
- "whoami",
- ]);
- return Effect.succeed(processOutput(""));
- },
- }),
- ),
- );
-});
-
-it.effect("loads later fj review pages when the server caps pages below the requested size", () => {
- const pages: number[] = [];
- let issueCommentRequests = 0;
- return Effect.gen(function* () {
- const cli = yield* ForgejoCli.make;
- const provider = yield* ForgejoPullRequestProvider.make.pipe(
- Effect.provideService(ForgejoCli.ForgejoCli, cli),
- );
- const activity = yield* provider.getChangeRequestActivity({
- cwd: "/repo",
- repository: "maria/project",
- host: "forgejo.test",
- number: 42,
- });
- assert.strictEqual(activity.commentCount, 42);
- assert.strictEqual(activity.comments.at(-1)?.id, "review:41");
- assert.strictEqual(activity.commentsTruncated, false);
- assert.deepStrictEqual(pages, [1, 2, 3]);
- assert.strictEqual(issueCommentRequests, 1);
- }).pipe(
- Effect.provideService(
- FileSystem.FileSystem,
- FileSystem.makeNoop({
- exists: () => Effect.succeed(true),
- readFileString: () =>
- Effect.succeed(
- encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }),
- ),
- }),
- ),
- Effect.provideService(
- HttpClient.HttpClient,
- HttpClient.make((request) => {
- const url = new URL(request.url);
- if (url.pathname === "/api/v1/repos/maria/project/issues/42/comments") {
- issueCommentRequests++;
- return Effect.succeed(
- HttpClientResponse.fromWeb(
- request,
- new Response(
- encodeJson([
- {
- id: 100,
- body: "Unpaginated issue comment",
- user: { login: "maria" },
- created_at: "2026-09-13T00:00:00Z",
- },
- ]),
- ),
- ),
- );
- }
- if (url.pathname.endsWith("/reviews")) {
- const page = Number(url.searchParams.get("page"));
- pages.push(page);
- assert.ok(page >= 1 && page <= 3);
- const reviews = Array.from({ length: page < 3 ? 20 : 1 }, (_, index) => ({
- id: (page - 1) * 20 + index + 1,
- body: "Review from a capped page",
- user: { login: "maria" },
- state: "COMMENT",
- submitted_at: "2026-09-13T00:00:00Z",
- comments_count: 0,
- }));
- return Effect.succeed(
- HttpClientResponse.fromWeb(
- request,
- new Response(encodeJson(reviews), {
- headers:
- page === 2
- ? {}
- : {
- Link:
- page === 1
- ? `<${url.origin}${url.pathname}?limit=50&page=2>; rel="next"`
- : `<${url.origin}${url.pathname}?limit=50&page=1>; rel="prev"`,
- },
- }),
- ),
- );
- }
- return Effect.succeed(
- HttpClientResponse.fromWeb(
- request,
- new Response(encodeJson(url.pathname === "/api/v1/user" ? { login: "maria" } : [])),
- ),
- );
- }),
- ),
- Effect.provide(
- Layer.mock(VcsProcess.VcsProcess)({
- run: (input) => {
- if (input.command === "git") {
- assert.deepStrictEqual(input.args, ["remote", "-v"]);
- return Effect.succeed(
- processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"),
- );
- }
- assert.strictEqual(input.command, "fj");
- assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]);
- return Effect.succeed(processOutput(""));
- },
- }),
- ),
- );
-});
-
-it.effect("falls back to tea when fj is missing or has no account for this server", () =>
- Effect.gen(function* () {
- for (const scenario of ["missing-cli", "missing-account", "stale-invalid-storage"] as const) {
- const commands: string[] = [];
- yield* Effect.gen(function* () {
- const cli = yield* ForgejoCli.make;
- const result = yield* cli.api({
- cwd: "/repo",
- repository: "https://forgejo.local:3000/maria/project",
- path: "repos/maria/project/pulls",
- });
- assert.strictEqual(result.stdout, "[]");
- assert.deepStrictEqual(
- commands,
- scenario === "missing-account" ? ["tea", "tea"] : ["fj", "tea", "tea"],
- );
- const viewer = yield* cli.api({
- cwd: "/upstream-only",
- host: "forgejo.local:3000",
- path: "user",
- });
- assert.strictEqual(viewer.stdout, "[]");
- }).pipe(
- Effect.provideService(
- FileSystem.FileSystem,
- FileSystem.makeNoop({
- exists: () => Effect.succeed(true),
- readFileString: () =>
- Effect.succeed(
- scenario === "stale-invalid-storage"
- ? "invalid json"
- : encodeJson({
- hosts: {
- [scenario === "missing-cli" ? "forgejo.local:3000" : "other.local"]: {
- type: "Application",
- token: "test-token",
- },
- },
- }),
- ),
- }),
- ),
- Effect.provideService(
- HttpClient.HttpClient,
- HttpClient.make(() => {
- throw new Error("tea must handle its own HTTP request");
- }),
- ),
- Effect.provide(
- Layer.mock(VcsProcess.VcsProcess)({
- run: (input) => {
- commands.push(input.command);
- if (input.command === "git")
- return Effect.succeed(
- processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }),
- );
- if (input.command === "fj")
- return Effect.fail(
- new VcsProcessSpawnError({
- operation: input.operation,
- command: input.command,
- cwd: input.cwd,
- cause: new Error("fj not found"),
- }),
- );
- assert.strictEqual(input.command, "tea");
- if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo");
- return Effect.succeed(
- input.args[0] === "login"
- ? processOutput(
- encodeJson([
- {
- name: "work",
- url: "https://forgejo.local:3000",
- user: "maria",
- default: "true",
- valid: "true",
- },
- ]),
- )
- : processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }),
- );
- },
- }),
- ),
- );
- }
- }),
-);
-
-it.effect("handles fj mutation statuses without retrying failures or reading absent bodies", () =>
- Effect.gen(function* () {
- for (const status of [204, 205, 302, 401, 403, 404, 429, 500]) {
- let writes = 0;
- yield* Effect.gen(function* () {
- const cli = yield* ForgejoCli.make;
- const result = yield* cli
- .api({
- cwd: "/repo",
- repository: "https://forgejo.local/maria/project",
- path: "repos/maria/project/issues/42/comments",
- method: "POST",
- body: { body: "only once" },
- })
- .pipe(Effect.result);
- assert.strictEqual(result._tag, status < 300 ? "Success" : "Failure");
- if (result._tag === "Success") assert.strictEqual(result.success.stdout, "");
- if (result._tag === "Failure") {
- assert.strictEqual(result.failure.command, "fj");
- assert.strictEqual(result.failure.httpStatus, status);
- }
- assert.strictEqual(writes, 1);
- }).pipe(
- Effect.provideService(
- FileSystem.FileSystem,
- FileSystem.makeNoop({
- exists: () => Effect.succeed(true),
- readFileString: () =>
- Effect.succeed(
- encodeJson({
- hosts: {
- "forgejo.local": { type: "Application", token: "test-token" },
- },
- }),
- ),
- }),
- ),
- Effect.provideService(
- HttpClient.HttpClient,
- HttpClient.make((request) => {
- writes++;
- assert.strictEqual(
- request.url,
- "https://forgejo.local/api/v1/repos/maria/project/issues/42/comments",
- );
- return Effect.succeed(
- HttpClientResponse.fromWeb(
- request,
- new Response(status < 300 ? null : "", {
- status,
- headers: { location: "https://other.local/" },
- }),
- ),
- );
- }),
- ),
- Effect.provide(
- Layer.mock(VcsProcess.VcsProcess)({
- run: (input) => {
- assert.strictEqual(
- input.command,
- "fj",
- "a failed mutation must never switch accounts or CLI",
- );
- return Effect.succeed(processOutput(""));
- },
- }),
- ),
- );
- }
- }),
-);
-
-it.effect(
- "discovers fj first and retains configured authentication failures instead of switching accounts",
- () =>
- Effect.gen(function* () {
- for (const scenario of ["authenticated", "revoked", "missing", "invalid-storage"] as const) {
- const commands: string[] = [];
- yield* Effect.gen(function* () {
- const spec = yield* ForgejoSourceControlProvider.makeDiscovery;
- assert.strictEqual(spec.type, "managed-cli");
- if (spec.type !== "managed-cli") return;
- const result = yield* spec.probe("/repo");
- assert.strictEqual(result.executable, scenario === "missing" ? "tea" : "fj");
- assert.strictEqual(
- result.auth.status,
- scenario === "revoked"
- ? "unauthenticated"
- : scenario === "invalid-storage"
- ? "unknown"
- : "authenticated",
- );
- if (scenario !== "revoked" && scenario !== "invalid-storage")
- assert.deepStrictEqual(result.auth.host, Option.some("forgejo.local:3000"));
- assert.deepStrictEqual(
- result.auth.account,
- scenario === "authenticated" || scenario === "missing"
- ? Option.some("maria")
- : Option.none(),
- );
- assert.strictEqual(
- commands.some((command) => command.startsWith("tea ")),
- scenario === "missing",
- );
- assert.include(commands, "fj version");
- }).pipe(
- Effect.provide(
- Layer.mergeAll(
- Layer.mock(ForgejoCli.ForgejoCli)({
- getAccount: (input) => {
- assert.strictEqual(scenario, "authenticated");
- assert.deepStrictEqual(input, {
- cwd: "/repo",
- baseUrl: "http://forgejo.local:3000",
- });
- return Effect.succeed("maria");
- },
- listLogins: (input) => {
- assert.strictEqual(
- input.remoteUrl,
- "http://forgejo.local:3000/maria/project.git",
- );
- if (scenario === "invalid-storage")
- return Effect.fail(
- new ForgejoCli.ForgejoCliError({
- command: "fj",
- cwd: input.cwd,
- reason: "authentication",
- detail: "fj authentication storage is invalid.",
- }),
- );
- return Effect.succeed([
- {
- name: "forgejo.local:3000",
- url: "http://forgejo.local:3000",
- user: "",
- default: "false",
- },
- ]);
- },
- }),
- Layer.mock(VcsProcess.VcsProcess)({
- run: (input) => {
- commands.push(`${input.command} ${input.args.join(" ")}`);
- if (input.command === "git")
- return Effect.succeed(
- processOutput("http://forgejo.local:3000/maria/project.git\n"),
- );
- if (input.command === "fj") {
- if (scenario === "missing")
- return Effect.fail(
- new VcsProcessSpawnError({
- operation: input.operation,
- command: input.command,
- cwd: input.cwd,
- cause: new Error("fj not found"),
- }),
- );
- if (input.args[0] === "version")
- return Effect.succeed(processOutput("fj 0.10.0"));
- if (scenario === "invalid-storage") {
- assert.deepStrictEqual(input.args, ["auth", "list"]);
- return Effect.succeed(processOutput(""));
- }
- assert.deepStrictEqual(input.args, [
- "--host",
- "http://forgejo.local:3000",
- "whoami",
- ]);
- return Effect.succeed(
- processOutput("", {
- exitCode: ChildProcessSpawner.ExitCode(scenario === "revoked" ? 1 : 0),
- }),
- );
- }
- assert.strictEqual(input.command, "tea");
- return Effect.succeed(
- input.args[0] === "--version"
- ? processOutput("tea version 0.16.0")
- : processOutput(
- encodeJson([
- {
- name: "work",
- url: "http://forgejo.local:3000",
- user: "maria",
- default: "true",
- valid: "true",
- },
- ]),
- ),
- );
- },
- }),
- ),
- ),
- );
- }
- }),
-);
-
it.effect(
"checks out fj pull refs and preserves existing branches and dirty files until forced",
() =>
@@ -1627,22 +385,26 @@ it.effect(
args: ["rev-parse", "HEAD"],
})).stdout.trim();
const fetched: string[] = [];
+ const host = yield* SourceControlHost.SourceControlHost;
const provider = yield* ForgejoSourceControlProvider.make.pipe(
Effect.provideService(
- VcsProcess.VcsProcess,
- VcsProcess.VcsProcess.of({
- run: (input) => {
- if (input.args[0] !== "fetch") return git.run(input);
- const url = input.args[2];
- assert.isDefined(url);
- fetched.push(url!);
- // Only SSH transport is substituted; both paths fetch the real pull ref.
- return git.run({
- ...input,
- args: input.args.map((arg) =>
- arg === "git@forgejo.test:reviewer/project.git" ? source : arg,
- ),
- });
+ SourceControlHost.SourceControlHost,
+ SourceControlHost.SourceControlHost.of({
+ settings: host.settings,
+ process: {
+ run: (input) => {
+ if (input.args[0] !== "fetch") return git.run(input);
+ const url = input.args[2];
+ assert.isDefined(url);
+ fetched.push(url!);
+ // Only SSH transport is substituted; both paths fetch the real pull ref.
+ return git.run({
+ ...input,
+ args: input.args.map((arg) =>
+ arg === "git@forgejo.test:reviewer/project.git" ? source : arg,
+ ),
+ });
+ },
},
}),
),
@@ -1759,6 +521,12 @@ it.effect(
assert.deepStrictEqual(fetched, [source, source, "git@forgejo.test:reviewer/project.git"]);
}).pipe(
Effect.scoped,
- Effect.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))),
+ Effect.provide(
+ ServerSourceControlHost.layer.pipe(
+ Layer.provideMerge(VcsProcess.layer),
+ Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()),
+ Layer.provideMerge(NodeServices.layer),
+ ),
+ ),
),
);
diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts
index 08a7259eb1e1fed88d441caccf736a4a804d784f..2e2969488d3d845636888ded014bc9b2d08ee25e 100644
--- a/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts
+++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts
@@ -19,7 +19,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as GitHubApi from "./GitHubApi.ts";
import * as GitHubCredentials from "./GitHubCredentials.ts";
import * as GitLabCli from "./GitLabCli.ts";
-import * as ForgejoCli from "./ForgejoCli.ts";
+import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
diff --git a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts
index 309f79b1509fca3f63346f8578b74b16e5f8ded2..22516853b836303488003b74c479ca32d1d262c5 100644
--- a/apps/server/src/sourceControl/SourceControlProviderRegistry.ts
+++ b/apps/server/src/sourceControl/SourceControlProviderRegistry.ts
@@ -15,7 +15,7 @@ import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlPro
import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
-import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
+import * as BuiltInDrivers from "./builtInDrivers.ts";
import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
import {
probeSourceControlProvider,
@@ -308,11 +308,18 @@ export const make = Effect.gen(function* () {
const github = yield* GitHubSourceControlProvider.make;
const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery;
const gitlab = yield* GitLabSourceControlProvider.make;
- const forgejo = yield* ForgejoSourceControlProvider.make;
- const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery;
const bitbucket = yield* BitbucketSourceControlProvider.make;
const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;
const azureDevOps = yield* AzureDevOpsSourceControlProvider.make;
+ const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
+ driver.make.pipe(
+ Effect.map((instance): SourceControlProviderRegistration => ({
+ kind: driver.kind,
+ provider: instance.sourceControl,
+ discovery: instance.discovery,
+ })),
+ ),
+ );
return yield* makeWithProviders([
{
kind: "github",
@@ -334,7 +341,7 @@ export const make = Effect.gen(function* () {
provider: bitbucket,
discovery: bitbucketDiscovery,
},
- { kind: "forgejo", provider: forgejo, discovery: forgejoDiscovery },
+ ...drivers,
]);
});
diff --git a/apps/server/src/sourceControl/builtInDrivers.ts b/apps/server/src/sourceControl/builtInDrivers.ts
new file mode 100644
index 0000000000000000000000000000000000000000..c49319ba0a1f8ff7c920dd4bc1f5f82774cdd27b
--- /dev/null
+++ b/apps/server/src/sourceControl/builtInDrivers.ts
@@ -0,0 +1,20 @@
+/**
+ * The source control drivers this build ships with. Both registries, repository operations and
+ * pull requests, iterate this list; a host with no driver here shows up as unsupported.
+ *
+ * Adding a host means writing its `@t3tools/source-control-` package, adding its driver
+ * here, and providing its services' layers in `layer` below.
+ *
+ * @module sourceControl/builtInDrivers
+ */
+import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
+import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver";
+import * as Layer from "effect/Layer";
+
+import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
+
+/** Ordered as the hosts appear in discovery, after the ones still built in the server. */
+export const BUILT_IN_SOURCE_CONTROL_DRIVERS = [ForgejoDriver.driver] as const;
+
+/** The services the built-in drivers' packages own, plus the host port they all run against. */
+export const layer = ForgejoCli.layer.pipe(Layer.provideMerge(ServerSourceControlHost.layer));
diff --git a/apps/server/src/sourceControl/forgejoPullRequests.ts b/apps/server/src/sourceControl/forgejoPullRequests.ts
deleted file mode 100644
index affb9bae74c7de80ae0e07fd6a631ab0285b7d6a..0000000000000000000000000000000000000000
--- a/apps/server/src/sourceControl/forgejoPullRequests.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import * as Schema from "effect/Schema";
-import * as Option from "effect/Option";
-import type { ChangeRequest } from "@t3tools/contracts";
-
-const Repository = Schema.Struct({
- full_name: Schema.String,
- owner: Schema.Struct({ login: Schema.String }),
-});
-const Branch = Schema.Struct({
- ref: Schema.String,
- sha: Schema.String,
- repo: Schema.NullOr(Repository),
-});
-export const ForgejoPullRequestSchema = Schema.Struct({
- number: Schema.Int,
- title: Schema.String,
- html_url: Schema.String,
- state: Schema.String,
- merged: Schema.Boolean,
- draft: Schema.optional(Schema.Boolean),
- base: Branch,
- head: Branch,
- closed_at: Schema.optional(Schema.NullOr(Schema.String)),
- merged_at: Schema.optional(Schema.NullOr(Schema.String)),
- updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
-});
-export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type): ChangeRequest {
- return {
- provider: "forgejo",
- number: raw.number,
- title: raw.title,
- url: raw.html_url,
- state: raw.merged ? "merged" : raw.state === "closed" ? "closed" : "open",
- isDraft: raw.draft ?? /^(?:\[WIP\]|WIP:)/i.test(raw.title),
- baseRefName: raw.base.ref,
- headRefName: raw.head.ref,
- closedAt: raw.closed_at ?? null,
- mergedAt: raw.merged_at ?? null,
- updatedAt: raw.updated_at ?? Option.none(),
- isCrossRepository:
- raw.head.repo !== null &&
- raw.base.repo !== null &&
- raw.head.repo.full_name !== raw.base.repo.full_name,
- headRepositoryNameWithOwner: raw.head.repo?.full_name ?? null,
- headRepositoryOwnerLogin: raw.head.repo?.owner.login ?? null,
- };
-}
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 666805e1d79eec32d230a2219eaea775c9f10795..d3e27ea0bc81267657b86b8779993b6f5c84fca7 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -233,9 +233,8 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
import * as GitHubApi from "./sourceControl/GitHubApi.ts";
import * as GitLabCli from "./sourceControl/GitLabCli.ts";
-import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
+import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
-import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts";
import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
@@ -3209,8 +3208,7 @@ export const layer = Layer.unwrap(
BitbucketApi.layer,
GitHubApi.layerWithDependencies,
GitLabCli.layer,
- ForgejoCli.layer,
- ServerSourceControlHost.layer,
+ SourceControlBuiltInDrivers.layer,
),
),
Layer.provideMerge(GitVcsDriver.layer),
diff --git a/package.json b/package.json
index 471e5361eef280ccc93f4fd3b69f51cd5911f805..ed9f936dfbf9e706532d18c4df9eea1e47bfedfa 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
"tc": "vp run -r --concurrency-limit 2 typecheck",
"lint": "vp lint --report-unused-disable-directives",
"knip": "knip --preprocessor ./scripts/knip-schemas.ts",
- "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
+ "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/server --workspace apps/desktop --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --workspace packages/provider-core --workspace packages/provider-acp --workspace packages/provider-acp-registry --workspace packages/provider-testing --workspace packages/provider-pi --workspace packages/provider-muse --workspace packages/provider-opencode --workspace packages/provider-cursor --workspace packages/provider-grok --workspace packages/source-control-core --workspace packages/source-control-forgejo --workspace packages/source-control-testing --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints",
"knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
"lint:mobile": "node scripts/mobile-native-static-check.ts",
"test": "vp run -r test",
diff --git a/packages/source-control-core/package.json b/packages/source-control-core/package.json
index 58742f9f3589bb23360ae5f27628217805467d2a..ee1045b278cd7abc1eb5b8473451f8a8a7de6b64 100644
--- a/packages/source-control-core/package.json
+++ b/packages/source-control-core/package.json
@@ -3,10 +3,18 @@
"private": true,
"type": "module",
"exports": {
+ "./server/diffFileRevisions": {
+ "types": "./src/server/diffFileRevisions.ts",
+ "import": "./src/server/diffFileRevisions.ts"
+ },
"./server/discovery": {
"types": "./src/server/discovery.ts",
"import": "./src/server/discovery.ts"
},
+ "./server/driver": {
+ "types": "./src/server/driver.ts",
+ "import": "./src/server/driver.ts"
+ },
"./server/mergeMessage": {
"types": "./src/server/mergeMessage.ts",
"import": "./src/server/mergeMessage.ts"
@@ -38,6 +46,7 @@
},
"dependencies": {
"@t3tools/contracts": "workspace:*",
+ "@t3tools/shared": "workspace:*",
"effect": "catalog:"
},
"devDependencies": {
diff --git a/packages/source-control-core/src/server/diffFileRevisions.test.ts b/packages/source-control-core/src/server/diffFileRevisions.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cd1790adf8bb5aaffcb007c517666166d72317ef
--- /dev/null
+++ b/packages/source-control-core/src/server/diffFileRevisions.test.ts
@@ -0,0 +1,280 @@
+import { assert, describe, it } from "@effect/vitest";
+
+import { parseDiffFileRevisions } from "./diffFileRevisions.ts";
+
+function patchOf(...lines: ReadonlyArray): string {
+ return `${lines.join("\n")}\n`;
+}
+
+describe("parseDiffFileRevisions", () => {
+ it("reads the head id of a changed file off its index line", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ "diff --git a/src/a.ts b/src/a.ts",
+ "index 7f2aa0ab6..b4a2a7c9a 100644",
+ "--- a/src/a.ts",
+ "+++ b/src/a.ts",
+ "@@ -1 +1 @@",
+ "-a",
+ "+b",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["src/a.ts", "b4a2a7c9a"]]);
+ });
+
+ it("names a deletion by the path it had, which is the one still on screen", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ "diff --git a/gone.ts b/gone.ts",
+ "deleted file mode 100644",
+ "index 1111111..0000000",
+ "--- a/gone.ts",
+ "+++ /dev/null",
+ "@@ -1 +0,0 @@",
+ "-x",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["gone.ts", "0000000"]]);
+ });
+
+ it("names a rename by where it moved to", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ "diff --git a/old.ts b/new.ts",
+ "similarity index 90%",
+ "rename from old.ts",
+ "rename to new.ts",
+ "index 2222222..3333333 100644",
+ "--- a/old.ts",
+ "+++ b/new.ts",
+ "@@ -1 +1 @@",
+ "-a",
+ "+b",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["new.ts", "3333333"]]);
+ });
+
+ it("names a rename that changed nothing, which states no paths of its own", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ "diff --git a/old.ts b/new.ts",
+ "similarity index 100%",
+ "rename from old.ts",
+ "rename to new.ts",
+ "index 2222222..2222222 100644",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["new.ts", "2222222"]]);
+ });
+
+ it("leaves out a file Bitbucket excluded, which it gives no index line for", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ "diff --git a/package.json b/package.json",
+ "index 7f2aa0ab6..b4a2a7c9a 100644",
+ "--- a/package.json",
+ "+++ b/package.json",
+ "@@ -1 +1 @@",
+ '- "x": "1"',
+ '+ "x": "2"',
+ "diff --git a/yarn.lock b/yarn.lock",
+ 'File excluded by pattern "yarn.lock"',
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["package.json", "b4a2a7c9a"]]);
+ });
+
+ it("stops reading headers at the first hunk, so content cannot pose as one", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ "diff --git a/notes.md b/notes.md",
+ "index aaaaaaa..bbbbbbb 100644",
+ "--- a/notes.md",
+ "+++ b/notes.md",
+ "@@ -1,2 +1,2 @@",
+ "--- a/decoy.ts",
+ "+++ b/decoy.ts",
+ "+index ccccccc..ddddddd 100644",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["notes.md", "bbbbbbb"]]);
+ });
+
+ it("splits a header whose paths contain the separator, by the sides agreeing", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ "diff --git a/one b/two.ts b/one b/two.ts",
+ "index eeeeeee..fffffff 100644",
+ "@@ -1 +1 @@",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["one b/two.ts", "fffffff"]]);
+ });
+
+ it("leaves out an added file that Bitbucket sent no index line for", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf("diff --git a/added.ts b/added.ts", "new file mode 100644", "--- /dev/null"),
+ );
+
+ assert.strictEqual(revisions.size, 0);
+ });
+
+ it("reads nothing out of an empty patch", () => {
+ assert.strictEqual(parseDiffFileRevisions("").size, 0);
+ });
+
+ it("reads a name git had to quote, here one holding a tab", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ 'diff --git "a/we\\tird.ts" "b/we\\tird.ts"',
+ "index 4444444..5555555 100644",
+ '--- "a/we\\tird.ts"',
+ '+++ "b/we\\tird.ts"',
+ "@@ -1 +1 @@",
+ "-a",
+ "+b",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["we\tird.ts", "5555555"]]);
+ });
+
+ it("rejoins the octal bytes git writes for a name outside ASCII", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ 'diff --git "a/caf\\303\\251/r\\303\\251sum\\303\\251.ts" "b/caf\\303\\251/r\\303\\251sum\\303\\251.ts"',
+ "index 6666666..7777777 100644",
+ "@@ -1 +1 @@",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["café/résumé.ts", "7777777"]]);
+ });
+
+ it("splits a rename header where git quoted only the side that needed it", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ 'diff --git a/old.ts "b/new\\tname.ts"',
+ "similarity index 90%",
+ "rename from old.ts",
+ 'rename to "new\\tname.ts"',
+ "index 8888888..9999999 100644",
+ "--- a/old.ts",
+ '+++ "b/new\\tname.ts"',
+ "@@ -1 +1 @@",
+ "-a",
+ "+b",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["new\tname.ts", "9999999"]]);
+ });
+
+ it("names a quoted rename that changed nothing, which states no paths of its own", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ 'diff --git "a/old\\tname.ts" "b/new\\tname.ts"',
+ "similarity index 100%",
+ 'rename from "old\\tname.ts"',
+ 'rename to "new\\tname.ts"',
+ "index abcabca..abcabca 100644",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["new\tname.ts", "abcabca"]]);
+ });
+
+ it("keeps a character from outside the basic plane that git left unescaped", () => {
+ // `core.quotePath` off leaves the name's own bytes in place, and git still quotes the header
+ // for the tab. Encoding what it left one unit at a time would split the pair into two halves.
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ 'diff --git "a/we\\tird-\u{1f680}.ts" "b/we\\tird-\u{1f680}.ts"',
+ "index aaaaaaa..bbbbbbb 100644",
+ "@@ -1 +1 @@",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["we\tird-\u{1f680}.ts", "bbbbbbb"]]);
+ });
+
+ it("drops the tab git ends a name holding a space with", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ "diff --git a/with space.txt b/with space.txt",
+ "index 1111111..6178079 100644",
+ "--- a/with space.txt\t",
+ "+++ b/with space.txt\t",
+ "@@ -1 +1 @@",
+ "-a",
+ "+b",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["with space.txt", "6178079"]]);
+ });
+
+ it("drops that tab from a quoted name too, where it lands past the closing quote", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ 'diff --git "a/we ird\\tname.ts" "b/we ird\\tname.ts"',
+ "index 2222222..7777777 100644",
+ '--- "a/we ird\\tname.ts"\t',
+ '+++ "b/we ird\\tname.ts"\t',
+ "@@ -1 +1 @@",
+ "-a",
+ "+b",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["we ird\tname.ts", "7777777"]]);
+ });
+
+ it("reads a deletion whose name holds a space, tab and all", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ "diff --git a/with space.txt b/with space.txt",
+ "deleted file mode 100644",
+ "index 3333333..0000000",
+ "--- a/with space.txt\t",
+ "+++ /dev/null",
+ "@@ -1 +0,0 @@",
+ "-a",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["with space.txt", "0000000"]]);
+ });
+
+ it("drops the timestamp other producers of the format write past that tab", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf(
+ "diff --git a/stamped.ts b/stamped.ts",
+ "index 4444444..5555555 100644",
+ "--- a/stamped.ts\t2024-01-01 00:00:00.000000000 +0000",
+ "+++ b/stamped.ts\t2024-01-02 00:00:00.000000000 +0000",
+ "@@ -1 +1 @@",
+ "-a",
+ "+b",
+ ),
+ );
+
+ assert.deepStrictEqual([...revisions], [["stamped.ts", "5555555"]]);
+ });
+
+ it("splits an unquoted header whose names hold a space, by the sides agreeing", () => {
+ const revisions = parseDiffFileRevisions(
+ patchOf("diff --git a/one two b/one two", "index ddddddd..eeeeeee 100644", "@@ -1 +1 @@"),
+ );
+
+ assert.deepStrictEqual([...revisions], [["one two", "eeeeeee"]]);
+ });
+});
diff --git a/packages/source-control-core/src/server/diffFileRevisions.ts b/packages/source-control-core/src/server/diffFileRevisions.ts
new file mode 100644
index 0000000000000000000000000000000000000000..135a23dfdff6fdc4593dd5148c6c0e2165366aa6
--- /dev/null
+++ b/packages/source-control-core/src/server/diffFileRevisions.ts
@@ -0,0 +1,129 @@
+import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
+
+const ENTRY = "diff --git ";
+const QUOTE = '"';
+
+interface Entry {
+ oldPath: string | null;
+ newPath: string | null;
+ deleted: boolean;
+ revision: string | null;
+ /** Past the first hunk header every line is content, and content can start like a header. */
+ inBody: boolean;
+}
+
+/** Where a quoted name closes, given git escapes every quote the name itself holds. */
+function quotedEnd(rest: string): number {
+ for (let at = 1; at < rest.length; at += 1) {
+ const char = rest.charAt(at);
+ if (char === "\\") {
+ at += 1;
+ continue;
+ }
+ if (char === QUOTE) return at;
+ }
+ return -1;
+}
+
+/**
+ * `a/x`/`b/x` on a `---`/`+++` line; `/dev/null` marks the side that has no file. Git ends the
+ * name with a tab when it holds a space, and a name with a tab of its own arrives quoted with
+ * that tab escaped, so the first literal tab is never part of what the file is called.
+ */
+function sidePath(rest: string, prefix: string): string | null {
+ const tab = rest.indexOf("\t");
+ const token = tab === -1 ? rest : rest.slice(0, tab);
+ if (token === "/dev/null") return null;
+ const path = unquoteGitPatchPath(token);
+ return path.startsWith(prefix) ? path.slice(prefix.length) : path;
+}
+
+function headerSide(token: string, prefix: string): string | null {
+ const path = unquoteGitPatchPath(token);
+ return path.startsWith(prefix) ? path.slice(prefix.length) : null;
+}
+
+/**
+ * The two names on a `diff --git` line, written with no delimiter between them. `a/one two b/one
+ * two` can split in more than one place, so the split leaving both sides equal wins; a rename
+ * (the only case where sides differ) states its names on separate lines instead. A quoted name
+ * ends at its own closing quote and needs none of that guessing.
+ */
+function headerPaths(rest: string): readonly [string | null, string | null] {
+ if (rest.startsWith(QUOTE)) {
+ const end = quotedEnd(rest);
+ if (end === -1 || rest.charAt(end + 1) !== " ") return [null, null];
+ return [headerSide(rest.slice(0, end + 1), "a/"), headerSide(rest.slice(end + 2), "b/")];
+ }
+ if (rest.endsWith(QUOTE)) {
+ const opens = rest.indexOf(QUOTE);
+ if (opens < 1 || rest.charAt(opens - 1) !== " ") return [null, null];
+ return [headerSide(rest.slice(0, opens - 1), "a/"), headerSide(rest.slice(opens), "b/")];
+ }
+ if (!rest.startsWith("a/")) return [null, null];
+ const splits: Array = [];
+ for (let at = rest.indexOf(" b/"); at !== -1; at = rest.indexOf(" b/", at + 1)) splits.push(at);
+ const chosen =
+ splits.find((at) => rest.slice(2, at) === rest.slice(at + 3)) ??
+ (splits.length === 1 ? splits[0] : undefined);
+ return chosen === undefined ? [null, null] : [rest.slice(2, chosen), rest.slice(chosen + 3)];
+}
+
+/** The right-hand id of `index .. `. */
+function headRevision(rest: string): string | null {
+ const gap = rest.indexOf("..");
+ if (gap === -1) return null;
+ const after = rest.slice(gap + 2);
+ const end = after.indexOf(" ");
+ const head = end === -1 ? after : after.slice(0, end);
+ return head.length === 0 ? null : head;
+}
+
+/**
+ * What the head has of each file, as the blob ids from a unified patch's `index ..`
+ * line: Bitbucket exposes no blob id for a file anywhere else. Keyed by the head's name, except
+ * for a deletion where only the old name exists. An entry with no `index` line (most often one
+ * Bitbucket excluded by pattern) is left out.
+ */
+export function parseDiffFileRevisions(patch: string): ReadonlyMap {
+ const revisions = new Map();
+ let entry: Entry | null = null;
+
+ const close = () => {
+ if (entry === null) return;
+ const path = entry.deleted ? entry.oldPath : (entry.newPath ?? entry.oldPath);
+ if (path !== null && path.length > 0 && entry.revision !== null) {
+ revisions.set(path, entry.revision);
+ }
+ entry = null;
+ };
+
+ for (const line of patch.split("\n")) {
+ if (line.startsWith(ENTRY)) {
+ close();
+ const [oldPath, newPath] = headerPaths(line.slice(ENTRY.length));
+ entry = { oldPath, newPath, deleted: false, revision: null, inBody: false };
+ continue;
+ }
+ if (entry === null || entry.inBody) continue;
+ if (line.startsWith("@@")) {
+ entry.inBody = true;
+ } else if (line.startsWith("index ")) {
+ entry.revision = headRevision(line.slice("index ".length));
+ } else if (line.startsWith("deleted file mode")) {
+ entry.deleted = true;
+ } else if (line.startsWith("rename from ")) {
+ entry.oldPath = unquoteGitPatchPath(line.slice("rename from ".length));
+ } else if (line.startsWith("rename to ")) {
+ entry.newPath = unquoteGitPatchPath(line.slice("rename to ".length));
+ } else if (line.startsWith("--- ")) {
+ entry.oldPath = sidePath(line.slice(4), "a/");
+ } else if (line.startsWith("+++ ")) {
+ const side = sidePath(line.slice(4), "b/");
+ entry.newPath = side;
+ if (side === null) entry.deleted = true;
+ }
+ }
+ close();
+ return revisions;
+}
diff --git a/packages/source-control-core/src/server/driver.ts b/packages/source-control-core/src/server/driver.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3a227d0928d8b648858a2b023b10cd902b66bb9d
--- /dev/null
+++ b/packages/source-control-core/src/server/driver.ts
@@ -0,0 +1,36 @@
+/**
+ * SourceControlDriver — what a source control provider package hands the server.
+ *
+ * One driver per host kind. The server lists its drivers once and both registries (repository
+ * operations and pull requests) iterate that list, so adding a host means writing its package
+ * and adding its driver to the list.
+ *
+ * `make` yields what it needs from the environment: the `SourceControlHost` port, Effect
+ * platform services, and the package's own services, which the server provides from the
+ * package's layers. Its `R` channel is the full list.
+ *
+ * @module source-control-core/server/driver
+ */
+import type { SourceControlProviderKind } from "@t3tools/contracts";
+import type * as Effect from "effect/Effect";
+
+import type { SourceControlProviderDiscoverySpec } from "./discovery.ts";
+import type { PullRequestProviderApi } from "./PullRequestProvider.ts";
+import type * as SourceControlProvider from "./SourceControlProvider.ts";
+
+export interface SourceControlDriverInstance {
+ readonly sourceControl: SourceControlProvider.SourceControlProvider["Service"];
+ readonly discovery: SourceControlProviderDiscoverySpec;
+ /** Null for a host whose pull requests this build cannot read. */
+ readonly pullRequests: PullRequestProviderApi | null;
+}
+
+export interface SourceControlDriver {
+ readonly kind: SourceControlProviderKind;
+ readonly make: Effect.Effect;
+}
+
+/** Declares a driver with its `R` inferred from `make`. */
+export const defineSourceControlDriver = (
+ driver: SourceControlDriver,
+): SourceControlDriver => driver;
diff --git a/packages/source-control-forgejo/package.json b/packages/source-control-forgejo/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..4c6b534e2e81b4356a284be53796c5bdf8c918cb
--- /dev/null
+++ b/packages/source-control-forgejo/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "@t3tools/source-control-forgejo",
+ "private": true,
+ "type": "module",
+ "exports": {
+ "./server/driver": {
+ "types": "./src/server/driver.ts",
+ "import": "./src/server/driver.ts"
+ },
+ "./server/ForgejoCli": {
+ "types": "./src/server/ForgejoCli.ts",
+ "import": "./src/server/ForgejoCli.ts"
+ },
+ "./server/forgejoPullRequestJson": {
+ "types": "./src/server/forgejoPullRequestJson.ts",
+ "import": "./src/server/forgejoPullRequestJson.ts"
+ },
+ "./server/ForgejoPullRequestProvider": {
+ "types": "./src/server/ForgejoPullRequestProvider.ts",
+ "import": "./src/server/ForgejoPullRequestProvider.ts"
+ },
+ "./server/forgejoPullRequests": {
+ "types": "./src/server/forgejoPullRequests.ts",
+ "import": "./src/server/forgejoPullRequests.ts"
+ },
+ "./server/ForgejoSourceControlProvider": {
+ "types": "./src/server/ForgejoSourceControlProvider.ts",
+ "import": "./src/server/ForgejoSourceControlProvider.ts"
+ }
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit",
+ "test": "vp test run --config ../../vite.config.ts --dir ."
+ },
+ "dependencies": {
+ "@t3tools/contracts": "workspace:*",
+ "@t3tools/provider-core": "workspace:*",
+ "@t3tools/shared": "workspace:*",
+ "@t3tools/source-control-core": "workspace:*",
+ "effect": "catalog:"
+ },
+ "devDependencies": {
+ "@effect/vitest": "catalog:",
+ "@t3tools/source-control-testing": "workspace:*",
+ "@types/node": "catalog:",
+ "vite-plus": "catalog:"
+ }
+}
diff --git a/packages/source-control-forgejo/src/server/ForgejoCli.ts b/packages/source-control-forgejo/src/server/ForgejoCli.ts
new file mode 100644
index 0000000000000000000000000000000000000000..73b0098aa40344876b0878f98e9485303b9a5e24
--- /dev/null
+++ b/packages/source-control-forgejo/src/server/ForgejoCli.ts
@@ -0,0 +1,724 @@
+import * as Context from "effect/Context";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Schema from "effect/Schema";
+import * as Result from "effect/Result";
+import * as Clock from "effect/Clock";
+import * as FileSystem from "effect/FileSystem";
+import * as Semaphore from "effect/Semaphore";
+import * as NodeOS from "node:os";
+// @effect-diagnostics-next-line nodeBuiltinImport:off - fj storage paths use explicit Windows and POSIX layouts, independently of this process's platform.
+import * as NodePath from "node:path";
+import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
+import { ChildProcessSpawner } from "effect/process";
+import { decodeJsonResult } from "@t3tools/shared/schemaJson";
+import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
+import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
+import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
+import type { SourceControlProviderContext } from "@t3tools/source-control-core/server/SourceControlProvider";
+
+const encodeApiBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
+
+export class ForgejoCliError extends Schema.TaggedError()("ForgejoCliError", {
+ command: Schema.Literals(["fj", "tea"]),
+ cwd: Schema.String,
+ detail: Schema.String,
+ reason: Schema.optional(
+ Schema.Literals([
+ "missing-cli",
+ "authentication",
+ "forbidden",
+ "not-found",
+ "rate-limit",
+ "invalid-response",
+ ]),
+ ),
+ httpStatus: Schema.optional(Schema.Int),
+ cause: Schema.optional(Schema.Defect()),
+}) {}
+
+export const ForgejoLoginSchema = Schema.Struct({
+ name: Schema.String,
+ url: Schema.String,
+ ssh_host: Schema.optional(Schema.String),
+ valid: Schema.optional(Schema.String),
+ user: Schema.String,
+ default: Schema.String,
+});
+
+export function parseForgejoLogins(raw: string) {
+ const decoded = decodeJsonResult(Schema.Array(ForgejoLoginSchema))(raw);
+ return Result.isSuccess(decoded) ? decoded.success : [];
+}
+
+export const ForgejoKeysSchema = Schema.Struct({
+ hosts: Schema.Record(
+ Schema.String,
+ Schema.Struct({
+ type: Schema.Literals(["Application", "OAuth"]),
+ token: Schema.String,
+ }),
+ ),
+ aliases: Schema.optional(Schema.Record(Schema.String, Schema.String)),
+});
+
+const parseForgejoKeys = decodeJsonResult(ForgejoKeysSchema);
+
+/** Matches fj's directories::ProjectDirs, including its pre-0.6 organization name. */
+function forgejoKeysPaths(input: {
+ readonly platform: string;
+ readonly home: string;
+ readonly dataHome?: string;
+ readonly appData?: string;
+}) {
+ if (input.platform === "darwin")
+ return ["forgejo-cli", "Cyborus"].map((organization) =>
+ NodePath.join(
+ input.home,
+ "Library",
+ "Application Support",
+ `${organization}.forgejo-cli`,
+ "keys.json",
+ ),
+ );
+ if (input.platform === "win32")
+ return ["forgejo-cli", "Cyborus"].map((organization) =>
+ NodePath.win32.join(
+ input.appData || NodePath.win32.join(input.home, "AppData", "Roaming"),
+ organization,
+ "forgejo-cli",
+ "data",
+ "keys.json",
+ ),
+ );
+ return [
+ NodePath.join(
+ input.dataHome && NodePath.isAbsolute(input.dataHome)
+ ? input.dataHome
+ : NodePath.join(input.home, ".local", "share"),
+ "forgejo-cli",
+ "keys.json",
+ ),
+ ];
+}
+
+export interface ForgejoRepositoryInput {
+ readonly cwd: string;
+ readonly context?: SourceControlProviderContext;
+ readonly repository?: string;
+ readonly reference?: string;
+ readonly host?: string;
+}
+
+export interface ForgejoRepository {
+ readonly command?: "fj" | "tea";
+ readonly login: string;
+ readonly repository: string;
+ readonly baseUrl: string;
+}
+
+export interface ForgejoApiInput extends ForgejoRepositoryInput {
+ readonly path: string;
+ readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
+ readonly body?: unknown;
+}
+
+export class ForgejoCli extends Context.Service<
+ ForgejoCli,
+ {
+ readonly execute: (input: {
+ readonly command?: "fj" | "tea";
+ readonly cwd: string;
+ readonly args: ReadonlyArray;
+ readonly stdin?: string;
+ readonly timeoutMs?: number;
+ readonly maxOutputBytes?: number;
+ }) => Effect.Effect;
+ readonly listLogins?: (input: {
+ readonly cwd: string;
+ readonly command: "fj" | "tea";
+ readonly remoteUrl?: string;
+ }) => Effect.Effect, ForgejoCliError>;
+ readonly getAccount?: (input: {
+ readonly cwd: string;
+ readonly baseUrl: string;
+ }) => Effect.Effect;
+ readonly resolveRepository: (
+ input: ForgejoRepositoryInput,
+ ) => Effect.Effect;
+ readonly api: (
+ input: ForgejoApiInput,
+ ) => Effect.Effect;
+ }
+>()("@t3tools/source-control-forgejo/server/ForgejoCli") {}
+
+export function parseForgejoRemote(value: string) {
+ if (/^(?:https?|ssh):\/\//i.test(value)) {
+ try {
+ const url = new URL(value);
+ return {
+ host: url.host.toLowerCase(),
+ hostname: url.hostname.toLowerCase(),
+ ssh: url.protocol === "ssh:",
+ path: url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""),
+ };
+ } catch {
+ return null;
+ }
+ }
+ // SCP remotes may omit the username; URL treats these as a custom scheme.
+ const ssh = /^(?:[^@/]+@)?([^:/]+):([^/].*)$/.exec(value);
+ return ssh?.[1] && ssh[2]
+ ? {
+ host: ssh[1].toLowerCase(),
+ hostname: ssh[1].toLowerCase(),
+ ssh: true,
+ path: ssh[2].replace(/\.git$/, ""),
+ }
+ : null;
+}
+
+export function matchForgejoLogin(
+ logins: ReturnType,
+ remote: NonNullable>,
+ requestedHost?: string,
+ hostOnly = false,
+) {
+ const matches = [
+ ...new Map(
+ logins
+ .filter((login) => {
+ const url = parseForgejoRemote(login.url);
+ if (!url) return false;
+ if (requestedHost !== undefined && url.host !== requestedHost.toLowerCase()) return false;
+ return remote.ssh
+ ? login.ssh_host?.toLowerCase() === remote.host ||
+ login.ssh_host?.toLowerCase() === remote.hostname ||
+ url.hostname === remote.hostname
+ : url.host === remote.host &&
+ ((hostOnly && !remote.path) ||
+ !url.path ||
+ remote.path === url.path ||
+ remote.path.startsWith(`${url.path}/`));
+ })
+ .map((login) => [login.name, login]),
+ ).values(),
+ ];
+ return matches.length === 1
+ ? matches[0]
+ : new Set(matches.map((login) => login.url)).size === 1
+ ? matches.find((login) => login.default === "true")
+ : undefined;
+}
+
+export const make = Effect.gen(function* () {
+ const { process } = yield* SourceControlHost.SourceControlHost;
+ const fileSystem = yield* FileSystem.FileSystem;
+ const httpClient = yield* HttpClient.HttpClient;
+ const authLock = yield* Semaphore.make(1);
+ const authenticated = new Map();
+ const execute: ForgejoCli["Service"]["execute"] = (input) =>
+ process
+ .run({
+ ...input,
+ operation: "ForgejoCli.execute",
+ command: input.command ?? "tea",
+ timeoutMs: input.timeoutMs ?? 30_000,
+ })
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new ForgejoCliError({
+ command: input.command ?? "tea",
+ cwd: input.cwd,
+ ...(input.command === "fj" ? {} : { cause }),
+ ...(cause._tag === "VcsProcessSpawnError"
+ ? { reason: "missing-cli" as const }
+ : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication"
+ ? { reason: "authentication" as const }
+ : {}),
+ detail:
+ cause._tag === "VcsProcessSpawnError"
+ ? "Install Forgejo CLI (`fj` 0.6 or later) or Gitea CLI (`tea` 0.16 or later) and retry."
+ : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication"
+ ? "Authenticate this server with `fj auth login`, `fj auth add-token`, or `tea login add`."
+ : "Forgejo CLI command failed.",
+ }),
+ ),
+ );
+
+ const readKeys = Effect.fn("ForgejoCli.readKeys")(function* (cwd: string) {
+ for (const path of forgejoKeysPaths({
+ platform: yield* HostProcessPlatform,
+ home: NodeOS.homedir(),
+ ...(globalThis.process.env.XDG_DATA_HOME
+ ? { dataHome: globalThis.process.env.XDG_DATA_HOME }
+ : {}),
+ ...(globalThis.process.env.APPDATA ? { appData: globalThis.process.env.APPDATA } : {}),
+ })) {
+ const exists = yield* fileSystem.exists(path).pipe(
+ Effect.mapError(
+ () =>
+ new ForgejoCliError({
+ command: "fj",
+ cwd,
+ reason: "authentication",
+ detail: "Could not read fj authentication storage.",
+ }),
+ ),
+ );
+ if (!exists) continue;
+ const raw = yield* fileSystem.readFileString(path).pipe(
+ Effect.mapError(
+ () =>
+ new ForgejoCliError({
+ command: "fj",
+ cwd,
+ reason: "authentication",
+ detail: "Could not read fj authentication storage.",
+ }),
+ ),
+ );
+ const decoded = parseForgejoKeys(raw);
+ if (Result.isFailure(decoded))
+ return yield* new ForgejoCliError({
+ command: "fj",
+ cwd,
+ reason: "authentication",
+ detail: "fj authentication storage is invalid. Authenticate again with fj.",
+ });
+ return decoded.success;
+ }
+ const empty: typeof ForgejoKeysSchema.Type = { hosts: {}, aliases: {} };
+ return empty;
+ });
+
+ const publicLogins = (
+ keys: typeof ForgejoKeysSchema.Type,
+ remoteUrl?: string,
+ ): ReturnType => {
+ const remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null;
+ return Object.keys(keys.hosts).flatMap((host) => {
+ const url = parseForgejoRemote(`https://${host}`);
+ // fj 0.6 drops URL mounts during whoami and OAuth renewal; tea supports them.
+ if (!url || url.path) return [];
+ // fj omits the scheme in storage. Only an explicit matching HTTP remote opts into HTTP.
+ const scheme =
+ remote && !remote.ssh && remote.host === url.host && /^http:\/\//i.test(remoteUrl ?? "")
+ ? "http"
+ : "https";
+ const login = { name: host, url: `${scheme}://${host}`, user: "", default: "false" };
+ const aliases = Object.entries(keys.aliases ?? {})
+ .filter(([, target]) => target === host)
+ .map(([alias]) => ({ ...login, ssh_host: alias }));
+ return aliases.length ? aliases : [login];
+ });
+ };
+
+ const listLogins: NonNullable = Effect.fn(
+ "ForgejoCli.listLogins",
+ )(function* (input) {
+ if (input.command === "fj") {
+ const keys = yield* readKeys(input.cwd).pipe(Effect.result);
+ if (Result.isFailure(keys)) {
+ // Stale credentials from an uninstalled fj must not disable an available tea login.
+ const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe(
+ Effect.result,
+ );
+ if (Result.isFailure(available) && available.failure.reason === "missing-cli") return [];
+ return yield* keys.failure;
+ }
+ return publicLogins(keys.success, input.remoteUrl);
+ }
+ return parseForgejoLogins(
+ (yield* execute({ cwd: input.cwd, args: ["login", "list", "--output", "json"] })).stdout,
+ );
+ });
+
+ const requestFj = Effect.fn("ForgejoCli.requestFj")(
+ function* (input: {
+ readonly cwd: string;
+ readonly baseUrl: string;
+ readonly token: string;
+ readonly path: string;
+ readonly method?: ForgejoApiInput["method"];
+ readonly body?: string;
+ }) {
+ const base = new URL(`${input.baseUrl}/api/v1/`);
+ const url = new URL(input.path, base);
+ if (
+ url.origin !== base.origin ||
+ !url.pathname.startsWith(base.pathname) ||
+ url.username ||
+ url.password
+ )
+ return yield* new ForgejoCliError({
+ command: "fj",
+ cwd: input.cwd,
+ detail: "Invalid Forgejo API path.",
+ });
+ let request = HttpClientRequest.make(input.method ?? "GET")(url.toString()).pipe(
+ HttpClientRequest.setHeader("Authorization", `token ${input.token}`),
+ );
+ if (input.body !== undefined)
+ request = request.pipe(HttpClientRequest.bodyText(input.body, "application/json"));
+ const response = yield* httpClient
+ .execute(request)
+ .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }));
+ const status = response.status;
+ const body =
+ status === 204 || status === 205
+ ? { text: "", truncated: false, invalidUtf8: false }
+ : yield* collectUint8StreamText({
+ stream: response.stream,
+ maxBytes: 8 * 1024 * 1024,
+ });
+ if (body.truncated || body.invalidUtf8)
+ return yield* new ForgejoCliError({
+ command: "fj",
+ cwd: input.cwd,
+ reason: "invalid-response",
+ detail: "Forgejo returned an oversized or invalid response.",
+ });
+ if (status < 200 || status >= 300) {
+ const detail =
+ status === 404
+ ? "Forgejo repository or pull request was not found."
+ : body.text
+ ? `Forgejo API request failed (HTTP ${status}): ${body.text}`
+ : `Forgejo API request failed (HTTP ${status}). Check this server's fj credentials and permissions.`;
+ return yield* new ForgejoCliError({
+ command: "fj",
+ cwd: input.cwd,
+ httpStatus: status,
+ ...(status === 401
+ ? { reason: "authentication" as const }
+ : status === 403
+ ? { reason: "forbidden" as const }
+ : status === 404
+ ? { reason: "not-found" as const }
+ : status === 429
+ ? { reason: "rate-limit" as const }
+ : {}),
+ detail,
+ });
+ }
+ return {
+ exitCode: ChildProcessSpawner.ExitCode(0),
+ stdout: body.text,
+ stderr: `HTTP/1.1 ${status}\n${response.headers.link ? `link: ${response.headers.link}\n` : ""}`,
+ stdoutTruncated: false,
+ stderrTruncated: false,
+ };
+ },
+ (effect, input) =>
+ effect.pipe(
+ Effect.timeout(30_000),
+ Effect.mapError((error) =>
+ error._tag === "ForgejoCliError"
+ ? error
+ : new ForgejoCliError({
+ command: "fj",
+ cwd: input.cwd,
+ detail: "Forgejo API request failed or timed out.",
+ }),
+ ),
+ ),
+ );
+
+ const authenticateFj = Effect.fn("ForgejoCli.authenticateFj")(function* (
+ cwd: string,
+ login: typeof ForgejoLoginSchema.Type,
+ ) {
+ const keys = yield* readKeys(cwd);
+ const token = keys.hosts[login.name]?.token;
+ const now = yield* Clock.currentTimeMillis;
+ const cached = authenticated.get(login.url);
+ if (token && cached?.token === token && now - cached.time < 30_000) return token;
+ yield* execute({ command: "fj", cwd, args: ["--host", login.url, "whoami"] });
+ // fj owns OAuth renewal. Re-read the file after it has refreshed an expired token.
+ const refreshed = (yield* readKeys(cwd)).hosts[login.name]?.token;
+ if (!refreshed)
+ return yield* new ForgejoCliError({
+ command: "fj",
+ cwd,
+ reason: "authentication",
+ detail: "fj has no credentials for this server. Authenticate again with fj.",
+ });
+ authenticated.set(login.url, { token: refreshed, time: now });
+ return refreshed;
+ }, authLock.withPermits(1));
+
+ const getAccount: NonNullable = Effect.fn(
+ "ForgejoCli.getAccount",
+ )(function* (input) {
+ const logins = yield* listLogins({ cwd: input.cwd, command: "fj", remoteUrl: input.baseUrl });
+ const login = logins.find(
+ (item) => item.url.replace(/\/+$/, "") === input.baseUrl.replace(/\/+$/, ""),
+ );
+ if (!login)
+ return yield* new ForgejoCliError({
+ command: "fj",
+ cwd: input.cwd,
+ reason: "authentication",
+ detail: "fj has no credentials for this server.",
+ });
+ const token = yield* authenticateFj(input.cwd, login);
+ const currentUser = yield* requestFj({
+ cwd: input.cwd,
+ baseUrl: login.url.replace(/\/+$/, ""),
+ token,
+ path: "user",
+ });
+ const user = decodeJsonResult(Schema.Struct({ login: Schema.String }))(currentUser.stdout);
+ if (Result.isFailure(user) || !user.success.login.trim())
+ return yield* new ForgejoCliError({
+ command: "fj",
+ cwd: input.cwd,
+ reason: "invalid-response",
+ detail: "Forgejo returned an invalid account response.",
+ });
+ return user.success.login;
+ });
+
+ const resolveTarget = Effect.fn("ForgejoCli.resolveTarget")(function* (
+ input: ForgejoRepositoryInput,
+ hostOnly = false,
+ ) {
+ const referenceRemote = input.reference ? parseForgejoRemote(input.reference) : null;
+ let remoteUrl = [input.reference, input.repository, input.context?.remoteUrl].find(
+ (value) => value && parseForgejoRemote(value),
+ );
+ let remote =
+ referenceRemote ??
+ (input.repository ? parseForgejoRemote(input.repository) : null) ??
+ (input.context ? parseForgejoRemote(input.context.remoteUrl) : null);
+ if (!remote && (!input.repository || input.host)) {
+ const result = yield* process
+ .run({
+ operation: "ForgejoCli.remote",
+ command: "git",
+ args: input.host ? ["remote", "-v"] : ["remote", "get-url", "origin"],
+ cwd: input.cwd,
+ allowNonZeroExit: true,
+ })
+ .pipe(
+ Effect.mapError(
+ (cause) =>
+ new ForgejoCliError({
+ command: "tea",
+ cwd: input.cwd,
+ detail: "Could not resolve the Forgejo repository remote.",
+ cause,
+ }),
+ ),
+ );
+ if (input.host) {
+ const matchingUrls = [
+ ...new Set(
+ result.stdout.split("\n").flatMap((line) => {
+ const url = /^\S+\s+(https?:\/\/\S+)\s+\(fetch\)$/.exec(line.trim())?.[1];
+ return url && parseForgejoRemote(url)?.host === input.host?.toLowerCase()
+ ? [url]
+ : [];
+ }),
+ ),
+ ];
+ const origins = [...new Set(matchingUrls.map((url) => new URL(url).origin))];
+ remoteUrl =
+ matchingUrls.length === 1
+ ? matchingUrls[0]
+ : origins.length === 1
+ ? origins[0]
+ : undefined;
+ } else {
+ remoteUrl = result.stdout.trim();
+ }
+ remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null;
+ }
+ if (
+ input.host &&
+ !remote?.ssh &&
+ remote?.host !== input.host.toLowerCase() &&
+ remote?.hostname !== input.host.toLowerCase()
+ )
+ remote = {
+ host: input.host.toLowerCase(),
+ hostname: input.host.split(":")[0] ?? input.host,
+ ssh: false,
+ path: remote?.path ?? "",
+ };
+ const schemeRemoteUrl = remote?.ssh ? input.context?.provider.baseUrl : remoteUrl;
+ const fjLogins = yield* listLogins({
+ cwd: input.cwd,
+ command: "fj",
+ ...(schemeRemoteUrl ? { remoteUrl: schemeRemoteUrl } : {}),
+ });
+ const requestedHost = input.host ?? input.context?.requestedHost;
+ const matchHostOnly = hostOnly || (!!input.host && !remote?.path);
+ const selectLogin = (logins: ReturnType) =>
+ remote
+ ? matchForgejoLogin(logins, remote, remote.ssh ? requestedHost : undefined, matchHostOnly)
+ : (logins.find((item) => item.default === "true") ??
+ (new Set(logins.map((item) => item.name)).size === 1 ? logins[0] : undefined));
+ let login = selectLogin(fjLogins);
+ let command: "fj" | "tea" = "fj";
+ if (
+ !login &&
+ fjLogins.some(
+ (item) =>
+ !remote ||
+ matchForgejoLogin([item], remote, remote.ssh ? requestedHost : undefined, matchHostOnly),
+ )
+ ) {
+ const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe(
+ Effect.result,
+ );
+ if (Result.isSuccess(available))
+ return yield* new ForgejoCliError({
+ command: "fj",
+ cwd: input.cwd,
+ reason: "authentication",
+ detail: "Multiple fj logins match this repository. Specify its full server URL.",
+ });
+ if (available.failure.reason !== "missing-cli") return yield* available.failure;
+ }
+ if (login) {
+ const auth = yield* authenticateFj(input.cwd, login).pipe(Effect.result);
+ if (Result.isFailure(auth)) {
+ if (auth.failure.reason === "missing-cli") login = undefined;
+ else return yield* auth.failure;
+ }
+ }
+ if (!login) {
+ command = "tea";
+ login = selectLogin(yield* listLogins({ cwd: input.cwd, command: "tea" }));
+ }
+ if (!login)
+ return yield* new ForgejoCliError({
+ command: "tea",
+ cwd: input.cwd,
+ reason: "authentication",
+ detail:
+ "No matching Forgejo login. Use `fj auth login`, `fj auth add-token`, or `tea login add` for this server; choose a default when multiple tea accounts match.",
+ });
+ if (hostOnly)
+ return { command, login: login.name, repository: "", baseUrl: login.url.replace(/\/+$/, "") };
+ const path =
+ referenceRemote?.path ??
+ (input.repository && !parseForgejoRemote(input.repository)
+ ? input.repository
+ : remote?.path) ??
+ "";
+ const basePath = new URL(login.url).pathname.replace(/^\/+|\/+$/g, "");
+ const relativePath =
+ basePath && path.split("/").length > 2 && path.startsWith(`${basePath}/`)
+ ? path.slice(basePath.length + 1)
+ : path;
+ const repositoryPath = relativePath.replace(/\/pulls\/\d+.*$/, "").replace(/\.git$/, "");
+ if (command === "fj" && !repositoryPath.includes("/")) {
+ login = { ...login, user: yield* getAccount({ cwd: input.cwd, baseUrl: login.url }) };
+ }
+ const repository = repositoryPath.includes("/")
+ ? repositoryPath
+ : `${login.user}/${repositoryPath}`;
+ if (!/^[^/\s]+\/[^/\s]+$/.test(repository))
+ return yield* new ForgejoCliError({
+ command,
+ cwd: input.cwd,
+ detail: "Specify a Forgejo repository as owner/repository or its full server URL.",
+ });
+ return { command, login: login.name, repository, baseUrl: login.url.replace(/\/+$/, "") };
+ });
+ const resolveRepository = (input: ForgejoRepositoryInput) => resolveTarget(input);
+ const api = Effect.fn("ForgejoCli.api")(function* (input: ForgejoApiInput) {
+ const repository = yield* resolveTarget(
+ input,
+ input.path.replace(/^\/+/, "") === "user" && (!input.method || input.method === "GET"),
+ );
+ const stdin =
+ input.body === undefined
+ ? undefined
+ : yield* encodeApiBody(input.body).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ForgejoCliError({
+ command: "tea",
+ cwd: input.cwd,
+ detail: "Could not encode the Forgejo request body.",
+ cause,
+ }),
+ ),
+ );
+ let path = input.path.replace(/^\/+/, "");
+ if (input.repository && input.repository !== repository.repository) {
+ // Repository identities retain the server mount path; API routes do not.
+ const prefix = `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`;
+ if (path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}?`)) {
+ path = `repos/${repository.repository.split("/").map(encodeURIComponent).join("/")}${path.slice(prefix.length)}`;
+ }
+ }
+ if (repository.command === "fj") {
+ const token = (yield* readKeys(input.cwd)).hosts[repository.login]?.token;
+ if (!token)
+ return yield* new ForgejoCliError({
+ command: "fj",
+ cwd: input.cwd,
+ reason: "authentication",
+ detail: "fj has no credentials for this server.",
+ });
+ return yield* requestFj({
+ cwd: input.cwd,
+ baseUrl: repository.baseUrl,
+ token,
+ path,
+ ...(input.method === undefined ? {} : { method: input.method }),
+ ...(stdin === undefined ? {} : { body: stdin }),
+ });
+ }
+ const result = yield* execute({
+ cwd: input.cwd,
+ args: [
+ "api",
+ "--include",
+ "--login",
+ repository.login,
+ ...(repository.repository ? ["--repo", repository.repository] : []),
+ "--method",
+ input.method ?? "GET",
+ ...(input.body === undefined ? [] : ["--data", "@-"]),
+ `${repository.baseUrl}/api/v1/${path}`,
+ ],
+ ...(stdin === undefined ? {} : { stdin }),
+ });
+ // tea reports HTTP failures with exit code zero; use its response status.
+ const status = Number(/^HTTP\/\S+ (\d{3})/m.exec(result.stderr)?.[1]);
+ if (!status || status >= 400)
+ return yield* new ForgejoCliError({
+ command: "tea",
+ cwd: input.cwd,
+ ...(status ? { httpStatus: status } : {}),
+ ...(status === 401
+ ? { reason: "authentication" as const }
+ : status === 403
+ ? { reason: "forbidden" as const }
+ : status === 404
+ ? { reason: "not-found" as const }
+ : status === 429
+ ? { reason: "rate-limit" as const }
+ : {}),
+ detail:
+ status === 401 || status === 403
+ ? "Forgejo denied access. Check this server's `tea login` credentials and permissions."
+ : status === 404
+ ? "Forgejo repository or pull request was not found."
+ : status === 429
+ ? "Forgejo API rate limit exceeded."
+ : `Forgejo API request failed${status ? ` (HTTP ${status})` : " without an HTTP status"}.`,
+ });
+ return result;
+ });
+ return ForgejoCli.of({ execute, listLogins, getAccount, resolveRepository, api });
+});
+
+export const layer = Layer.effect(ForgejoCli, make);
diff --git a/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts
new file mode 100644
index 0000000000000000000000000000000000000000..4a2d8afba121e525bcccacf187ffc0bbd55ec109
--- /dev/null
+++ b/packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts
@@ -0,0 +1,614 @@
+import * as Effect from "effect/Effect";
+import * as Schema from "effect/Schema";
+import * as Result from "effect/Result";
+import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
+import { decodeJsonResult } from "@t3tools/shared/schemaJson";
+import * as ForgejoCli from "./ForgejoCli.ts";
+import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions";
+import {
+ PullRequestProviderError,
+ type ProviderChangeRequestDetail,
+ type ProviderRepositoryRef,
+ type PullRequestProviderApi,
+} from "@t3tools/source-control-core/server/PullRequestProvider";
+import {
+ ForgejoPullRequest,
+ ForgejoRepository,
+ ForgejoUser,
+ ForgejoComment,
+ ForgejoReview,
+ ForgejoReviewComment,
+ ForgejoCommit,
+ ForgejoStatus,
+ ForgejoLabel,
+ ForgejoReaction,
+ FORGEJO_REACTIONS,
+ forgejoChangeRequest,
+ forgejoActor,
+ forgejoComment,
+ forgejoReview,
+ forgejoReviewThread,
+ forgejoCommit,
+ forgejoChecks,
+ forgejoReactions,
+} from "./forgejoPullRequestJson.ts";
+
+const CAPABILITIES: PullRequestCapabilities = {
+ diff: true,
+ viewedFiles: "environment",
+ comment: true,
+ actions: ["merge", "close", "reopen", "update-branch"],
+ mergeMethods: ["merge", "squash", "rebase"],
+ updateMethods: ["merge", "rebase"],
+ search: false,
+ reactions: true,
+ labels: true,
+ // Forgejo's public API has no thread replies/resolution or draft conversion endpoint.
+ review: {
+ inlineComment: true,
+ reply: false,
+ resolve: false,
+ verdicts: ["comment", "approve", "request-changes"],
+ },
+ reviewers: { request: true, listCandidates: true },
+ edit: { changeRequest: true, comment: true },
+};
+const repoPath = (input: ProviderRepositoryRef) =>
+ `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`;
+const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) =>
+ `${repoPath(input)}/pulls/${input.number}`;
+const issuePath = (input: ProviderRepositoryRef & { readonly number: number }) =>
+ `${repoPath(input)}/issues/${input.number}`;
+// Review IDs differ from the issue-comment IDs used by Forgejo's reactions API.
+const reviewCommentId = (review: typeof ForgejoReview.Type) =>
+ /#issuecomment-([1-9]\d*)$/.exec(review.html_url ?? "")?.[1];
+
+export const make = Effect.gen(function* () {
+ const cli = yield* ForgejoCli.ForgejoCli;
+ const failure = (operation: string, detail: string, cause?: unknown) =>
+ new PullRequestProviderError({
+ provider: "forgejo",
+ operation,
+ reason: "failed",
+ detail,
+ ...(cause === undefined ? {} : { cause }),
+ });
+ const request = (input: ForgejoCli.ForgejoApiInput) =>
+ cli.api(input).pipe(
+ Effect.mapError(
+ (error) =>
+ new PullRequestProviderError({
+ provider: "forgejo",
+ operation: input.path,
+ detail: error.detail,
+ cause: error,
+ reason:
+ error.reason === "missing-cli"
+ ? "missing-tool"
+ : error.reason === "authentication"
+ ? "unauthenticated"
+ : error.reason === "rate-limit"
+ ? "rate-limited"
+ : error.reason === "not-found"
+ ? "not-found"
+ : "failed",
+ }),
+ ),
+ );
+ const read = Effect.fn("ForgejoPullRequestProvider.read")(function* (
+ input: ForgejoCli.ForgejoApiInput,
+ schema: Schema.Codec,
+ ) {
+ const result = yield* request(input);
+ if (result.stdoutTruncated)
+ return yield* failure(input.path, "Forgejo response exceeded the output limit.");
+ const decoded = decodeJsonResult(schema)(result.stdout);
+ return Result.isSuccess(decoded)
+ ? decoded.success
+ : yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure);
+ });
+ const readArray = (
+ input: ForgejoCli.ForgejoApiInput,
+ schema: Schema.Codec,
+ ) => read(input, Schema.NullOr(Schema.Array(schema))).pipe(Effect.map((rows) => rows ?? []));
+ const readPage = Effect.fn("ForgejoPullRequestProvider.readPage")(function* (
+ input: ForgejoCli.ForgejoApiInput,
+ schema: Schema.Codec,
+ index: number,
+ ) {
+ const result = yield* request({
+ ...input,
+ path: `${input.path}${input.path.includes("?") ? "&" : "?"}limit=50&page=${index}`,
+ });
+ if (result.stdoutTruncated)
+ return yield* failure(input.path, "Forgejo response exceeded the output limit.");
+ const decoded = decodeJsonResult(Schema.NullOr(Schema.Array(schema)))(result.stdout);
+ if (Result.isFailure(decoded))
+ return yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure);
+ const rows = decoded.success ?? [];
+ const links = /^link:\s*(.*)$/im.exec(result.stderr)?.[1];
+ return {
+ rows,
+ more: rows.length > 0 && (links === undefined || /rel="?next"?/i.test(links)),
+ };
+ });
+ // Use a stable, small page size that also works with Forgejo's default maximum of 50.
+ const page = Effect.fn("ForgejoPullRequestProvider.page")(function* (
+ input: ForgejoCli.ForgejoApiInput,
+ schema: Schema.Codec,
+ limit = 500,
+ ) {
+ const items: A[] = [];
+ for (let index = 1; items.length < limit; index++) {
+ const { rows, more } = yield* readPage(input, schema, index);
+ items.push(...rows);
+ if (!more) return { items, truncated: false };
+ }
+ return { items, truncated: true };
+ });
+ const write = (input: ForgejoCli.ForgejoApiInput) => request(input).pipe(Effect.asVoid);
+ const getPull = (input: ProviderRepositoryRef & { readonly number: number }) =>
+ read(
+ {
+ cwd: input.cwd,
+ repository: input.repository,
+ host: input.host,
+ path: pullPath(input),
+ },
+ ForgejoPullRequest,
+ );
+ const getRepo = (input: ProviderRepositoryRef) =>
+ read({ ...input, path: repoPath(input) }, ForgejoRepository);
+ const getViewer = (input: {
+ readonly cwd: string;
+ readonly repository?: string;
+ readonly host?: string;
+ }) => read({ ...input, path: "user" }, ForgejoUser).pipe(Effect.map((user) => user.login));
+ const permissions = (
+ repo: typeof ForgejoRepository.Type,
+ pr: typeof ForgejoPullRequest.Type,
+ viewer: string,
+ ): PullRequestViewerPermissions => {
+ const canWrite = repo.permissions?.push ?? false;
+ const canEdit = canWrite || pr.user?.login === viewer;
+ const active = !repo.archived;
+ return {
+ actions: active
+ ? CAPABILITIES.actions.filter((action) =>
+ action === "merge" || action === "update-branch" ? canWrite : canEdit,
+ )
+ : [],
+ comment: active && (!pr.is_locked || canWrite),
+ resolve: false,
+ verdicts: active
+ ? pr.user?.login === viewer
+ ? ["comment"]
+ : CAPABILITIES.review.verdicts
+ : [],
+ requestReviewers: active && canEdit,
+ labels: active && canWrite,
+ updateMethods:
+ active && canWrite ? (repo.allow_rebase_update ? ["merge", "rebase"] : ["merge"]) : [],
+ };
+ };
+ const getPermissions = Effect.fn("ForgejoPullRequestProvider.getPermissions")(function* (
+ input: ProviderRepositoryRef & { readonly number: number },
+ ) {
+ const [repo, pr, viewer] = yield* Effect.all(
+ [getRepo(input), getPull(input), getViewer(input)],
+ { concurrency: 3 },
+ );
+ return permissions(repo, pr, viewer);
+ });
+ const unsupported = (operation: string) =>
+ Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`));
+ const provider: PullRequestProviderApi = {
+ kind: "forgejo",
+ capabilities: CAPABILITIES,
+ getViewer,
+ listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")(
+ function* (input) {
+ const offset = input.cursor?.delivered ?? 0;
+ const items: ReturnType[] = [];
+ const state = input.state === "merged" ? "closed" : input.state;
+ const query = {
+ ...input,
+ path: `${repoPath(input)}/pulls?state=${state}&sort=recentupdate`,
+ };
+ // Self-hosted servers may cap pages below 50. Establish their actual page size before
+ // translating the service's row offset into an API page number.
+ const first = yield* readPage(query, Schema.NullOr(ForgejoPullRequest), 1);
+ const pageSize = first.rows.length || 50;
+ const firstIndex = Math.floor(offset / pageSize) + 1;
+ let consumed = 0;
+ let more = true;
+ for (let index = firstIndex; more && consumed < input.limit; index++) {
+ const result =
+ index === 1 ? first : yield* readPage(query, Schema.NullOr(ForgejoPullRequest), index);
+ const rows = result.rows;
+ const start = index === firstIndex ? offset % pageSize : 0;
+ const countBefore = consumed;
+ for (const row of rows.slice(start)) {
+ if (consumed >= input.limit) break;
+ consumed++;
+ if (row) items.push(forgejoChangeRequest(row));
+ }
+ more = result.more || rows.length - start > consumed - countBefore;
+ }
+ return { items, truncated: more, continues: true, cursorAdvance: consumed };
+ },
+ ),
+ getChangeRequestSummary: (input) => getPull(input).pipe(Effect.map(forgejoChangeRequest)),
+ 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) };
+ },
+ ),
+ 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,
+ );
+ return {
+ ...forgejoChangeRequest(pr),
+ body: pr.body ?? "",
+ changedFiles: pr.changed_files ?? 0,
+ reviewers: (pr.requested_reviewers ?? []).flatMap((user) => {
+ const actor = forgejoActor(user);
+ return actor ? [actor] : [];
+ }),
+ checks: forgejoChecks(statuses.items),
+ baseComparison: !pr.merge_base
+ ? "unknown"
+ : pr.merge_base === pr.base.sha
+ ? "up-to-date"
+ : "behind",
+ viewerPermissions: permissions(repo, pr, viewer),
+ mergeCapabilities: {
+ merge: repo.allow_merge_commits ?? true,
+ squash: repo.allow_squash_merge ?? true,
+ rebase: repo.allow_rebase ?? true,
+ },
+ } satisfies ProviderChangeRequestDetail;
+ }),
+ getViewerPermissions: getPermissions,
+ getChangeRequestActivity: Effect.fn("ForgejoPullRequestProvider.getChangeRequestActivity")(
+ function* (input) {
+ const [comments, reviews, commits, reactions, viewer] = yield* Effect.all(
+ [
+ // Issue comments ignore page/limit; fetch this unpaginated endpoint once.
+ readArray({ ...input, path: `${issuePath(input)}/comments` }, ForgejoComment).pipe(
+ Effect.map((items) => ({
+ items: items.slice(0, 500),
+ truncated: items.length > 500,
+ })),
+ ),
+ page({ ...input, path: `${pullPath(input)}/reviews` }, ForgejoReview),
+ page({ ...input, path: `${pullPath(input)}/commits` }, ForgejoCommit),
+ page({ ...input, path: `${issuePath(input)}/reactions` }, ForgejoReaction),
+ getViewer(input),
+ ],
+ { concurrency: 5 },
+ );
+ const reviewComments = yield* Effect.forEach(
+ reviews.items.filter((review) => review.comments_count > 0 && review.state !== "PENDING"),
+ (review) =>
+ readArray(
+ { ...input, path: `${pullPath(input)}/reviews/${review.id}/comments` },
+ ForgejoReviewComment,
+ ),
+ { concurrency: 4 },
+ );
+ const allInline = reviewComments.flat();
+ const inline = allInline.slice(0, 500);
+ const entries = [
+ ...comments.items.map((comment) => ({
+ comment: forgejoComment(comment),
+ reactionId: String(comment.id),
+ })),
+ ...reviews.items
+ .filter((review) => review.state !== "PENDING" && review.state !== "REQUEST_REVIEW")
+ .map((review) => ({
+ comment: forgejoReview(review),
+ reactionId: reviewCommentId(review),
+ })),
+ ...inline.map((comment) => ({
+ comment: {
+ ...forgejoComment(comment),
+ kind: "review-comment" as const,
+ path: comment.path,
+ },
+ reactionId: String(comment.id),
+ })),
+ ];
+ const enriched = yield* Effect.forEach(
+ entries,
+ ({ comment, reactionId }) =>
+ (reactionId === undefined
+ ? Effect.succeed([])
+ : readArray(
+ { ...input, path: `${repoPath(input)}/issues/comments/${reactionId}/reactions` },
+ ForgejoReaction,
+ )
+ ).pipe(
+ Effect.map((rows) => ({ ...comment, reactions: forgejoReactions(rows, viewer) })),
+ ),
+ { concurrency: 4 },
+ );
+ const byId = new Map(enriched.map((comment) => [comment.id, comment]));
+ const timeline = enriched.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
+ return {
+ comments: timeline,
+ commentCount: timeline.length,
+ commentsTruncated:
+ comments.truncated || reviews.truncated || allInline.length > inline.length,
+ reviewThreads: inline.map((comment) => ({
+ ...forgejoReviewThread(comment),
+ comments: [byId.get(String(comment.id)) ?? forgejoComment(comment)],
+ })),
+ commits: commits.items.map(forgejoCommit),
+ reactions: forgejoReactions(reactions.items, viewer),
+ };
+ },
+ ),
+ getFileRevisions: Effect.fn("ForgejoPullRequestProvider.getFileRevisions")(function* (input) {
+ const result = yield* request({ ...input, path: `${pullPath(input)}.diff` });
+ if (result.stdoutTruncated) {
+ return yield* failure("getFileRevisions", "Forgejo diff exceeded the output limit.");
+ }
+ const revisions = new Map(parseDiffFileRevisions(result.stdout));
+ for (const path of input.paths) {
+ if (!revisions.has(path)) revisions.set(path, "");
+ }
+ return { revisions, complete: true };
+ }),
+ getDiff: (input) =>
+ request({
+ ...input,
+ path: input.commit
+ ? `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}.diff`
+ : `${pullPath(input)}.diff`,
+ }).pipe(
+ Effect.map((result) => ({
+ patch: result.stdout,
+ truncated: result.stdoutTruncated,
+ nextCursor: null,
+ })),
+ ),
+ getDiffFileContents: Effect.fn("ForgejoPullRequestProvider.getDiffFileContents")(
+ function* (input) {
+ const pr = yield* getPull(input);
+ const commit = input.commit
+ ? yield* read(
+ {
+ ...input,
+ path: `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}`,
+ },
+ ForgejoCommit,
+ )
+ : null;
+ const oldRef = commit ? commit.parents[0]?.sha : pr.merge_base || pr.base.sha;
+ const newRef = input.commit ?? pr.head.sha;
+ const content = (repository: string, ref: string, path: string) =>
+ read(
+ {
+ ...input,
+ repository,
+ path: `${repoPath({ ...input, repository })}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`,
+ },
+ Schema.Struct({ content: Schema.String, encoding: Schema.Literal("base64") }),
+ ).pipe(Effect.map((file) => Buffer.from(file.content, "base64").toString("utf8")));
+ const [oldContents, newContents] = yield* Effect.all(
+ [
+ input.changeType === "new" || !oldRef
+ ? Effect.succeed("")
+ : content(input.repository, oldRef, input.oldPath),
+ input.changeType === "deleted"
+ ? Effect.succeed("")
+ : content(pr.head.repo?.full_name ?? input.repository, newRef, input.newPath),
+ ],
+ { concurrency: 2 },
+ );
+ return { oldContents, newContents };
+ },
+ ),
+ runAction: (input) => {
+ switch (input.action) {
+ case "merge":
+ return write({
+ ...input,
+ path: `${pullPath(input)}/merge`,
+ method: "POST",
+ body: { Do: input.mergeMethod ?? "merge" },
+ });
+ case "close":
+ case "reopen":
+ return write({
+ ...input,
+ path: pullPath(input),
+ method: "PATCH",
+ body: { state: input.action === "close" ? "closed" : "open" },
+ });
+ case "update-branch":
+ return write({
+ ...input,
+ path: `${pullPath(input)}/update?style=${input.updateMethod ?? "merge"}`,
+ method: "POST",
+ });
+ default:
+ return unsupported(input.action);
+ }
+ },
+ updateChangeRequest: (input) =>
+ write({
+ ...input,
+ path: pullPath(input),
+ method: "PATCH",
+ body: {
+ ...(input.title === undefined ? {} : { title: input.title }),
+ ...(input.body === undefined ? {} : { body: input.body }),
+ },
+ }),
+ comment: (input) =>
+ write({
+ ...input,
+ path: `${issuePath(input)}/comments`,
+ method: "POST",
+ body: { body: input.body },
+ }),
+ updateComment: (input) =>
+ write({
+ ...input,
+ path: `${repoPath(input)}/issues/comments/${encodeURIComponent(input.commentId)}`,
+ method: "PATCH",
+ body: { body: input.body },
+ }),
+ submitReview: Effect.fn("ForgejoPullRequestProvider.submitReview")(function* (input) {
+ const pr = yield* getPull(input);
+ yield* write({
+ ...input,
+ path: `${pullPath(input)}/reviews`,
+ method: "POST",
+ body: {
+ event:
+ input.verdict === "approve"
+ ? "APPROVED"
+ : input.verdict === "request-changes"
+ ? "REQUEST_CHANGES"
+ : "COMMENT",
+ body: input.body,
+ commit_id: pr.head.sha,
+ comments: input.comments.map((comment) => {
+ const position = comment.position;
+ const old =
+ position.kind === "deleted" ||
+ (position.kind === "context" && position.side === "left");
+ return {
+ path: old ? (comment.oldPath ?? comment.path) : comment.path,
+ body: comment.body,
+ old_position: old ? position.oldLine : 0,
+ new_position: old ? 0 : position.newLine,
+ };
+ }),
+ },
+ });
+ }),
+ listReviewerCandidates: Effect.fn("ForgejoPullRequestProvider.listReviewerCandidates")(
+ function* (input) {
+ const [pr, users] = yield* Effect.all(
+ [getPull(input), page({ ...input, path: `${repoPath(input)}/assignees` }, ForgejoUser)],
+ { concurrency: 2 },
+ );
+ return {
+ candidates: users.items
+ .filter((user) => user.login !== pr.user?.login)
+ .flatMap((user) => {
+ const actor = forgejoActor(user);
+ return actor
+ ? [
+ {
+ ...actor,
+ id: user.login,
+ kind: "user" as const,
+ isRequested:
+ pr.requested_reviewers?.some((reviewer) => reviewer.login === user.login) ??
+ false,
+ },
+ ]
+ : [];
+ }),
+ truncated: users.truncated,
+ };
+ },
+ ),
+ setReviewerRequest: (input) =>
+ write({
+ ...input,
+ path: `${pullPath(input)}/requested_reviewers`,
+ method: input.requested ? "POST" : "DELETE",
+ body: { reviewers: input.reviewers.map((reviewer) => reviewer.id) },
+ }),
+ listLabelCandidates: Effect.fn("ForgejoPullRequestProvider.listLabelCandidates")(
+ function* (input) {
+ const [pr, labels] = yield* Effect.all(
+ [getPull(input), page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel)],
+ { concurrency: 2 },
+ );
+ return {
+ candidates: labels.items.map((label) => ({
+ name: label.name,
+ color: label.color ?? null,
+ description: label.description ?? null,
+ isApplied: pr.labels?.some((applied) => applied.id === label.id) ?? false,
+ })),
+ truncated: labels.truncated,
+ };
+ },
+ ),
+ setLabels: Effect.fn("ForgejoPullRequestProvider.setLabels")(function* (input) {
+ const labels = yield* page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel);
+ const selected = labels.items.filter((label) => input.labels.includes(label.name));
+ if (selected.length !== input.labels.length)
+ return yield* failure("setLabels", "One or more requested labels could not be found.");
+ if (input.applied)
+ yield* write({
+ ...input,
+ path: `${issuePath(input)}/labels`,
+ method: "POST",
+ body: { labels: selected.map((label) => label.id) },
+ });
+ else
+ yield* Effect.forEach(
+ selected,
+ (label) =>
+ write({ ...input, path: `${issuePath(input)}/labels/${label.id}`, method: "DELETE" }),
+ { concurrency: 1 },
+ );
+ }),
+ setReaction: Effect.fn("ForgejoPullRequestProvider.setReaction")(function* (input) {
+ let commentId = input.subjectId;
+ if (commentId?.startsWith("review:")) {
+ const reviewId = /^review:([1-9]\d*)$/.exec(commentId)?.[1];
+ if (!reviewId) return yield* failure("setReaction", "Invalid Forgejo review ID.");
+ const review = yield* read(
+ { ...input, path: `${pullPath(input)}/reviews/${reviewId}` },
+ ForgejoReview,
+ );
+ commentId = reviewCommentId(review);
+ if (!commentId)
+ return yield* failure(
+ "setReaction",
+ "Forgejo did not return a comment ID for this review.",
+ );
+ }
+ if (commentId && !/^[1-9]\d*$/.test(commentId))
+ return yield* failure("setReaction", "Invalid Forgejo comment ID.");
+ yield* write({
+ ...input,
+ path: commentId
+ ? `${repoPath(input)}/issues/comments/${commentId}/reactions`
+ : `${issuePath(input)}/reactions`,
+ method: input.reacted ? "POST" : "DELETE",
+ body: { content: FORGEJO_REACTIONS[input.content] },
+ });
+ }),
+ replyToThread: () => unsupported("thread replies"),
+ setThreadResolution: () => unsupported("thread resolution"),
+ };
+ return provider;
+});
diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..679a4e69028c296ad0a9cbada6ac242a164d307b
--- /dev/null
+++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts
@@ -0,0 +1,1283 @@
+import { assert, it } from "@effect/vitest";
+import * as Effect from "effect/Effect";
+import type * as Context from "effect/Context";
+import * as FileSystem from "effect/FileSystem";
+import * as Layer from "effect/Layer";
+import * as Option from "effect/Option";
+import * as Schema from "effect/Schema";
+import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
+import * as FetchHttpClient from "effect/http/FetchHttpClient";
+import * as HttpClient from "effect/http/HttpClient";
+import * as HttpClientResponse from "effect/http/HttpClientResponse";
+import { VcsProcessSpawnError } from "@t3tools/contracts";
+import { firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery";
+import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
+
+import * as ForgejoCli from "./ForgejoCli.ts";
+import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
+import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
+
+const processOutput = TestSourceControlHost.processOutput;
+
+const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
+const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
+
+it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => {
+ const methods: string[] = [];
+ const fetchReview = async (
+ ...[input, init]: Parameters>
+ ) => {
+ const request = new Request(input instanceof Request ? input.url : String(input), {
+ ...(init?.method === undefined ? {} : { method: init.method }),
+ ...(init?.headers === undefined ? {} : { headers: init.headers }),
+ ...(init?.body === undefined ? {} : { body: init.body }),
+ });
+ methods.push(request.method);
+ if (request.method === "GET") {
+ assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42");
+ return new Response(
+ encodeJson({
+ number: 42,
+ title: "Review target",
+ body: "",
+ html_url: "https://forgejo.test/maria/project/pulls/42",
+ user: { login: "maria" },
+ state: "open",
+ merged: false,
+ head: { ref: "feature", sha: "head", repo: null },
+ base: { ref: "main", sha: "base", repo: null },
+ created_at: "2026-09-13T00:00:00Z",
+ updated_at: "2026-09-13T00:00:00Z",
+ closed_at: null,
+ merged_at: null,
+ labels: [],
+ }),
+ );
+ }
+ assert.strictEqual(request.method, "POST");
+ assert.strictEqual(
+ request.url,
+ "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews",
+ );
+ assert.deepStrictEqual(JSON.parse(await request.text()), {
+ event: "COMMENT",
+ body: "Review summary",
+ commit_id: "head",
+ comments: [],
+ });
+ return new Response('{"id":1}', { status: 200 });
+ };
+ return Effect.gen(function* () {
+ const cli = yield* ForgejoCli.make;
+ const provider = yield* ForgejoPullRequestProvider.make.pipe(
+ Effect.provideService(ForgejoCli.ForgejoCli, cli),
+ );
+ yield* provider.submitReview({
+ cwd: "/repo",
+ repository: "maria/project",
+ host: "forgejo.test",
+ number: 42,
+ verdict: "comment",
+ body: "Review summary",
+ comments: [],
+ });
+ assert.deepStrictEqual(methods, ["GET", "POST"]);
+ }).pipe(
+ Effect.provideService(
+ FetchHttpClient.Fetch,
+ Object.assign(fetchReview, { preconnect: () => undefined }),
+ ),
+ Effect.provide(FetchHttpClient.layer),
+ Effect.provideService(
+ FileSystem.FileSystem,
+ FileSystem.makeNoop({
+ exists: () => Effect.succeed(true),
+ readFileString: () =>
+ Effect.succeed(
+ encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }),
+ ),
+ }),
+ ),
+ Effect.provide(
+ TestSourceControlHost.layer({
+ process: {
+ run: (input) => {
+ if (input.command === "git") {
+ assert.deepStrictEqual(input.args, ["remote", "-v"]);
+ return Effect.succeed(
+ processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"),
+ );
+ }
+ assert.strictEqual(input.command, "fj");
+ assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]);
+ return Effect.succeed(processOutput(""));
+ },
+ },
+ }),
+ ),
+ );
+});
+
+it.effect("reads Forgejo checks without repository or viewer requests", () => {
+ const paths: string[] = [];
+ return Effect.gen(function* () {
+ const provider = yield* ForgejoPullRequestProvider.make;
+ const read = provider.getChangeRequestChecks;
+ if (read === undefined) return yield* Effect.die("checks read missing");
+ const result = yield* read({
+ cwd: "/repo",
+ repository: "acme/web",
+ host: "forgejo.test",
+ number: 1,
+ });
+ assert.strictEqual(result.state, "open");
+ assert.strictEqual(result.checks[0]?.status, "failure");
+ assert.deepStrictEqual(paths, [
+ "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",
+ ]);
+ }).pipe(
+ Effect.provide(
+ Layer.mock(ForgejoCli.ForgejoCli)({
+ api: (input) => {
+ paths.push(input.path);
+ assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head)/);
+ 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,
+ "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:00Z"}]`
+ : "[]",
+ ),
+ );
+ },
+ }),
+ ),
+ );
+});
+
+it.effect.each([
+ ["Ready", false, true, false, "mergeable"],
+ ["Draft", true, false, true, "unknown"],
+ ["Blocked", false, false, false, "unknown"],
+ ["Unchecked", false, undefined, false, "unknown"],
+ ["WIP: Legacy draft", undefined, false, true, "unknown"],
+ ["[WIP] Legacy draft", undefined, false, true, "unknown"],
+ ["WIP: Explicitly ready", false, false, false, "unknown"],
+] as const)(
+ "reads Forgejo mergeability for %s (draft=%s, mergeable=%s) across list, summary and detail",
+ ([title, draft, mergeable, isDraft, mergeability]) => {
+ const pr = {
+ number: 42,
+ title,
+ body: "",
+ html_url: "https://forgejo.test/maria/project/pulls/42",
+ user: { login: "maria" },
+ state: "open",
+ merged: false,
+ ...(draft === undefined ? {} : { draft }),
+ ...(mergeable === undefined ? {} : { mergeable }),
+ 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: [],
+ };
+ return Effect.gen(function* () {
+ const provider = yield* ForgejoPullRequestProvider.make;
+ const readSummary = provider.getChangeRequestSummary;
+ if (readSummary === undefined) return yield* Effect.die("summary read missing");
+ const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 42 };
+ const list = yield* provider.listChangeRequests({
+ ...input,
+ state: "open",
+ involvement: "all",
+ viewer: "maria",
+ limit: 10,
+ });
+ const summary = yield* readSummary(input);
+ const detail = yield* provider.getChangeRequest(input);
+ assert.strictEqual(list.items.length, 1);
+ for (const result of [list.items[0]!, summary, detail]) {
+ assert.strictEqual(result.mergeability, mergeability);
+ assert.strictEqual(result.isDraft, isDraft);
+ }
+ }).pipe(
+ Effect.provide(
+ Layer.mock(ForgejoCli.ForgejoCli)({
+ api: (input) => {
+ const [path, query] = input.path.split("?");
+ let response: unknown;
+ switch (path) {
+ case "repos/maria/project/pulls":
+ response = new URLSearchParams(query).get("page") === "1" ? [pr] : [];
+ break;
+ case "repos/maria/project/pulls/42":
+ response = pr;
+ break;
+ case "repos/maria/project":
+ response = { full_name: "maria/project", permissions: { push: true, admin: true } };
+ break;
+ case "user":
+ response = pr.user;
+ break;
+ case "repos/maria/project/statuses/head":
+ response = [];
+ break;
+ default:
+ return Effect.die(`Unexpected Forgejo request: ${input.path}`);
+ }
+ return encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput));
+ },
+ }),
+ ),
+ );
+ },
+);
+
+it.effect("loads Forgejo pull request references from files and commits views", () =>
+ Effect.gen(function* () {
+ const provider = yield* ForgejoSourceControlProvider.make;
+ for (const reference of [
+ "42",
+ "#42",
+ "https://forgejo.test/maria/project/pulls/42",
+ "https://forgejo.test/maria/project/pulls/42/",
+ "https://forgejo.test/maria/project/pulls/42/files?w=1#diff-1",
+ "http://forgejo.test:3000/git/maria/project/pulls/42/commits",
+ ]) {
+ const result = yield* provider.getChangeRequest({ cwd: "/repo", reference });
+ assert.strictEqual(result.number, 42);
+ assert.strictEqual(result.title, "Forgejo view reference");
+ }
+ const invalid = yield* provider
+ .getChangeRequest({
+ cwd: "/repo",
+ reference: "https://forgejo.test/maria/project/pulls/42invalid/files",
+ })
+ .pipe(Effect.result);
+ assert.strictEqual(invalid._tag, "Failure");
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})),
+ TestSourceControlHost.layer({ process: {} }),
+ Layer.mock(ForgejoCli.ForgejoCli)({
+ resolveRepository: () =>
+ Effect.succeed({
+ login: "work",
+ repository: "maria/project",
+ baseUrl: "https://forgejo.test",
+ }),
+ api: (input) => {
+ assert.strictEqual(input.path, "repos/maria/project/pulls/42");
+ return encodeJsonEffect({
+ number: 42,
+ title: "Forgejo view reference",
+ html_url: "https://forgejo.test/maria/project/pulls/42",
+ state: "open",
+ merged: false,
+ base: { ref: "main", sha: "base", repo: null },
+ head: { ref: "feature", sha: "head", repo: null },
+ }).pipe(Effect.orDie, Effect.map(processOutput));
+ },
+ }),
+ ),
+ ),
+ ),
+);
+
+it.effect(
+ "loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations",
+ () => {
+ const user = { login: "maria" };
+ const review = {
+ id: 8,
+ body: "review body",
+ user,
+ state: "COMMENT",
+ submitted_at: "2026-09-12T00:00:00Z",
+ html_url: "https://forgejo.test/maria/project/pulls/2#issuecomment-37",
+ comments_count: 1,
+ };
+ const comment = { id: 12, body: "ordinary", user, created_at: review.submitted_at };
+ const responses: Record = {
+ user,
+ "repos/maria/project/issues/2/comments": [comment],
+ "repos/maria/project/pulls/2/reviews": [review],
+ "repos/maria/project/pulls/2/reviews/8": review,
+ "repos/maria/project/pulls/2/reviews/9": { ...review, id: 9, html_url: "" },
+ "repos/maria/project/pulls/2/commits": [],
+ "repos/maria/project/issues/2/reactions": [],
+ "repos/maria/project/pulls/2/reviews/8/comments": [
+ {
+ ...comment,
+ id: 38,
+ body: "inline",
+ path: "file.ts",
+ position: 1,
+ original_position: 1,
+ commit_id: "head",
+ original_commit_id: "head",
+ resolver: null,
+ },
+ ],
+ "repos/maria/project/issues/comments/12/reactions": [{ content: "+1", user }],
+ "repos/maria/project/issues/comments/37/reactions": [{ content: "heart", user }],
+ "repos/maria/project/issues/comments/38/reactions": [
+ { content: "rocket", user: { login: "reviewer" } },
+ ],
+ };
+ const writes: ForgejoCli.ForgejoApiInput[] = [];
+ let reactionReads = 0;
+ return Effect.gen(function* () {
+ const provider = yield* ForgejoPullRequestProvider.make;
+ const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 2 };
+ const activity = yield* provider.getChangeRequestActivity(input);
+ assert.deepStrictEqual(
+ activity.comments.map((entry) => ({
+ id: entry.id,
+ kind: entry.kind,
+ reactions: entry.reactions,
+ })),
+ [
+ {
+ id: "12",
+ kind: "issue-comment",
+ reactions: [{ content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }],
+ },
+ {
+ id: "review:8",
+ kind: "review",
+ reactions: [{ content: "heart", count: 1, actors: [], viewerHasReacted: true }],
+ },
+ {
+ id: "38",
+ kind: "review-comment",
+ reactions: [
+ { content: "rocket", count: 1, actors: ["reviewer"], viewerHasReacted: false },
+ ],
+ },
+ ],
+ );
+ const inlineComment = activity.comments[2];
+ assert.ok(inlineComment);
+ assert.deepStrictEqual(activity.reviewThreads[0]?.comments, [inlineComment]);
+ for (const reacted of [true, false]) {
+ yield* provider.setReaction({ ...input, subjectId: "review:8", content: "heart", reacted });
+ yield* provider.setReaction({ ...input, subjectId: "38", content: "rocket", reacted });
+ }
+ assert.deepStrictEqual(
+ writes.map(({ path, method, body }) => ({ path, method, body })),
+ [
+ {
+ path: "repos/maria/project/issues/comments/37/reactions",
+ method: "POST",
+ body: { content: "heart" },
+ },
+ {
+ path: "repos/maria/project/issues/comments/38/reactions",
+ method: "POST",
+ body: { content: "rocket" },
+ },
+ {
+ path: "repos/maria/project/issues/comments/37/reactions",
+ method: "DELETE",
+ body: { content: "heart" },
+ },
+ {
+ path: "repos/maria/project/issues/comments/38/reactions",
+ method: "DELETE",
+ body: { content: "rocket" },
+ },
+ ],
+ );
+ const missing = yield* provider
+ .setReaction({ ...input, subjectId: "review:9", content: "heart", reacted: true })
+ .pipe(Effect.result);
+ assert.strictEqual(missing._tag, "Failure");
+ if (missing._tag === "Failure") assert.include(missing.failure.detail, "comment ID");
+ assert.strictEqual(writes.length, 4);
+ responses["repos/maria/project/pulls/2/reviews/8/comments"] = Array.from(
+ { length: 501 },
+ (_, index) => ({
+ ...comment,
+ id: 1000 + index,
+ path: "file.ts",
+ position: 1,
+ original_position: 1,
+ commit_id: "head",
+ original_commit_id: "head",
+ resolver: null,
+ }),
+ );
+ for (let index = 0; index < 500; index++) {
+ responses[`repos/maria/project/issues/comments/${1000 + index}/reactions`] = [];
+ }
+ reactionReads = 0;
+ const bounded = yield* provider.getChangeRequestActivity(input);
+ assert.strictEqual(bounded.reviewThreads.length, 500);
+ assert.strictEqual(
+ bounded.comments.filter((entry) => entry.kind === "review-comment").length,
+ 500,
+ );
+ assert.strictEqual(bounded.commentsTruncated, true);
+ assert.strictEqual(reactionReads, 502);
+ }).pipe(
+ Effect.provide(
+ Layer.mock(ForgejoCli.ForgejoCli)({
+ api: (input) => {
+ if (input.method) {
+ writes.push(input);
+ return Effect.succeed(processOutput("{}"));
+ }
+ const path = input.path.split("?")[0]!;
+ if (/\/issues\/comments\/\d+\/reactions$/.test(path)) reactionReads++;
+ assert.ok(Object.hasOwn(responses, path), `Unexpected Forgejo request: ${path}`);
+ const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page"));
+ return encodeJsonEffect(page > 1 ? [] : responses[path]).pipe(
+ Effect.orDie,
+ Effect.map(processOutput),
+ );
+ },
+ }),
+ ),
+ );
+ },
+);
+
+it.effect("discovers Forgejo accounts and retains the server port", () =>
+ Effect.gen(function* () {
+ const auth = ForgejoSourceControlProvider.discovery.parseAuth(
+ processOutput(
+ yield* encodeJsonEffect([
+ {
+ name: "work",
+ url: "http://forgejo.local:3000",
+ ssh_host: "git.forgejo.local",
+ user: "maria",
+ default: "true",
+ valid: "true",
+ },
+ ]),
+ ),
+ );
+ assert.deepStrictEqual(
+ firstNonEmptyLine("\u001b[1mtea version 0.16.0\u001b[0m\n"),
+ Option.some("tea version 0.16.0"),
+ );
+ assert.strictEqual(auth.status, "authenticated");
+ assert.deepStrictEqual(auth.account, Option.some("maria"));
+ assert.deepStrictEqual(auth.host, Option.some("forgejo.local:3000"));
+ const revoked = ForgejoSourceControlProvider.discovery.parseAuth(
+ processOutput(
+ encodeJson([
+ {
+ name: "work",
+ url: "http://forgejo.local:3000",
+ user: "maria",
+ default: "true",
+ valid: "false",
+ },
+ ]),
+ ),
+ );
+ assert.strictEqual(revoked.status, "unauthenticated");
+ const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
+ cwd: "/repo",
+ context: {
+ provider: {
+ kind: "unknown",
+ name: "git.forgejo.local",
+ baseUrl: "https://git.forgejo.local",
+ },
+ remoteName: "origin",
+ remoteUrl: "git@git.forgejo.local:maria/project.git",
+ },
+ auth: processOutput(
+ yield* encodeJsonEffect([
+ {
+ name: "work",
+ url: "http://forgejo.local:3000",
+ ssh_host: "git.forgejo.local",
+ user: "maria",
+ default: "true",
+ },
+ ]),
+ ),
+ });
+ assert.deepStrictEqual(refined, {
+ kind: "forgejo",
+ name: "Forgejo / Gitea",
+ baseUrl: "http://forgejo.local:3000",
+ });
+ }),
+);
+
+it.effect("does not choose a default Forgejo login across ambiguous SSH server ports", () =>
+ Effect.gen(function* () {
+ const logins = ForgejoCli.parseForgejoLogins(
+ yield* encodeJsonEffect([
+ {
+ name: "one",
+ url: "http://forgejo.local:3000",
+ ssh_host: "forgejo.local",
+ user: "maria",
+ default: "true",
+ },
+ {
+ name: "two",
+ url: "http://forgejo.local:4000",
+ ssh_host: "forgejo.local",
+ user: "maria",
+ default: "false",
+ },
+ ]),
+ );
+ const remote = ForgejoCli.parseForgejoRemote("git@forgejo.local:maria/project.git");
+ assert.isNotNull(remote);
+ assert.deepStrictEqual(
+ ForgejoCli.parseForgejoRemote("forgejo.local:maria/project.git"),
+ remote,
+ );
+ assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!));
+ assert.strictEqual(
+ ForgejoCli.matchForgejoLogin(logins, remote!, "forgejo.local:4000")?.name,
+ "two",
+ );
+ assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!, "other.local:4000"));
+ const alias = ForgejoCli.parseForgejoRemote("git@ssh.forgejo.local:maria/project.git");
+ assert.isNotNull(alias);
+ assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, alias!, "forgejo.local:4000"));
+ const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
+ cwd: "/repo",
+ context: {
+ provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" },
+ remoteName: "origin",
+ remoteUrl: "git@forgejo.local:maria/project.git",
+ requestedHost: "forgejo.local:4000",
+ },
+ auth: processOutput(yield* encodeJsonEffect(logins)),
+ });
+ assert.strictEqual(refined?.baseUrl, "http://forgejo.local:4000");
+ const https = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000/maria/project.git");
+ assert.isNotNull(https);
+ assert.strictEqual(ForgejoCli.matchForgejoLogin(logins, https!)?.name, "two");
+ const hostOnly = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000");
+ assert.strictEqual(
+ ForgejoCli.matchForgejoLogin(logins, hostOnly!, undefined, true)?.name,
+ "two",
+ );
+ const mounted = logins.map((login) => ({
+ ...login,
+ url: `http://forgejo.local:4000/${login.name}`,
+ }));
+ assert.isUndefined(ForgejoCli.matchForgejoLogin(mounted, hostOnly!, undefined, true));
+ }),
+);
+
+it.effect("rejects HTTP failures even when tea exits successfully", () =>
+ Effect.gen(function* () {
+ const cli = yield* ForgejoCli.make;
+ const result = yield* cli
+ .api({
+ cwd: "/repo",
+ repository: "http://forgejo.local:3000/maria/project",
+ path: "repos/maria/project/pulls/42",
+ method: "PATCH",
+ body: { state: "closed" },
+ })
+ .pipe(Effect.result);
+ assert.strictEqual(result._tag, "Failure");
+ if (result._tag === "Failure")
+ assert.strictEqual(
+ result.failure.detail,
+ "Forgejo repository or pull request was not found.",
+ );
+ }).pipe(
+ Effect.provideService(
+ FileSystem.FileSystem,
+ FileSystem.makeNoop({
+ exists: () => Effect.succeed(false),
+ }),
+ ),
+ Effect.provideService(
+ HttpClient.HttpClient,
+ HttpClient.make(() => {
+ throw new Error("tea must handle its own HTTP request");
+ }),
+ ),
+ Effect.provide(
+ TestSourceControlHost.layer({
+ process: {
+ run: (input) => {
+ if (input.args[0] === "api") {
+ assert.strictEqual(input.stdin, '{"state":"closed"}');
+ assert.include(input.args, "work");
+ assert.include(
+ input.args,
+ "http://forgejo.local:3000/api/v1/repos/maria/project/pulls/42",
+ );
+ }
+ return Effect.succeed(
+ input.args[0] === "login"
+ ? processOutput(
+ encodeJson([
+ {
+ name: "work",
+ url: "http://forgejo.local:3000",
+ ssh_host: "forgejo.local",
+ user: "maria",
+ default: "true",
+ },
+ ]),
+ )
+ : processOutput('{"message":"not found"}', { stderr: "HTTP/1.1 404 Not Found\n" }),
+ );
+ },
+ },
+ }),
+ ),
+ ),
+);
+
+it.effect("routes mounted Forgejo repositories without repeating the mount in API paths", () =>
+ Effect.gen(function* () {
+ const cli = yield* ForgejoCli.make;
+ const viewer = yield* cli.api({ cwd: "/upstream-only", host: "code.test", path: "user" });
+ assert.strictEqual(viewer.stdout, "[]");
+ const mountedRepository = yield* cli.resolveRepository({
+ cwd: "/upstream-only",
+ host: "code.test",
+ repository: "maria/project",
+ });
+ assert.strictEqual(mountedRepository.baseUrl, "https://code.test/forgejo");
+ assert.strictEqual(mountedRepository.repository, "maria/project");
+ for (const path of [
+ "repos/forgejo/maria/project/pulls?state=open",
+ "repos/forgejo/maria/project",
+ "repos/reviewer/project/contents/file.ts",
+ ]) {
+ const result = yield* cli.api({
+ cwd: "/repo",
+ repository: "forgejo/maria/project",
+ context: {
+ provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" },
+ remoteName: "origin",
+ remoteUrl: "https://code.test/forgejo/maria/project.git",
+ },
+ path,
+ });
+ assert.strictEqual(result.stdout, "[]");
+ }
+ const sameOwnerAsMount = yield* cli.resolveRepository({
+ cwd: "/repo",
+ repository: "forgejo/project",
+ context: {
+ provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" },
+ remoteName: "origin",
+ remoteUrl: "ssh://git@code.test/forgejo/project.git",
+ },
+ });
+ assert.strictEqual(sameOwnerAsMount.command, "tea");
+ assert.strictEqual(sameOwnerAsMount.repository, "forgejo/project");
+ }).pipe(
+ Effect.provideService(
+ FileSystem.FileSystem,
+ FileSystem.makeNoop({
+ exists: () => Effect.succeed(true),
+ readFileString: () =>
+ Effect.succeed(
+ encodeJson({
+ hosts: { "code.test/forgejo": { type: "Application", token: "test-token" } },
+ }),
+ ),
+ }),
+ ),
+ Effect.provideService(
+ HttpClient.HttpClient,
+ HttpClient.make(() => {
+ throw new Error("tea must handle its own HTTP request");
+ }),
+ ),
+ Effect.provide(
+ TestSourceControlHost.layer({
+ process: {
+ run: (input) => {
+ if (input.command === "git")
+ return Effect.succeed(
+ processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }),
+ );
+ if (input.args[0] === "login")
+ return Effect.succeed(
+ processOutput(
+ encodeJson([
+ {
+ name: "mounted",
+ url: "https://code.test/forgejo",
+ ssh_host: "code.test",
+ user: "maria",
+ default: "true",
+ },
+ ]),
+ ),
+ );
+ const supported = [
+ "https://code.test/forgejo/api/v1/user",
+ "https://code.test/forgejo/api/v1/repos/maria/project/pulls?state=open",
+ "https://code.test/forgejo/api/v1/repos/maria/project",
+ "https://code.test/forgejo/api/v1/repos/reviewer/project/contents/file.ts",
+ ];
+ if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo");
+ assert.strictEqual(input.command, "tea");
+ return Effect.succeed(
+ supported.includes(input.args.at(-1) ?? "")
+ ? processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" })
+ : processOutput("{}", { stderr: "HTTP/1.1 404 Not Found\n" }),
+ );
+ },
+ },
+ }),
+ ),
+ ),
+);
+
+it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => {
+ const commands: string[] = [];
+ const requests: string[] = [];
+ return Effect.gen(function* () {
+ const cli = yield* ForgejoCli.make;
+ for (const remoteUrl of [
+ "http://forgejo.local:3000/maria/project.git",
+ "ssh://git@ssh.forgejo.local:2222/maria/project.git",
+ "ssh://git@forgejo.local:2222/maria/project.git",
+ ]) {
+ const result = yield* cli.api({
+ cwd: "/repo",
+ repository: "maria/project",
+ context: {
+ provider: {
+ kind: "forgejo",
+ name: "Forgejo",
+ baseUrl: "http://forgejo.local:3000",
+ },
+ remoteName: "origin",
+ remoteUrl,
+ requestedHost: "forgejo.local:3000",
+ },
+ path: "repos/maria/project/issues/42/comments",
+ method: "POST",
+ body: { body: "verified through fj" },
+ });
+ assert.strictEqual(result.stdout, '{"id":99}');
+ }
+ assert.deepStrictEqual(commands, ["fj"]);
+ assert.deepStrictEqual(requests, [
+ "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
+ "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
+ "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
+ ]);
+ const viewer = yield* cli.api({
+ cwd: "/no-remotes",
+ host: "forgejo.local:3000",
+ path: "user",
+ });
+ assert.strictEqual(viewer.stdout, '{"login":"maria"}');
+ assert.strictEqual(requests.at(-1), "https://forgejo.local:3000/api/v1/user");
+ const upstreamViewer = yield* cli.api({
+ cwd: "/upstream-only",
+ host: "forgejo.local:3000",
+ path: "user",
+ });
+ assert.strictEqual(upstreamViewer.stdout, '{"login":"maria"}');
+ assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user");
+ const upstreamRepository = yield* cli.resolveRepository({
+ cwd: "/upstream-only",
+ host: "forgejo.local:3000",
+ repository: "maria/project",
+ });
+ assert.strictEqual(upstreamRepository.baseUrl, "http://forgejo.local:3000");
+ assert.strictEqual(upstreamRepository.repository, "maria/project");
+ const httpViewer = yield* cli.api({ cwd: "/repo", host: "forgejo.local:3000", path: "user" });
+ assert.strictEqual(httpViewer.stdout, '{"login":"maria"}');
+ assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user");
+ }).pipe(
+ Effect.provideService(
+ FileSystem.FileSystem,
+ FileSystem.makeNoop({
+ exists: () => Effect.succeed(true),
+ readFileString: () =>
+ Effect.succeed(
+ encodeJson({
+ hosts: {
+ "forgejo.local:3000": { type: "Application", token: "test-token" },
+ "forgejo.local:4000": { type: "Application", token: "other-token" },
+ },
+ aliases: { "ssh.forgejo.local:2222": "forgejo.local:3000" },
+ }),
+ ),
+ }),
+ ),
+ Effect.provideService(
+ HttpClient.HttpClient,
+ HttpClient.make((request) => {
+ requests.push(request.url);
+ if (request.url.endsWith("/user")) {
+ assert.strictEqual(request.method, "GET");
+ assert.strictEqual(request.headers.authorization, "token test-token");
+ return Effect.succeed(
+ HttpClientResponse.fromWeb(request, new Response('{"login":"maria"}')),
+ );
+ }
+ assert.strictEqual(request.method, "POST");
+ assert.strictEqual(request.headers.authorization, "token test-token");
+ assert.strictEqual(request.body._tag, "Uint8Array");
+ if (request.body._tag === "Uint8Array")
+ assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), {
+ body: "verified through fj",
+ });
+ return Effect.succeed(
+ HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })),
+ );
+ }),
+ ),
+ Effect.provide(
+ TestSourceControlHost.layer({
+ process: {
+ run: (input) => {
+ commands.push(input.command);
+ if (input.command === "git")
+ return Effect.succeed(
+ input.cwd === "/no-remotes"
+ ? processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) })
+ : processOutput(
+ `${input.cwd === "/upstream-only" ? "upstream" : "origin"}\thttp://forgejo.local:3000/maria/project.git (fetch)\nother\thttp://forgejo.local:3000/maria/other.git (fetch)\nunrelated\thttp://other.local:3000/maria/project.git (fetch)`,
+ ),
+ );
+ assert.strictEqual(input.command, "fj");
+ assert.deepStrictEqual(input.args, [
+ "--host",
+ input.cwd === "/no-remotes"
+ ? "https://forgejo.local:3000"
+ : "http://forgejo.local:3000",
+ "whoami",
+ ]);
+ return Effect.succeed(processOutput(""));
+ },
+ },
+ }),
+ ),
+ );
+});
+
+it.effect("loads later fj review pages when the server caps pages below the requested size", () => {
+ const pages: number[] = [];
+ let issueCommentRequests = 0;
+ return Effect.gen(function* () {
+ const cli = yield* ForgejoCli.make;
+ const provider = yield* ForgejoPullRequestProvider.make.pipe(
+ Effect.provideService(ForgejoCli.ForgejoCli, cli),
+ );
+ const activity = yield* provider.getChangeRequestActivity({
+ cwd: "/repo",
+ repository: "maria/project",
+ host: "forgejo.test",
+ number: 42,
+ });
+ assert.strictEqual(activity.commentCount, 42);
+ assert.strictEqual(activity.comments.at(-1)?.id, "review:41");
+ assert.strictEqual(activity.commentsTruncated, false);
+ assert.deepStrictEqual(pages, [1, 2, 3]);
+ assert.strictEqual(issueCommentRequests, 1);
+ }).pipe(
+ Effect.provideService(
+ FileSystem.FileSystem,
+ FileSystem.makeNoop({
+ exists: () => Effect.succeed(true),
+ readFileString: () =>
+ Effect.succeed(
+ encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }),
+ ),
+ }),
+ ),
+ Effect.provideService(
+ HttpClient.HttpClient,
+ HttpClient.make((request) => {
+ const url = new URL(request.url);
+ if (url.pathname === "/api/v1/repos/maria/project/issues/42/comments") {
+ issueCommentRequests++;
+ return Effect.succeed(
+ HttpClientResponse.fromWeb(
+ request,
+ new Response(
+ encodeJson([
+ {
+ id: 100,
+ body: "Unpaginated issue comment",
+ user: { login: "maria" },
+ created_at: "2026-09-13T00:00:00Z",
+ },
+ ]),
+ ),
+ ),
+ );
+ }
+ if (url.pathname.endsWith("/reviews")) {
+ const page = Number(url.searchParams.get("page"));
+ pages.push(page);
+ assert.ok(page >= 1 && page <= 3);
+ const reviews = Array.from({ length: page < 3 ? 20 : 1 }, (_, index) => ({
+ id: (page - 1) * 20 + index + 1,
+ body: "Review from a capped page",
+ user: { login: "maria" },
+ state: "COMMENT",
+ submitted_at: "2026-09-13T00:00:00Z",
+ comments_count: 0,
+ }));
+ return Effect.succeed(
+ HttpClientResponse.fromWeb(
+ request,
+ new Response(encodeJson(reviews), {
+ headers:
+ page === 2
+ ? {}
+ : {
+ Link:
+ page === 1
+ ? `<${url.origin}${url.pathname}?limit=50&page=2>; rel="next"`
+ : `<${url.origin}${url.pathname}?limit=50&page=1>; rel="prev"`,
+ },
+ }),
+ ),
+ );
+ }
+ return Effect.succeed(
+ HttpClientResponse.fromWeb(
+ request,
+ new Response(encodeJson(url.pathname === "/api/v1/user" ? { login: "maria" } : [])),
+ ),
+ );
+ }),
+ ),
+ Effect.provide(
+ TestSourceControlHost.layer({
+ process: {
+ run: (input) => {
+ if (input.command === "git") {
+ assert.deepStrictEqual(input.args, ["remote", "-v"]);
+ return Effect.succeed(
+ processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"),
+ );
+ }
+ assert.strictEqual(input.command, "fj");
+ assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]);
+ return Effect.succeed(processOutput(""));
+ },
+ },
+ }),
+ ),
+ );
+});
+
+it.effect("falls back to tea when fj is missing or has no account for this server", () =>
+ Effect.gen(function* () {
+ for (const scenario of ["missing-cli", "missing-account", "stale-invalid-storage"] as const) {
+ const commands: string[] = [];
+ yield* Effect.gen(function* () {
+ const cli = yield* ForgejoCli.make;
+ const result = yield* cli.api({
+ cwd: "/repo",
+ repository: "https://forgejo.local:3000/maria/project",
+ path: "repos/maria/project/pulls",
+ });
+ assert.strictEqual(result.stdout, "[]");
+ assert.deepStrictEqual(
+ commands,
+ scenario === "missing-account" ? ["tea", "tea"] : ["fj", "tea", "tea"],
+ );
+ const viewer = yield* cli.api({
+ cwd: "/upstream-only",
+ host: "forgejo.local:3000",
+ path: "user",
+ });
+ assert.strictEqual(viewer.stdout, "[]");
+ }).pipe(
+ Effect.provideService(
+ FileSystem.FileSystem,
+ FileSystem.makeNoop({
+ exists: () => Effect.succeed(true),
+ readFileString: () =>
+ Effect.succeed(
+ scenario === "stale-invalid-storage"
+ ? "invalid json"
+ : encodeJson({
+ hosts: {
+ [scenario === "missing-cli" ? "forgejo.local:3000" : "other.local"]: {
+ type: "Application",
+ token: "test-token",
+ },
+ },
+ }),
+ ),
+ }),
+ ),
+ Effect.provideService(
+ HttpClient.HttpClient,
+ HttpClient.make(() => {
+ throw new Error("tea must handle its own HTTP request");
+ }),
+ ),
+ Effect.provide(
+ TestSourceControlHost.layer({
+ process: {
+ run: (input) => {
+ commands.push(input.command);
+ if (input.command === "git")
+ return Effect.succeed(
+ processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }),
+ );
+ if (input.command === "fj")
+ return Effect.fail(
+ new VcsProcessSpawnError({
+ operation: input.operation,
+ command: input.command,
+ cwd: input.cwd,
+ cause: new Error("fj not found"),
+ }),
+ );
+ assert.strictEqual(input.command, "tea");
+ if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo");
+ return Effect.succeed(
+ input.args[0] === "login"
+ ? processOutput(
+ encodeJson([
+ {
+ name: "work",
+ url: "https://forgejo.local:3000",
+ user: "maria",
+ default: "true",
+ valid: "true",
+ },
+ ]),
+ )
+ : processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }),
+ );
+ },
+ },
+ }),
+ ),
+ );
+ }
+ }),
+);
+
+it.effect("handles fj mutation statuses without retrying failures or reading absent bodies", () =>
+ Effect.gen(function* () {
+ for (const status of [204, 205, 302, 401, 403, 404, 429, 500]) {
+ let writes = 0;
+ yield* Effect.gen(function* () {
+ const cli = yield* ForgejoCli.make;
+ const result = yield* cli
+ .api({
+ cwd: "/repo",
+ repository: "https://forgejo.local/maria/project",
+ path: "repos/maria/project/issues/42/comments",
+ method: "POST",
+ body: { body: "only once" },
+ })
+ .pipe(Effect.result);
+ assert.strictEqual(result._tag, status < 300 ? "Success" : "Failure");
+ if (result._tag === "Success") assert.strictEqual(result.success.stdout, "");
+ if (result._tag === "Failure") {
+ assert.strictEqual(result.failure.command, "fj");
+ assert.strictEqual(result.failure.httpStatus, status);
+ }
+ assert.strictEqual(writes, 1);
+ }).pipe(
+ Effect.provideService(
+ FileSystem.FileSystem,
+ FileSystem.makeNoop({
+ exists: () => Effect.succeed(true),
+ readFileString: () =>
+ Effect.succeed(
+ encodeJson({
+ hosts: {
+ "forgejo.local": { type: "Application", token: "test-token" },
+ },
+ }),
+ ),
+ }),
+ ),
+ Effect.provideService(
+ HttpClient.HttpClient,
+ HttpClient.make((request) => {
+ writes++;
+ assert.strictEqual(
+ request.url,
+ "https://forgejo.local/api/v1/repos/maria/project/issues/42/comments",
+ );
+ return Effect.succeed(
+ HttpClientResponse.fromWeb(
+ request,
+ new Response(status < 300 ? null : "", {
+ status,
+ headers: { location: "https://other.local/" },
+ }),
+ ),
+ );
+ }),
+ ),
+ Effect.provide(
+ TestSourceControlHost.layer({
+ process: {
+ run: (input) => {
+ assert.strictEqual(
+ input.command,
+ "fj",
+ "a failed mutation must never switch accounts or CLI",
+ );
+ return Effect.succeed(processOutput(""));
+ },
+ },
+ }),
+ ),
+ );
+ }
+ }),
+);
+
+it.effect(
+ "discovers fj first and retains configured authentication failures instead of switching accounts",
+ () =>
+ Effect.gen(function* () {
+ for (const scenario of ["authenticated", "revoked", "missing", "invalid-storage"] as const) {
+ const commands: string[] = [];
+ yield* Effect.gen(function* () {
+ const spec = yield* ForgejoSourceControlProvider.makeDiscovery;
+ assert.strictEqual(spec.type, "managed-cli");
+ if (spec.type !== "managed-cli") return;
+ const result = yield* spec.probe("/repo");
+ assert.strictEqual(result.executable, scenario === "missing" ? "tea" : "fj");
+ assert.strictEqual(
+ result.auth.status,
+ scenario === "revoked"
+ ? "unauthenticated"
+ : scenario === "invalid-storage"
+ ? "unknown"
+ : "authenticated",
+ );
+ if (scenario !== "revoked" && scenario !== "invalid-storage")
+ assert.deepStrictEqual(result.auth.host, Option.some("forgejo.local:3000"));
+ assert.deepStrictEqual(
+ result.auth.account,
+ scenario === "authenticated" || scenario === "missing"
+ ? Option.some("maria")
+ : Option.none(),
+ );
+ assert.strictEqual(
+ commands.some((command) => command.startsWith("tea ")),
+ scenario === "missing",
+ );
+ assert.include(commands, "fj version");
+ }).pipe(
+ Effect.provide(
+ Layer.mergeAll(
+ Layer.mock(ForgejoCli.ForgejoCli)({
+ getAccount: (input) => {
+ assert.strictEqual(scenario, "authenticated");
+ assert.deepStrictEqual(input, {
+ cwd: "/repo",
+ baseUrl: "http://forgejo.local:3000",
+ });
+ return Effect.succeed("maria");
+ },
+ listLogins: (input) => {
+ assert.strictEqual(
+ input.remoteUrl,
+ "http://forgejo.local:3000/maria/project.git",
+ );
+ if (scenario === "invalid-storage")
+ return Effect.fail(
+ new ForgejoCli.ForgejoCliError({
+ command: "fj",
+ cwd: input.cwd,
+ reason: "authentication",
+ detail: "fj authentication storage is invalid.",
+ }),
+ );
+ return Effect.succeed([
+ {
+ name: "forgejo.local:3000",
+ url: "http://forgejo.local:3000",
+ user: "",
+ default: "false",
+ },
+ ]);
+ },
+ }),
+ TestSourceControlHost.layer({
+ process: {
+ run: (input) => {
+ commands.push(`${input.command} ${input.args.join(" ")}`);
+ if (input.command === "git")
+ return Effect.succeed(
+ processOutput("http://forgejo.local:3000/maria/project.git\n"),
+ );
+ if (input.command === "fj") {
+ if (scenario === "missing")
+ return Effect.fail(
+ new VcsProcessSpawnError({
+ operation: input.operation,
+ command: input.command,
+ cwd: input.cwd,
+ cause: new Error("fj not found"),
+ }),
+ );
+ if (input.args[0] === "version")
+ return Effect.succeed(processOutput("fj 0.10.0"));
+ if (scenario === "invalid-storage") {
+ assert.deepStrictEqual(input.args, ["auth", "list"]);
+ return Effect.succeed(processOutput(""));
+ }
+ assert.deepStrictEqual(input.args, [
+ "--host",
+ "http://forgejo.local:3000",
+ "whoami",
+ ]);
+ return Effect.succeed(
+ processOutput("", {
+ exitCode: ChildProcessSpawner.ExitCode(scenario === "revoked" ? 1 : 0),
+ }),
+ );
+ }
+ assert.strictEqual(input.command, "tea");
+ return Effect.succeed(
+ input.args[0] === "--version"
+ ? processOutput("tea version 0.16.0")
+ : processOutput(
+ encodeJson([
+ {
+ name: "work",
+ url: "http://forgejo.local:3000",
+ user: "maria",
+ default: "true",
+ valid: "true",
+ },
+ ]),
+ ),
+ );
+ },
+ },
+ }),
+ ),
+ ),
+ );
+ }
+ }),
+);
diff --git a/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts
new file mode 100644
index 0000000000000000000000000000000000000000..cbcb3405f4ec4f0f99c781f32bf68f8c9d539f0d
--- /dev/null
+++ b/packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts
@@ -0,0 +1,387 @@
+import * as Effect from "effect/Effect";
+import * as FileSystem from "effect/FileSystem";
+import * as Schema from "effect/Schema";
+import * as Result from "effect/Result";
+import { SourceControlProviderError } from "@t3tools/contracts";
+import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
+import * as ForgejoCli from "./ForgejoCli.ts";
+import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
+import {
+ providerAuth,
+ probeSourceControlProvider,
+ type SourceControlCliDiscoverySpec,
+ type SourceControlManagedCliDiscoverySpec,
+} from "@t3tools/source-control-core/server/discovery";
+import { ForgejoPullRequestSchema, toForgejoChangeRequest } from "./forgejoPullRequests.ts";
+
+const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError);
+
+export const discovery = {
+ type: "cli",
+ kind: "forgejo",
+ label: "Forgejo / Gitea",
+ executable: "tea",
+ versionArgs: ["--version"],
+ authArgs: ["login", "status", "--output", "json"],
+ remoteRefinementArgs: ["login", "list", "--output", "json"],
+ parseAuth: (input) => {
+ const logins = ForgejoCli.parseForgejoLogins(input.stdout);
+ const login = logins.find((entry) => entry.default === "true") ?? logins[0];
+ return login
+ ? providerAuth({
+ status: login.valid === "true" ? "authenticated" : "unauthenticated",
+ account: login.user,
+ host: ForgejoCli.parseForgejoRemote(login.url)?.host,
+ })
+ : providerAuth({
+ status: "unauthenticated",
+ detail: "Run `tea login add` to authenticate a Forgejo or Gitea server.",
+ });
+ },
+ refineUnknownRemote: (input) => {
+ const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl);
+ const login =
+ remote &&
+ ForgejoCli.matchForgejoLogin(
+ ForgejoCli.parseForgejoLogins(input.auth.stdout),
+ remote,
+ input.context.requestedHost,
+ );
+ return login ? { kind: "forgejo", name: "Forgejo / Gitea", baseUrl: login.url } : null;
+ },
+ installHint:
+ "Install `fj` 0.6 or later from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj --host auth add-token`, or install `tea` 0.16 or later from https://gitea.com/gitea/tea and run `tea login add` for each Forgejo or Gitea server.",
+} satisfies SourceControlCliDiscoverySpec;
+
+export const makeDiscovery = Effect.gen(function* () {
+ const cli = yield* ForgejoCli.ForgejoCli;
+ const { process } = yield* SourceControlHost.SourceControlHost;
+ const listLogins = cli.listLogins;
+ if (!listLogins) return discovery;
+ return {
+ type: "managed-cli",
+ kind: "forgejo",
+ label: discovery.label,
+ installHint: discovery.installHint,
+ probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) {
+ const remoteUrl = yield* process
+ .run({
+ operation: "source-control.discovery.remote",
+ command: "git",
+ args: ["remote", "get-url", "origin"],
+ cwd,
+ allowNonZeroExit: true,
+ timeoutMs: 5_000,
+ maxOutputBytes: 8_000,
+ })
+ .pipe(
+ Effect.map((result) => result.stdout.trim()),
+ Effect.orElseSucceed(() => ""),
+ );
+ const credentials = yield* Effect.result(listLogins({ cwd, command: "fj", remoteUrl }));
+ const logins = Result.isSuccess(credentials) ? credentials.success : [];
+ const remote = ForgejoCli.parseForgejoRemote(remoteUrl);
+ const login =
+ (remote && ForgejoCli.matchForgejoLogin(logins, remote)) ||
+ logins.find((entry) => entry.default === "true") ||
+ logins[0];
+ const fj = yield* probeSourceControlProvider({
+ cwd,
+ process,
+ spec: {
+ ...discovery,
+ executable: "fj",
+ versionArgs: ["version"],
+ authArgs: login ? ["--host", login.url, "whoami"] : ["auth", "list"],
+ parseAuth: (result) =>
+ Result.isFailure(credentials)
+ ? providerAuth({
+ status: "unknown",
+ detail: "Could not read fj authentication storage. Authenticate again with fj.",
+ })
+ : login && result.exitCode === 0
+ ? providerAuth({
+ status: "authenticated",
+ account: login.user,
+ host: ForgejoCli.parseForgejoRemote(login.url)?.host,
+ })
+ : providerAuth({
+ status: "unauthenticated",
+ detail:
+ "Authenticate this server with `fj --host auth add-token`.",
+ }),
+ },
+ });
+ // A configured fj account owns its requests, including authentication errors.
+ if (fj.status === "available" && (login || Result.isFailure(credentials))) {
+ if (login && fj.auth.status === "authenticated" && cli.getAccount) {
+ const account = yield* cli.getAccount({ cwd, baseUrl: login.url }).pipe(Effect.result);
+ return {
+ ...fj,
+ auth: Result.isSuccess(account)
+ ? providerAuth({
+ status: "authenticated",
+ account: account.success,
+ host: ForgejoCli.parseForgejoRemote(login.url)?.host,
+ })
+ : providerAuth({
+ status: "unknown",
+ detail: account.failure.detail,
+ host: ForgejoCli.parseForgejoRemote(login.url)?.host,
+ }),
+ };
+ }
+ return fj;
+ }
+ const tea = yield* probeSourceControlProvider({ cwd, process, spec: discovery });
+ return tea.status === "available" || fj.status === "missing" ? tea : fj;
+ }),
+ refineUnknownRemote: Effect.fn("ForgejoSourceControlProvider.refineUnknownRemote")(
+ function* (input: {
+ readonly cwd: string;
+ readonly context: SourceControlProvider.SourceControlProviderContext;
+ }) {
+ const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl);
+ if (!remote) return null;
+ for (const command of ["fj", "tea"] as const) {
+ const logins = yield* listLogins({
+ cwd: input.cwd,
+ command,
+ remoteUrl: input.context.remoteUrl,
+ }).pipe(Effect.orElseSucceed(() => []));
+ const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost);
+ if (login) return { kind: "forgejo" as const, name: discovery.label, baseUrl: login.url };
+ }
+ return null;
+ },
+ ),
+ } satisfies SourceControlManagedCliDiscoverySpec;
+});
+
+const RepositorySchema = Schema.Struct({
+ full_name: Schema.String,
+ clone_url: Schema.String,
+ ssh_url: Schema.String,
+ default_branch: Schema.optional(Schema.NullOr(Schema.String)),
+});
+const cloneUrls = (raw: typeof RepositorySchema.Type) => ({
+ nameWithOwner: raw.full_name,
+ url: raw.clone_url,
+ sshUrl: raw.ssh_url,
+});
+const repositoryPath = (repository: string) =>
+ `repos/${repository.split("/").map(encodeURIComponent).join("/")}`;
+
+export const make = Effect.gen(function* () {
+ const cli = yield* ForgejoCli.ForgejoCli;
+ const fs = yield* FileSystem.FileSystem;
+ const { process } = yield* SourceControlHost.SourceControlHost;
+ const request = >(
+ input: ForgejoCli.ForgejoApiInput,
+ schema: S,
+ ) =>
+ cli.api(input).pipe(
+ Effect.flatMap((result) =>
+ Schema.decodeEffect(Schema.fromJsonString(schema))(result.stdout).pipe(
+ Effect.mapError(
+ (cause) =>
+ new ForgejoCli.ForgejoCliError({
+ command: "tea",
+ cwd: input.cwd,
+ detail: "Forgejo API returned an invalid response.",
+ reason: "invalid-response",
+ cause,
+ }),
+ ),
+ ),
+ ),
+ );
+ const mapError = (operation: string, cwd: string) =>
+ Effect.mapError(
+ (cause: unknown) =>
+ new SourceControlProviderError({
+ provider: "forgejo",
+ operation,
+ cwd,
+ ...(isForgejoCliError(cause) ? { command: cause.command } : {}),
+ detail: isForgejoCliError(cause) ? cause.detail : "Forgejo operation failed.",
+ cause,
+ }),
+ );
+ const getPull = Effect.fn("ForgejoSourceControlProvider.getPull")(function* (
+ input: Parameters<
+ SourceControlProvider.SourceControlProvider["Service"]["getChangeRequest"]
+ >[0],
+ ) {
+ const repo = yield* cli.resolveRepository(input);
+ const number = /(?:^#?|\/pulls\/)(\d+)(?:\/[^?#]*)?(?:[?#].*)?$/.exec(input.reference)?.[1];
+ if (!number)
+ return yield* new ForgejoCli.ForgejoCliError({
+ command: "tea",
+ cwd: input.cwd,
+ detail: "Specify a pull request number or Forgejo pull request URL.",
+ });
+ return yield* request(
+ { ...input, path: `${repositoryPath(repo.repository)}/pulls/${number}` },
+ ForgejoPullRequestSchema,
+ );
+ });
+ return SourceControlProvider.SourceControlProvider.of({
+ kind: "forgejo",
+ listChangeRequests: (input) =>
+ Effect.gen(function* () {
+ const repo = yield* cli.resolveRepository(input);
+ const source = SourceControlProvider.sourceControlRefFromInput(input);
+ const branch = SourceControlProvider.sourceBranch(input);
+ const results: ReturnType[] = [];
+ const limit = input.limit ?? 20;
+ for (let page = 1; results.length < limit; page++) {
+ const items = yield* request(
+ {
+ ...input,
+ path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&sort=recentupdate&limit=50&page=${page}`,
+ },
+ Schema.Array(ForgejoPullRequestSchema),
+ );
+ for (const item of items) {
+ if (
+ item.head.ref !== branch ||
+ (source?.repository && item.head.repo?.full_name !== source.repository) ||
+ (source?.owner && item.head.repo?.owner.login !== source.owner)
+ )
+ continue;
+ const normalized = toForgejoChangeRequest(item);
+ if (input.state === "all" || normalized.state === input.state) results.push(normalized);
+ }
+ if (items.length === 0) break;
+ }
+ return results.slice(0, limit);
+ }).pipe(mapError("listChangeRequests", input.cwd)),
+ getChangeRequest: (input) =>
+ getPull(input).pipe(
+ Effect.map(toForgejoChangeRequest),
+ mapError("getChangeRequest", input.cwd),
+ ),
+ createChangeRequest: (input) =>
+ Effect.gen(function* () {
+ const repo = yield* cli.resolveRepository(input);
+ const source = SourceControlProvider.sourceControlRefFromInput(input);
+ const owner = source?.owner ?? source?.repository?.split("/")[0];
+ const head = SourceControlProvider.sourceBranch(input);
+ yield* cli.api({
+ ...input,
+ path: `${repositoryPath(input.target?.repository ?? repo.repository)}/pulls`,
+ method: "POST",
+ body: {
+ base: input.target?.refName ?? input.baseRefName,
+ head: owner ? `${owner}:${head}` : head,
+ title: input.title,
+ body: yield* fs.readFileString(input.bodyFile),
+ },
+ });
+ }).pipe(mapError("createChangeRequest", input.cwd)),
+ getRepositoryCloneUrls: (input) =>
+ Effect.gen(function* () {
+ const repo = yield* cli.resolveRepository(input);
+ return cloneUrls(
+ yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema),
+ );
+ }).pipe(mapError("getRepositoryCloneUrls", input.cwd)),
+ createRepository: (input) =>
+ Effect.gen(function* () {
+ const repo = yield* cli.resolveRepository(input);
+ const user = yield* request(
+ { ...input, path: "user" },
+ Schema.Struct({ login: Schema.String }),
+ );
+ const [owner, name] = repo.repository.split("/");
+ return cloneUrls(
+ yield* request(
+ {
+ ...input,
+ path:
+ owner === user.login
+ ? "user/repos"
+ : `orgs/${encodeURIComponent(owner ?? "")}/repos`,
+ method: "POST",
+ body: { name, private: input.visibility === "private", auto_init: false },
+ },
+ RepositorySchema,
+ ),
+ );
+ }).pipe(mapError("createRepository", input.cwd)),
+ getDefaultBranch: (input) =>
+ Effect.gen(function* () {
+ const repo = yield* cli.resolveRepository(input);
+ return (
+ (yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema))
+ .default_branch ?? null
+ );
+ }).pipe(mapError("getDefaultBranch", input.cwd)),
+ checkoutChangeRequest: (input) =>
+ Effect.gen(function* () {
+ const repo = yield* cli.resolveRepository(input);
+ const pull = yield* getPull(input);
+ if (repo.command === "fj") {
+ // fj checkout cannot target a repository outside the local remotes.
+ const urls = yield* request(
+ { ...input, path: repositoryPath(repo.repository) },
+ RepositorySchema,
+ );
+ const remote = input.context?.remoteUrl;
+ const useSsh = remote && ForgejoCli.parseForgejoRemote(remote)?.ssh;
+ yield* process.run({
+ operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
+ command: "git",
+ cwd: input.cwd,
+ args: [
+ "fetch",
+ "--",
+ useSsh ? urls.ssh_url : urls.clone_url,
+ `refs/pull/${pull.number}/head`,
+ ],
+ });
+ const branch = `pulls/${pull.number}`;
+ const existing = yield* process.run({
+ operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
+ command: "git",
+ cwd: input.cwd,
+ args: ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
+ allowNonZeroExit: true,
+ });
+ yield* process.run({
+ operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
+ command: "git",
+ cwd: input.cwd,
+ args:
+ existing.exitCode === 0
+ ? ["checkout", branch]
+ : ["checkout", "-b", branch, "FETCH_HEAD"],
+ });
+ } else
+ yield* cli.execute({
+ cwd: input.cwd,
+ args: [
+ "pulls",
+ "checkout",
+ "--login",
+ repo.login,
+ "--repo",
+ repo.repository,
+ "--branch",
+ String(pull.number),
+ ],
+ });
+ if (input.force) {
+ // tea leaves an existing PR branch at its old tip. Keep dirty files safe
+ // while bringing the selected branch to the PR revision we fetched.
+ yield* process.run({
+ operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
+ command: "git",
+ cwd: input.cwd,
+ args: ["reset", "--keep", pull.head.sha],
+ });
+ }
+ }).pipe(mapError("checkoutChangeRequest", input.cwd)),
+ });
+});
diff --git a/packages/source-control-forgejo/src/server/driver.ts b/packages/source-control-forgejo/src/server/driver.ts
new file mode 100644
index 0000000000000000000000000000000000000000..ae76d7c6409a682544348fa5efcf6926a501f935
--- /dev/null
+++ b/packages/source-control-forgejo/src/server/driver.ts
@@ -0,0 +1,20 @@
+/**
+ * The Forgejo / Gitea driver: repository operations, discovery, and pull requests through
+ * `fj` or `tea`.
+ *
+ * @module source-control-forgejo/server/driver
+ */
+import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver";
+import * as Effect from "effect/Effect";
+
+import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
+import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
+
+export const driver = defineSourceControlDriver({
+ kind: "forgejo",
+ make: Effect.all({
+ sourceControl: ForgejoSourceControlProvider.make,
+ discovery: ForgejoSourceControlProvider.makeDiscovery,
+ pullRequests: ForgejoPullRequestProvider.make,
+ }),
+});
diff --git a/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts
new file mode 100644
index 0000000000000000000000000000000000000000..a937c00c2ef842a273f0b20d70c0bd1caded6e0b
--- /dev/null
+++ b/packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts
@@ -0,0 +1,262 @@
+import * as Schema from "effect/Schema";
+import * as DateTime from "effect/DateTime";
+import * as Option from "effect/Option";
+import type {
+ PullRequestActor,
+ PullRequestCheck,
+ PullRequestComment,
+ PullRequestCommit,
+ PullRequestReaction,
+ PullRequestReactionContent,
+ PullRequestReviewThread,
+} from "@t3tools/contracts";
+import type { ProviderChangeRequest } from "@t3tools/source-control-core/server/PullRequestProvider";
+import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
+
+export const ForgejoUser = Schema.Struct({
+ login: Schema.String,
+ full_name: Schema.optional(Schema.NullOr(Schema.String)),
+ avatar_url: Schema.optional(Schema.NullOr(Schema.String)),
+});
+export const ForgejoLabel = Schema.Struct({
+ id: Schema.Int,
+ name: Schema.String,
+ color: Schema.optional(Schema.NullOr(Schema.String)),
+ description: Schema.optional(Schema.NullOr(Schema.String)),
+});
+export const ForgejoRepository = Schema.Struct({
+ full_name: Schema.String,
+ permissions: Schema.optional(Schema.Struct({ push: Schema.Boolean, admin: Schema.Boolean })),
+ archived: Schema.optional(Schema.Boolean),
+ allow_merge_commits: Schema.optional(Schema.Boolean),
+ allow_squash_merge: Schema.optional(Schema.Boolean),
+ allow_rebase: Schema.optional(Schema.Boolean),
+ allow_rebase_update: Schema.optional(Schema.Boolean),
+});
+const Branch = Schema.Struct({
+ ref: Schema.String,
+ sha: Schema.String,
+ repo: Schema.NullOr(ForgejoRepository),
+});
+export const ForgejoPullRequest = Schema.Struct({
+ number: Schema.Int,
+ title: Schema.String,
+ body: Schema.NullOr(Schema.String),
+ html_url: Schema.String,
+ user: Schema.NullOr(ForgejoUser),
+ state: Schema.String,
+ draft: Schema.optional(Schema.Boolean),
+ merged: Schema.Boolean,
+ mergeable: Schema.optional(Schema.Boolean),
+ is_locked: Schema.optional(Schema.Boolean),
+ head: Branch,
+ base: Branch,
+ merge_base: Schema.optional(Schema.String),
+ created_at: Schema.String,
+ updated_at: Schema.String,
+ closed_at: Schema.NullOr(Schema.String),
+ merged_at: Schema.NullOr(Schema.String),
+ additions: Schema.optional(Schema.NullOr(Schema.Int)),
+ deletions: Schema.optional(Schema.NullOr(Schema.Int)),
+ changed_files: Schema.optional(Schema.NullOr(Schema.Int)),
+ comments: Schema.optional(Schema.Int),
+ labels: Schema.NullOr(Schema.Array(ForgejoLabel)),
+ requested_reviewers: Schema.optional(Schema.NullOr(Schema.Array(ForgejoUser))),
+});
+export const ForgejoComment = Schema.Struct({
+ id: Schema.Int,
+ body: Schema.String,
+ user: Schema.NullOr(ForgejoUser),
+ created_at: Schema.String,
+ html_url: Schema.optional(Schema.String),
+});
+export const ForgejoReview = Schema.Struct({
+ id: Schema.Int,
+ body: Schema.String,
+ user: Schema.NullOr(ForgejoUser),
+ state: Schema.String,
+ submitted_at: Schema.String,
+ html_url: Schema.optional(Schema.String),
+ comments_count: Schema.Int,
+});
+export const ForgejoReviewComment = Schema.Struct({
+ ...ForgejoComment.fields,
+ path: Schema.String,
+ position: Schema.Int,
+ original_position: Schema.Int,
+ commit_id: Schema.String,
+ original_commit_id: Schema.String,
+ resolver: Schema.NullOr(ForgejoUser),
+});
+export const ForgejoCommit = Schema.Struct({
+ sha: Schema.String,
+ author: Schema.NullOr(ForgejoUser),
+ commit: Schema.Struct({
+ message: Schema.String,
+ committer: Schema.Struct({ date: Schema.String }),
+ }),
+ parents: Schema.Array(Schema.Struct({ sha: Schema.String })),
+ stats: Schema.optional(
+ Schema.NullOr(Schema.Struct({ additions: Schema.Int, deletions: Schema.Int })),
+ ),
+});
+export const ForgejoStatus = Schema.Struct({
+ context: Schema.String,
+ status: Schema.String,
+ description: Schema.NullOr(Schema.String),
+ target_url: Schema.NullOr(Schema.String),
+ updated_at: Schema.String,
+});
+export const ForgejoReaction = Schema.Struct({
+ content: Schema.String,
+ user: Schema.NullOr(ForgejoUser),
+});
+
+export function forgejoActor(
+ user: typeof ForgejoUser.Type | null | undefined,
+): PullRequestActor | null {
+ return user?.login
+ ? { login: user.login, name: user.full_name || null, avatarUrl: user.avatar_url || null }
+ : null;
+}
+
+function toIsoUtc(value: string): string {
+ return Option.match(DateTime.make(value), { onNone: () => value, onSome: DateTime.formatIso });
+}
+
+export function forgejoChangeRequest(pr: typeof ForgejoPullRequest.Type) {
+ return {
+ number: pr.number,
+ title: pr.title,
+ url: pr.html_url,
+ author: forgejoActor(pr.user),
+ headBranch: pr.head.ref,
+ baseBranch: pr.base.ref,
+ headRepositoryNameWithOwner: pr.head.repo?.full_name ?? null,
+ state: pr.merged ? "merged" : pr.state === "closed" ? "closed" : "open",
+ isDraft: pr.draft ?? /^(?:\[WIP\]|WIP:)/i.test(pr.title),
+ // A false value also covers drafts and unfinished or failed conflict checks.
+ mergeability: pr.mergeable === true ? "mergeable" : "unknown",
+ additions: pr.additions ?? 0,
+ deletions: pr.deletions ?? 0,
+ createdAt: toIsoUtc(pr.created_at),
+ updatedAt: toIsoUtc(pr.updated_at),
+ closedAt: pr.closed_at === null ? null : toIsoUtc(pr.closed_at),
+ mergedAt: pr.merged_at === null ? null : toIsoUtc(pr.merged_at),
+ reviewRequestLogins: (pr.requested_reviewers ?? []).map((user) => user.login),
+ labels: (pr.labels ?? []).map((label) => ({ name: label.name, color: label.color ?? null })),
+ } satisfies ProviderChangeRequest;
+}
+
+export function forgejoComment(comment: typeof ForgejoComment.Type): PullRequestComment {
+ return {
+ id: String(comment.id),
+ kind: "issue-comment",
+ author: forgejoActor(comment.user),
+ body: comment.body,
+ createdAt: toIsoUtc(comment.created_at),
+ url: comment.html_url || null,
+ path: null,
+ reviewState: null,
+ };
+}
+
+export function forgejoReview(review: typeof ForgejoReview.Type): PullRequestComment {
+ return {
+ id: `review:${review.id}`,
+ kind: "review",
+ author: forgejoActor(review.user),
+ body: review.body,
+ createdAt: toIsoUtc(review.submitted_at),
+ url: review.html_url || null,
+ path: null,
+ reviewState:
+ review.state === "REQUEST_CHANGES"
+ ? "CHANGES_REQUESTED"
+ : review.state === "COMMENT"
+ ? "COMMENTED"
+ : review.state,
+ };
+}
+
+export function forgejoReviewThread(
+ comment: typeof ForgejoReviewComment.Type,
+): PullRequestReviewThread {
+ const oldSide = comment.position === 0 && comment.original_position > 0;
+ const line = oldSide ? comment.original_position : comment.position;
+ return {
+ id: String(comment.id),
+ path: comment.path,
+ line: line > 0 ? line : null,
+ side: oldSide ? "left" : "right",
+ isResolved: comment.resolver !== null,
+ isOutdated: false,
+ comments: [forgejoComment(comment)],
+ };
+}
+
+export function forgejoCommit(commit: typeof ForgejoCommit.Type): PullRequestCommit {
+ const author = forgejoActor(commit.author);
+ return {
+ oid: commit.sha,
+ messageHeadline: commit.commit.message.split("\n")[0] ?? "",
+ committedDate: toIsoUtc(commit.commit.committer.date),
+ authors: author ? [author] : [],
+ ...(commit.stats
+ ? { additions: commit.stats.additions, deletions: commit.stats.deletions }
+ : {}),
+ };
+}
+
+export function forgejoChecks(
+ statuses: ReadonlyArray,
+): ReadonlyArray {
+ return dedupeChecks(
+ statuses.map((status) => ({
+ workflowName: null,
+ at: status.updated_at,
+ check: {
+ name: status.context || "check",
+ description: status.description || null,
+ url: status.target_url || null,
+ status:
+ status.status === "success"
+ ? "success"
+ : status.status === "failure" || status.status === "error"
+ ? "failure"
+ : "pending",
+ },
+ })),
+ );
+}
+
+export const FORGEJO_REACTIONS: Record = {
+ "thumbs-up": "+1",
+ "thumbs-down": "-1",
+ laugh: "laugh",
+ hooray: "hooray",
+ confused: "confused",
+ heart: "heart",
+ rocket: "rocket",
+ eyes: "eyes",
+};
+export function forgejoReactions(
+ reactions: ReadonlyArray,
+ viewer: string,
+): ReadonlyArray {
+ return Object.entries(FORGEJO_REACTIONS).flatMap(([content, emoji]) => {
+ const matching = reactions.filter((reaction) => reaction.content === emoji);
+ return matching.length === 0
+ ? []
+ : [
+ {
+ content: content as PullRequestReactionContent,
+ count: matching.length,
+ actors: matching.flatMap((reaction) =>
+ reaction.user?.login && reaction.user.login !== viewer ? [reaction.user.login] : [],
+ ),
+ viewerHasReacted: matching.some((reaction) => reaction.user?.login === viewer),
+ },
+ ];
+ });
+}
diff --git a/packages/source-control-forgejo/src/server/forgejoPullRequests.ts b/packages/source-control-forgejo/src/server/forgejoPullRequests.ts
new file mode 100644
index 0000000000000000000000000000000000000000..affb9bae74c7de80ae0e07fd6a631ab0285b7d6a
--- /dev/null
+++ b/packages/source-control-forgejo/src/server/forgejoPullRequests.ts
@@ -0,0 +1,47 @@
+import * as Schema from "effect/Schema";
+import * as Option from "effect/Option";
+import type { ChangeRequest } from "@t3tools/contracts";
+
+const Repository = Schema.Struct({
+ full_name: Schema.String,
+ owner: Schema.Struct({ login: Schema.String }),
+});
+const Branch = Schema.Struct({
+ ref: Schema.String,
+ sha: Schema.String,
+ repo: Schema.NullOr(Repository),
+});
+export const ForgejoPullRequestSchema = Schema.Struct({
+ number: Schema.Int,
+ title: Schema.String,
+ html_url: Schema.String,
+ state: Schema.String,
+ merged: Schema.Boolean,
+ draft: Schema.optional(Schema.Boolean),
+ base: Branch,
+ head: Branch,
+ closed_at: Schema.optional(Schema.NullOr(Schema.String)),
+ merged_at: Schema.optional(Schema.NullOr(Schema.String)),
+ updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
+});
+export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type): ChangeRequest {
+ return {
+ provider: "forgejo",
+ number: raw.number,
+ title: raw.title,
+ url: raw.html_url,
+ state: raw.merged ? "merged" : raw.state === "closed" ? "closed" : "open",
+ isDraft: raw.draft ?? /^(?:\[WIP\]|WIP:)/i.test(raw.title),
+ baseRefName: raw.base.ref,
+ headRefName: raw.head.ref,
+ closedAt: raw.closed_at ?? null,
+ mergedAt: raw.merged_at ?? null,
+ updatedAt: raw.updated_at ?? Option.none(),
+ isCrossRepository:
+ raw.head.repo !== null &&
+ raw.base.repo !== null &&
+ raw.head.repo.full_name !== raw.base.repo.full_name,
+ headRepositoryNameWithOwner: raw.head.repo?.full_name ?? null,
+ headRepositoryOwnerLogin: raw.head.repo?.owner.login ?? null,
+ };
+}
diff --git a/packages/source-control-forgejo/tsconfig.json b/packages/source-control-forgejo/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..374bac55202ddcad695ede1c567ec219a61e9c48
--- /dev/null
+++ b/packages/source-control-forgejo/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "types": ["node"]
+ },
+ "include": ["src"]
+}
diff --git a/packages/source-control-testing/package.json b/packages/source-control-testing/package.json
new file mode 100644
index 0000000000000000000000000000000000000000..66426fe0337726e3bd85873dfc27b3ec250d0806
--- /dev/null
+++ b/packages/source-control-testing/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@t3tools/source-control-testing",
+ "private": true,
+ "type": "module",
+ "exports": {
+ "./TestSourceControlHost": {
+ "types": "./src/TestSourceControlHost.ts",
+ "import": "./src/TestSourceControlHost.ts"
+ }
+ },
+ "scripts": {
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@t3tools/contracts": "workspace:*",
+ "@t3tools/source-control-core": "workspace:*",
+ "effect": "catalog:"
+ },
+ "devDependencies": {
+ "@types/node": "catalog:"
+ }
+}
diff --git a/packages/source-control-testing/src/TestSourceControlHost.ts b/packages/source-control-testing/src/TestSourceControlHost.ts
new file mode 100644
index 0000000000000000000000000000000000000000..293339d0867e6c6357af68971a4a6f78a6b25877
--- /dev/null
+++ b/packages/source-control-testing/src/TestSourceControlHost.ts
@@ -0,0 +1,47 @@
+/**
+ * A `SourceControlHost.SourceControlHost` for provider tests. Settings are fixed unless the test
+ * supplies its own, and every process run goes to the test's `run`, which fails by default so an
+ * unexpected CLI call is visible.
+ *
+ * @module source-control-testing/TestSourceControlHost
+ */
+import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";
+import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
+
+export interface TestSourceControlHostOptions {
+ readonly settings?: ServerSettings;
+ readonly process?: Partial;
+}
+
+export const layer = (
+ options: TestSourceControlHostOptions = {},
+): Layer.Layer =>
+ Layer.succeed(
+ SourceControlHost.SourceControlHost,
+ SourceControlHost.SourceControlHost.of({
+ settings: { get: Effect.succeed(options.settings ?? DEFAULT_SERVER_SETTINGS) },
+ process: {
+ run:
+ options.process?.run ??
+ ((input) => Effect.die(`Unexpected ${input.command} run in ${input.operation}.`)),
+ },
+ }),
+ );
+
+/** A successful run's output, for tests that script CLI responses. */
+export const processOutput = (
+ stdout: string,
+ options?: {
+ readonly stderr?: string;
+ readonly exitCode?: ChildProcessSpawner.ExitCode;
+ },
+): SourceControlHost.SourceControlProcessOutput => ({
+ exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0),
+ stdout,
+ stderr: options?.stderr ?? "",
+ stdoutTruncated: false,
+ stderrTruncated: false,
+});
diff --git a/packages/source-control-testing/tsconfig.json b/packages/source-control-testing/tsconfig.json
new file mode 100644
index 0000000000000000000000000000000000000000..374bac55202ddcad695ede1c567ec219a61e9c48
--- /dev/null
+++ b/packages/source-control-testing/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "types": ["node"]
+ },
+ "include": ["src"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 387677cd3d52233f45581c61902da3d2269bfa41..e91a9bb0555fb177e7c964a5f19fa69b727ff131 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -651,6 +651,9 @@ importers:
'@t3tools/source-control-core':
specifier: workspace:*
version: link:../../packages/source-control-core
+ '@t3tools/source-control-forgejo':
+ specifier: workspace:*
+ version: link:../../packages/source-control-forgejo
diff:
specifier: 8.0.3
version: 8.0.3
@@ -1518,6 +1521,9 @@ importers:
'@t3tools/contracts':
specifier: workspace:*
version: link:../contracts
+ '@t3tools/shared':
+ specifier: workspace:*
+ version: link:../shared
effect:
specifier: 4.0.2
version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
@@ -1532,6 +1538,53 @@ importers:
specifier: 1.0.0
version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ packages/source-control-forgejo:
+ dependencies:
+ '@t3tools/contracts':
+ specifier: workspace:*
+ version: link:../contracts
+ '@t3tools/provider-core':
+ specifier: workspace:*
+ version: link:../provider-core
+ '@t3tools/shared':
+ specifier: workspace:*
+ version: link:../shared
+ '@t3tools/source-control-core':
+ specifier: workspace:*
+ version: link:../source-control-core
+ effect:
+ specifier: 4.0.2
+ version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
+ devDependencies:
+ '@effect/vitest':
+ specifier: 4.0.2
+ version: 4.0.2(patch_hash=595b7d26fb635caba9c3d39fcb189bb78a000192f8d3b60ead7174b310d1ade9)(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(effect@4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688))(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+ '@t3tools/source-control-testing':
+ specifier: workspace:*
+ version: link:../source-control-testing
+ '@types/node':
+ specifier: 24.12.4
+ version: 24.12.4
+ vite-plus:
+ specifier: 1.0.0
+ version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
+
+ packages/source-control-testing:
+ dependencies:
+ '@t3tools/contracts':
+ specifier: workspace:*
+ version: link:../contracts
+ '@t3tools/source-control-core':
+ specifier: workspace:*
+ version: link:../source-control-core
+ effect:
+ specifier: 4.0.2
+ version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
+ devDependencies:
+ '@types/node':
+ specifier: 24.12.4
+ version: 24.12.4
+
packages/ssh:
dependencies:
'@t3tools/contracts':
diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts
index f1c903bc8fe1c380cb0adde879771d0bc9974fb9..6482dd28f295f7e118204d512b527c8e53b86851 100644
--- a/scripts/release-smoke.ts
+++ b/scripts/release-smoke.ts
@@ -38,6 +38,8 @@ const workspaceFiles = [
"packages/provider-muse/package.json",
"packages/provider-opencode/package.json",
"packages/source-control-core/package.json",
+ "packages/source-control-forgejo/package.json",
+ "packages/source-control-testing/package.json",
"packages/effect-acp/package.json",
"packages/effect-codex-app-server/package.json",
"scripts/package.json",