1import { SourceControlProviderKind } from "@t3tools/contracts";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import * as Result from "effect/Result";
5import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
6import { decodeJsonResult } from "@t3tools/shared/schemaJson";
7import * as ForgejoCli from "./ForgejoCli.ts";
8import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions";
9import {
10 PullRequestProviderError,
11 type ProviderChangeRequestDetail,
12 type ProviderRepositoryRef,
13 type PullRequestProviderApi,
14} from "@t3tools/source-control-core/server/PullRequestProvider";
15import {
16 ForgejoPullRequest,
17 ForgejoRepository,
18 ForgejoUser,
19 ForgejoComment,
20 ForgejoReview,
21 ForgejoReviewComment,
22 ForgejoCommit,
23 ForgejoStatus,
24 ForgejoLabel,
25 ForgejoReaction,
26 FORGEJO_REACTIONS,
27 forgejoChangeRequest,
28 forgejoActor,
29 forgejoComment,
30 forgejoReview,
31 forgejoReviewThread,
32 forgejoCommit,
33 forgejoChecks,
34 forgejoReactions,
35} from "./forgejoPullRequestJson.ts";
36import { definition } from "../client/definition.ts";
37
38const CAPABILITIES: PullRequestCapabilities = {
39 diff: true,
40 viewedFiles: "environment",
41 comment: true,
42 actions: [...definition.changeRequestActions],
43 mergeMethods: ["merge", "squash", "rebase"],
44 updateMethods: ["merge", "rebase"],
45 search: false,
46 reactions: true,
47 labels: true,
48 // Forgejo's public API has no thread replies/resolution or draft conversion endpoint.
49 review: {
50 inlineComment: true,
51 reply: false,
52 resolve: false,
53 verdicts: ["comment", "approve", "request-changes"],
54 },
55 reviewers: { request: true, listCandidates: true },
56 edit: { changeRequest: true, comment: true },
57};
58const repoPath = (input: ProviderRepositoryRef) =>
59 `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`;
60const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) =>
61 `${repoPath(input)}/pulls/${input.number}`;
62const issuePath = (input: ProviderRepositoryRef & { readonly number: number }) =>
63 `${repoPath(input)}/issues/${input.number}`;
64// Review IDs differ from the issue-comment IDs used by Forgejo's reactions API.
65const reviewCommentId = (review: typeof ForgejoReview.Type) =>
66 /#issuecomment-([1-9]\d*)$/.exec(review.html_url ?? "")?.[1];
67
68export const make = Effect.gen(function* () {
69 const cli = yield* ForgejoCli.ForgejoCli;
70 const failure = (operation: string, detail: string, cause?: unknown) =>
71 new PullRequestProviderError({
72 provider: SourceControlProviderKind.make("forgejo"),
73 operation,
74 reason: "failed",
75 detail,
76 ...(cause === undefined ? {} : { cause }),
77 });
78 const request = (input: ForgejoCli.ForgejoApiInput) =>
79 cli.api(input).pipe(
80 Effect.mapError(
81 (error) =>
82 new PullRequestProviderError({
83 provider: SourceControlProviderKind.make("forgejo"),
84 operation: input.path,
85 detail: error.detail,
86 cause: error,
87 reason:
88 error.reason === "missing-cli"
89 ? "missing-tool"
90 : error.reason === "authentication"
91 ? "unauthenticated"
92 : error.reason === "rate-limit"
93 ? "rate-limited"
94 : error.reason === "not-found"
95 ? "not-found"
96 : "failed",
97 }),
98 ),
99 );
100 const read = Effect.fn("ForgejoPullRequestProvider.read")(function* <A>(
101 input: ForgejoCli.ForgejoApiInput,
102 schema: Schema.Codec<A, unknown, never, never>,
103 ) {
104 const result = yield* request(input);
105 if (result.stdoutTruncated)
106 return yield* failure(input.path, "Forgejo response exceeded the output limit.");
107 const decoded = decodeJsonResult(schema)(result.stdout);
108 return Result.isSuccess(decoded)
109 ? decoded.success
110 : yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure);
111 });
112 const readArray = <A>(
113 input: ForgejoCli.ForgejoApiInput,
114 schema: Schema.Codec<A, unknown, never, never>,
115 ) => read(input, Schema.NullOr(Schema.Array(schema))).pipe(Effect.map((rows) => rows ?? []));
116 const readPage = Effect.fn("ForgejoPullRequestProvider.readPage")(function* <A>(
117 input: ForgejoCli.ForgejoApiInput,
118 schema: Schema.Codec<A, unknown, never, never>,
119 index: number,
120 ) {
121 const result = yield* request({
122 ...input,
123 path: `${input.path}${input.path.includes("?") ? "&" : "?"}limit=50&page=${index}`,
124 });
125 if (result.stdoutTruncated)
126 return yield* failure(input.path, "Forgejo response exceeded the output limit.");
127 const decoded = decodeJsonResult(Schema.NullOr(Schema.Array(schema)))(result.stdout);
128 if (Result.isFailure(decoded))
129 return yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure);
130 const rows = decoded.success ?? [];
131 const links = /^link:\s*(.*)$/im.exec(result.stderr)?.[1];
132 return {
133 rows,
134 more: rows.length > 0 && (links === undefined || /rel="?next"?/i.test(links)),
135 };
136 });
137 // Use a stable, small page size that also works with Forgejo's default maximum of 50.
138 const page = Effect.fn("ForgejoPullRequestProvider.page")(function* <A>(
139 input: ForgejoCli.ForgejoApiInput,
140 schema: Schema.Codec<A, unknown, never, never>,
141 limit = 500,
142 ) {
143 const items: A[] = [];
144 for (let index = 1; items.length < limit; index++) {
145 const { rows, more } = yield* readPage(input, schema, index);
146 items.push(...rows);
147 if (!more) return { items, truncated: false };
148 }
149 return { items, truncated: true };
150 });
151 const write = (input: ForgejoCli.ForgejoApiInput) => request(input).pipe(Effect.asVoid);
152 const getPull = (input: ProviderRepositoryRef & { readonly number: number }) =>
153 read(
154 {
155 cwd: input.cwd,
156 repository: input.repository,
157 host: input.host,
158 path: pullPath(input),
159 },
160 ForgejoPullRequest,
161 );
162 const getRepo = (input: ProviderRepositoryRef) =>
163 read({ ...input, path: repoPath(input) }, ForgejoRepository);
164 const getViewer = (input: {
165 readonly cwd: string;
166 readonly repository?: string;
167 readonly host?: string;
168 }) => read({ ...input, path: "user" }, ForgejoUser).pipe(Effect.map((user) => user.login));
169 const permissions = (
170 repo: typeof ForgejoRepository.Type,
171 pr: typeof ForgejoPullRequest.Type,
172 viewer: string,
173 ): PullRequestViewerPermissions => {
174 const canWrite = repo.permissions?.push ?? false;
175 const canEdit = canWrite || pr.user?.login === viewer;
176 const active = !repo.archived;
177 return {
178 actions: active
179 ? CAPABILITIES.actions.filter((action) =>
180 action === "merge" || action === "update-branch" ? canWrite : canEdit,
181 )
182 : [],
183 comment: active && (!pr.is_locked || canWrite),
184 resolve: false,
185 verdicts: active
186 ? pr.user?.login === viewer
187 ? ["comment"]
188 : CAPABILITIES.review.verdicts
189 : [],
190 requestReviewers: active && canEdit,
191 labels: active && canWrite,
192 updateMethods:
193 active && canWrite ? (repo.allow_rebase_update ? ["merge", "rebase"] : ["merge"]) : [],
194 };
195 };
196 const getPermissions = Effect.fn("ForgejoPullRequestProvider.getPermissions")(function* (
197 input: ProviderRepositoryRef & { readonly number: number },
198 ) {
199 const [repo, pr, viewer] = yield* Effect.all(
200 [getRepo(input), getPull(input), getViewer(input)],
201 { concurrency: 3 },
202 );
203 return permissions(repo, pr, viewer);
204 });
205 const unsupported = (operation: string) =>
206 Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`));
207 const provider: PullRequestProviderApi = {
208 kind: SourceControlProviderKind.make("forgejo"),
209 capabilities: CAPABILITIES,
210 getViewer,
211 listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")(
212 function* (input) {
213 const offset = input.cursor?.delivered ?? 0;
214 const items: ReturnType<typeof forgejoChangeRequest>[] = [];
215 const state = input.state === "merged" ? "closed" : input.state;
216 const query = {
217 ...input,
218 path: `${repoPath(input)}/pulls?state=${state}&sort=recentupdate`,
219 };
220 // Self-hosted servers may cap pages below 50. Establish their actual page size before
221 // translating the service's row offset into an API page number.
222 const first = yield* readPage(query, Schema.NullOr(ForgejoPullRequest), 1);
223 const pageSize = first.rows.length || 50;
224 const firstIndex = Math.floor(offset / pageSize) + 1;
225 let consumed = 0;
226 let more = true;
227 for (let index = firstIndex; more && consumed < input.limit; index++) {
228 const result =
229 index === 1 ? first : yield* readPage(query, Schema.NullOr(ForgejoPullRequest), index);
230 const rows = result.rows;
231 const start = index === firstIndex ? offset % pageSize : 0;
232 const countBefore = consumed;
233 for (const row of rows.slice(start)) {
234 if (consumed >= input.limit) break;
235 consumed++;
236 if (row) items.push(forgejoChangeRequest(row));
237 }
238 more = result.more || rows.length - start > consumed - countBefore;
239 }
240 return { items, truncated: more, continues: true, cursorAdvance: consumed };
241 },
242 ),
243 getChangeRequestSummary: (input) => getPull(input).pipe(Effect.map(forgejoChangeRequest)),
244 getChangeRequestChecks: Effect.fn("ForgejoPullRequestProvider.getChangeRequestChecks")(
245 function* (input) {
246 const pr = yield* getPull(input);
247 const statuses = yield* page(
248 {
249 ...input,
250 path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`,
251 },
252 ForgejoStatus,
253 );
254 return { state: forgejoChangeRequest(pr).state, checks: forgejoChecks(statuses.items) };
255 },
256 ),
257 getChangeRequest: Effect.fn("ForgejoPullRequestProvider.getChangeRequest")(function* (input) {
258 const [pr, repo, viewer] = yield* Effect.all(
259 [getPull(input), getRepo(input), getViewer(input)],
260 { concurrency: 3 },
261 );
262 const statuses = yield* page(
263 {
264 ...input,
265 path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`,
266 },
267 ForgejoStatus,
268 );
269 return {
270 ...forgejoChangeRequest(pr),
271 headSha: pr.head.sha,
272 body: pr.body ?? "",
273 changedFiles: pr.changed_files ?? 0,
274 reviewers: (pr.requested_reviewers ?? []).flatMap((user) => {
275 const actor = forgejoActor(user);
276 return actor ? [actor] : [];
277 }),
278 checks: forgejoChecks(statuses.items),
279 baseComparison: !pr.merge_base
280 ? "unknown"
281 : pr.merge_base === pr.base.sha
282 ? "up-to-date"
283 : "behind",
284 viewerPermissions: permissions(repo, pr, viewer),
285 mergeCapabilities: {
286 merge: repo.allow_merge_commits ?? true,
287 squash: repo.allow_squash_merge ?? true,
288 rebase: repo.allow_rebase ?? true,
289 },
290 } satisfies ProviderChangeRequestDetail;
291 }),
292 getViewerPermissions: getPermissions,
293 getChangeRequestActivity: Effect.fn("ForgejoPullRequestProvider.getChangeRequestActivity")(
294 function* (input) {
295 const [comments, reviews, commits, reactions, viewer] = yield* Effect.all(
296 [
297 // Issue comments ignore page/limit; fetch this unpaginated endpoint once.
298 readArray({ ...input, path: `${issuePath(input)}/comments` }, ForgejoComment).pipe(
299 Effect.map((items) => ({
300 items: items.slice(0, 500),
301 truncated: items.length > 500,
302 })),
303 ),
304 page({ ...input, path: `${pullPath(input)}/reviews` }, ForgejoReview),
305 page({ ...input, path: `${pullPath(input)}/commits` }, ForgejoCommit),
306 page({ ...input, path: `${issuePath(input)}/reactions` }, ForgejoReaction),
307 getViewer(input),
308 ],
309 { concurrency: 5 },
310 );
311 const reviewComments = yield* Effect.forEach(
312 reviews.items.filter((review) => review.comments_count > 0 && review.state !== "PENDING"),
313 (review) =>
314 readArray(
315 { ...input, path: `${pullPath(input)}/reviews/${review.id}/comments` },
316 ForgejoReviewComment,
317 ),
318 { concurrency: 4 },
319 );
320 const allInline = reviewComments.flat();
321 const inline = allInline.slice(0, 500);
322 const entries = [
323 ...comments.items.map((comment) => ({
324 comment: forgejoComment(comment),
325 reactionId: String(comment.id),
326 })),
327 ...reviews.items
328 .filter((review) => review.state !== "PENDING" && review.state !== "REQUEST_REVIEW")
329 .map((review) => ({
330 comment: forgejoReview(review),
331 reactionId: reviewCommentId(review),
332 })),
333 ...inline.map((comment) => ({
334 comment: {
335 ...forgejoComment(comment),
336 kind: "review-comment" as const,
337 path: comment.path,
338 },
339 reactionId: String(comment.id),
340 })),
341 ];
342 const enriched = yield* Effect.forEach(
343 entries,
344 ({ comment, reactionId }) =>
345 (reactionId === undefined
346 ? Effect.succeed([])
347 : readArray(
348 { ...input, path: `${repoPath(input)}/issues/comments/${reactionId}/reactions` },
349 ForgejoReaction,
350 )
351 ).pipe(
352 Effect.map((rows) => ({ ...comment, reactions: forgejoReactions(rows, viewer) })),
353 ),
354 { concurrency: 4 },
355 );
356 const byId = new Map(enriched.map((comment) => [comment.id, comment]));
357 const timeline = enriched.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
358 return {
359 comments: timeline,
360 commentCount: timeline.length,
361 commentsTruncated:
362 comments.truncated || reviews.truncated || allInline.length > inline.length,
363 reviewThreads: inline.map((comment) => ({
364 ...forgejoReviewThread(comment),
365 comments: [byId.get(String(comment.id)) ?? forgejoComment(comment)],
366 })),
367 commits: commits.items.map(forgejoCommit),
368 reactions: forgejoReactions(reactions.items, viewer),
369 };
370 },
371 ),
372 getFileRevisions: Effect.fn("ForgejoPullRequestProvider.getFileRevisions")(function* (input) {
373 const result = yield* request({ ...input, path: `${pullPath(input)}.diff` });
374 if (result.stdoutTruncated) {
375 return yield* failure("getFileRevisions", "Forgejo diff exceeded the output limit.");
376 }
377 const revisions = new Map(parseDiffFileRevisions(result.stdout));
378 for (const path of input.paths) {
379 if (!revisions.has(path)) revisions.set(path, "");
380 }
381 return { revisions, complete: true };
382 }),
383 getDiff: (input) =>
384 request({
385 ...input,
386 path: input.commit
387 ? `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}.diff`
388 : `${pullPath(input)}.diff`,
389 }).pipe(
390 Effect.map((result) => ({
391 patch: result.stdout,
392 truncated: result.stdoutTruncated,
393 nextCursor: null,
394 })),
395 ),
396 getDiffFileContents: Effect.fn("ForgejoPullRequestProvider.getDiffFileContents")(
397 function* (input) {
398 const pr = yield* getPull(input);
399 const commit = input.commit
400 ? yield* read(
401 {
402 ...input,
403 path: `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}`,
404 },
405 ForgejoCommit,
406 )
407 : null;
408 const oldRef = commit ? commit.parents[0]?.sha : pr.merge_base || pr.base.sha;
409 const newRef = input.commit ?? pr.head.sha;
410 const content = (repository: string, ref: string, path: string) =>
411 read(
412 {
413 ...input,
414 repository,
415 path: `${repoPath({ ...input, repository })}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`,
416 },
417 Schema.Struct({ content: Schema.String, encoding: Schema.Literal("base64") }),
418 ).pipe(Effect.map((file) => Buffer.from(file.content, "base64").toString("utf8")));
419 const [oldContents, newContents] = yield* Effect.all(
420 [
421 input.changeType === "new" || !oldRef
422 ? Effect.succeed("")
423 : content(input.repository, oldRef, input.oldPath),
424 input.changeType === "deleted"
425 ? Effect.succeed("")
426 : content(pr.head.repo?.full_name ?? input.repository, newRef, input.newPath),
427 ],
428 { concurrency: 2 },
429 );
430 return { oldContents, newContents };
431 },
432 ),
433 runAction: (input) => {
434 switch (input.action) {
435 case "merge":
436 return write({
437 ...input,
438 path: `${pullPath(input)}/merge`,
439 method: "POST",
440 body: { Do: input.mergeMethod ?? "merge" },
441 });
442 case "close":
443 case "reopen":
444 return write({
445 ...input,
446 path: pullPath(input),
447 method: "PATCH",
448 body: { state: input.action === "close" ? "closed" : "open" },
449 });
450 case "update-branch":
451 return write({
452 ...input,
453 path: `${pullPath(input)}/update?style=${input.updateMethod ?? "merge"}`,
454 method: "POST",
455 });
456 default:
457 return unsupported(input.action);
458 }
459 },
460 updateChangeRequest: (input) =>
461 write({
462 ...input,
463 path: pullPath(input),
464 method: "PATCH",
465 body: {
466 ...(input.title === undefined ? {} : { title: input.title }),
467 ...(input.body === undefined ? {} : { body: input.body }),
468 },
469 }),
470 comment: (input) =>
471 write({
472 ...input,
473 path: `${issuePath(input)}/comments`,
474 method: "POST",
475 body: { body: input.body },
476 }),
477 updateComment: (input) =>
478 write({
479 ...input,
480 path: `${repoPath(input)}/issues/comments/${encodeURIComponent(input.commentId)}`,
481 method: "PATCH",
482 body: { body: input.body },
483 }),
484 submitReview: Effect.fn("ForgejoPullRequestProvider.submitReview")(function* (input) {
485 const pr = yield* getPull(input);
486 yield* write({
487 ...input,
488 path: `${pullPath(input)}/reviews`,
489 method: "POST",
490 body: {
491 event:
492 input.verdict === "approve"
493 ? "APPROVED"
494 : input.verdict === "request-changes"
495 ? "REQUEST_CHANGES"
496 : "COMMENT",
497 body: input.body,
498 commit_id: pr.head.sha,
499 comments: input.comments.map((comment) => {
500 const position = comment.position;
501 const old =
502 position.kind === "deleted" ||
503 (position.kind === "context" && position.side === "left");
504 return {
505 path: old ? (comment.oldPath ?? comment.path) : comment.path,
506 body: comment.body,
507 old_position: old ? position.oldLine : 0,
508 new_position: old ? 0 : position.newLine,
509 };
510 }),
511 },
512 });
513 }),
514 listReviewerCandidates: Effect.fn("ForgejoPullRequestProvider.listReviewerCandidates")(
515 function* (input) {
516 const [pr, users] = yield* Effect.all(
517 [getPull(input), page({ ...input, path: `${repoPath(input)}/assignees` }, ForgejoUser)],
518 { concurrency: 2 },
519 );
520 return {
521 candidates: users.items
522 .filter((user) => user.login !== pr.user?.login)
523 .flatMap((user) => {
524 const actor = forgejoActor(user);
525 return actor
526 ? [
527 {
528 ...actor,
529 id: user.login,
530 kind: "user" as const,
531 isRequested:
532 pr.requested_reviewers?.some((reviewer) => reviewer.login === user.login) ??
533 false,
534 },
535 ]
536 : [];
537 }),
538 truncated: users.truncated,
539 };
540 },
541 ),
542 setReviewerRequest: (input) =>
543 write({
544 ...input,
545 path: `${pullPath(input)}/requested_reviewers`,
546 method: input.requested ? "POST" : "DELETE",
547 body: { reviewers: input.reviewers.map((reviewer) => reviewer.id) },
548 }),
549 listLabelCandidates: Effect.fn("ForgejoPullRequestProvider.listLabelCandidates")(
550 function* (input) {
551 const [pr, labels] = yield* Effect.all(
552 [getPull(input), page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel)],
553 { concurrency: 2 },
554 );
555 return {
556 candidates: labels.items.map((label) => ({
557 name: label.name,
558 color: label.color ?? null,
559 description: label.description ?? null,
560 isApplied: pr.labels?.some((applied) => applied.id === label.id) ?? false,
561 })),
562 truncated: labels.truncated,
563 };
564 },
565 ),
566 setLabels: Effect.fn("ForgejoPullRequestProvider.setLabels")(function* (input) {
567 const labels = yield* page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel);
568 const selected = labels.items.filter((label) => input.labels.includes(label.name));
569 if (selected.length !== input.labels.length)
570 return yield* failure("setLabels", "One or more requested labels could not be found.");
571 if (input.applied)
572 yield* write({
573 ...input,
574 path: `${issuePath(input)}/labels`,
575 method: "POST",
576 body: { labels: selected.map((label) => label.id) },
577 });
578 else
579 yield* Effect.forEach(
580 selected,
581 (label) =>
582 write({ ...input, path: `${issuePath(input)}/labels/${label.id}`, method: "DELETE" }),
583 { concurrency: 1 },
584 );
585 }),
586 setReaction: Effect.fn("ForgejoPullRequestProvider.setReaction")(function* (input) {
587 let commentId = input.subjectId;
588 if (commentId?.startsWith("review:")) {
589 const reviewId = /^review:([1-9]\d*)$/.exec(commentId)?.[1];
590 if (!reviewId) return yield* failure("setReaction", "Invalid Forgejo review ID.");
591 const review = yield* read(
592 { ...input, path: `${pullPath(input)}/reviews/${reviewId}` },
593 ForgejoReview,
594 );
595 commentId = reviewCommentId(review);
596 if (!commentId)
597 return yield* failure(
598 "setReaction",
599 "Forgejo did not return a comment ID for this review.",
600 );
601 }
602 if (commentId && !/^[1-9]\d*$/.test(commentId))
603 return yield* failure("setReaction", "Invalid Forgejo comment ID.");
604 yield* write({
605 ...input,
606 path: commentId
607 ? `${repoPath(input)}/issues/comments/${commentId}/reactions`
608 : `${issuePath(input)}/reactions`,
609 method: input.reacted ? "POST" : "DELETE",
610 body: { content: FORGEJO_REACTIONS[input.content] },
611 });
612 }),
613 replyToThread: () => unsupported("thread replies"),
614 setThreadResolution: () => unsupported("thread resolution"),
615 };
616 return provider;
617});