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",