authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-09 14:50:00-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 14:50:00-07:00
logf6e45028efb69d208a45aee8c2fa514adde44977
tree9b7b76913381bbf155c96786e11de57c99ec2d91
parent28f11ed7a5a2251ebe4689643b64425760ee5fe4
signature Signed by PGP key B5690EEEBB952194

refactor(source-control): Forgejo lives in @t3tools/source-control-forgejo (#17581)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

38 files changed, 4064 insertions(+), 3735 deletions(-)

apps/server/package.json+1
......@@ -46,6 +46,7 @@
4646 "@t3tools/provider-opencode": "workspace:*",
4747 "@t3tools/provider-pi": "workspace:*",
4848 "@t3tools/source-control-core": "workspace:*",
49 "@t3tools/source-control-forgejo": "workspace:*",
4950 "diff": "8.0.3",
5051 "effect": "catalog:",
5152 "jose": "catalog:",
apps/server/scripts/evaluate-thread-titles.ts+2-4
......@@ -23,10 +23,9 @@ import {
2323} from "@t3tools/provider-core/server/threadTitleContext";
2424import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";
2525import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";
26import * as ServerSourceControlHost from "../src/sourceControl/ServerSourceControlHost.ts";
2726import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
2827import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";
29import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";
28import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts";
3029import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";
3130import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";
3231import * as ServerSettings from "../src/serverSettings.ts";
......@@ -155,11 +154,10 @@ await Effect.runPromise(
155154 Layer.mergeAll(
156155 GitHubApi.layerWithDependencies,
157156 GitLabCli.layer,
158 ForgejoCli.layer,
157 SourceControlBuiltInDrivers.layer,
159158 AzureDevOpsCli.layer,
160159 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.
161160 BitbucketApi.layer,
162 ServerSourceControlHost.layer,
163161 ),
164162 ),
165163 // Default settings: no saved Bitbucket token, gh's own GitHub account choice.
apps/server/src/git/GitManager.test.ts+1-1
......@@ -57,7 +57,7 @@ import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceContr
5757import {
5858 ForgejoPullRequestSchema,
5959 toForgejoChangeRequest,
60} from "../sourceControl/forgejoPullRequests.ts";
60} from "@t3tools/source-control-forgejo/server/forgejoPullRequests";
6161import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider";
6262import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
6363import * as ServerConfig from "../config.ts";
apps/server/src/pullRequest/BitbucketPullRequestApi.ts+1-1
......@@ -22,7 +22,7 @@ import type {
2222} from "@t3tools/contracts";
2323
2424import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
25import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts";
25import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions";
2626import {
2727 buildReviewThreads,
2828 decodeCommentsJson,
apps/server/src/pullRequest/ForgejoPullRequestProvider.ts deleted-614
......@@ -1,614 +0,0 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3import * as Result from "effect/Result";
4import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
5import { decodeJsonResult } from "@t3tools/shared/schemaJson";
6import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
7import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts";
8import {
9 PullRequestProviderError,
10 type ProviderChangeRequestDetail,
11 type ProviderRepositoryRef,
12 type PullRequestProviderApi,
13} from "@t3tools/source-control-core/server/PullRequestProvider";
14import {
15 ForgejoPullRequest,
16 ForgejoRepository,
17 ForgejoUser,
18 ForgejoComment,
19 ForgejoReview,
20 ForgejoReviewComment,
21 ForgejoCommit,
22 ForgejoStatus,
23 ForgejoLabel,
24 ForgejoReaction,
25 FORGEJO_REACTIONS,
26 forgejoChangeRequest,
27 forgejoActor,
28 forgejoComment,
29 forgejoReview,
30 forgejoReviewThread,
31 forgejoCommit,
32 forgejoChecks,
33 forgejoReactions,
34} from "./forgejoPullRequestJson.ts";
35
36const CAPABILITIES: PullRequestCapabilities = {
37 diff: true,
38 viewedFiles: "environment",
39 comment: true,
40 actions: ["merge", "close", "reopen", "update-branch"],
41 mergeMethods: ["merge", "squash", "rebase"],
42 updateMethods: ["merge", "rebase"],
43 search: false,
44 reactions: true,
45 labels: true,
46 // Forgejo's public API has no thread replies/resolution or draft conversion endpoint.
47 review: {
48 inlineComment: true,
49 reply: false,
50 resolve: false,
51 verdicts: ["comment", "approve", "request-changes"],
52 },
53 reviewers: { request: true, listCandidates: true },
54 edit: { changeRequest: true, comment: true },
55};
56const repoPath = (input: ProviderRepositoryRef) =>
57 `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`;
58const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) =>
59 `${repoPath(input)}/pulls/${input.number}`;
60const issuePath = (input: ProviderRepositoryRef & { readonly number: number }) =>
61 `${repoPath(input)}/issues/${input.number}`;
62// Review IDs differ from the issue-comment IDs used by Forgejo's reactions API.
63const reviewCommentId = (review: typeof ForgejoReview.Type) =>
64 /#issuecomment-([1-9]\d*)$/.exec(review.html_url ?? "")?.[1];
65
66export const make = Effect.gen(function* () {
67 const cli = yield* ForgejoCli.ForgejoCli;
68 const failure = (operation: string, detail: string, cause?: unknown) =>
69 new PullRequestProviderError({
70 provider: "forgejo",
71 operation,
72 reason: "failed",
73 detail,
74 ...(cause === undefined ? {} : { cause }),
75 });
76 const request = (input: ForgejoCli.ForgejoApiInput) =>
77 cli.api(input).pipe(
78 Effect.mapError(
79 (error) =>
80 new PullRequestProviderError({
81 provider: "forgejo",
82 operation: input.path,
83 detail: error.detail,
84 cause: error,
85 reason:
86 error.reason === "missing-cli"
87 ? "missing-tool"
88 : error.reason === "authentication"
89 ? "unauthenticated"
90 : error.reason === "rate-limit"
91 ? "rate-limited"
92 : error.reason === "not-found"
93 ? "not-found"
94 : "failed",
95 }),
96 ),
97 );
98 const read = Effect.fn("ForgejoPullRequestProvider.read")(function* <A>(
99 input: ForgejoCli.ForgejoApiInput,
100 schema: Schema.Codec<A, unknown, never, never>,
101 ) {
102 const result = yield* request(input);
103 if (result.stdoutTruncated)
104 return yield* failure(input.path, "Forgejo response exceeded the output limit.");
105 const decoded = decodeJsonResult(schema)(result.stdout);
106 return Result.isSuccess(decoded)
107 ? decoded.success
108 : yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure);
109 });
110 const readArray = <A>(
111 input: ForgejoCli.ForgejoApiInput,
112 schema: Schema.Codec<A, unknown, never, never>,
113 ) => read(input, Schema.NullOr(Schema.Array(schema))).pipe(Effect.map((rows) => rows ?? []));
114 const readPage = Effect.fn("ForgejoPullRequestProvider.readPage")(function* <A>(
115 input: ForgejoCli.ForgejoApiInput,
116 schema: Schema.Codec<A, unknown, never, never>,
117 index: number,
118 ) {
119 const result = yield* request({
120 ...input,
121 path: `${input.path}${input.path.includes("?") ? "&" : "?"}limit=50&page=${index}`,
122 });
123 if (result.stdoutTruncated)
124 return yield* failure(input.path, "Forgejo response exceeded the output limit.");
125 const decoded = decodeJsonResult(Schema.NullOr(Schema.Array(schema)))(result.stdout);
126 if (Result.isFailure(decoded))
127 return yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure);
128 const rows = decoded.success ?? [];
129 const links = /^link:\s*(.*)$/im.exec(result.stderr)?.[1];
130 return {
131 rows,
132 more: rows.length > 0 && (links === undefined || /rel="?next"?/i.test(links)),
133 };
134 });
135 // Use a stable, small page size that also works with Forgejo's default maximum of 50.
136 const page = Effect.fn("ForgejoPullRequestProvider.page")(function* <A>(
137 input: ForgejoCli.ForgejoApiInput,
138 schema: Schema.Codec<A, unknown, never, never>,
139 limit = 500,
140 ) {
141 const items: A[] = [];
142 for (let index = 1; items.length < limit; index++) {
143 const { rows, more } = yield* readPage(input, schema, index);
144 items.push(...rows);
145 if (!more) return { items, truncated: false };
146 }
147 return { items, truncated: true };
148 });
149 const write = (input: ForgejoCli.ForgejoApiInput) => request(input).pipe(Effect.asVoid);
150 const getPull = (input: ProviderRepositoryRef & { readonly number: number }) =>
151 read(
152 {
153 cwd: input.cwd,
154 repository: input.repository,
155 host: input.host,
156 path: pullPath(input),
157 },
158 ForgejoPullRequest,
159 );
160 const getRepo = (input: ProviderRepositoryRef) =>
161 read({ ...input, path: repoPath(input) }, ForgejoRepository);
162 const getViewer = (input: {
163 readonly cwd: string;
164 readonly repository?: string;
165 readonly host?: string;
166 }) => read({ ...input, path: "user" }, ForgejoUser).pipe(Effect.map((user) => user.login));
167 const permissions = (
168 repo: typeof ForgejoRepository.Type,
169 pr: typeof ForgejoPullRequest.Type,
170 viewer: string,
171 ): PullRequestViewerPermissions => {
172 const canWrite = repo.permissions?.push ?? false;
173 const canEdit = canWrite || pr.user?.login === viewer;
174 const active = !repo.archived;
175 return {
176 actions: active
177 ? CAPABILITIES.actions.filter((action) =>
178 action === "merge" || action === "update-branch" ? canWrite : canEdit,
179 )
180 : [],
181 comment: active && (!pr.is_locked || canWrite),
182 resolve: false,
183 verdicts: active
184 ? pr.user?.login === viewer
185 ? ["comment"]
186 : CAPABILITIES.review.verdicts
187 : [],
188 requestReviewers: active && canEdit,
189 labels: active && canWrite,
190 updateMethods:
191 active && canWrite ? (repo.allow_rebase_update ? ["merge", "rebase"] : ["merge"]) : [],
192 };
193 };
194 const getPermissions = Effect.fn("ForgejoPullRequestProvider.getPermissions")(function* (
195 input: ProviderRepositoryRef & { readonly number: number },
196 ) {
197 const [repo, pr, viewer] = yield* Effect.all(
198 [getRepo(input), getPull(input), getViewer(input)],
199 { concurrency: 3 },
200 );
201 return permissions(repo, pr, viewer);
202 });
203 const unsupported = (operation: string) =>
204 Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`));
205 const provider: PullRequestProviderApi = {
206 kind: "forgejo",
207 capabilities: CAPABILITIES,
208 getViewer,
209 listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")(
210 function* (input) {
211 const offset = input.cursor?.delivered ?? 0;
212 const items: ReturnType<typeof forgejoChangeRequest>[] = [];
213 const state = input.state === "merged" ? "closed" : input.state;
214 const query = {
215 ...input,
216 path: `${repoPath(input)}/pulls?state=${state}&sort=recentupdate`,
217 };
218 // Self-hosted servers may cap pages below 50. Establish their actual page size before
219 // translating the service's row offset into an API page number.
220 const first = yield* readPage(query, Schema.NullOr(ForgejoPullRequest), 1);
221 const pageSize = first.rows.length || 50;
222 const firstIndex = Math.floor(offset / pageSize) + 1;
223 let consumed = 0;
224 let more = true;
225 for (let index = firstIndex; more && consumed < input.limit; index++) {
226 const result =
227 index === 1 ? first : yield* readPage(query, Schema.NullOr(ForgejoPullRequest), index);
228 const rows = result.rows;
229 const start = index === firstIndex ? offset % pageSize : 0;
230 const countBefore = consumed;
231 for (const row of rows.slice(start)) {
232 if (consumed >= input.limit) break;
233 consumed++;
234 if (row) items.push(forgejoChangeRequest(row));
235 }
236 more = result.more || rows.length - start > consumed - countBefore;
237 }
238 return { items, truncated: more, continues: true, cursorAdvance: consumed };
239 },
240 ),
241 getChangeRequestSummary: (input) => getPull(input).pipe(Effect.map(forgejoChangeRequest)),
242 getChangeRequestChecks: Effect.fn("ForgejoPullRequestProvider.getChangeRequestChecks")(
243 function* (input) {
244 const pr = yield* getPull(input);
245 const statuses = yield* page(
246 {
247 ...input,
248 path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`,
249 },
250 ForgejoStatus,
251 );
252 return { state: forgejoChangeRequest(pr).state, checks: forgejoChecks(statuses.items) };
253 },
254 ),
255 getChangeRequest: Effect.fn("ForgejoPullRequestProvider.getChangeRequest")(function* (input) {
256 const [pr, repo, viewer] = yield* Effect.all(
257 [getPull(input), getRepo(input), getViewer(input)],
258 { concurrency: 3 },
259 );
260 const statuses = yield* page(
261 {
262 ...input,
263 path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`,
264 },
265 ForgejoStatus,
266 );
267 return {
268 ...forgejoChangeRequest(pr),
269 body: pr.body ?? "",
270 changedFiles: pr.changed_files ?? 0,
271 reviewers: (pr.requested_reviewers ?? []).flatMap((user) => {
272 const actor = forgejoActor(user);
273 return actor ? [actor] : [];
274 }),
275 checks: forgejoChecks(statuses.items),
276 baseComparison: !pr.merge_base
277 ? "unknown"
278 : pr.merge_base === pr.base.sha
279 ? "up-to-date"
280 : "behind",
281 viewerPermissions: permissions(repo, pr, viewer),
282 mergeCapabilities: {
283 merge: repo.allow_merge_commits ?? true,
284 squash: repo.allow_squash_merge ?? true,
285 rebase: repo.allow_rebase ?? true,
286 },
287 } satisfies ProviderChangeRequestDetail;
288 }),
289 getViewerPermissions: getPermissions,
290 getChangeRequestActivity: Effect.fn("ForgejoPullRequestProvider.getChangeRequestActivity")(
291 function* (input) {
292 const [comments, reviews, commits, reactions, viewer] = yield* Effect.all(
293 [
294 // Issue comments ignore page/limit; fetch this unpaginated endpoint once.
295 readArray({ ...input, path: `${issuePath(input)}/comments` }, ForgejoComment).pipe(
296 Effect.map((items) => ({
297 items: items.slice(0, 500),
298 truncated: items.length > 500,
299 })),
300 ),
301 page({ ...input, path: `${pullPath(input)}/reviews` }, ForgejoReview),
302 page({ ...input, path: `${pullPath(input)}/commits` }, ForgejoCommit),
303 page({ ...input, path: `${issuePath(input)}/reactions` }, ForgejoReaction),
304 getViewer(input),
305 ],
306 { concurrency: 5 },
307 );
308 const reviewComments = yield* Effect.forEach(
309 reviews.items.filter((review) => review.comments_count > 0 && review.state !== "PENDING"),
310 (review) =>
311 readArray(
312 { ...input, path: `${pullPath(input)}/reviews/${review.id}/comments` },
313 ForgejoReviewComment,
314 ),
315 { concurrency: 4 },
316 );
317 const allInline = reviewComments.flat();
318 const inline = allInline.slice(0, 500);
319 const entries = [
320 ...comments.items.map((comment) => ({
321 comment: forgejoComment(comment),
322 reactionId: String(comment.id),
323 })),
324 ...reviews.items
325 .filter((review) => review.state !== "PENDING" && review.state !== "REQUEST_REVIEW")
326 .map((review) => ({
327 comment: forgejoReview(review),
328 reactionId: reviewCommentId(review),
329 })),
330 ...inline.map((comment) => ({
331 comment: {
332 ...forgejoComment(comment),
333 kind: "review-comment" as const,
334 path: comment.path,
335 },
336 reactionId: String(comment.id),
337 })),
338 ];
339 const enriched = yield* Effect.forEach(
340 entries,
341 ({ comment, reactionId }) =>
342 (reactionId === undefined
343 ? Effect.succeed([])
344 : readArray(
345 { ...input, path: `${repoPath(input)}/issues/comments/${reactionId}/reactions` },
346 ForgejoReaction,
347 )
348 ).pipe(
349 Effect.map((rows) => ({ ...comment, reactions: forgejoReactions(rows, viewer) })),
350 ),
351 { concurrency: 4 },
352 );
353 const byId = new Map(enriched.map((comment) => [comment.id, comment]));
354 const timeline = enriched.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
355 return {
356 comments: timeline,
357 commentCount: timeline.length,
358 commentsTruncated:
359 comments.truncated || reviews.truncated || allInline.length > inline.length,
360 reviewThreads: inline.map((comment) => ({
361 ...forgejoReviewThread(comment),
362 comments: [byId.get(String(comment.id)) ?? forgejoComment(comment)],
363 })),
364 commits: commits.items.map(forgejoCommit),
365 reactions: forgejoReactions(reactions.items, viewer),
366 };
367 },
368 ),
369 getFileRevisions: Effect.fn("ForgejoPullRequestProvider.getFileRevisions")(function* (input) {
370 const result = yield* request({ ...input, path: `${pullPath(input)}.diff` });
371 if (result.stdoutTruncated) {
372 return yield* failure("getFileRevisions", "Forgejo diff exceeded the output limit.");
373 }
374 const revisions = new Map(parseDiffFileRevisions(result.stdout));
375 for (const path of input.paths) {
376 if (!revisions.has(path)) revisions.set(path, "");
377 }
378 return { revisions, complete: true };
379 }),
380 getDiff: (input) =>
381 request({
382 ...input,
383 path: input.commit
384 ? `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}.diff`
385 : `${pullPath(input)}.diff`,
386 }).pipe(
387 Effect.map((result) => ({
388 patch: result.stdout,
389 truncated: result.stdoutTruncated,
390 nextCursor: null,
391 })),
392 ),
393 getDiffFileContents: Effect.fn("ForgejoPullRequestProvider.getDiffFileContents")(
394 function* (input) {
395 const pr = yield* getPull(input);
396 const commit = input.commit
397 ? yield* read(
398 {
399 ...input,
400 path: `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}`,
401 },
402 ForgejoCommit,
403 )
404 : null;
405 const oldRef = commit ? commit.parents[0]?.sha : pr.merge_base || pr.base.sha;
406 const newRef = input.commit ?? pr.head.sha;
407 const content = (repository: string, ref: string, path: string) =>
408 read(
409 {
410 ...input,
411 repository,
412 path: `${repoPath({ ...input, repository })}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`,
413 },
414 Schema.Struct({ content: Schema.String, encoding: Schema.Literal("base64") }),
415 ).pipe(Effect.map((file) => Buffer.from(file.content, "base64").toString("utf8")));
416 const [oldContents, newContents] = yield* Effect.all(
417 [
418 input.changeType === "new" || !oldRef
419 ? Effect.succeed("")
420 : content(input.repository, oldRef, input.oldPath),
421 input.changeType === "deleted"
422 ? Effect.succeed("")
423 : content(pr.head.repo?.full_name ?? input.repository, newRef, input.newPath),
424 ],
425 { concurrency: 2 },
426 );
427 return { oldContents, newContents };
428 },
429 ),
430 runAction: (input) => {
431 switch (input.action) {
432 case "merge":
433 return write({
434 ...input,
435 path: `${pullPath(input)}/merge`,
436 method: "POST",
437 body: { Do: input.mergeMethod ?? "merge" },
438 });
439 case "close":
440 case "reopen":
441 return write({
442 ...input,
443 path: pullPath(input),
444 method: "PATCH",
445 body: { state: input.action === "close" ? "closed" : "open" },
446 });
447 case "update-branch":
448 return write({
449 ...input,
450 path: `${pullPath(input)}/update?style=${input.updateMethod ?? "merge"}`,
451 method: "POST",
452 });
453 default:
454 return unsupported(input.action);
455 }
456 },
457 updateChangeRequest: (input) =>
458 write({
459 ...input,
460 path: pullPath(input),
461 method: "PATCH",
462 body: {
463 ...(input.title === undefined ? {} : { title: input.title }),
464 ...(input.body === undefined ? {} : { body: input.body }),
465 },
466 }),
467 comment: (input) =>
468 write({
469 ...input,
470 path: `${issuePath(input)}/comments`,
471 method: "POST",
472 body: { body: input.body },
473 }),
474 updateComment: (input) =>
475 write({
476 ...input,
477 path: `${repoPath(input)}/issues/comments/${encodeURIComponent(input.commentId)}`,
478 method: "PATCH",
479 body: { body: input.body },
480 }),
481 submitReview: Effect.fn("ForgejoPullRequestProvider.submitReview")(function* (input) {
482 const pr = yield* getPull(input);
483 yield* write({
484 ...input,
485 path: `${pullPath(input)}/reviews`,
486 method: "POST",
487 body: {
488 event:
489 input.verdict === "approve"
490 ? "APPROVED"
491 : input.verdict === "request-changes"
492 ? "REQUEST_CHANGES"
493 : "COMMENT",
494 body: input.body,
495 commit_id: pr.head.sha,
496 comments: input.comments.map((comment) => {
497 const position = comment.position;
498 const old =
499 position.kind === "deleted" ||
500 (position.kind === "context" && position.side === "left");
501 return {
502 path: old ? (comment.oldPath ?? comment.path) : comment.path,
503 body: comment.body,
504 old_position: old ? position.oldLine : 0,
505 new_position: old ? 0 : position.newLine,
506 };
507 }),
508 },
509 });
510 }),
511 listReviewerCandidates: Effect.fn("ForgejoPullRequestProvider.listReviewerCandidates")(
512 function* (input) {
513 const [pr, users] = yield* Effect.all(
514 [getPull(input), page({ ...input, path: `${repoPath(input)}/assignees` }, ForgejoUser)],
515 { concurrency: 2 },
516 );
517 return {
518 candidates: users.items
519 .filter((user) => user.login !== pr.user?.login)
520 .flatMap((user) => {
521 const actor = forgejoActor(user);
522 return actor
523 ? [
524 {
525 ...actor,
526 id: user.login,
527 kind: "user" as const,
528 isRequested:
529 pr.requested_reviewers?.some((reviewer) => reviewer.login === user.login) ??
530 false,
531 },
532 ]
533 : [];
534 }),
535 truncated: users.truncated,
536 };
537 },
538 ),
539 setReviewerRequest: (input) =>
540 write({
541 ...input,
542 path: `${pullPath(input)}/requested_reviewers`,
543 method: input.requested ? "POST" : "DELETE",
544 body: { reviewers: input.reviewers.map((reviewer) => reviewer.id) },
545 }),
546 listLabelCandidates: Effect.fn("ForgejoPullRequestProvider.listLabelCandidates")(
547 function* (input) {
548 const [pr, labels] = yield* Effect.all(
549 [getPull(input), page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel)],
550 { concurrency: 2 },
551 );
552 return {
553 candidates: labels.items.map((label) => ({
554 name: label.name,
555 color: label.color ?? null,
556 description: label.description ?? null,
557 isApplied: pr.labels?.some((applied) => applied.id === label.id) ?? false,
558 })),
559 truncated: labels.truncated,
560 };
561 },
562 ),
563 setLabels: Effect.fn("ForgejoPullRequestProvider.setLabels")(function* (input) {
564 const labels = yield* page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel);
565 const selected = labels.items.filter((label) => input.labels.includes(label.name));
566 if (selected.length !== input.labels.length)
567 return yield* failure("setLabels", "One or more requested labels could not be found.");
568 if (input.applied)
569 yield* write({
570 ...input,
571 path: `${issuePath(input)}/labels`,
572 method: "POST",
573 body: { labels: selected.map((label) => label.id) },
574 });
575 else
576 yield* Effect.forEach(
577 selected,
578 (label) =>
579 write({ ...input, path: `${issuePath(input)}/labels/${label.id}`, method: "DELETE" }),
580 { concurrency: 1 },
581 );
582 }),
583 setReaction: Effect.fn("ForgejoPullRequestProvider.setReaction")(function* (input) {
584 let commentId = input.subjectId;
585 if (commentId?.startsWith("review:")) {
586 const reviewId = /^review:([1-9]\d*)$/.exec(commentId)?.[1];
587 if (!reviewId) return yield* failure("setReaction", "Invalid Forgejo review ID.");
588 const review = yield* read(
589 { ...input, path: `${pullPath(input)}/reviews/${reviewId}` },
590 ForgejoReview,
591 );
592 commentId = reviewCommentId(review);
593 if (!commentId)
594 return yield* failure(
595 "setReaction",
596 "Forgejo did not return a comment ID for this review.",
597 );
598 }
599 if (commentId && !/^[1-9]\d*$/.test(commentId))
600 return yield* failure("setReaction", "Invalid Forgejo comment ID.");
601 yield* write({
602 ...input,
603 path: commentId
604 ? `${repoPath(input)}/issues/comments/${commentId}/reactions`
605 : `${issuePath(input)}/reactions`,
606 method: input.reacted ? "POST" : "DELETE",
607 body: { content: FORGEJO_REACTIONS[input.content] },
608 });
609 }),
610 replyToThread: () => unsupported("thread replies"),
611 setThreadResolution: () => unsupported("thread resolution"),
612 };
613 return provider;
614});
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+13-9
......@@ -7,8 +7,6 @@ import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
77import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
88import * as GitHubApi from "../sourceControl/GitHubApi.ts";
99import * as GitLabCli from "../sourceControl/GitLabCli.ts";
10import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
11import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
1210import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
1311import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";
1412import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
......@@ -17,6 +15,7 @@ import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
1715import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
1816import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
1917import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";
18import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts";
2019import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider";
2120
2221export class PullRequestProviderRegistry extends Context.Service<
......@@ -45,16 +44,21 @@ export function fromProviders(
4544 *
4645 * @public Service construction is part of the canonical Effect module API.
4746 */
48export const make = Effect.map(
49 Effect.all([
47export const make = Effect.gen(function* () {
48 const providers = yield* Effect.all([
5049 GitHubPullRequestProvider.make,
5150 GitLabPullRequestProvider.make,
52 ForgejoPullRequestProvider.make,
5351 BitbucketPullRequestProvider.make,
5452 AzureDevOpsPullRequestProvider.make,
55 ]),
56 fromProviders,
57);
53 ]);
54 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
55 driver.make.pipe(Effect.map((instance) => instance.pullRequests)),
56 );
57 return fromProviders([
58 ...providers,
59 ...drivers.filter((provider): provider is PullRequestProviderApi => provider !== null),
60 ]);
61});
5862
5963export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
6064 Layer.provide(
......@@ -66,7 +70,7 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
6670 ),
6771 ),
6872 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),
69 Layer.provide(ForgejoCli.layer),
73 Layer.provide(BuiltInDrivers.layer),
7074 Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),
7175 Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))),
7276);
apps/server/src/pullRequest/PullRequestService.test.ts+2-2
......@@ -30,8 +30,8 @@ import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.t
3030import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
3131import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
3232import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
33import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
34import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
33import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
34import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
3535import {
3636 PullRequestProviderError,
3737 type ProviderChangeRequest,
apps/server/src/pullRequest/bitbucketDiffRevisions.test.ts deleted-280
......@@ -1,280 +0,0 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts";
4
5function patchOf(...lines: ReadonlyArray<string>): string {
6 return `${lines.join("\n")}\n`;
7}
8
9describe("parseDiffFileRevisions", () => {
10 it("reads the head id of a changed file off its index line", () => {
11 const revisions = parseDiffFileRevisions(
12 patchOf(
13 "diff --git a/src/a.ts b/src/a.ts",
14 "index 7f2aa0ab6..b4a2a7c9a 100644",
15 "--- a/src/a.ts",
16 "+++ b/src/a.ts",
17 "@@ -1 +1 @@",
18 "-a",
19 "+b",
20 ),
21 );
22
23 assert.deepStrictEqual([...revisions], [["src/a.ts", "b4a2a7c9a"]]);
24 });
25
26 it("names a deletion by the path it had, which is the one still on screen", () => {
27 const revisions = parseDiffFileRevisions(
28 patchOf(
29 "diff --git a/gone.ts b/gone.ts",
30 "deleted file mode 100644",
31 "index 1111111..0000000",
32 "--- a/gone.ts",
33 "+++ /dev/null",
34 "@@ -1 +0,0 @@",
35 "-x",
36 ),
37 );
38
39 assert.deepStrictEqual([...revisions], [["gone.ts", "0000000"]]);
40 });
41
42 it("names a rename by where it moved to", () => {
43 const revisions = parseDiffFileRevisions(
44 patchOf(
45 "diff --git a/old.ts b/new.ts",
46 "similarity index 90%",
47 "rename from old.ts",
48 "rename to new.ts",
49 "index 2222222..3333333 100644",
50 "--- a/old.ts",
51 "+++ b/new.ts",
52 "@@ -1 +1 @@",
53 "-a",
54 "+b",
55 ),
56 );
57
58 assert.deepStrictEqual([...revisions], [["new.ts", "3333333"]]);
59 });
60
61 it("names a rename that changed nothing, which states no paths of its own", () => {
62 const revisions = parseDiffFileRevisions(
63 patchOf(
64 "diff --git a/old.ts b/new.ts",
65 "similarity index 100%",
66 "rename from old.ts",
67 "rename to new.ts",
68 "index 2222222..2222222 100644",
69 ),
70 );
71
72 assert.deepStrictEqual([...revisions], [["new.ts", "2222222"]]);
73 });
74
75 it("leaves out a file Bitbucket excluded, which it gives no index line for", () => {
76 const revisions = parseDiffFileRevisions(
77 patchOf(
78 "diff --git a/package.json b/package.json",
79 "index 7f2aa0ab6..b4a2a7c9a 100644",
80 "--- a/package.json",
81 "+++ b/package.json",
82 "@@ -1 +1 @@",
83 '- "x": "1"',
84 '+ "x": "2"',
85 "diff --git a/yarn.lock b/yarn.lock",
86 'File excluded by pattern "yarn.lock"',
87 ),
88 );
89
90 assert.deepStrictEqual([...revisions], [["package.json", "b4a2a7c9a"]]);
91 });
92
93 it("stops reading headers at the first hunk, so content cannot pose as one", () => {
94 const revisions = parseDiffFileRevisions(
95 patchOf(
96 "diff --git a/notes.md b/notes.md",
97 "index aaaaaaa..bbbbbbb 100644",
98 "--- a/notes.md",
99 "+++ b/notes.md",
100 "@@ -1,2 +1,2 @@",
101 "--- a/decoy.ts",
102 "+++ b/decoy.ts",
103 "+index ccccccc..ddddddd 100644",
104 ),
105 );
106
107 assert.deepStrictEqual([...revisions], [["notes.md", "bbbbbbb"]]);
108 });
109
110 it("splits a header whose paths contain the separator, by the sides agreeing", () => {
111 const revisions = parseDiffFileRevisions(
112 patchOf(
113 "diff --git a/one b/two.ts b/one b/two.ts",
114 "index eeeeeee..fffffff 100644",
115 "@@ -1 +1 @@",
116 ),
117 );
118
119 assert.deepStrictEqual([...revisions], [["one b/two.ts", "fffffff"]]);
120 });
121
122 it("leaves out an added file that Bitbucket sent no index line for", () => {
123 const revisions = parseDiffFileRevisions(
124 patchOf("diff --git a/added.ts b/added.ts", "new file mode 100644", "--- /dev/null"),
125 );
126
127 assert.strictEqual(revisions.size, 0);
128 });
129
130 it("reads nothing out of an empty patch", () => {
131 assert.strictEqual(parseDiffFileRevisions("").size, 0);
132 });
133
134 it("reads a name git had to quote, here one holding a tab", () => {
135 const revisions = parseDiffFileRevisions(
136 patchOf(
137 'diff --git "a/we\\tird.ts" "b/we\\tird.ts"',
138 "index 4444444..5555555 100644",
139 '--- "a/we\\tird.ts"',
140 '+++ "b/we\\tird.ts"',
141 "@@ -1 +1 @@",
142 "-a",
143 "+b",
144 ),
145 );
146
147 assert.deepStrictEqual([...revisions], [["we\tird.ts", "5555555"]]);
148 });
149
150 it("rejoins the octal bytes git writes for a name outside ASCII", () => {
151 const revisions = parseDiffFileRevisions(
152 patchOf(
153 'diff --git "a/caf\\303\\251/r\\303\\251sum\\303\\251.ts" "b/caf\\303\\251/r\\303\\251sum\\303\\251.ts"',
154 "index 6666666..7777777 100644",
155 "@@ -1 +1 @@",
156 ),
157 );
158
159 assert.deepStrictEqual([...revisions], [["café/résumé.ts", "7777777"]]);
160 });
161
162 it("splits a rename header where git quoted only the side that needed it", () => {
163 const revisions = parseDiffFileRevisions(
164 patchOf(
165 'diff --git a/old.ts "b/new\\tname.ts"',
166 "similarity index 90%",
167 "rename from old.ts",
168 'rename to "new\\tname.ts"',
169 "index 8888888..9999999 100644",
170 "--- a/old.ts",
171 '+++ "b/new\\tname.ts"',
172 "@@ -1 +1 @@",
173 "-a",
174 "+b",
175 ),
176 );
177
178 assert.deepStrictEqual([...revisions], [["new\tname.ts", "9999999"]]);
179 });
180
181 it("names a quoted rename that changed nothing, which states no paths of its own", () => {
182 const revisions = parseDiffFileRevisions(
183 patchOf(
184 'diff --git "a/old\\tname.ts" "b/new\\tname.ts"',
185 "similarity index 100%",
186 'rename from "old\\tname.ts"',
187 'rename to "new\\tname.ts"',
188 "index abcabca..abcabca 100644",
189 ),
190 );
191
192 assert.deepStrictEqual([...revisions], [["new\tname.ts", "abcabca"]]);
193 });
194
195 it("keeps a character from outside the basic plane that git left unescaped", () => {
196 // `core.quotePath` off leaves the name's own bytes in place, and git still quotes the header
197 // for the tab. Encoding what it left one unit at a time would split the pair into two halves.
198 const revisions = parseDiffFileRevisions(
199 patchOf(
200 'diff --git "a/we\\tird-\u{1f680}.ts" "b/we\\tird-\u{1f680}.ts"',
201 "index aaaaaaa..bbbbbbb 100644",
202 "@@ -1 +1 @@",
203 ),
204 );
205
206 assert.deepStrictEqual([...revisions], [["we\tird-\u{1f680}.ts", "bbbbbbb"]]);
207 });
208
209 it("drops the tab git ends a name holding a space with", () => {
210 const revisions = parseDiffFileRevisions(
211 patchOf(
212 "diff --git a/with space.txt b/with space.txt",
213 "index 1111111..6178079 100644",
214 "--- a/with space.txt\t",
215 "+++ b/with space.txt\t",
216 "@@ -1 +1 @@",
217 "-a",
218 "+b",
219 ),
220 );
221
222 assert.deepStrictEqual([...revisions], [["with space.txt", "6178079"]]);
223 });
224
225 it("drops that tab from a quoted name too, where it lands past the closing quote", () => {
226 const revisions = parseDiffFileRevisions(
227 patchOf(
228 'diff --git "a/we ird\\tname.ts" "b/we ird\\tname.ts"',
229 "index 2222222..7777777 100644",
230 '--- "a/we ird\\tname.ts"\t',
231 '+++ "b/we ird\\tname.ts"\t',
232 "@@ -1 +1 @@",
233 "-a",
234 "+b",
235 ),
236 );
237
238 assert.deepStrictEqual([...revisions], [["we ird\tname.ts", "7777777"]]);
239 });
240
241 it("reads a deletion whose name holds a space, tab and all", () => {
242 const revisions = parseDiffFileRevisions(
243 patchOf(
244 "diff --git a/with space.txt b/with space.txt",
245 "deleted file mode 100644",
246 "index 3333333..0000000",
247 "--- a/with space.txt\t",
248 "+++ /dev/null",
249 "@@ -1 +0,0 @@",
250 "-a",
251 ),
252 );
253
254 assert.deepStrictEqual([...revisions], [["with space.txt", "0000000"]]);
255 });
256
257 it("drops the timestamp other producers of the format write past that tab", () => {
258 const revisions = parseDiffFileRevisions(
259 patchOf(
260 "diff --git a/stamped.ts b/stamped.ts",
261 "index 4444444..5555555 100644",
262 "--- a/stamped.ts\t2024-01-01 00:00:00.000000000 +0000",
263 "+++ b/stamped.ts\t2024-01-02 00:00:00.000000000 +0000",
264 "@@ -1 +1 @@",
265 "-a",
266 "+b",
267 ),
268 );
269
270 assert.deepStrictEqual([...revisions], [["stamped.ts", "5555555"]]);
271 });
272
273 it("splits an unquoted header whose names hold a space, by the sides agreeing", () => {
274 const revisions = parseDiffFileRevisions(
275 patchOf("diff --git a/one two b/one two", "index ddddddd..eeeeeee 100644", "@@ -1 +1 @@"),
276 );
277
278 assert.deepStrictEqual([...revisions], [["one two", "eeeeeee"]]);
279 });
280});
apps/server/src/pullRequest/bitbucketDiffRevisions.ts deleted-129
......@@ -1,129 +0,0 @@
1import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
2
3const ENTRY = "diff --git ";
4const QUOTE = '"';
5
6interface Entry {
7 oldPath: string | null;
8 newPath: string | null;
9 deleted: boolean;
10 revision: string | null;
11 /** Past the first hunk header every line is content, and content can start like a header. */
12 inBody: boolean;
13}
14
15/** Where a quoted name closes, given git escapes every quote the name itself holds. */
16function quotedEnd(rest: string): number {
17 for (let at = 1; at < rest.length; at += 1) {
18 const char = rest.charAt(at);
19 if (char === "\\") {
20 at += 1;
21 continue;
22 }
23 if (char === QUOTE) return at;
24 }
25 return -1;
26}
27
28/**
29 * `a/x`/`b/x` on a `---`/`+++` line; `/dev/null` marks the side that has no file. Git ends the
30 * name with a tab when it holds a space, and a name with a tab of its own arrives quoted with
31 * that tab escaped, so the first literal tab is never part of what the file is called.
32 */
33function sidePath(rest: string, prefix: string): string | null {
34 const tab = rest.indexOf("\t");
35 const token = tab === -1 ? rest : rest.slice(0, tab);
36 if (token === "/dev/null") return null;
37 const path = unquoteGitPatchPath(token);
38 return path.startsWith(prefix) ? path.slice(prefix.length) : path;
39}
40
41function headerSide(token: string, prefix: string): string | null {
42 const path = unquoteGitPatchPath(token);
43 return path.startsWith(prefix) ? path.slice(prefix.length) : null;
44}
45
46/**
47 * The two names on a `diff --git` line, written with no delimiter between them. `a/one two b/one
48 * two` can split in more than one place, so the split leaving both sides equal wins; a rename
49 * (the only case where sides differ) states its names on separate lines instead. A quoted name
50 * ends at its own closing quote and needs none of that guessing.
51 */
52function headerPaths(rest: string): readonly [string | null, string | null] {
53 if (rest.startsWith(QUOTE)) {
54 const end = quotedEnd(rest);
55 if (end === -1 || rest.charAt(end + 1) !== " ") return [null, null];
56 return [headerSide(rest.slice(0, end + 1), "a/"), headerSide(rest.slice(end + 2), "b/")];
57 }
58 if (rest.endsWith(QUOTE)) {
59 const opens = rest.indexOf(QUOTE);
60 if (opens < 1 || rest.charAt(opens - 1) !== " ") return [null, null];
61 return [headerSide(rest.slice(0, opens - 1), "a/"), headerSide(rest.slice(opens), "b/")];
62 }
63 if (!rest.startsWith("a/")) return [null, null];
64 const splits: Array<number> = [];
65 for (let at = rest.indexOf(" b/"); at !== -1; at = rest.indexOf(" b/", at + 1)) splits.push(at);
66 const chosen =
67 splits.find((at) => rest.slice(2, at) === rest.slice(at + 3)) ??
68 (splits.length === 1 ? splits[0] : undefined);
69 return chosen === undefined ? [null, null] : [rest.slice(2, chosen), rest.slice(chosen + 3)];
70}
71
72/** The right-hand id of `index <before>..<after> <mode>`. */
73function headRevision(rest: string): string | null {
74 const gap = rest.indexOf("..");
75 if (gap === -1) return null;
76 const after = rest.slice(gap + 2);
77 const end = after.indexOf(" ");
78 const head = end === -1 ? after : after.slice(0, end);
79 return head.length === 0 ? null : head;
80}
81
82/**
83 * What the head has of each file, as the blob ids from a unified patch's `index <before>..<after>`
84 * line: Bitbucket exposes no blob id for a file anywhere else. Keyed by the head's name, except
85 * for a deletion where only the old name exists. An entry with no `index` line (most often one
86 * Bitbucket excluded by pattern) is left out.
87 */
88export function parseDiffFileRevisions(patch: string): ReadonlyMap<string, string> {
89 const revisions = new Map<string, string>();
90 let entry: Entry | null = null;
91
92 const close = () => {
93 if (entry === null) return;
94 const path = entry.deleted ? entry.oldPath : (entry.newPath ?? entry.oldPath);
95 if (path !== null && path.length > 0 && entry.revision !== null) {
96 revisions.set(path, entry.revision);
97 }
98 entry = null;
99 };
100
101 for (const line of patch.split("\n")) {
102 if (line.startsWith(ENTRY)) {
103 close();
104 const [oldPath, newPath] = headerPaths(line.slice(ENTRY.length));
105 entry = { oldPath, newPath, deleted: false, revision: null, inBody: false };
106 continue;
107 }
108 if (entry === null || entry.inBody) continue;
109 if (line.startsWith("@@")) {
110 entry.inBody = true;
111 } else if (line.startsWith("index ")) {
112 entry.revision = headRevision(line.slice("index ".length));
113 } else if (line.startsWith("deleted file mode")) {
114 entry.deleted = true;
115 } else if (line.startsWith("rename from ")) {
116 entry.oldPath = unquoteGitPatchPath(line.slice("rename from ".length));
117 } else if (line.startsWith("rename to ")) {
118 entry.newPath = unquoteGitPatchPath(line.slice("rename to ".length));
119 } else if (line.startsWith("--- ")) {
120 entry.oldPath = sidePath(line.slice(4), "a/");
121 } else if (line.startsWith("+++ ")) {
122 const side = sidePath(line.slice(4), "b/");
123 entry.newPath = side;
124 if (side === null) entry.deleted = true;
125 }
126 }
127 close();
128 return revisions;
129}
apps/server/src/pullRequest/forgejoPullRequestJson.ts deleted-262
......@@ -1,262 +0,0 @@
1import * as Schema from "effect/Schema";
2import * as DateTime from "effect/DateTime";
3import * as Option from "effect/Option";
4import type {
5 PullRequestActor,
6 PullRequestCheck,
7 PullRequestComment,
8 PullRequestCommit,
9 PullRequestReaction,
10 PullRequestReactionContent,
11 PullRequestReviewThread,
12} from "@t3tools/contracts";
13import type { ProviderChangeRequest } from "@t3tools/source-control-core/server/PullRequestProvider";
14import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
15
16export const ForgejoUser = Schema.Struct({
17 login: Schema.String,
18 full_name: Schema.optional(Schema.NullOr(Schema.String)),
19 avatar_url: Schema.optional(Schema.NullOr(Schema.String)),
20});
21export const ForgejoLabel = Schema.Struct({
22 id: Schema.Int,
23 name: Schema.String,
24 color: Schema.optional(Schema.NullOr(Schema.String)),
25 description: Schema.optional(Schema.NullOr(Schema.String)),
26});
27export const ForgejoRepository = Schema.Struct({
28 full_name: Schema.String,
29 permissions: Schema.optional(Schema.Struct({ push: Schema.Boolean, admin: Schema.Boolean })),
30 archived: Schema.optional(Schema.Boolean),
31 allow_merge_commits: Schema.optional(Schema.Boolean),
32 allow_squash_merge: Schema.optional(Schema.Boolean),
33 allow_rebase: Schema.optional(Schema.Boolean),
34 allow_rebase_update: Schema.optional(Schema.Boolean),
35});
36const Branch = Schema.Struct({
37 ref: Schema.String,
38 sha: Schema.String,
39 repo: Schema.NullOr(ForgejoRepository),
40});
41export const ForgejoPullRequest = Schema.Struct({
42 number: Schema.Int,
43 title: Schema.String,
44 body: Schema.NullOr(Schema.String),
45 html_url: Schema.String,
46 user: Schema.NullOr(ForgejoUser),
47 state: Schema.String,
48 draft: Schema.optional(Schema.Boolean),
49 merged: Schema.Boolean,
50 mergeable: Schema.optional(Schema.Boolean),
51 is_locked: Schema.optional(Schema.Boolean),
52 head: Branch,
53 base: Branch,
54 merge_base: Schema.optional(Schema.String),
55 created_at: Schema.String,
56 updated_at: Schema.String,
57 closed_at: Schema.NullOr(Schema.String),
58 merged_at: Schema.NullOr(Schema.String),
59 additions: Schema.optional(Schema.NullOr(Schema.Int)),
60 deletions: Schema.optional(Schema.NullOr(Schema.Int)),
61 changed_files: Schema.optional(Schema.NullOr(Schema.Int)),
62 comments: Schema.optional(Schema.Int),
63 labels: Schema.NullOr(Schema.Array(ForgejoLabel)),
64 requested_reviewers: Schema.optional(Schema.NullOr(Schema.Array(ForgejoUser))),
65});
66export const ForgejoComment = Schema.Struct({
67 id: Schema.Int,
68 body: Schema.String,
69 user: Schema.NullOr(ForgejoUser),
70 created_at: Schema.String,
71 html_url: Schema.optional(Schema.String),
72});
73export const ForgejoReview = Schema.Struct({
74 id: Schema.Int,
75 body: Schema.String,
76 user: Schema.NullOr(ForgejoUser),
77 state: Schema.String,
78 submitted_at: Schema.String,
79 html_url: Schema.optional(Schema.String),
80 comments_count: Schema.Int,
81});
82export const ForgejoReviewComment = Schema.Struct({
83 ...ForgejoComment.fields,
84 path: Schema.String,
85 position: Schema.Int,
86 original_position: Schema.Int,
87 commit_id: Schema.String,
88 original_commit_id: Schema.String,
89 resolver: Schema.NullOr(ForgejoUser),
90});
91export const ForgejoCommit = Schema.Struct({
92 sha: Schema.String,
93 author: Schema.NullOr(ForgejoUser),
94 commit: Schema.Struct({
95 message: Schema.String,
96 committer: Schema.Struct({ date: Schema.String }),
97 }),
98 parents: Schema.Array(Schema.Struct({ sha: Schema.String })),
99 stats: Schema.optional(
100 Schema.NullOr(Schema.Struct({ additions: Schema.Int, deletions: Schema.Int })),
101 ),
102});
103export const ForgejoStatus = Schema.Struct({
104 context: Schema.String,
105 status: Schema.String,
106 description: Schema.NullOr(Schema.String),
107 target_url: Schema.NullOr(Schema.String),
108 updated_at: Schema.String,
109});
110export const ForgejoReaction = Schema.Struct({
111 content: Schema.String,
112 user: Schema.NullOr(ForgejoUser),
113});
114
115export function forgejoActor(
116 user: typeof ForgejoUser.Type | null | undefined,
117): PullRequestActor | null {
118 return user?.login
119 ? { login: user.login, name: user.full_name || null, avatarUrl: user.avatar_url || null }
120 : null;
121}
122
123function toIsoUtc(value: string): string {
124 return Option.match(DateTime.make(value), { onNone: () => value, onSome: DateTime.formatIso });
125}
126
127export function forgejoChangeRequest(pr: typeof ForgejoPullRequest.Type) {
128 return {
129 number: pr.number,
130 title: pr.title,
131 url: pr.html_url,
132 author: forgejoActor(pr.user),
133 headBranch: pr.head.ref,
134 baseBranch: pr.base.ref,
135 headRepositoryNameWithOwner: pr.head.repo?.full_name ?? null,
136 state: pr.merged ? "merged" : pr.state === "closed" ? "closed" : "open",
137 isDraft: pr.draft ?? /^(?:\[WIP\]|WIP:)/i.test(pr.title),
138 // A false value also covers drafts and unfinished or failed conflict checks.
139 mergeability: pr.mergeable === true ? "mergeable" : "unknown",
140 additions: pr.additions ?? 0,
141 deletions: pr.deletions ?? 0,
142 createdAt: toIsoUtc(pr.created_at),
143 updatedAt: toIsoUtc(pr.updated_at),
144 closedAt: pr.closed_at === null ? null : toIsoUtc(pr.closed_at),
145 mergedAt: pr.merged_at === null ? null : toIsoUtc(pr.merged_at),
146 reviewRequestLogins: (pr.requested_reviewers ?? []).map((user) => user.login),
147 labels: (pr.labels ?? []).map((label) => ({ name: label.name, color: label.color ?? null })),
148 } satisfies ProviderChangeRequest;
149}
150
151export function forgejoComment(comment: typeof ForgejoComment.Type): PullRequestComment {
152 return {
153 id: String(comment.id),
154 kind: "issue-comment",
155 author: forgejoActor(comment.user),
156 body: comment.body,
157 createdAt: toIsoUtc(comment.created_at),
158 url: comment.html_url || null,
159 path: null,
160 reviewState: null,
161 };
162}
163
164export function forgejoReview(review: typeof ForgejoReview.Type): PullRequestComment {
165 return {
166 id: `review:${review.id}`,
167 kind: "review",
168 author: forgejoActor(review.user),
169 body: review.body,
170 createdAt: toIsoUtc(review.submitted_at),
171 url: review.html_url || null,
172 path: null,
173 reviewState:
174 review.state === "REQUEST_CHANGES"
175 ? "CHANGES_REQUESTED"
176 : review.state === "COMMENT"
177 ? "COMMENTED"
178 : review.state,
179 };
180}
181
182export function forgejoReviewThread(
183 comment: typeof ForgejoReviewComment.Type,
184): PullRequestReviewThread {
185 const oldSide = comment.position === 0 && comment.original_position > 0;
186 const line = oldSide ? comment.original_position : comment.position;
187 return {
188 id: String(comment.id),
189 path: comment.path,
190 line: line > 0 ? line : null,
191 side: oldSide ? "left" : "right",
192 isResolved: comment.resolver !== null,
193 isOutdated: false,
194 comments: [forgejoComment(comment)],
195 };
196}
197
198export function forgejoCommit(commit: typeof ForgejoCommit.Type): PullRequestCommit {
199 const author = forgejoActor(commit.author);
200 return {
201 oid: commit.sha,
202 messageHeadline: commit.commit.message.split("\n")[0] ?? "",
203 committedDate: toIsoUtc(commit.commit.committer.date),
204 authors: author ? [author] : [],
205 ...(commit.stats
206 ? { additions: commit.stats.additions, deletions: commit.stats.deletions }
207 : {}),
208 };
209}
210
211export function forgejoChecks(
212 statuses: ReadonlyArray<typeof ForgejoStatus.Type>,
213): ReadonlyArray<PullRequestCheck> {
214 return dedupeChecks(
215 statuses.map((status) => ({
216 workflowName: null,
217 at: status.updated_at,
218 check: {
219 name: status.context || "check",
220 description: status.description || null,
221 url: status.target_url || null,
222 status:
223 status.status === "success"
224 ? "success"
225 : status.status === "failure" || status.status === "error"
226 ? "failure"
227 : "pending",
228 },
229 })),
230 );
231}
232
233export const FORGEJO_REACTIONS: Record<PullRequestReactionContent, string> = {
234 "thumbs-up": "+1",
235 "thumbs-down": "-1",
236 laugh: "laugh",
237 hooray: "hooray",
238 confused: "confused",
239 heart: "heart",
240 rocket: "rocket",
241 eyes: "eyes",
242};
243export function forgejoReactions(
244 reactions: ReadonlyArray<typeof ForgejoReaction.Type>,
245 viewer: string,
246): ReadonlyArray<PullRequestReaction> {
247 return Object.entries(FORGEJO_REACTIONS).flatMap(([content, emoji]) => {
248 const matching = reactions.filter((reaction) => reaction.content === emoji);
249 return matching.length === 0
250 ? []
251 : [
252 {
253 content: content as PullRequestReactionContent,
254 count: matching.length,
255 actors: matching.flatMap((reaction) =>
256 reaction.user?.login && reaction.user.login !== viewer ? [reaction.user.login] : [],
257 ),
258 viewerHasReacted: matching.some((reaction) => reaction.user?.login === viewer),
259 },
260 ];
261 });
262}
apps/server/src/server.ts+3-4
......@@ -56,7 +56,8 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
5656import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
5757import * as GitHubApi from "./sourceControl/GitHubApi.ts";
5858import * as GitLabCli from "./sourceControl/GitLabCli.ts";
59import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
59import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
60import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
6061import * as TextGeneration from "./textGeneration/TextGeneration.ts";
6162import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts";
6263import * as TerminalManager from "./terminal/Manager.ts";
......@@ -104,7 +105,6 @@ import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
104105import * as GitWorkflowService from "./git/GitWorkflowService.ts";
105106import * as ReviewService from "./review/ReviewService.ts";
106107import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
107import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts";
108108import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts";
109109import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
110110import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
......@@ -305,8 +305,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p
305305 BitbucketApi.layer,
306306 GitHubApi.layerWithDependencies,
307307 GitLabCli.layer,
308 ForgejoCli.layer,
309 ServerSourceControlHost.layer,
308 SourceControlBuiltInDrivers.layer,
310309 ),
311310 ),
312311 Layer.provideMerge(GitVcsDriver.layer),
apps/server/src/sourceControl/ForgejoCli.ts deleted-724
......@@ -1,724 +0,0 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";
5import * as Result from "effect/Result";
6import * as Clock from "effect/Clock";
7import * as FileSystem from "effect/FileSystem";
8import * as Semaphore from "effect/Semaphore";
9import * as NodeOS from "node:os";
10// @effect-diagnostics-next-line nodeBuiltinImport:off - fj storage paths use explicit Windows and POSIX layouts, independently of this process's platform.
11import * as NodePath from "node:path";
12import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
13import { ChildProcessSpawner } from "effect/process";
14import { decodeJsonResult } from "@t3tools/shared/schemaJson";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import * as VcsProcess from "../vcs/VcsProcess.ts";
17import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
18import type { SourceControlProviderContext } from "@t3tools/source-control-core/server/SourceControlProvider";
19
20const encodeApiBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
21
22export class ForgejoCliError extends Schema.TaggedError<ForgejoCliError>()("ForgejoCliError", {
23 command: Schema.Literals(["fj", "tea"]),
24 cwd: Schema.String,
25 detail: Schema.String,
26 reason: Schema.optional(
27 Schema.Literals([
28 "missing-cli",
29 "authentication",
30 "forbidden",
31 "not-found",
32 "rate-limit",
33 "invalid-response",
34 ]),
35 ),
36 httpStatus: Schema.optional(Schema.Int),
37 cause: Schema.optional(Schema.Defect()),
38}) {}
39
40export const ForgejoLoginSchema = Schema.Struct({
41 name: Schema.String,
42 url: Schema.String,
43 ssh_host: Schema.optional(Schema.String),
44 valid: Schema.optional(Schema.String),
45 user: Schema.String,
46 default: Schema.String,
47});
48
49export function parseForgejoLogins(raw: string) {
50 const decoded = decodeJsonResult(Schema.Array(ForgejoLoginSchema))(raw);
51 return Result.isSuccess(decoded) ? decoded.success : [];
52}
53
54export const ForgejoKeysSchema = Schema.Struct({
55 hosts: Schema.Record(
56 Schema.String,
57 Schema.Struct({
58 type: Schema.Literals(["Application", "OAuth"]),
59 token: Schema.String,
60 }),
61 ),
62 aliases: Schema.optional(Schema.Record(Schema.String, Schema.String)),
63});
64
65const parseForgejoKeys = decodeJsonResult(ForgejoKeysSchema);
66
67/** Matches fj's directories::ProjectDirs, including its pre-0.6 organization name. */
68function forgejoKeysPaths(input: {
69 readonly platform: string;
70 readonly home: string;
71 readonly dataHome?: string;
72 readonly appData?: string;
73}) {
74 if (input.platform === "darwin")
75 return ["forgejo-cli", "Cyborus"].map((organization) =>
76 NodePath.join(
77 input.home,
78 "Library",
79 "Application Support",
80 `${organization}.forgejo-cli`,
81 "keys.json",
82 ),
83 );
84 if (input.platform === "win32")
85 return ["forgejo-cli", "Cyborus"].map((organization) =>
86 NodePath.win32.join(
87 input.appData || NodePath.win32.join(input.home, "AppData", "Roaming"),
88 organization,
89 "forgejo-cli",
90 "data",
91 "keys.json",
92 ),
93 );
94 return [
95 NodePath.join(
96 input.dataHome && NodePath.isAbsolute(input.dataHome)
97 ? input.dataHome
98 : NodePath.join(input.home, ".local", "share"),
99 "forgejo-cli",
100 "keys.json",
101 ),
102 ];
103}
104
105export interface ForgejoRepositoryInput {
106 readonly cwd: string;
107 readonly context?: SourceControlProviderContext;
108 readonly repository?: string;
109 readonly reference?: string;
110 readonly host?: string;
111}
112
113export interface ForgejoRepository {
114 readonly command?: "fj" | "tea";
115 readonly login: string;
116 readonly repository: string;
117 readonly baseUrl: string;
118}
119
120export interface ForgejoApiInput extends ForgejoRepositoryInput {
121 readonly path: string;
122 readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
123 readonly body?: unknown;
124}
125
126export class ForgejoCli extends Context.Service<
127 ForgejoCli,
128 {
129 readonly execute: (input: {
130 readonly command?: "fj" | "tea";
131 readonly cwd: string;
132 readonly args: ReadonlyArray<string>;
133 readonly stdin?: string;
134 readonly timeoutMs?: number;
135 readonly maxOutputBytes?: number;
136 }) => Effect.Effect<VcsProcess.VcsProcessOutput, ForgejoCliError>;
137 readonly listLogins?: (input: {
138 readonly cwd: string;
139 readonly command: "fj" | "tea";
140 readonly remoteUrl?: string;
141 }) => Effect.Effect<ReturnType<typeof parseForgejoLogins>, ForgejoCliError>;
142 readonly getAccount?: (input: {
143 readonly cwd: string;
144 readonly baseUrl: string;
145 }) => Effect.Effect<string, ForgejoCliError>;
146 readonly resolveRepository: (
147 input: ForgejoRepositoryInput,
148 ) => Effect.Effect<ForgejoRepository, ForgejoCliError>;
149 readonly api: (
150 input: ForgejoApiInput,
151 ) => Effect.Effect<VcsProcess.VcsProcessOutput, ForgejoCliError>;
152 }
153>()("t3/sourceControl/ForgejoCli") {}
154
155export function parseForgejoRemote(value: string) {
156 if (/^(?:https?|ssh):\/\//i.test(value)) {
157 try {
158 const url = new URL(value);
159 return {
160 host: url.host.toLowerCase(),
161 hostname: url.hostname.toLowerCase(),
162 ssh: url.protocol === "ssh:",
163 path: url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""),
164 };
165 } catch {
166 return null;
167 }
168 }
169 // SCP remotes may omit the username; URL treats these as a custom scheme.
170 const ssh = /^(?:[^@/]+@)?([^:/]+):([^/].*)$/.exec(value);
171 return ssh?.[1] && ssh[2]
172 ? {
173 host: ssh[1].toLowerCase(),
174 hostname: ssh[1].toLowerCase(),
175 ssh: true,
176 path: ssh[2].replace(/\.git$/, ""),
177 }
178 : null;
179}
180
181export function matchForgejoLogin(
182 logins: ReturnType<typeof parseForgejoLogins>,
183 remote: NonNullable<ReturnType<typeof parseForgejoRemote>>,
184 requestedHost?: string,
185 hostOnly = false,
186) {
187 const matches = [
188 ...new Map(
189 logins
190 .filter((login) => {
191 const url = parseForgejoRemote(login.url);
192 if (!url) return false;
193 if (requestedHost !== undefined && url.host !== requestedHost.toLowerCase()) return false;
194 return remote.ssh
195 ? login.ssh_host?.toLowerCase() === remote.host ||
196 login.ssh_host?.toLowerCase() === remote.hostname ||
197 url.hostname === remote.hostname
198 : url.host === remote.host &&
199 ((hostOnly && !remote.path) ||
200 !url.path ||
201 remote.path === url.path ||
202 remote.path.startsWith(`${url.path}/`));
203 })
204 .map((login) => [login.name, login]),
205 ).values(),
206 ];
207 return matches.length === 1
208 ? matches[0]
209 : new Set(matches.map((login) => login.url)).size === 1
210 ? matches.find((login) => login.default === "true")
211 : undefined;
212}
213
214export const make = Effect.gen(function* () {
215 const process = yield* VcsProcess.VcsProcess;
216 const fileSystem = yield* FileSystem.FileSystem;
217 const httpClient = yield* HttpClient.HttpClient;
218 const authLock = yield* Semaphore.make(1);
219 const authenticated = new Map<string, { token: string; time: number }>();
220 const execute: ForgejoCli["Service"]["execute"] = (input) =>
221 process
222 .run({
223 ...input,
224 operation: "ForgejoCli.execute",
225 command: input.command ?? "tea",
226 timeoutMs: input.timeoutMs ?? 30_000,
227 })
228 .pipe(
229 Effect.mapError(
230 (cause) =>
231 new ForgejoCliError({
232 command: input.command ?? "tea",
233 cwd: input.cwd,
234 ...(input.command === "fj" ? {} : { cause }),
235 ...(cause._tag === "VcsProcessSpawnError"
236 ? { reason: "missing-cli" as const }
237 : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication"
238 ? { reason: "authentication" as const }
239 : {}),
240 detail:
241 cause._tag === "VcsProcessSpawnError"
242 ? "Install Forgejo CLI (`fj` 0.6 or later) or Gitea CLI (`tea` 0.16 or later) and retry."
243 : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication"
244 ? "Authenticate this server with `fj auth login`, `fj auth add-token`, or `tea login add`."
245 : "Forgejo CLI command failed.",
246 }),
247 ),
248 );
249
250 const readKeys = Effect.fn("ForgejoCli.readKeys")(function* (cwd: string) {
251 for (const path of forgejoKeysPaths({
252 platform: yield* HostProcessPlatform,
253 home: NodeOS.homedir(),
254 ...(globalThis.process.env.XDG_DATA_HOME
255 ? { dataHome: globalThis.process.env.XDG_DATA_HOME }
256 : {}),
257 ...(globalThis.process.env.APPDATA ? { appData: globalThis.process.env.APPDATA } : {}),
258 })) {
259 const exists = yield* fileSystem.exists(path).pipe(
260 Effect.mapError(
261 () =>
262 new ForgejoCliError({
263 command: "fj",
264 cwd,
265 reason: "authentication",
266 detail: "Could not read fj authentication storage.",
267 }),
268 ),
269 );
270 if (!exists) continue;
271 const raw = yield* fileSystem.readFileString(path).pipe(
272 Effect.mapError(
273 () =>
274 new ForgejoCliError({
275 command: "fj",
276 cwd,
277 reason: "authentication",
278 detail: "Could not read fj authentication storage.",
279 }),
280 ),
281 );
282 const decoded = parseForgejoKeys(raw);
283 if (Result.isFailure(decoded))
284 return yield* new ForgejoCliError({
285 command: "fj",
286 cwd,
287 reason: "authentication",
288 detail: "fj authentication storage is invalid. Authenticate again with fj.",
289 });
290 return decoded.success;
291 }
292 const empty: typeof ForgejoKeysSchema.Type = { hosts: {}, aliases: {} };
293 return empty;
294 });
295
296 const publicLogins = (
297 keys: typeof ForgejoKeysSchema.Type,
298 remoteUrl?: string,
299 ): ReturnType<typeof parseForgejoLogins> => {
300 const remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null;
301 return Object.keys(keys.hosts).flatMap((host) => {
302 const url = parseForgejoRemote(`https://${host}`);
303 // fj 0.6 drops URL mounts during whoami and OAuth renewal; tea supports them.
304 if (!url || url.path) return [];
305 // fj omits the scheme in storage. Only an explicit matching HTTP remote opts into HTTP.
306 const scheme =
307 remote && !remote.ssh && remote.host === url.host && /^http:\/\//i.test(remoteUrl ?? "")
308 ? "http"
309 : "https";
310 const login = { name: host, url: `${scheme}://${host}`, user: "", default: "false" };
311 const aliases = Object.entries(keys.aliases ?? {})
312 .filter(([, target]) => target === host)
313 .map(([alias]) => ({ ...login, ssh_host: alias }));
314 return aliases.length ? aliases : [login];
315 });
316 };
317
318 const listLogins: NonNullable<ForgejoCli["Service"]["listLogins"]> = Effect.fn(
319 "ForgejoCli.listLogins",
320 )(function* (input) {
321 if (input.command === "fj") {
322 const keys = yield* readKeys(input.cwd).pipe(Effect.result);
323 if (Result.isFailure(keys)) {
324 // Stale credentials from an uninstalled fj must not disable an available tea login.
325 const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe(
326 Effect.result,
327 );
328 if (Result.isFailure(available) && available.failure.reason === "missing-cli") return [];
329 return yield* keys.failure;
330 }
331 return publicLogins(keys.success, input.remoteUrl);
332 }
333 return parseForgejoLogins(
334 (yield* execute({ cwd: input.cwd, args: ["login", "list", "--output", "json"] })).stdout,
335 );
336 });
337
338 const requestFj = Effect.fn("ForgejoCli.requestFj")(
339 function* (input: {
340 readonly cwd: string;
341 readonly baseUrl: string;
342 readonly token: string;
343 readonly path: string;
344 readonly method?: ForgejoApiInput["method"];
345 readonly body?: string;
346 }) {
347 const base = new URL(`${input.baseUrl}/api/v1/`);
348 const url = new URL(input.path, base);
349 if (
350 url.origin !== base.origin ||
351 !url.pathname.startsWith(base.pathname) ||
352 url.username ||
353 url.password
354 )
355 return yield* new ForgejoCliError({
356 command: "fj",
357 cwd: input.cwd,
358 detail: "Invalid Forgejo API path.",
359 });
360 let request = HttpClientRequest.make(input.method ?? "GET")(url.toString()).pipe(
361 HttpClientRequest.setHeader("Authorization", `token ${input.token}`),
362 );
363 if (input.body !== undefined)
364 request = request.pipe(HttpClientRequest.bodyText(input.body, "application/json"));
365 const response = yield* httpClient
366 .execute(request)
367 .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }));
368 const status = response.status;
369 const body =
370 status === 204 || status === 205
371 ? { text: "", truncated: false, invalidUtf8: false }
372 : yield* collectUint8StreamText({
373 stream: response.stream,
374 maxBytes: 8 * 1024 * 1024,
375 });
376 if (body.truncated || body.invalidUtf8)
377 return yield* new ForgejoCliError({
378 command: "fj",
379 cwd: input.cwd,
380 reason: "invalid-response",
381 detail: "Forgejo returned an oversized or invalid response.",
382 });
383 if (status < 200 || status >= 300) {
384 const detail =
385 status === 404
386 ? "Forgejo repository or pull request was not found."
387 : body.text
388 ? `Forgejo API request failed (HTTP ${status}): ${body.text}`
389 : `Forgejo API request failed (HTTP ${status}). Check this server's fj credentials and permissions.`;
390 return yield* new ForgejoCliError({
391 command: "fj",
392 cwd: input.cwd,
393 httpStatus: status,
394 ...(status === 401
395 ? { reason: "authentication" as const }
396 : status === 403
397 ? { reason: "forbidden" as const }
398 : status === 404
399 ? { reason: "not-found" as const }
400 : status === 429
401 ? { reason: "rate-limit" as const }
402 : {}),
403 detail,
404 });
405 }
406 return {
407 exitCode: ChildProcessSpawner.ExitCode(0),
408 stdout: body.text,
409 stderr: `HTTP/1.1 ${status}\n${response.headers.link ? `link: ${response.headers.link}\n` : ""}`,
410 stdoutTruncated: false,
411 stderrTruncated: false,
412 };
413 },
414 (effect, input) =>
415 effect.pipe(
416 Effect.timeout(30_000),
417 Effect.mapError((error) =>
418 error._tag === "ForgejoCliError"
419 ? error
420 : new ForgejoCliError({
421 command: "fj",
422 cwd: input.cwd,
423 detail: "Forgejo API request failed or timed out.",
424 }),
425 ),
426 ),
427 );
428
429 const authenticateFj = Effect.fn("ForgejoCli.authenticateFj")(function* (
430 cwd: string,
431 login: typeof ForgejoLoginSchema.Type,
432 ) {
433 const keys = yield* readKeys(cwd);
434 const token = keys.hosts[login.name]?.token;
435 const now = yield* Clock.currentTimeMillis;
436 const cached = authenticated.get(login.url);
437 if (token && cached?.token === token && now - cached.time < 30_000) return token;
438 yield* execute({ command: "fj", cwd, args: ["--host", login.url, "whoami"] });
439 // fj owns OAuth renewal. Re-read the file after it has refreshed an expired token.
440 const refreshed = (yield* readKeys(cwd)).hosts[login.name]?.token;
441 if (!refreshed)
442 return yield* new ForgejoCliError({
443 command: "fj",
444 cwd,
445 reason: "authentication",
446 detail: "fj has no credentials for this server. Authenticate again with fj.",
447 });
448 authenticated.set(login.url, { token: refreshed, time: now });
449 return refreshed;
450 }, authLock.withPermits(1));
451
452 const getAccount: NonNullable<ForgejoCli["Service"]["getAccount"]> = Effect.fn(
453 "ForgejoCli.getAccount",
454 )(function* (input) {
455 const logins = yield* listLogins({ cwd: input.cwd, command: "fj", remoteUrl: input.baseUrl });
456 const login = logins.find(
457 (item) => item.url.replace(/\/+$/, "") === input.baseUrl.replace(/\/+$/, ""),
458 );
459 if (!login)
460 return yield* new ForgejoCliError({
461 command: "fj",
462 cwd: input.cwd,
463 reason: "authentication",
464 detail: "fj has no credentials for this server.",
465 });
466 const token = yield* authenticateFj(input.cwd, login);
467 const currentUser = yield* requestFj({
468 cwd: input.cwd,
469 baseUrl: login.url.replace(/\/+$/, ""),
470 token,
471 path: "user",
472 });
473 const user = decodeJsonResult(Schema.Struct({ login: Schema.String }))(currentUser.stdout);
474 if (Result.isFailure(user) || !user.success.login.trim())
475 return yield* new ForgejoCliError({
476 command: "fj",
477 cwd: input.cwd,
478 reason: "invalid-response",
479 detail: "Forgejo returned an invalid account response.",
480 });
481 return user.success.login;
482 });
483
484 const resolveTarget = Effect.fn("ForgejoCli.resolveTarget")(function* (
485 input: ForgejoRepositoryInput,
486 hostOnly = false,
487 ) {
488 const referenceRemote = input.reference ? parseForgejoRemote(input.reference) : null;
489 let remoteUrl = [input.reference, input.repository, input.context?.remoteUrl].find(
490 (value) => value && parseForgejoRemote(value),
491 );
492 let remote =
493 referenceRemote ??
494 (input.repository ? parseForgejoRemote(input.repository) : null) ??
495 (input.context ? parseForgejoRemote(input.context.remoteUrl) : null);
496 if (!remote && (!input.repository || input.host)) {
497 const result = yield* process
498 .run({
499 operation: "ForgejoCli.remote",
500 command: "git",
501 args: input.host ? ["remote", "-v"] : ["remote", "get-url", "origin"],
502 cwd: input.cwd,
503 allowNonZeroExit: true,
504 })
505 .pipe(
506 Effect.mapError(
507 (cause) =>
508 new ForgejoCliError({
509 command: "tea",
510 cwd: input.cwd,
511 detail: "Could not resolve the Forgejo repository remote.",
512 cause,
513 }),
514 ),
515 );
516 if (input.host) {
517 const matchingUrls = [
518 ...new Set(
519 result.stdout.split("\n").flatMap((line) => {
520 const url = /^\S+\s+(https?:\/\/\S+)\s+\(fetch\)$/.exec(line.trim())?.[1];
521 return url && parseForgejoRemote(url)?.host === input.host?.toLowerCase()
522 ? [url]
523 : [];
524 }),
525 ),
526 ];
527 const origins = [...new Set(matchingUrls.map((url) => new URL(url).origin))];
528 remoteUrl =
529 matchingUrls.length === 1
530 ? matchingUrls[0]
531 : origins.length === 1
532 ? origins[0]
533 : undefined;
534 } else {
535 remoteUrl = result.stdout.trim();
536 }
537 remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null;
538 }
539 if (
540 input.host &&
541 !remote?.ssh &&
542 remote?.host !== input.host.toLowerCase() &&
543 remote?.hostname !== input.host.toLowerCase()
544 )
545 remote = {
546 host: input.host.toLowerCase(),
547 hostname: input.host.split(":")[0] ?? input.host,
548 ssh: false,
549 path: remote?.path ?? "",
550 };
551 const schemeRemoteUrl = remote?.ssh ? input.context?.provider.baseUrl : remoteUrl;
552 const fjLogins = yield* listLogins({
553 cwd: input.cwd,
554 command: "fj",
555 ...(schemeRemoteUrl ? { remoteUrl: schemeRemoteUrl } : {}),
556 });
557 const requestedHost = input.host ?? input.context?.requestedHost;
558 const matchHostOnly = hostOnly || (!!input.host && !remote?.path);
559 const selectLogin = (logins: ReturnType<typeof parseForgejoLogins>) =>
560 remote
561 ? matchForgejoLogin(logins, remote, remote.ssh ? requestedHost : undefined, matchHostOnly)
562 : (logins.find((item) => item.default === "true") ??
563 (new Set(logins.map((item) => item.name)).size === 1 ? logins[0] : undefined));
564 let login = selectLogin(fjLogins);
565 let command: "fj" | "tea" = "fj";
566 if (
567 !login &&
568 fjLogins.some(
569 (item) =>
570 !remote ||
571 matchForgejoLogin([item], remote, remote.ssh ? requestedHost : undefined, matchHostOnly),
572 )
573 ) {
574 const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe(
575 Effect.result,
576 );
577 if (Result.isSuccess(available))
578 return yield* new ForgejoCliError({
579 command: "fj",
580 cwd: input.cwd,
581 reason: "authentication",
582 detail: "Multiple fj logins match this repository. Specify its full server URL.",
583 });
584 if (available.failure.reason !== "missing-cli") return yield* available.failure;
585 }
586 if (login) {
587 const auth = yield* authenticateFj(input.cwd, login).pipe(Effect.result);
588 if (Result.isFailure(auth)) {
589 if (auth.failure.reason === "missing-cli") login = undefined;
590 else return yield* auth.failure;
591 }
592 }
593 if (!login) {
594 command = "tea";
595 login = selectLogin(yield* listLogins({ cwd: input.cwd, command: "tea" }));
596 }
597 if (!login)
598 return yield* new ForgejoCliError({
599 command: "tea",
600 cwd: input.cwd,
601 reason: "authentication",
602 detail:
603 "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.",
604 });
605 if (hostOnly)
606 return { command, login: login.name, repository: "", baseUrl: login.url.replace(/\/+$/, "") };
607 const path =
608 referenceRemote?.path ??
609 (input.repository && !parseForgejoRemote(input.repository)
610 ? input.repository
611 : remote?.path) ??
612 "";
613 const basePath = new URL(login.url).pathname.replace(/^\/+|\/+$/g, "");
614 const relativePath =
615 basePath && path.split("/").length > 2 && path.startsWith(`${basePath}/`)
616 ? path.slice(basePath.length + 1)
617 : path;
618 const repositoryPath = relativePath.replace(/\/pulls\/\d+.*$/, "").replace(/\.git$/, "");
619 if (command === "fj" && !repositoryPath.includes("/")) {
620 login = { ...login, user: yield* getAccount({ cwd: input.cwd, baseUrl: login.url }) };
621 }
622 const repository = repositoryPath.includes("/")
623 ? repositoryPath
624 : `${login.user}/${repositoryPath}`;
625 if (!/^[^/\s]+\/[^/\s]+$/.test(repository))
626 return yield* new ForgejoCliError({
627 command,
628 cwd: input.cwd,
629 detail: "Specify a Forgejo repository as owner/repository or its full server URL.",
630 });
631 return { command, login: login.name, repository, baseUrl: login.url.replace(/\/+$/, "") };
632 });
633 const resolveRepository = (input: ForgejoRepositoryInput) => resolveTarget(input);
634 const api = Effect.fn("ForgejoCli.api")(function* (input: ForgejoApiInput) {
635 const repository = yield* resolveTarget(
636 input,
637 input.path.replace(/^\/+/, "") === "user" && (!input.method || input.method === "GET"),
638 );
639 const stdin =
640 input.body === undefined
641 ? undefined
642 : yield* encodeApiBody(input.body).pipe(
643 Effect.mapError(
644 (cause) =>
645 new ForgejoCliError({
646 command: "tea",
647 cwd: input.cwd,
648 detail: "Could not encode the Forgejo request body.",
649 cause,
650 }),
651 ),
652 );
653 let path = input.path.replace(/^\/+/, "");
654 if (input.repository && input.repository !== repository.repository) {
655 // Repository identities retain the server mount path; API routes do not.
656 const prefix = `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`;
657 if (path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}?`)) {
658 path = `repos/${repository.repository.split("/").map(encodeURIComponent).join("/")}${path.slice(prefix.length)}`;
659 }
660 }
661 if (repository.command === "fj") {
662 const token = (yield* readKeys(input.cwd)).hosts[repository.login]?.token;
663 if (!token)
664 return yield* new ForgejoCliError({
665 command: "fj",
666 cwd: input.cwd,
667 reason: "authentication",
668 detail: "fj has no credentials for this server.",
669 });
670 return yield* requestFj({
671 cwd: input.cwd,
672 baseUrl: repository.baseUrl,
673 token,
674 path,
675 ...(input.method === undefined ? {} : { method: input.method }),
676 ...(stdin === undefined ? {} : { body: stdin }),
677 });
678 }
679 const result = yield* execute({
680 cwd: input.cwd,
681 args: [
682 "api",
683 "--include",
684 "--login",
685 repository.login,
686 ...(repository.repository ? ["--repo", repository.repository] : []),
687 "--method",
688 input.method ?? "GET",
689 ...(input.body === undefined ? [] : ["--data", "@-"]),
690 `${repository.baseUrl}/api/v1/${path}`,
691 ],
692 ...(stdin === undefined ? {} : { stdin }),
693 });
694 // tea reports HTTP failures with exit code zero; use its response status.
695 const status = Number(/^HTTP\/\S+ (\d{3})/m.exec(result.stderr)?.[1]);
696 if (!status || status >= 400)
697 return yield* new ForgejoCliError({
698 command: "tea",
699 cwd: input.cwd,
700 ...(status ? { httpStatus: status } : {}),
701 ...(status === 401
702 ? { reason: "authentication" as const }
703 : status === 403
704 ? { reason: "forbidden" as const }
705 : status === 404
706 ? { reason: "not-found" as const }
707 : status === 429
708 ? { reason: "rate-limit" as const }
709 : {}),
710 detail:
711 status === 401 || status === 403
712 ? "Forgejo denied access. Check this server's `tea login` credentials and permissions."
713 : status === 404
714 ? "Forgejo repository or pull request was not found."
715 : status === 429
716 ? "Forgejo API rate limit exceeded."
717 : `Forgejo API request failed${status ? ` (HTTP ${status})` : " without an HTTP status"}.`,
718 });
719 return result;
720 });
721 return ForgejoCli.of({ execute, listLogins, getAccount, resolveRepository, api });
722});
723
724export const layer = Layer.effect(ForgejoCli, make);
apps/server/src/sourceControl/ForgejoSourceControlProvider.ts deleted-387
......@@ -1,387 +0,0 @@
1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Schema from "effect/Schema";
4import * as Result from "effect/Result";
5import { SourceControlProviderError } from "@t3tools/contracts";
6import * as VcsProcess from "../vcs/VcsProcess.ts";
7import * as ForgejoCli from "./ForgejoCli.ts";
8import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
9import {
10 providerAuth,
11 probeSourceControlProvider,
12 type SourceControlCliDiscoverySpec,
13 type SourceControlManagedCliDiscoverySpec,
14} from "@t3tools/source-control-core/server/discovery";
15import { ForgejoPullRequestSchema, toForgejoChangeRequest } from "./forgejoPullRequests.ts";
16
17const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError);
18
19export const discovery = {
20 type: "cli",
21 kind: "forgejo",
22 label: "Forgejo / Gitea",
23 executable: "tea",
24 versionArgs: ["--version"],
25 authArgs: ["login", "status", "--output", "json"],
26 remoteRefinementArgs: ["login", "list", "--output", "json"],
27 parseAuth: (input) => {
28 const logins = ForgejoCli.parseForgejoLogins(input.stdout);
29 const login = logins.find((entry) => entry.default === "true") ?? logins[0];
30 return login
31 ? providerAuth({
32 status: login.valid === "true" ? "authenticated" : "unauthenticated",
33 account: login.user,
34 host: ForgejoCli.parseForgejoRemote(login.url)?.host,
35 })
36 : providerAuth({
37 status: "unauthenticated",
38 detail: "Run `tea login add` to authenticate a Forgejo or Gitea server.",
39 });
40 },
41 refineUnknownRemote: (input) => {
42 const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl);
43 const login =
44 remote &&
45 ForgejoCli.matchForgejoLogin(
46 ForgejoCli.parseForgejoLogins(input.auth.stdout),
47 remote,
48 input.context.requestedHost,
49 );
50 return login ? { kind: "forgejo", name: "Forgejo / Gitea", baseUrl: login.url } : null;
51 },
52 installHint:
53 "Install `fj` 0.6 or later from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj --host <server-url> 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.",
54} satisfies SourceControlCliDiscoverySpec;
55
56export const makeDiscovery = Effect.gen(function* () {
57 const cli = yield* ForgejoCli.ForgejoCli;
58 const process = yield* VcsProcess.VcsProcess;
59 const listLogins = cli.listLogins;
60 if (!listLogins) return discovery;
61 return {
62 type: "managed-cli",
63 kind: "forgejo",
64 label: discovery.label,
65 installHint: discovery.installHint,
66 probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) {
67 const remoteUrl = yield* process
68 .run({
69 operation: "source-control.discovery.remote",
70 command: "git",
71 args: ["remote", "get-url", "origin"],
72 cwd,
73 allowNonZeroExit: true,
74 timeoutMs: 5_000,
75 maxOutputBytes: 8_000,
76 })
77 .pipe(
78 Effect.map((result) => result.stdout.trim()),
79 Effect.orElseSucceed(() => ""),
80 );
81 const credentials = yield* Effect.result(listLogins({ cwd, command: "fj", remoteUrl }));
82 const logins = Result.isSuccess(credentials) ? credentials.success : [];
83 const remote = ForgejoCli.parseForgejoRemote(remoteUrl);
84 const login =
85 (remote && ForgejoCli.matchForgejoLogin(logins, remote)) ||
86 logins.find((entry) => entry.default === "true") ||
87 logins[0];
88 const fj = yield* probeSourceControlProvider({
89 cwd,
90 process,
91 spec: {
92 ...discovery,
93 executable: "fj",
94 versionArgs: ["version"],
95 authArgs: login ? ["--host", login.url, "whoami"] : ["auth", "list"],
96 parseAuth: (result) =>
97 Result.isFailure(credentials)
98 ? providerAuth({
99 status: "unknown",
100 detail: "Could not read fj authentication storage. Authenticate again with fj.",
101 })
102 : login && result.exitCode === 0
103 ? providerAuth({
104 status: "authenticated",
105 account: login.user,
106 host: ForgejoCli.parseForgejoRemote(login.url)?.host,
107 })
108 : providerAuth({
109 status: "unauthenticated",
110 detail:
111 "Authenticate this server with `fj --host <server-url> auth add-token`.",
112 }),
113 },
114 });
115 // A configured fj account owns its requests, including authentication errors.
116 if (fj.status === "available" && (login || Result.isFailure(credentials))) {
117 if (login && fj.auth.status === "authenticated" && cli.getAccount) {
118 const account = yield* cli.getAccount({ cwd, baseUrl: login.url }).pipe(Effect.result);
119 return {
120 ...fj,
121 auth: Result.isSuccess(account)
122 ? providerAuth({
123 status: "authenticated",
124 account: account.success,
125 host: ForgejoCli.parseForgejoRemote(login.url)?.host,
126 })
127 : providerAuth({
128 status: "unknown",
129 detail: account.failure.detail,
130 host: ForgejoCli.parseForgejoRemote(login.url)?.host,
131 }),
132 };
133 }
134 return fj;
135 }
136 const tea = yield* probeSourceControlProvider({ cwd, process, spec: discovery });
137 return tea.status === "available" || fj.status === "missing" ? tea : fj;
138 }),
139 refineUnknownRemote: Effect.fn("ForgejoSourceControlProvider.refineUnknownRemote")(
140 function* (input: {
141 readonly cwd: string;
142 readonly context: SourceControlProvider.SourceControlProviderContext;
143 }) {
144 const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl);
145 if (!remote) return null;
146 for (const command of ["fj", "tea"] as const) {
147 const logins = yield* listLogins({
148 cwd: input.cwd,
149 command,
150 remoteUrl: input.context.remoteUrl,
151 }).pipe(Effect.orElseSucceed(() => []));
152 const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost);
153 if (login) return { kind: "forgejo" as const, name: discovery.label, baseUrl: login.url };
154 }
155 return null;
156 },
157 ),
158 } satisfies SourceControlManagedCliDiscoverySpec;
159});
160
161const RepositorySchema = Schema.Struct({
162 full_name: Schema.String,
163 clone_url: Schema.String,
164 ssh_url: Schema.String,
165 default_branch: Schema.optional(Schema.NullOr(Schema.String)),
166});
167const cloneUrls = (raw: typeof RepositorySchema.Type) => ({
168 nameWithOwner: raw.full_name,
169 url: raw.clone_url,
170 sshUrl: raw.ssh_url,
171});
172const repositoryPath = (repository: string) =>
173 `repos/${repository.split("/").map(encodeURIComponent).join("/")}`;
174
175export const make = Effect.gen(function* () {
176 const cli = yield* ForgejoCli.ForgejoCli;
177 const fs = yield* FileSystem.FileSystem;
178 const process = yield* VcsProcess.VcsProcess;
179 const request = <S extends Schema.Codec<unknown, unknown, never, never>>(
180 input: ForgejoCli.ForgejoApiInput,
181 schema: S,
182 ) =>
183 cli.api(input).pipe(
184 Effect.flatMap((result) =>
185 Schema.decodeEffect(Schema.fromJsonString(schema))(result.stdout).pipe(
186 Effect.mapError(
187 (cause) =>
188 new ForgejoCli.ForgejoCliError({
189 command: "tea",
190 cwd: input.cwd,
191 detail: "Forgejo API returned an invalid response.",
192 reason: "invalid-response",
193 cause,
194 }),
195 ),
196 ),
197 ),
198 );
199 const mapError = (operation: string, cwd: string) =>
200 Effect.mapError(
201 (cause: unknown) =>
202 new SourceControlProviderError({
203 provider: "forgejo",
204 operation,
205 cwd,
206 ...(isForgejoCliError(cause) ? { command: cause.command } : {}),
207 detail: isForgejoCliError(cause) ? cause.detail : "Forgejo operation failed.",
208 cause,
209 }),
210 );
211 const getPull = Effect.fn("ForgejoSourceControlProvider.getPull")(function* (
212 input: Parameters<
213 SourceControlProvider.SourceControlProvider["Service"]["getChangeRequest"]
214 >[0],
215 ) {
216 const repo = yield* cli.resolveRepository(input);
217 const number = /(?:^#?|\/pulls\/)(\d+)(?:\/[^?#]*)?(?:[?#].*)?$/.exec(input.reference)?.[1];
218 if (!number)
219 return yield* new ForgejoCli.ForgejoCliError({
220 command: "tea",
221 cwd: input.cwd,
222 detail: "Specify a pull request number or Forgejo pull request URL.",
223 });
224 return yield* request(
225 { ...input, path: `${repositoryPath(repo.repository)}/pulls/${number}` },
226 ForgejoPullRequestSchema,
227 );
228 });
229 return SourceControlProvider.SourceControlProvider.of({
230 kind: "forgejo",
231 listChangeRequests: (input) =>
232 Effect.gen(function* () {
233 const repo = yield* cli.resolveRepository(input);
234 const source = SourceControlProvider.sourceControlRefFromInput(input);
235 const branch = SourceControlProvider.sourceBranch(input);
236 const results: ReturnType<typeof toForgejoChangeRequest>[] = [];
237 const limit = input.limit ?? 20;
238 for (let page = 1; results.length < limit; page++) {
239 const items = yield* request(
240 {
241 ...input,
242 path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&sort=recentupdate&limit=50&page=${page}`,
243 },
244 Schema.Array(ForgejoPullRequestSchema),
245 );
246 for (const item of items) {
247 if (
248 item.head.ref !== branch ||
249 (source?.repository && item.head.repo?.full_name !== source.repository) ||
250 (source?.owner && item.head.repo?.owner.login !== source.owner)
251 )
252 continue;
253 const normalized = toForgejoChangeRequest(item);
254 if (input.state === "all" || normalized.state === input.state) results.push(normalized);
255 }
256 if (items.length === 0) break;
257 }
258 return results.slice(0, limit);
259 }).pipe(mapError("listChangeRequests", input.cwd)),
260 getChangeRequest: (input) =>
261 getPull(input).pipe(
262 Effect.map(toForgejoChangeRequest),
263 mapError("getChangeRequest", input.cwd),
264 ),
265 createChangeRequest: (input) =>
266 Effect.gen(function* () {
267 const repo = yield* cli.resolveRepository(input);
268 const source = SourceControlProvider.sourceControlRefFromInput(input);
269 const owner = source?.owner ?? source?.repository?.split("/")[0];
270 const head = SourceControlProvider.sourceBranch(input);
271 yield* cli.api({
272 ...input,
273 path: `${repositoryPath(input.target?.repository ?? repo.repository)}/pulls`,
274 method: "POST",
275 body: {
276 base: input.target?.refName ?? input.baseRefName,
277 head: owner ? `${owner}:${head}` : head,
278 title: input.title,
279 body: yield* fs.readFileString(input.bodyFile),
280 },
281 });
282 }).pipe(mapError("createChangeRequest", input.cwd)),
283 getRepositoryCloneUrls: (input) =>
284 Effect.gen(function* () {
285 const repo = yield* cli.resolveRepository(input);
286 return cloneUrls(
287 yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema),
288 );
289 }).pipe(mapError("getRepositoryCloneUrls", input.cwd)),
290 createRepository: (input) =>
291 Effect.gen(function* () {
292 const repo = yield* cli.resolveRepository(input);
293 const user = yield* request(
294 { ...input, path: "user" },
295 Schema.Struct({ login: Schema.String }),
296 );
297 const [owner, name] = repo.repository.split("/");
298 return cloneUrls(
299 yield* request(
300 {
301 ...input,
302 path:
303 owner === user.login
304 ? "user/repos"
305 : `orgs/${encodeURIComponent(owner ?? "")}/repos`,
306 method: "POST",
307 body: { name, private: input.visibility === "private", auto_init: false },
308 },
309 RepositorySchema,
310 ),
311 );
312 }).pipe(mapError("createRepository", input.cwd)),
313 getDefaultBranch: (input) =>
314 Effect.gen(function* () {
315 const repo = yield* cli.resolveRepository(input);
316 return (
317 (yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema))
318 .default_branch ?? null
319 );
320 }).pipe(mapError("getDefaultBranch", input.cwd)),
321 checkoutChangeRequest: (input) =>
322 Effect.gen(function* () {
323 const repo = yield* cli.resolveRepository(input);
324 const pull = yield* getPull(input);
325 if (repo.command === "fj") {
326 // fj checkout cannot target a repository outside the local remotes.
327 const urls = yield* request(
328 { ...input, path: repositoryPath(repo.repository) },
329 RepositorySchema,
330 );
331 const remote = input.context?.remoteUrl;
332 const useSsh = remote && ForgejoCli.parseForgejoRemote(remote)?.ssh;
333 yield* process.run({
334 operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
335 command: "git",
336 cwd: input.cwd,
337 args: [
338 "fetch",
339 "--",
340 useSsh ? urls.ssh_url : urls.clone_url,
341 `refs/pull/${pull.number}/head`,
342 ],
343 });
344 const branch = `pulls/${pull.number}`;
345 const existing = yield* process.run({
346 operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
347 command: "git",
348 cwd: input.cwd,
349 args: ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
350 allowNonZeroExit: true,
351 });
352 yield* process.run({
353 operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
354 command: "git",
355 cwd: input.cwd,
356 args:
357 existing.exitCode === 0
358 ? ["checkout", branch]
359 : ["checkout", "-b", branch, "FETCH_HEAD"],
360 });
361 } else
362 yield* cli.execute({
363 cwd: input.cwd,
364 args: [
365 "pulls",
366 "checkout",
367 "--login",
368 repo.login,
369 "--repo",
370 repo.repository,
371 "--branch",
372 String(pull.number),
373 ],
374 });
375 if (input.force) {
376 // tea leaves an existing PR branch at its old tip. Keep dirty files safe
377 // while bringing the selected branch to the PR revision we fetched.
378 yield* process.run({
379 operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
380 command: "git",
381 cwd: input.cwd,
382 args: ["reset", "--keep", pull.head.sha],
383 });
384 }
385 }).pipe(mapError("checkoutChangeRequest", input.cwd)),
386 });
387});
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+29-1261
......@@ -20,13 +20,13 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
2020import * as BitbucketApi from "./BitbucketApi.ts";
2121import * as GitHubApi from "./GitHubApi.ts";
2222import * as GitLabCli from "./GitLabCli.ts";
23import * as ForgejoCli from "./ForgejoCli.ts";
24import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
25import * as ForgejoPullRequestProvider from "../pullRequest/ForgejoPullRequestProvider.ts";
23import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
24import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";
25import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
2626import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
2727import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
2828import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
29import { firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery";
29import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
3030
3131const layerSourceControlProviderRegistryTest = (input: {
3232 readonly bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>;
......@@ -76,434 +76,6 @@ const processOutput = (
7676const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
7777const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
7878
79it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => {
80 const methods: string[] = [];
81 const fetchReview = async (
82 ...[input, init]: Parameters<Context.Service.Shape<typeof FetchHttpClient.Fetch>>
83 ) => {
84 const request = new Request(input instanceof Request ? input.url : String(input), {
85 ...(init?.method === undefined ? {} : { method: init.method }),
86 ...(init?.headers === undefined ? {} : { headers: init.headers }),
87 ...(init?.body === undefined ? {} : { body: init.body }),
88 });
89 methods.push(request.method);
90 if (request.method === "GET") {
91 assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42");
92 return new Response(
93 encodeJson({
94 number: 42,
95 title: "Review target",
96 body: "",
97 html_url: "https://forgejo.test/maria/project/pulls/42",
98 user: { login: "maria" },
99 state: "open",
100 merged: false,
101 head: { ref: "feature", sha: "head", repo: null },
102 base: { ref: "main", sha: "base", repo: null },
103 created_at: "2026-09-13T00:00:00Z",
104 updated_at: "2026-09-13T00:00:00Z",
105 closed_at: null,
106 merged_at: null,
107 labels: [],
108 }),
109 );
110 }
111 assert.strictEqual(request.method, "POST");
112 assert.strictEqual(
113 request.url,
114 "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews",
115 );
116 assert.deepStrictEqual(JSON.parse(await request.text()), {
117 event: "COMMENT",
118 body: "Review summary",
119 commit_id: "head",
120 comments: [],
121 });
122 return new Response('{"id":1}', { status: 200 });
123 };
124 return Effect.gen(function* () {
125 const cli = yield* ForgejoCli.make;
126 const provider = yield* ForgejoPullRequestProvider.make.pipe(
127 Effect.provideService(ForgejoCli.ForgejoCli, cli),
128 );
129 yield* provider.submitReview({
130 cwd: "/repo",
131 repository: "maria/project",
132 host: "forgejo.test",
133 number: 42,
134 verdict: "comment",
135 body: "Review summary",
136 comments: [],
137 });
138 assert.deepStrictEqual(methods, ["GET", "POST"]);
139 }).pipe(
140 Effect.provideService(
141 FetchHttpClient.Fetch,
142 Object.assign(fetchReview, { preconnect: () => undefined }),
143 ),
144 Effect.provide(FetchHttpClient.layer),
145 Effect.provideService(
146 FileSystem.FileSystem,
147 FileSystem.makeNoop({
148 exists: () => Effect.succeed(true),
149 readFileString: () =>
150 Effect.succeed(
151 encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }),
152 ),
153 }),
154 ),
155 Effect.provide(
156 Layer.mock(VcsProcess.VcsProcess)({
157 run: (input) => {
158 if (input.command === "git") {
159 assert.deepStrictEqual(input.args, ["remote", "-v"]);
160 return Effect.succeed(
161 processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"),
162 );
163 }
164 assert.strictEqual(input.command, "fj");
165 assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]);
166 return Effect.succeed(processOutput(""));
167 },
168 }),
169 ),
170 );
171});
172
173it.effect("reads Forgejo checks without repository or viewer requests", () => {
174 const paths: string[] = [];
175 return Effect.gen(function* () {
176 const provider = yield* ForgejoPullRequestProvider.make;
177 const read = provider.getChangeRequestChecks;
178 if (read === undefined) return yield* Effect.die("checks read missing");
179 const result = yield* read({
180 cwd: "/repo",
181 repository: "acme/web",
182 host: "forgejo.test",
183 number: 1,
184 });
185 assert.strictEqual(result.state, "open");
186 assert.strictEqual(result.checks[0]?.status, "failure");
187 assert.deepStrictEqual(paths, [
188 "repos/acme/web/pulls/1",
189 "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=1",
190 "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=2",
191 ]);
192 }).pipe(
193 Effect.provide(
194 Layer.mock(ForgejoCli.ForgejoCli)({
195 api: (input) => {
196 paths.push(input.path);
197 assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head)/);
198 return Effect.succeed(
199 processOutput(
200 input.path.endsWith("pulls/1")
201 ? `{"number":1,"title":"Checks","body":"","html_url":"https://forgejo.test/acme/web/pulls/1", "user":null,"state":"open","merged":false,
202 "head":{"ref":"feature","sha":"head","repo":null},"base":{"ref":"main","sha":"base","repo":null},
203 "created_at":"2026-09-16T00:00:00Z","updated_at":"2026-09-16T00:00:00Z","closed_at":null,"merged_at":null,"labels":[]}`
204 : input.path.endsWith("page=1")
205 ? `[{"context":"build","status":"failure","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00Z"}]`
206 : "[]",
207 ),
208 );
209 },
210 }),
211 ),
212 );
213});
214
215it.effect.each([
216 ["Ready", false, true, false, "mergeable"],
217 ["Draft", true, false, true, "unknown"],
218 ["Blocked", false, false, false, "unknown"],
219 ["Unchecked", false, undefined, false, "unknown"],
220 ["WIP: Legacy draft", undefined, false, true, "unknown"],
221 ["[WIP] Legacy draft", undefined, false, true, "unknown"],
222 ["WIP: Explicitly ready", false, false, false, "unknown"],
223] as const)(
224 "reads Forgejo mergeability for %s (draft=%s, mergeable=%s) across list, summary and detail",
225 ([title, draft, mergeable, isDraft, mergeability]) => {
226 const pr = {
227 number: 42,
228 title,
229 body: "",
230 html_url: "https://forgejo.test/maria/project/pulls/42",
231 user: { login: "maria" },
232 state: "open",
233 merged: false,
234 ...(draft === undefined ? {} : { draft }),
235 ...(mergeable === undefined ? {} : { mergeable }),
236 head: { ref: "feature", sha: "head", repo: null },
237 base: { ref: "main", sha: "base", repo: null },
238 created_at: "2026-09-16T00:00:00Z",
239 updated_at: "2026-09-16T00:00:00Z",
240 closed_at: null,
241 merged_at: null,
242 labels: [],
243 };
244 return Effect.gen(function* () {
245 const provider = yield* ForgejoPullRequestProvider.make;
246 const readSummary = provider.getChangeRequestSummary;
247 if (readSummary === undefined) return yield* Effect.die("summary read missing");
248 const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 42 };
249 const list = yield* provider.listChangeRequests({
250 ...input,
251 state: "open",
252 involvement: "all",
253 viewer: "maria",
254 limit: 10,
255 });
256 const summary = yield* readSummary(input);
257 const detail = yield* provider.getChangeRequest(input);
258 assert.strictEqual(list.items.length, 1);
259 for (const result of [list.items[0]!, summary, detail]) {
260 assert.strictEqual(result.mergeability, mergeability);
261 assert.strictEqual(result.isDraft, isDraft);
262 }
263 }).pipe(
264 Effect.provide(
265 Layer.mock(ForgejoCli.ForgejoCli)({
266 api: (input) => {
267 const [path, query] = input.path.split("?");
268 let response: unknown;
269 switch (path) {
270 case "repos/maria/project/pulls":
271 response = new URLSearchParams(query).get("page") === "1" ? [pr] : [];
272 break;
273 case "repos/maria/project/pulls/42":
274 response = pr;
275 break;
276 case "repos/maria/project":
277 response = { full_name: "maria/project", permissions: { push: true, admin: true } };
278 break;
279 case "user":
280 response = pr.user;
281 break;
282 case "repos/maria/project/statuses/head":
283 response = [];
284 break;
285 default:
286 return Effect.die(`Unexpected Forgejo request: ${input.path}`);
287 }
288 return encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput));
289 },
290 }),
291 ),
292 );
293 },
294);
295
296it.effect("loads Forgejo pull request references from files and commits views", () =>
297 Effect.gen(function* () {
298 const provider = yield* ForgejoSourceControlProvider.make;
299 for (const reference of [
300 "42",
301 "#42",
302 "https://forgejo.test/maria/project/pulls/42",
303 "https://forgejo.test/maria/project/pulls/42/",
304 "https://forgejo.test/maria/project/pulls/42/files?w=1#diff-1",
305 "http://forgejo.test:3000/git/maria/project/pulls/42/commits",
306 ]) {
307 const result = yield* provider.getChangeRequest({ cwd: "/repo", reference });
308 assert.strictEqual(result.number, 42);
309 assert.strictEqual(result.title, "Forgejo view reference");
310 }
311 const invalid = yield* provider
312 .getChangeRequest({
313 cwd: "/repo",
314 reference: "https://forgejo.test/maria/project/pulls/42invalid/files",
315 })
316 .pipe(Effect.result);
317 assert.strictEqual(invalid._tag, "Failure");
318 }).pipe(
319 Effect.provide(
320 Layer.mergeAll(
321 Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})),
322 Layer.mock(VcsProcess.VcsProcess)({}),
323 Layer.mock(ForgejoCli.ForgejoCli)({
324 resolveRepository: () =>
325 Effect.succeed({
326 login: "work",
327 repository: "maria/project",
328 baseUrl: "https://forgejo.test",
329 }),
330 api: (input) => {
331 assert.strictEqual(input.path, "repos/maria/project/pulls/42");
332 return encodeJsonEffect({
333 number: 42,
334 title: "Forgejo view reference",
335 html_url: "https://forgejo.test/maria/project/pulls/42",
336 state: "open",
337 merged: false,
338 base: { ref: "main", sha: "base", repo: null },
339 head: { ref: "feature", sha: "head", repo: null },
340 }).pipe(Effect.orDie, Effect.map(processOutput));
341 },
342 }),
343 ),
344 ),
345 ),
346);
347
348it.effect(
349 "loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations",
350 () => {
351 const user = { login: "maria" };
352 const review = {
353 id: 8,
354 body: "review body",
355 user,
356 state: "COMMENT",
357 submitted_at: "2026-09-12T00:00:00Z",
358 html_url: "https://forgejo.test/maria/project/pulls/2#issuecomment-37",
359 comments_count: 1,
360 };
361 const comment = { id: 12, body: "ordinary", user, created_at: review.submitted_at };
362 const responses: Record<string, unknown> = {
363 user,
364 "repos/maria/project/issues/2/comments": [comment],
365 "repos/maria/project/pulls/2/reviews": [review],
366 "repos/maria/project/pulls/2/reviews/8": review,
367 "repos/maria/project/pulls/2/reviews/9": { ...review, id: 9, html_url: "" },
368 "repos/maria/project/pulls/2/commits": [],
369 "repos/maria/project/issues/2/reactions": [],
370 "repos/maria/project/pulls/2/reviews/8/comments": [
371 {
372 ...comment,
373 id: 38,
374 body: "inline",
375 path: "file.ts",
376 position: 1,
377 original_position: 1,
378 commit_id: "head",
379 original_commit_id: "head",
380 resolver: null,
381 },
382 ],
383 "repos/maria/project/issues/comments/12/reactions": [{ content: "+1", user }],
384 "repos/maria/project/issues/comments/37/reactions": [{ content: "heart", user }],
385 "repos/maria/project/issues/comments/38/reactions": [
386 { content: "rocket", user: { login: "reviewer" } },
387 ],
388 };
389 const writes: ForgejoCli.ForgejoApiInput[] = [];
390 let reactionReads = 0;
391 return Effect.gen(function* () {
392 const provider = yield* ForgejoPullRequestProvider.make;
393 const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 2 };
394 const activity = yield* provider.getChangeRequestActivity(input);
395 assert.deepStrictEqual(
396 activity.comments.map((entry) => ({
397 id: entry.id,
398 kind: entry.kind,
399 reactions: entry.reactions,
400 })),
401 [
402 {
403 id: "12",
404 kind: "issue-comment",
405 reactions: [{ content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }],
406 },
407 {
408 id: "review:8",
409 kind: "review",
410 reactions: [{ content: "heart", count: 1, actors: [], viewerHasReacted: true }],
411 },
412 {
413 id: "38",
414 kind: "review-comment",
415 reactions: [
416 { content: "rocket", count: 1, actors: ["reviewer"], viewerHasReacted: false },
417 ],
418 },
419 ],
420 );
421 const inlineComment = activity.comments[2];
422 assert.ok(inlineComment);
423 assert.deepStrictEqual(activity.reviewThreads[0]?.comments, [inlineComment]);
424 for (const reacted of [true, false]) {
425 yield* provider.setReaction({ ...input, subjectId: "review:8", content: "heart", reacted });
426 yield* provider.setReaction({ ...input, subjectId: "38", content: "rocket", reacted });
427 }
428 assert.deepStrictEqual(
429 writes.map(({ path, method, body }) => ({ path, method, body })),
430 [
431 {
432 path: "repos/maria/project/issues/comments/37/reactions",
433 method: "POST",
434 body: { content: "heart" },
435 },
436 {
437 path: "repos/maria/project/issues/comments/38/reactions",
438 method: "POST",
439 body: { content: "rocket" },
440 },
441 {
442 path: "repos/maria/project/issues/comments/37/reactions",
443 method: "DELETE",
444 body: { content: "heart" },
445 },
446 {
447 path: "repos/maria/project/issues/comments/38/reactions",
448 method: "DELETE",
449 body: { content: "rocket" },
450 },
451 ],
452 );
453 const missing = yield* provider
454 .setReaction({ ...input, subjectId: "review:9", content: "heart", reacted: true })
455 .pipe(Effect.result);
456 assert.strictEqual(missing._tag, "Failure");
457 if (missing._tag === "Failure") assert.include(missing.failure.detail, "comment ID");
458 assert.strictEqual(writes.length, 4);
459 responses["repos/maria/project/pulls/2/reviews/8/comments"] = Array.from(
460 { length: 501 },
461 (_, index) => ({
462 ...comment,
463 id: 1000 + index,
464 path: "file.ts",
465 position: 1,
466 original_position: 1,
467 commit_id: "head",
468 original_commit_id: "head",
469 resolver: null,
470 }),
471 );
472 for (let index = 0; index < 500; index++) {
473 responses[`repos/maria/project/issues/comments/${1000 + index}/reactions`] = [];
474 }
475 reactionReads = 0;
476 const bounded = yield* provider.getChangeRequestActivity(input);
477 assert.strictEqual(bounded.reviewThreads.length, 500);
478 assert.strictEqual(
479 bounded.comments.filter((entry) => entry.kind === "review-comment").length,
480 500,
481 );
482 assert.strictEqual(bounded.commentsTruncated, true);
483 assert.strictEqual(reactionReads, 502);
484 }).pipe(
485 Effect.provide(
486 Layer.mock(ForgejoCli.ForgejoCli)({
487 api: (input) => {
488 if (input.method) {
489 writes.push(input);
490 return Effect.succeed(processOutput("{}"));
491 }
492 const path = input.path.split("?")[0]!;
493 if (/\/issues\/comments\/\d+\/reactions$/.test(path)) reactionReads++;
494 assert.ok(Object.hasOwn(responses, path), `Unexpected Forgejo request: ${path}`);
495 const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page"));
496 return encodeJsonEffect(page > 1 ? [] : responses[path]).pipe(
497 Effect.orDie,
498 Effect.map(processOutput),
499 );
500 },
501 }),
502 ),
503 );
504 },
505);
506
50779it.effect("reports implemented tools separately from locally available executables", () => {
50880 const processMock = {
50981 run: (input: VcsProcess.VcsProcessInput) => {
......@@ -764,820 +336,6 @@ Logged in to gitlab.com as gitlab-user
764336 }).pipe(Effect.provide(layerTest));
765337});
766338
767it.effect("discovers Forgejo accounts and retains the server port", () =>
768 Effect.gen(function* () {
769 const auth = ForgejoSourceControlProvider.discovery.parseAuth(
770 processOutput(
771 yield* encodeJsonEffect([
772 {
773 name: "work",
774 url: "http://forgejo.local:3000",
775 ssh_host: "git.forgejo.local",
776 user: "maria",
777 default: "true",
778 valid: "true",
779 },
780 ]),
781 ),
782 );
783 assert.deepStrictEqual(
784 firstNonEmptyLine("\u001b[1mtea version 0.16.0\u001b[0m\n"),
785 Option.some("tea version 0.16.0"),
786 );
787 assert.strictEqual(auth.status, "authenticated");
788 assert.deepStrictEqual(auth.account, Option.some("maria"));
789 assert.deepStrictEqual(auth.host, Option.some("forgejo.local:3000"));
790 const revoked = ForgejoSourceControlProvider.discovery.parseAuth(
791 processOutput(
792 encodeJson([
793 {
794 name: "work",
795 url: "http://forgejo.local:3000",
796 user: "maria",
797 default: "true",
798 valid: "false",
799 },
800 ]),
801 ),
802 );
803 assert.strictEqual(revoked.status, "unauthenticated");
804 const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
805 cwd: "/repo",
806 context: {
807 provider: {
808 kind: "unknown",
809 name: "git.forgejo.local",
810 baseUrl: "https://git.forgejo.local",
811 },
812 remoteName: "origin",
813 remoteUrl: "git@git.forgejo.local:maria/project.git",
814 },
815 auth: processOutput(
816 yield* encodeJsonEffect([
817 {
818 name: "work",
819 url: "http://forgejo.local:3000",
820 ssh_host: "git.forgejo.local",
821 user: "maria",
822 default: "true",
823 },
824 ]),
825 ),
826 });
827 assert.deepStrictEqual(refined, {
828 kind: "forgejo",
829 name: "Forgejo / Gitea",
830 baseUrl: "http://forgejo.local:3000",
831 });
832 }),
833);
834
835it.effect("does not choose a default Forgejo login across ambiguous SSH server ports", () =>
836 Effect.gen(function* () {
837 const logins = ForgejoCli.parseForgejoLogins(
838 yield* encodeJsonEffect([
839 {
840 name: "one",
841 url: "http://forgejo.local:3000",
842 ssh_host: "forgejo.local",
843 user: "maria",
844 default: "true",
845 },
846 {
847 name: "two",
848 url: "http://forgejo.local:4000",
849 ssh_host: "forgejo.local",
850 user: "maria",
851 default: "false",
852 },
853 ]),
854 );
855 const remote = ForgejoCli.parseForgejoRemote("git@forgejo.local:maria/project.git");
856 assert.isNotNull(remote);
857 assert.deepStrictEqual(
858 ForgejoCli.parseForgejoRemote("forgejo.local:maria/project.git"),
859 remote,
860 );
861 assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!));
862 assert.strictEqual(
863 ForgejoCli.matchForgejoLogin(logins, remote!, "forgejo.local:4000")?.name,
864 "two",
865 );
866 assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!, "other.local:4000"));
867 const alias = ForgejoCli.parseForgejoRemote("git@ssh.forgejo.local:maria/project.git");
868 assert.isNotNull(alias);
869 assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, alias!, "forgejo.local:4000"));
870 const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
871 cwd: "/repo",
872 context: {
873 provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" },
874 remoteName: "origin",
875 remoteUrl: "git@forgejo.local:maria/project.git",
876 requestedHost: "forgejo.local:4000",
877 },
878 auth: processOutput(yield* encodeJsonEffect(logins)),
879 });
880 assert.strictEqual(refined?.baseUrl, "http://forgejo.local:4000");
881 const https = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000/maria/project.git");
882 assert.isNotNull(https);
883 assert.strictEqual(ForgejoCli.matchForgejoLogin(logins, https!)?.name, "two");
884 const hostOnly = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000");
885 assert.strictEqual(
886 ForgejoCli.matchForgejoLogin(logins, hostOnly!, undefined, true)?.name,
887 "two",
888 );
889 const mounted = logins.map((login) => ({
890 ...login,
891 url: `http://forgejo.local:4000/${login.name}`,
892 }));
893 assert.isUndefined(ForgejoCli.matchForgejoLogin(mounted, hostOnly!, undefined, true));
894 }),
895);
896
897it.effect("rejects HTTP failures even when tea exits successfully", () =>
898 Effect.gen(function* () {
899 const cli = yield* ForgejoCli.make;
900 const result = yield* cli
901 .api({
902 cwd: "/repo",
903 repository: "http://forgejo.local:3000/maria/project",
904 path: "repos/maria/project/pulls/42",
905 method: "PATCH",
906 body: { state: "closed" },
907 })
908 .pipe(Effect.result);
909 assert.strictEqual(result._tag, "Failure");
910 if (result._tag === "Failure")
911 assert.strictEqual(
912 result.failure.detail,
913 "Forgejo repository or pull request was not found.",
914 );
915 }).pipe(
916 Effect.provideService(
917 FileSystem.FileSystem,
918 FileSystem.makeNoop({
919 exists: () => Effect.succeed(false),
920 }),
921 ),
922 Effect.provideService(
923 HttpClient.HttpClient,
924 HttpClient.make(() => {
925 throw new Error("tea must handle its own HTTP request");
926 }),
927 ),
928 Effect.provide(
929 Layer.mock(VcsProcess.VcsProcess)({
930 run: (input) => {
931 if (input.args[0] === "api") {
932 assert.strictEqual(input.stdin, '{"state":"closed"}');
933 assert.include(input.args, "work");
934 assert.include(
935 input.args,
936 "http://forgejo.local:3000/api/v1/repos/maria/project/pulls/42",
937 );
938 }
939 return Effect.succeed(
940 input.args[0] === "login"
941 ? processOutput(
942 encodeJson([
943 {
944 name: "work",
945 url: "http://forgejo.local:3000",
946 ssh_host: "forgejo.local",
947 user: "maria",
948 default: "true",
949 },
950 ]),
951 )
952 : processOutput('{"message":"not found"}', { stderr: "HTTP/1.1 404 Not Found\n" }),
953 );
954 },
955 }),
956 ),
957 ),
958);
959
960it.effect("routes mounted Forgejo repositories without repeating the mount in API paths", () =>
961 Effect.gen(function* () {
962 const cli = yield* ForgejoCli.make;
963 const viewer = yield* cli.api({ cwd: "/upstream-only", host: "code.test", path: "user" });
964 assert.strictEqual(viewer.stdout, "[]");
965 const mountedRepository = yield* cli.resolveRepository({
966 cwd: "/upstream-only",
967 host: "code.test",
968 repository: "maria/project",
969 });
970 assert.strictEqual(mountedRepository.baseUrl, "https://code.test/forgejo");
971 assert.strictEqual(mountedRepository.repository, "maria/project");
972 for (const path of [
973 "repos/forgejo/maria/project/pulls?state=open",
974 "repos/forgejo/maria/project",
975 "repos/reviewer/project/contents/file.ts",
976 ]) {
977 const result = yield* cli.api({
978 cwd: "/repo",
979 repository: "forgejo/maria/project",
980 context: {
981 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" },
982 remoteName: "origin",
983 remoteUrl: "https://code.test/forgejo/maria/project.git",
984 },
985 path,
986 });
987 assert.strictEqual(result.stdout, "[]");
988 }
989 const sameOwnerAsMount = yield* cli.resolveRepository({
990 cwd: "/repo",
991 repository: "forgejo/project",
992 context: {
993 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" },
994 remoteName: "origin",
995 remoteUrl: "ssh://git@code.test/forgejo/project.git",
996 },
997 });
998 assert.strictEqual(sameOwnerAsMount.command, "tea");
999 assert.strictEqual(sameOwnerAsMount.repository, "forgejo/project");
1000 }).pipe(
1001 Effect.provideService(
1002 FileSystem.FileSystem,
1003 FileSystem.makeNoop({
1004 exists: () => Effect.succeed(true),
1005 readFileString: () =>
1006 Effect.succeed(
1007 encodeJson({
1008 hosts: { "code.test/forgejo": { type: "Application", token: "test-token" } },
1009 }),
1010 ),
1011 }),
1012 ),
1013 Effect.provideService(
1014 HttpClient.HttpClient,
1015 HttpClient.make(() => {
1016 throw new Error("tea must handle its own HTTP request");
1017 }),
1018 ),
1019 Effect.provide(
1020 Layer.mock(VcsProcess.VcsProcess)({
1021 run: (input) => {
1022 if (input.command === "git")
1023 return Effect.succeed(processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }));
1024 if (input.args[0] === "login")
1025 return Effect.succeed(
1026 processOutput(
1027 encodeJson([
1028 {
1029 name: "mounted",
1030 url: "https://code.test/forgejo",
1031 ssh_host: "code.test",
1032 user: "maria",
1033 default: "true",
1034 },
1035 ]),
1036 ),
1037 );
1038 const supported = [
1039 "https://code.test/forgejo/api/v1/user",
1040 "https://code.test/forgejo/api/v1/repos/maria/project/pulls?state=open",
1041 "https://code.test/forgejo/api/v1/repos/maria/project",
1042 "https://code.test/forgejo/api/v1/repos/reviewer/project/contents/file.ts",
1043 ];
1044 if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo");
1045 assert.strictEqual(input.command, "tea");
1046 return Effect.succeed(
1047 supported.includes(input.args.at(-1) ?? "")
1048 ? processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" })
1049 : processOutput("{}", { stderr: "HTTP/1.1 404 Not Found\n" }),
1050 );
1051 },
1052 }),
1053 ),
1054 ),
1055);
1056
1057it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => {
1058 const commands: string[] = [];
1059 const requests: string[] = [];
1060 return Effect.gen(function* () {
1061 const cli = yield* ForgejoCli.make;
1062 for (const remoteUrl of [
1063 "http://forgejo.local:3000/maria/project.git",
1064 "ssh://git@ssh.forgejo.local:2222/maria/project.git",
1065 "ssh://git@forgejo.local:2222/maria/project.git",
1066 ]) {
1067 const result = yield* cli.api({
1068 cwd: "/repo",
1069 repository: "maria/project",
1070 context: {
1071 provider: {
1072 kind: "forgejo",
1073 name: "Forgejo",
1074 baseUrl: "http://forgejo.local:3000",
1075 },
1076 remoteName: "origin",
1077 remoteUrl,
1078 requestedHost: "forgejo.local:3000",
1079 },
1080 path: "repos/maria/project/issues/42/comments",
1081 method: "POST",
1082 body: { body: "verified through fj" },
1083 });
1084 assert.strictEqual(result.stdout, '{"id":99}');
1085 }
1086 assert.deepStrictEqual(commands, ["fj"]);
1087 assert.deepStrictEqual(requests, [
1088 "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
1089 "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
1090 "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
1091 ]);
1092 const viewer = yield* cli.api({
1093 cwd: "/no-remotes",
1094 host: "forgejo.local:3000",
1095 path: "user",
1096 });
1097 assert.strictEqual(viewer.stdout, '{"login":"maria"}');
1098 assert.strictEqual(requests.at(-1), "https://forgejo.local:3000/api/v1/user");
1099 const upstreamViewer = yield* cli.api({
1100 cwd: "/upstream-only",
1101 host: "forgejo.local:3000",
1102 path: "user",
1103 });
1104 assert.strictEqual(upstreamViewer.stdout, '{"login":"maria"}');
1105 assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user");
1106 const upstreamRepository = yield* cli.resolveRepository({
1107 cwd: "/upstream-only",
1108 host: "forgejo.local:3000",
1109 repository: "maria/project",
1110 });
1111 assert.strictEqual(upstreamRepository.baseUrl, "http://forgejo.local:3000");
1112 assert.strictEqual(upstreamRepository.repository, "maria/project");
1113 const httpViewer = yield* cli.api({ cwd: "/repo", host: "forgejo.local:3000", path: "user" });
1114 assert.strictEqual(httpViewer.stdout, '{"login":"maria"}');
1115 assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user");
1116 }).pipe(
1117 Effect.provideService(
1118 FileSystem.FileSystem,
1119 FileSystem.makeNoop({
1120 exists: () => Effect.succeed(true),
1121 readFileString: () =>
1122 Effect.succeed(
1123 encodeJson({
1124 hosts: {
1125 "forgejo.local:3000": { type: "Application", token: "test-token" },
1126 "forgejo.local:4000": { type: "Application", token: "other-token" },
1127 },
1128 aliases: { "ssh.forgejo.local:2222": "forgejo.local:3000" },
1129 }),
1130 ),
1131 }),
1132 ),
1133 Effect.provideService(
1134 HttpClient.HttpClient,
1135 HttpClient.make((request) => {
1136 requests.push(request.url);
1137 if (request.url.endsWith("/user")) {
1138 assert.strictEqual(request.method, "GET");
1139 assert.strictEqual(request.headers.authorization, "token test-token");
1140 return Effect.succeed(
1141 HttpClientResponse.fromWeb(request, new Response('{"login":"maria"}')),
1142 );
1143 }
1144 assert.strictEqual(request.method, "POST");
1145 assert.strictEqual(request.headers.authorization, "token test-token");
1146 assert.strictEqual(request.body._tag, "Uint8Array");
1147 if (request.body._tag === "Uint8Array")
1148 assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), {
1149 body: "verified through fj",
1150 });
1151 return Effect.succeed(
1152 HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })),
1153 );
1154 }),
1155 ),
1156 Effect.provide(
1157 Layer.mock(VcsProcess.VcsProcess)({
1158 run: (input) => {
1159 commands.push(input.command);
1160 if (input.command === "git")
1161 return Effect.succeed(
1162 input.cwd === "/no-remotes"
1163 ? processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) })
1164 : processOutput(
1165 `${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)`,
1166 ),
1167 );
1168 assert.strictEqual(input.command, "fj");
1169 assert.deepStrictEqual(input.args, [
1170 "--host",
1171 input.cwd === "/no-remotes"
1172 ? "https://forgejo.local:3000"
1173 : "http://forgejo.local:3000",
1174 "whoami",
1175 ]);
1176 return Effect.succeed(processOutput(""));
1177 },
1178 }),
1179 ),
1180 );
1181});
1182
1183it.effect("loads later fj review pages when the server caps pages below the requested size", () => {
1184 const pages: number[] = [];
1185 let issueCommentRequests = 0;
1186 return Effect.gen(function* () {
1187 const cli = yield* ForgejoCli.make;
1188 const provider = yield* ForgejoPullRequestProvider.make.pipe(
1189 Effect.provideService(ForgejoCli.ForgejoCli, cli),
1190 );
1191 const activity = yield* provider.getChangeRequestActivity({
1192 cwd: "/repo",
1193 repository: "maria/project",
1194 host: "forgejo.test",
1195 number: 42,
1196 });
1197 assert.strictEqual(activity.commentCount, 42);
1198 assert.strictEqual(activity.comments.at(-1)?.id, "review:41");
1199 assert.strictEqual(activity.commentsTruncated, false);
1200 assert.deepStrictEqual(pages, [1, 2, 3]);
1201 assert.strictEqual(issueCommentRequests, 1);
1202 }).pipe(
1203 Effect.provideService(
1204 FileSystem.FileSystem,
1205 FileSystem.makeNoop({
1206 exists: () => Effect.succeed(true),
1207 readFileString: () =>
1208 Effect.succeed(
1209 encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }),
1210 ),
1211 }),
1212 ),
1213 Effect.provideService(
1214 HttpClient.HttpClient,
1215 HttpClient.make((request) => {
1216 const url = new URL(request.url);
1217 if (url.pathname === "/api/v1/repos/maria/project/issues/42/comments") {
1218 issueCommentRequests++;
1219 return Effect.succeed(
1220 HttpClientResponse.fromWeb(
1221 request,
1222 new Response(
1223 encodeJson([
1224 {
1225 id: 100,
1226 body: "Unpaginated issue comment",
1227 user: { login: "maria" },
1228 created_at: "2026-09-13T00:00:00Z",
1229 },
1230 ]),
1231 ),
1232 ),
1233 );
1234 }
1235 if (url.pathname.endsWith("/reviews")) {
1236 const page = Number(url.searchParams.get("page"));
1237 pages.push(page);
1238 assert.ok(page >= 1 && page <= 3);
1239 const reviews = Array.from({ length: page < 3 ? 20 : 1 }, (_, index) => ({
1240 id: (page - 1) * 20 + index + 1,
1241 body: "Review from a capped page",
1242 user: { login: "maria" },
1243 state: "COMMENT",
1244 submitted_at: "2026-09-13T00:00:00Z",
1245 comments_count: 0,
1246 }));
1247 return Effect.succeed(
1248 HttpClientResponse.fromWeb(
1249 request,
1250 new Response(encodeJson(reviews), {
1251 headers:
1252 page === 2
1253 ? {}
1254 : {
1255 Link:
1256 page === 1
1257 ? `<${url.origin}${url.pathname}?limit=50&page=2>; rel="next"`
1258 : `<${url.origin}${url.pathname}?limit=50&page=1>; rel="prev"`,
1259 },
1260 }),
1261 ),
1262 );
1263 }
1264 return Effect.succeed(
1265 HttpClientResponse.fromWeb(
1266 request,
1267 new Response(encodeJson(url.pathname === "/api/v1/user" ? { login: "maria" } : [])),
1268 ),
1269 );
1270 }),
1271 ),
1272 Effect.provide(
1273 Layer.mock(VcsProcess.VcsProcess)({
1274 run: (input) => {
1275 if (input.command === "git") {
1276 assert.deepStrictEqual(input.args, ["remote", "-v"]);
1277 return Effect.succeed(
1278 processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"),
1279 );
1280 }
1281 assert.strictEqual(input.command, "fj");
1282 assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]);
1283 return Effect.succeed(processOutput(""));
1284 },
1285 }),
1286 ),
1287 );
1288});
1289
1290it.effect("falls back to tea when fj is missing or has no account for this server", () =>
1291 Effect.gen(function* () {
1292 for (const scenario of ["missing-cli", "missing-account", "stale-invalid-storage"] as const) {
1293 const commands: string[] = [];
1294 yield* Effect.gen(function* () {
1295 const cli = yield* ForgejoCli.make;
1296 const result = yield* cli.api({
1297 cwd: "/repo",
1298 repository: "https://forgejo.local:3000/maria/project",
1299 path: "repos/maria/project/pulls",
1300 });
1301 assert.strictEqual(result.stdout, "[]");
1302 assert.deepStrictEqual(
1303 commands,
1304 scenario === "missing-account" ? ["tea", "tea"] : ["fj", "tea", "tea"],
1305 );
1306 const viewer = yield* cli.api({
1307 cwd: "/upstream-only",
1308 host: "forgejo.local:3000",
1309 path: "user",
1310 });
1311 assert.strictEqual(viewer.stdout, "[]");
1312 }).pipe(
1313 Effect.provideService(
1314 FileSystem.FileSystem,
1315 FileSystem.makeNoop({
1316 exists: () => Effect.succeed(true),
1317 readFileString: () =>
1318 Effect.succeed(
1319 scenario === "stale-invalid-storage"
1320 ? "invalid json"
1321 : encodeJson({
1322 hosts: {
1323 [scenario === "missing-cli" ? "forgejo.local:3000" : "other.local"]: {
1324 type: "Application",
1325 token: "test-token",
1326 },
1327 },
1328 }),
1329 ),
1330 }),
1331 ),
1332 Effect.provideService(
1333 HttpClient.HttpClient,
1334 HttpClient.make(() => {
1335 throw new Error("tea must handle its own HTTP request");
1336 }),
1337 ),
1338 Effect.provide(
1339 Layer.mock(VcsProcess.VcsProcess)({
1340 run: (input) => {
1341 commands.push(input.command);
1342 if (input.command === "git")
1343 return Effect.succeed(
1344 processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }),
1345 );
1346 if (input.command === "fj")
1347 return Effect.fail(
1348 new VcsProcessSpawnError({
1349 operation: input.operation,
1350 command: input.command,
1351 cwd: input.cwd,
1352 cause: new Error("fj not found"),
1353 }),
1354 );
1355 assert.strictEqual(input.command, "tea");
1356 if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo");
1357 return Effect.succeed(
1358 input.args[0] === "login"
1359 ? processOutput(
1360 encodeJson([
1361 {
1362 name: "work",
1363 url: "https://forgejo.local:3000",
1364 user: "maria",
1365 default: "true",
1366 valid: "true",
1367 },
1368 ]),
1369 )
1370 : processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }),
1371 );
1372 },
1373 }),
1374 ),
1375 );
1376 }
1377 }),
1378);
1379
1380it.effect("handles fj mutation statuses without retrying failures or reading absent bodies", () =>
1381 Effect.gen(function* () {
1382 for (const status of [204, 205, 302, 401, 403, 404, 429, 500]) {
1383 let writes = 0;
1384 yield* Effect.gen(function* () {
1385 const cli = yield* ForgejoCli.make;
1386 const result = yield* cli
1387 .api({
1388 cwd: "/repo",
1389 repository: "https://forgejo.local/maria/project",
1390 path: "repos/maria/project/issues/42/comments",
1391 method: "POST",
1392 body: { body: "only once" },
1393 })
1394 .pipe(Effect.result);
1395 assert.strictEqual(result._tag, status < 300 ? "Success" : "Failure");
1396 if (result._tag === "Success") assert.strictEqual(result.success.stdout, "");
1397 if (result._tag === "Failure") {
1398 assert.strictEqual(result.failure.command, "fj");
1399 assert.strictEqual(result.failure.httpStatus, status);
1400 }
1401 assert.strictEqual(writes, 1);
1402 }).pipe(
1403 Effect.provideService(
1404 FileSystem.FileSystem,
1405 FileSystem.makeNoop({
1406 exists: () => Effect.succeed(true),
1407 readFileString: () =>
1408 Effect.succeed(
1409 encodeJson({
1410 hosts: {
1411 "forgejo.local": { type: "Application", token: "test-token" },
1412 },
1413 }),
1414 ),
1415 }),
1416 ),
1417 Effect.provideService(
1418 HttpClient.HttpClient,
1419 HttpClient.make((request) => {
1420 writes++;
1421 assert.strictEqual(
1422 request.url,
1423 "https://forgejo.local/api/v1/repos/maria/project/issues/42/comments",
1424 );
1425 return Effect.succeed(
1426 HttpClientResponse.fromWeb(
1427 request,
1428 new Response(status < 300 ? null : "", {
1429 status,
1430 headers: { location: "https://other.local/" },
1431 }),
1432 ),
1433 );
1434 }),
1435 ),
1436 Effect.provide(
1437 Layer.mock(VcsProcess.VcsProcess)({
1438 run: (input) => {
1439 assert.strictEqual(
1440 input.command,
1441 "fj",
1442 "a failed mutation must never switch accounts or CLI",
1443 );
1444 return Effect.succeed(processOutput(""));
1445 },
1446 }),
1447 ),
1448 );
1449 }
1450 }),
1451);
1452
1453it.effect(
1454 "discovers fj first and retains configured authentication failures instead of switching accounts",
1455 () =>
1456 Effect.gen(function* () {
1457 for (const scenario of ["authenticated", "revoked", "missing", "invalid-storage"] as const) {
1458 const commands: string[] = [];
1459 yield* Effect.gen(function* () {
1460 const spec = yield* ForgejoSourceControlProvider.makeDiscovery;
1461 assert.strictEqual(spec.type, "managed-cli");
1462 if (spec.type !== "managed-cli") return;
1463 const result = yield* spec.probe("/repo");
1464 assert.strictEqual(result.executable, scenario === "missing" ? "tea" : "fj");
1465 assert.strictEqual(
1466 result.auth.status,
1467 scenario === "revoked"
1468 ? "unauthenticated"
1469 : scenario === "invalid-storage"
1470 ? "unknown"
1471 : "authenticated",
1472 );
1473 if (scenario !== "revoked" && scenario !== "invalid-storage")
1474 assert.deepStrictEqual(result.auth.host, Option.some("forgejo.local:3000"));
1475 assert.deepStrictEqual(
1476 result.auth.account,
1477 scenario === "authenticated" || scenario === "missing"
1478 ? Option.some("maria")
1479 : Option.none(),
1480 );
1481 assert.strictEqual(
1482 commands.some((command) => command.startsWith("tea ")),
1483 scenario === "missing",
1484 );
1485 assert.include(commands, "fj version");
1486 }).pipe(
1487 Effect.provide(
1488 Layer.mergeAll(
1489 Layer.mock(ForgejoCli.ForgejoCli)({
1490 getAccount: (input) => {
1491 assert.strictEqual(scenario, "authenticated");
1492 assert.deepStrictEqual(input, {
1493 cwd: "/repo",
1494 baseUrl: "http://forgejo.local:3000",
1495 });
1496 return Effect.succeed("maria");
1497 },
1498 listLogins: (input) => {
1499 assert.strictEqual(
1500 input.remoteUrl,
1501 "http://forgejo.local:3000/maria/project.git",
1502 );
1503 if (scenario === "invalid-storage")
1504 return Effect.fail(
1505 new ForgejoCli.ForgejoCliError({
1506 command: "fj",
1507 cwd: input.cwd,
1508 reason: "authentication",
1509 detail: "fj authentication storage is invalid.",
1510 }),
1511 );
1512 return Effect.succeed([
1513 {
1514 name: "forgejo.local:3000",
1515 url: "http://forgejo.local:3000",
1516 user: "",
1517 default: "false",
1518 },
1519 ]);
1520 },
1521 }),
1522 Layer.mock(VcsProcess.VcsProcess)({
1523 run: (input) => {
1524 commands.push(`${input.command} ${input.args.join(" ")}`);
1525 if (input.command === "git")
1526 return Effect.succeed(
1527 processOutput("http://forgejo.local:3000/maria/project.git\n"),
1528 );
1529 if (input.command === "fj") {
1530 if (scenario === "missing")
1531 return Effect.fail(
1532 new VcsProcessSpawnError({
1533 operation: input.operation,
1534 command: input.command,
1535 cwd: input.cwd,
1536 cause: new Error("fj not found"),
1537 }),
1538 );
1539 if (input.args[0] === "version")
1540 return Effect.succeed(processOutput("fj 0.10.0"));
1541 if (scenario === "invalid-storage") {
1542 assert.deepStrictEqual(input.args, ["auth", "list"]);
1543 return Effect.succeed(processOutput(""));
1544 }
1545 assert.deepStrictEqual(input.args, [
1546 "--host",
1547 "http://forgejo.local:3000",
1548 "whoami",
1549 ]);
1550 return Effect.succeed(
1551 processOutput("", {
1552 exitCode: ChildProcessSpawner.ExitCode(scenario === "revoked" ? 1 : 0),
1553 }),
1554 );
1555 }
1556 assert.strictEqual(input.command, "tea");
1557 return Effect.succeed(
1558 input.args[0] === "--version"
1559 ? processOutput("tea version 0.16.0")
1560 : processOutput(
1561 encodeJson([
1562 {
1563 name: "work",
1564 url: "http://forgejo.local:3000",
1565 user: "maria",
1566 default: "true",
1567 valid: "true",
1568 },
1569 ]),
1570 ),
1571 );
1572 },
1573 }),
1574 ),
1575 ),
1576 );
1577 }
1578 }),
1579);
1580
1581339it.effect(
1582340 "checks out fj pull refs and preserves existing branches and dirty files until forced",
1583341 () =>
......@@ -1627,22 +385,26 @@ it.effect(
1627385 args: ["rev-parse", "HEAD"],
1628386 })).stdout.trim();
1629387 const fetched: string[] = [];
388 const host = yield* SourceControlHost.SourceControlHost;
1630389 const provider = yield* ForgejoSourceControlProvider.make.pipe(
1631390 Effect.provideService(
1632 VcsProcess.VcsProcess,
1633 VcsProcess.VcsProcess.of({
1634 run: (input) => {
1635 if (input.args[0] !== "fetch") return git.run(input);
1636 const url = input.args[2];
1637 assert.isDefined(url);
1638 fetched.push(url!);
1639 // Only SSH transport is substituted; both paths fetch the real pull ref.
1640 return git.run({
1641 ...input,
1642 args: input.args.map((arg) =>
1643 arg === "git@forgejo.test:reviewer/project.git" ? source : arg,
1644 ),
1645 });
391 SourceControlHost.SourceControlHost,
392 SourceControlHost.SourceControlHost.of({
393 settings: host.settings,
394 process: {
395 run: (input) => {
396 if (input.args[0] !== "fetch") return git.run(input);
397 const url = input.args[2];
398 assert.isDefined(url);
399 fetched.push(url!);
400 // Only SSH transport is substituted; both paths fetch the real pull ref.
401 return git.run({
402 ...input,
403 args: input.args.map((arg) =>
404 arg === "git@forgejo.test:reviewer/project.git" ? source : arg,
405 ),
406 });
407 },
1646408 },
1647409 }),
1648410 ),
......@@ -1759,6 +521,12 @@ it.effect(
1759521 assert.deepStrictEqual(fetched, [source, source, "git@forgejo.test:reviewer/project.git"]);
1760522 }).pipe(
1761523 Effect.scoped,
1762 Effect.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))),
524 Effect.provide(
525 ServerSourceControlHost.layer.pipe(
526 Layer.provideMerge(VcsProcess.layer),
527 Layer.provideMerge(ServerSettings.ServerSettingsService.layerTest()),
528 Layer.provideMerge(NodeServices.layer),
529 ),
530 ),
1763531 ),
1764532);
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+1-1
......@@ -19,7 +19,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
1919import * as GitHubApi from "./GitHubApi.ts";
2020import * as GitHubCredentials from "./GitHubCredentials.ts";
2121import * as GitLabCli from "./GitLabCli.ts";
22import * as ForgejoCli from "./ForgejoCli.ts";
22import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
2323import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
2424import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
2525
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+11-4
......@@ -15,7 +15,7 @@ import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlPro
1515import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
1616import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1717import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
18import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
18import * as BuiltInDrivers from "./builtInDrivers.ts";
1919import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
2020import {
2121 probeSourceControlProvider,
......@@ -308,11 +308,18 @@ export const make = Effect.gen(function* () {
308308 const github = yield* GitHubSourceControlProvider.make;
309309 const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery;
310310 const gitlab = yield* GitLabSourceControlProvider.make;
311 const forgejo = yield* ForgejoSourceControlProvider.make;
312 const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery;
313311 const bitbucket = yield* BitbucketSourceControlProvider.make;
314312 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;
315313 const azureDevOps = yield* AzureDevOpsSourceControlProvider.make;
314 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
315 driver.make.pipe(
316 Effect.map((instance): SourceControlProviderRegistration => ({
317 kind: driver.kind,
318 provider: instance.sourceControl,
319 discovery: instance.discovery,
320 })),
321 ),
322 );
316323 return yield* makeWithProviders([
317324 {
318325 kind: "github",
......@@ -334,7 +341,7 @@ export const make = Effect.gen(function* () {
334341 provider: bitbucket,
335342 discovery: bitbucketDiscovery,
336343 },
337 { kind: "forgejo", provider: forgejo, discovery: forgejoDiscovery },
344 ...drivers,
338345 ]);
339346});
340347
apps/server/src/sourceControl/builtInDrivers.ts created+20
......@@ -0,0 +1,20 @@
1/**
2 * The source control drivers this build ships with. Both registries, repository operations and
3 * pull requests, iterate this list; a host with no driver here shows up as unsupported.
4 *
5 * Adding a host means writing its `@t3tools/source-control-<host>` package, adding its driver
6 * here, and providing its services' layers in `layer` below.
7 *
8 * @module sourceControl/builtInDrivers
9 */
10import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
11import * as ForgejoDriver from "@t3tools/source-control-forgejo/server/driver";
12import * as Layer from "effect/Layer";
13
14import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
15
16/** Ordered as the hosts appear in discovery, after the ones still built in the server. */
17export const BUILT_IN_SOURCE_CONTROL_DRIVERS = [ForgejoDriver.driver] as const;
18
19/** The services the built-in drivers' packages own, plus the host port they all run against. */
20export const layer = ForgejoCli.layer.pipe(Layer.provideMerge(ServerSourceControlHost.layer));
apps/server/src/sourceControl/forgejoPullRequests.ts deleted-47
......@@ -1,47 +0,0 @@
1import * as Schema from "effect/Schema";
2import * as Option from "effect/Option";
3import type { ChangeRequest } from "@t3tools/contracts";
4
5const Repository = Schema.Struct({
6 full_name: Schema.String,
7 owner: Schema.Struct({ login: Schema.String }),
8});
9const Branch = Schema.Struct({
10 ref: Schema.String,
11 sha: Schema.String,
12 repo: Schema.NullOr(Repository),
13});
14export const ForgejoPullRequestSchema = Schema.Struct({
15 number: Schema.Int,
16 title: Schema.String,
17 html_url: Schema.String,
18 state: Schema.String,
19 merged: Schema.Boolean,
20 draft: Schema.optional(Schema.Boolean),
21 base: Branch,
22 head: Branch,
23 closed_at: Schema.optional(Schema.NullOr(Schema.String)),
24 merged_at: Schema.optional(Schema.NullOr(Schema.String)),
25 updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
26});
27export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type): ChangeRequest {
28 return {
29 provider: "forgejo",
30 number: raw.number,
31 title: raw.title,
32 url: raw.html_url,
33 state: raw.merged ? "merged" : raw.state === "closed" ? "closed" : "open",
34 isDraft: raw.draft ?? /^(?:\[WIP\]|WIP:)/i.test(raw.title),
35 baseRefName: raw.base.ref,
36 headRefName: raw.head.ref,
37 closedAt: raw.closed_at ?? null,
38 mergedAt: raw.merged_at ?? null,
39 updatedAt: raw.updated_at ?? Option.none(),
40 isCrossRepository:
41 raw.head.repo !== null &&
42 raw.base.repo !== null &&
43 raw.head.repo.full_name !== raw.base.repo.full_name,
44 headRepositoryNameWithOwner: raw.head.repo?.full_name ?? null,
45 headRepositoryOwnerLogin: raw.head.repo?.owner.login ?? null,
46 };
47}
apps/server/src/ws.ts+2-4
......@@ -233,9 +233,8 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
233233import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
234234import * as GitHubApi from "./sourceControl/GitHubApi.ts";
235235import * as GitLabCli from "./sourceControl/GitLabCli.ts";
236import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
236import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
237237import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
238import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts";
239238import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
240239import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
241240import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
......@@ -3209,8 +3208,7 @@ export const layer = Layer.unwrap(
32093208 BitbucketApi.layer,
32103209 GitHubApi.layerWithDependencies,
32113210 GitLabCli.layer,
3212 ForgejoCli.layer,
3213 ServerSourceControlHost.layer,
3211 SourceControlBuiltInDrivers.layer,
32143212 ),
32153213 ),
32163214 Layer.provideMerge(GitVcsDriver.layer),
package.json+1-1
......@@ -28,7 +28,7 @@
2828 "tc": "vp run -r --concurrency-limit 2 typecheck",
2929 "lint": "vp lint --report-unused-disable-directives",
3030 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",
31 "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",
31 "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",
3232 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
3333 "lint:mobile": "node scripts/mobile-native-static-check.ts",
3434 "test": "vp run -r test",
packages/source-control-core/package.json+9
......@@ -3,10 +3,18 @@
33 "private": true,
44 "type": "module",
55 "exports": {
6 "./server/diffFileRevisions": {
7 "types": "./src/server/diffFileRevisions.ts",
8 "import": "./src/server/diffFileRevisions.ts"
9 },
610 "./server/discovery": {
711 "types": "./src/server/discovery.ts",
812 "import": "./src/server/discovery.ts"
913 },
14 "./server/driver": {
15 "types": "./src/server/driver.ts",
16 "import": "./src/server/driver.ts"
17 },
1018 "./server/mergeMessage": {
1119 "types": "./src/server/mergeMessage.ts",
1220 "import": "./src/server/mergeMessage.ts"
......@@ -38,6 +46,7 @@
3846 },
3947 "dependencies": {
4048 "@t3tools/contracts": "workspace:*",
49 "@t3tools/shared": "workspace:*",
4150 "effect": "catalog:"
4251 },
4352 "devDependencies": {
packages/source-control-core/src/server/diffFileRevisions.test.ts created+280
......@@ -0,0 +1,280 @@
1import { assert, describe, it } from "@effect/vitest";
2
3import { parseDiffFileRevisions } from "./diffFileRevisions.ts";
4
5function patchOf(...lines: ReadonlyArray<string>): string {
6 return `${lines.join("\n")}\n`;
7}
8
9describe("parseDiffFileRevisions", () => {
10 it("reads the head id of a changed file off its index line", () => {
11 const revisions = parseDiffFileRevisions(
12 patchOf(
13 "diff --git a/src/a.ts b/src/a.ts",
14 "index 7f2aa0ab6..b4a2a7c9a 100644",
15 "--- a/src/a.ts",
16 "+++ b/src/a.ts",
17 "@@ -1 +1 @@",
18 "-a",
19 "+b",
20 ),
21 );
22
23 assert.deepStrictEqual([...revisions], [["src/a.ts", "b4a2a7c9a"]]);
24 });
25
26 it("names a deletion by the path it had, which is the one still on screen", () => {
27 const revisions = parseDiffFileRevisions(
28 patchOf(
29 "diff --git a/gone.ts b/gone.ts",
30 "deleted file mode 100644",
31 "index 1111111..0000000",
32 "--- a/gone.ts",
33 "+++ /dev/null",
34 "@@ -1 +0,0 @@",
35 "-x",
36 ),
37 );
38
39 assert.deepStrictEqual([...revisions], [["gone.ts", "0000000"]]);
40 });
41
42 it("names a rename by where it moved to", () => {
43 const revisions = parseDiffFileRevisions(
44 patchOf(
45 "diff --git a/old.ts b/new.ts",
46 "similarity index 90%",
47 "rename from old.ts",
48 "rename to new.ts",
49 "index 2222222..3333333 100644",
50 "--- a/old.ts",
51 "+++ b/new.ts",
52 "@@ -1 +1 @@",
53 "-a",
54 "+b",
55 ),
56 );
57
58 assert.deepStrictEqual([...revisions], [["new.ts", "3333333"]]);
59 });
60
61 it("names a rename that changed nothing, which states no paths of its own", () => {
62 const revisions = parseDiffFileRevisions(
63 patchOf(
64 "diff --git a/old.ts b/new.ts",
65 "similarity index 100%",
66 "rename from old.ts",
67 "rename to new.ts",
68 "index 2222222..2222222 100644",
69 ),
70 );
71
72 assert.deepStrictEqual([...revisions], [["new.ts", "2222222"]]);
73 });
74
75 it("leaves out a file Bitbucket excluded, which it gives no index line for", () => {
76 const revisions = parseDiffFileRevisions(
77 patchOf(
78 "diff --git a/package.json b/package.json",
79 "index 7f2aa0ab6..b4a2a7c9a 100644",
80 "--- a/package.json",
81 "+++ b/package.json",
82 "@@ -1 +1 @@",
83 '- "x": "1"',
84 '+ "x": "2"',
85 "diff --git a/yarn.lock b/yarn.lock",
86 'File excluded by pattern "yarn.lock"',
87 ),
88 );
89
90 assert.deepStrictEqual([...revisions], [["package.json", "b4a2a7c9a"]]);
91 });
92
93 it("stops reading headers at the first hunk, so content cannot pose as one", () => {
94 const revisions = parseDiffFileRevisions(
95 patchOf(
96 "diff --git a/notes.md b/notes.md",
97 "index aaaaaaa..bbbbbbb 100644",
98 "--- a/notes.md",
99 "+++ b/notes.md",
100 "@@ -1,2 +1,2 @@",
101 "--- a/decoy.ts",
102 "+++ b/decoy.ts",
103 "+index ccccccc..ddddddd 100644",
104 ),
105 );
106
107 assert.deepStrictEqual([...revisions], [["notes.md", "bbbbbbb"]]);
108 });
109
110 it("splits a header whose paths contain the separator, by the sides agreeing", () => {
111 const revisions = parseDiffFileRevisions(
112 patchOf(
113 "diff --git a/one b/two.ts b/one b/two.ts",
114 "index eeeeeee..fffffff 100644",
115 "@@ -1 +1 @@",
116 ),
117 );
118
119 assert.deepStrictEqual([...revisions], [["one b/two.ts", "fffffff"]]);
120 });
121
122 it("leaves out an added file that Bitbucket sent no index line for", () => {
123 const revisions = parseDiffFileRevisions(
124 patchOf("diff --git a/added.ts b/added.ts", "new file mode 100644", "--- /dev/null"),
125 );
126
127 assert.strictEqual(revisions.size, 0);
128 });
129
130 it("reads nothing out of an empty patch", () => {
131 assert.strictEqual(parseDiffFileRevisions("").size, 0);
132 });
133
134 it("reads a name git had to quote, here one holding a tab", () => {
135 const revisions = parseDiffFileRevisions(
136 patchOf(
137 'diff --git "a/we\\tird.ts" "b/we\\tird.ts"',
138 "index 4444444..5555555 100644",
139 '--- "a/we\\tird.ts"',
140 '+++ "b/we\\tird.ts"',
141 "@@ -1 +1 @@",
142 "-a",
143 "+b",
144 ),
145 );
146
147 assert.deepStrictEqual([...revisions], [["we\tird.ts", "5555555"]]);
148 });
149
150 it("rejoins the octal bytes git writes for a name outside ASCII", () => {
151 const revisions = parseDiffFileRevisions(
152 patchOf(
153 'diff --git "a/caf\\303\\251/r\\303\\251sum\\303\\251.ts" "b/caf\\303\\251/r\\303\\251sum\\303\\251.ts"',
154 "index 6666666..7777777 100644",
155 "@@ -1 +1 @@",
156 ),
157 );
158
159 assert.deepStrictEqual([...revisions], [["café/résumé.ts", "7777777"]]);
160 });
161
162 it("splits a rename header where git quoted only the side that needed it", () => {
163 const revisions = parseDiffFileRevisions(
164 patchOf(
165 'diff --git a/old.ts "b/new\\tname.ts"',
166 "similarity index 90%",
167 "rename from old.ts",
168 'rename to "new\\tname.ts"',
169 "index 8888888..9999999 100644",
170 "--- a/old.ts",
171 '+++ "b/new\\tname.ts"',
172 "@@ -1 +1 @@",
173 "-a",
174 "+b",
175 ),
176 );
177
178 assert.deepStrictEqual([...revisions], [["new\tname.ts", "9999999"]]);
179 });
180
181 it("names a quoted rename that changed nothing, which states no paths of its own", () => {
182 const revisions = parseDiffFileRevisions(
183 patchOf(
184 'diff --git "a/old\\tname.ts" "b/new\\tname.ts"',
185 "similarity index 100%",
186 'rename from "old\\tname.ts"',
187 'rename to "new\\tname.ts"',
188 "index abcabca..abcabca 100644",
189 ),
190 );
191
192 assert.deepStrictEqual([...revisions], [["new\tname.ts", "abcabca"]]);
193 });
194
195 it("keeps a character from outside the basic plane that git left unescaped", () => {
196 // `core.quotePath` off leaves the name's own bytes in place, and git still quotes the header
197 // for the tab. Encoding what it left one unit at a time would split the pair into two halves.
198 const revisions = parseDiffFileRevisions(
199 patchOf(
200 'diff --git "a/we\\tird-\u{1f680}.ts" "b/we\\tird-\u{1f680}.ts"',
201 "index aaaaaaa..bbbbbbb 100644",
202 "@@ -1 +1 @@",
203 ),
204 );
205
206 assert.deepStrictEqual([...revisions], [["we\tird-\u{1f680}.ts", "bbbbbbb"]]);
207 });
208
209 it("drops the tab git ends a name holding a space with", () => {
210 const revisions = parseDiffFileRevisions(
211 patchOf(
212 "diff --git a/with space.txt b/with space.txt",
213 "index 1111111..6178079 100644",
214 "--- a/with space.txt\t",
215 "+++ b/with space.txt\t",
216 "@@ -1 +1 @@",
217 "-a",
218 "+b",
219 ),
220 );
221
222 assert.deepStrictEqual([...revisions], [["with space.txt", "6178079"]]);
223 });
224
225 it("drops that tab from a quoted name too, where it lands past the closing quote", () => {
226 const revisions = parseDiffFileRevisions(
227 patchOf(
228 'diff --git "a/we ird\\tname.ts" "b/we ird\\tname.ts"',
229 "index 2222222..7777777 100644",
230 '--- "a/we ird\\tname.ts"\t',
231 '+++ "b/we ird\\tname.ts"\t',
232 "@@ -1 +1 @@",
233 "-a",
234 "+b",
235 ),
236 );
237
238 assert.deepStrictEqual([...revisions], [["we ird\tname.ts", "7777777"]]);
239 });
240
241 it("reads a deletion whose name holds a space, tab and all", () => {
242 const revisions = parseDiffFileRevisions(
243 patchOf(
244 "diff --git a/with space.txt b/with space.txt",
245 "deleted file mode 100644",
246 "index 3333333..0000000",
247 "--- a/with space.txt\t",
248 "+++ /dev/null",
249 "@@ -1 +0,0 @@",
250 "-a",
251 ),
252 );
253
254 assert.deepStrictEqual([...revisions], [["with space.txt", "0000000"]]);
255 });
256
257 it("drops the timestamp other producers of the format write past that tab", () => {
258 const revisions = parseDiffFileRevisions(
259 patchOf(
260 "diff --git a/stamped.ts b/stamped.ts",
261 "index 4444444..5555555 100644",
262 "--- a/stamped.ts\t2024-01-01 00:00:00.000000000 +0000",
263 "+++ b/stamped.ts\t2024-01-02 00:00:00.000000000 +0000",
264 "@@ -1 +1 @@",
265 "-a",
266 "+b",
267 ),
268 );
269
270 assert.deepStrictEqual([...revisions], [["stamped.ts", "5555555"]]);
271 });
272
273 it("splits an unquoted header whose names hold a space, by the sides agreeing", () => {
274 const revisions = parseDiffFileRevisions(
275 patchOf("diff --git a/one two b/one two", "index ddddddd..eeeeeee 100644", "@@ -1 +1 @@"),
276 );
277
278 assert.deepStrictEqual([...revisions], [["one two", "eeeeeee"]]);
279 });
280});
packages/source-control-core/src/server/diffFileRevisions.ts created+129
......@@ -0,0 +1,129 @@
1import { unquoteGitPatchPath } from "@t3tools/shared/gitPatchPath";
2
3const ENTRY = "diff --git ";
4const QUOTE = '"';
5
6interface Entry {
7 oldPath: string | null;
8 newPath: string | null;
9 deleted: boolean;
10 revision: string | null;
11 /** Past the first hunk header every line is content, and content can start like a header. */
12 inBody: boolean;
13}
14
15/** Where a quoted name closes, given git escapes every quote the name itself holds. */
16function quotedEnd(rest: string): number {
17 for (let at = 1; at < rest.length; at += 1) {
18 const char = rest.charAt(at);
19 if (char === "\\") {
20 at += 1;
21 continue;
22 }
23 if (char === QUOTE) return at;
24 }
25 return -1;
26}
27
28/**
29 * `a/x`/`b/x` on a `---`/`+++` line; `/dev/null` marks the side that has no file. Git ends the
30 * name with a tab when it holds a space, and a name with a tab of its own arrives quoted with
31 * that tab escaped, so the first literal tab is never part of what the file is called.
32 */
33function sidePath(rest: string, prefix: string): string | null {
34 const tab = rest.indexOf("\t");
35 const token = tab === -1 ? rest : rest.slice(0, tab);
36 if (token === "/dev/null") return null;
37 const path = unquoteGitPatchPath(token);
38 return path.startsWith(prefix) ? path.slice(prefix.length) : path;
39}
40
41function headerSide(token: string, prefix: string): string | null {
42 const path = unquoteGitPatchPath(token);
43 return path.startsWith(prefix) ? path.slice(prefix.length) : null;
44}
45
46/**
47 * The two names on a `diff --git` line, written with no delimiter between them. `a/one two b/one
48 * two` can split in more than one place, so the split leaving both sides equal wins; a rename
49 * (the only case where sides differ) states its names on separate lines instead. A quoted name
50 * ends at its own closing quote and needs none of that guessing.
51 */
52function headerPaths(rest: string): readonly [string | null, string | null] {
53 if (rest.startsWith(QUOTE)) {
54 const end = quotedEnd(rest);
55 if (end === -1 || rest.charAt(end + 1) !== " ") return [null, null];
56 return [headerSide(rest.slice(0, end + 1), "a/"), headerSide(rest.slice(end + 2), "b/")];
57 }
58 if (rest.endsWith(QUOTE)) {
59 const opens = rest.indexOf(QUOTE);
60 if (opens < 1 || rest.charAt(opens - 1) !== " ") return [null, null];
61 return [headerSide(rest.slice(0, opens - 1), "a/"), headerSide(rest.slice(opens), "b/")];
62 }
63 if (!rest.startsWith("a/")) return [null, null];
64 const splits: Array<number> = [];
65 for (let at = rest.indexOf(" b/"); at !== -1; at = rest.indexOf(" b/", at + 1)) splits.push(at);
66 const chosen =
67 splits.find((at) => rest.slice(2, at) === rest.slice(at + 3)) ??
68 (splits.length === 1 ? splits[0] : undefined);
69 return chosen === undefined ? [null, null] : [rest.slice(2, chosen), rest.slice(chosen + 3)];
70}
71
72/** The right-hand id of `index <before>..<after> <mode>`. */
73function headRevision(rest: string): string | null {
74 const gap = rest.indexOf("..");
75 if (gap === -1) return null;
76 const after = rest.slice(gap + 2);
77 const end = after.indexOf(" ");
78 const head = end === -1 ? after : after.slice(0, end);
79 return head.length === 0 ? null : head;
80}
81
82/**
83 * What the head has of each file, as the blob ids from a unified patch's `index <before>..<after>`
84 * line: Bitbucket exposes no blob id for a file anywhere else. Keyed by the head's name, except
85 * for a deletion where only the old name exists. An entry with no `index` line (most often one
86 * Bitbucket excluded by pattern) is left out.
87 */
88export function parseDiffFileRevisions(patch: string): ReadonlyMap<string, string> {
89 const revisions = new Map<string, string>();
90 let entry: Entry | null = null;
91
92 const close = () => {
93 if (entry === null) return;
94 const path = entry.deleted ? entry.oldPath : (entry.newPath ?? entry.oldPath);
95 if (path !== null && path.length > 0 && entry.revision !== null) {
96 revisions.set(path, entry.revision);
97 }
98 entry = null;
99 };
100
101 for (const line of patch.split("\n")) {
102 if (line.startsWith(ENTRY)) {
103 close();
104 const [oldPath, newPath] = headerPaths(line.slice(ENTRY.length));
105 entry = { oldPath, newPath, deleted: false, revision: null, inBody: false };
106 continue;
107 }
108 if (entry === null || entry.inBody) continue;
109 if (line.startsWith("@@")) {
110 entry.inBody = true;
111 } else if (line.startsWith("index ")) {
112 entry.revision = headRevision(line.slice("index ".length));
113 } else if (line.startsWith("deleted file mode")) {
114 entry.deleted = true;
115 } else if (line.startsWith("rename from ")) {
116 entry.oldPath = unquoteGitPatchPath(line.slice("rename from ".length));
117 } else if (line.startsWith("rename to ")) {
118 entry.newPath = unquoteGitPatchPath(line.slice("rename to ".length));
119 } else if (line.startsWith("--- ")) {
120 entry.oldPath = sidePath(line.slice(4), "a/");
121 } else if (line.startsWith("+++ ")) {
122 const side = sidePath(line.slice(4), "b/");
123 entry.newPath = side;
124 if (side === null) entry.deleted = true;
125 }
126 }
127 close();
128 return revisions;
129}
packages/source-control-core/src/server/driver.ts created+36
......@@ -0,0 +1,36 @@
1/**
2 * SourceControlDriver — what a source control provider package hands the server.
3 *
4 * One driver per host kind. The server lists its drivers once and both registries (repository
5 * operations and pull requests) iterate that list, so adding a host means writing its package
6 * and adding its driver to the list.
7 *
8 * `make` yields what it needs from the environment: the `SourceControlHost` port, Effect
9 * platform services, and the package's own services, which the server provides from the
10 * package's layers. Its `R` channel is the full list.
11 *
12 * @module source-control-core/server/driver
13 */
14import type { SourceControlProviderKind } from "@t3tools/contracts";
15import type * as Effect from "effect/Effect";
16
17import type { SourceControlProviderDiscoverySpec } from "./discovery.ts";
18import type { PullRequestProviderApi } from "./PullRequestProvider.ts";
19import type * as SourceControlProvider from "./SourceControlProvider.ts";
20
21export interface SourceControlDriverInstance {
22 readonly sourceControl: SourceControlProvider.SourceControlProvider["Service"];
23 readonly discovery: SourceControlProviderDiscoverySpec;
24 /** Null for a host whose pull requests this build cannot read. */
25 readonly pullRequests: PullRequestProviderApi | null;
26}
27
28export interface SourceControlDriver<R = never> {
29 readonly kind: SourceControlProviderKind;
30 readonly make: Effect.Effect<SourceControlDriverInstance, never, R>;
31}
32
33/** Declares a driver with its `R` inferred from `make`. */
34export const defineSourceControlDriver = <R>(
35 driver: SourceControlDriver<R>,
36): SourceControlDriver<R> => driver;
packages/source-control-forgejo/package.json created+48
......@@ -0,0 +1,48 @@
1{
2 "name": "@t3tools/source-control-forgejo",
3 "private": true,
4 "type": "module",
5 "exports": {
6 "./server/driver": {
7 "types": "./src/server/driver.ts",
8 "import": "./src/server/driver.ts"
9 },
10 "./server/ForgejoCli": {
11 "types": "./src/server/ForgejoCli.ts",
12 "import": "./src/server/ForgejoCli.ts"
13 },
14 "./server/forgejoPullRequestJson": {
15 "types": "./src/server/forgejoPullRequestJson.ts",
16 "import": "./src/server/forgejoPullRequestJson.ts"
17 },
18 "./server/ForgejoPullRequestProvider": {
19 "types": "./src/server/ForgejoPullRequestProvider.ts",
20 "import": "./src/server/ForgejoPullRequestProvider.ts"
21 },
22 "./server/forgejoPullRequests": {
23 "types": "./src/server/forgejoPullRequests.ts",
24 "import": "./src/server/forgejoPullRequests.ts"
25 },
26 "./server/ForgejoSourceControlProvider": {
27 "types": "./src/server/ForgejoSourceControlProvider.ts",
28 "import": "./src/server/ForgejoSourceControlProvider.ts"
29 }
30 },
31 "scripts": {
32 "typecheck": "tsc --noEmit",
33 "test": "vp test run --config ../../vite.config.ts --dir ."
34 },
35 "dependencies": {
36 "@t3tools/contracts": "workspace:*",
37 "@t3tools/provider-core": "workspace:*",
38 "@t3tools/shared": "workspace:*",
39 "@t3tools/source-control-core": "workspace:*",
40 "effect": "catalog:"
41 },
42 "devDependencies": {
43 "@effect/vitest": "catalog:",
44 "@t3tools/source-control-testing": "workspace:*",
45 "@types/node": "catalog:",
46 "vite-plus": "catalog:"
47 }
48}
packages/source-control-forgejo/src/server/ForgejoCli.ts created+724
......@@ -0,0 +1,724 @@
1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Schema from "effect/Schema";
5import * as Result from "effect/Result";
6import * as Clock from "effect/Clock";
7import * as FileSystem from "effect/FileSystem";
8import * as Semaphore from "effect/Semaphore";
9import * as NodeOS from "node:os";
10// @effect-diagnostics-next-line nodeBuiltinImport:off - fj storage paths use explicit Windows and POSIX layouts, independently of this process's platform.
11import * as NodePath from "node:path";
12import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
13import { ChildProcessSpawner } from "effect/process";
14import { decodeJsonResult } from "@t3tools/shared/schemaJson";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
17import { collectUint8StreamText } from "@t3tools/provider-core/server/collectStreamText";
18import type { SourceControlProviderContext } from "@t3tools/source-control-core/server/SourceControlProvider";
19
20const encodeApiBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
21
22export class ForgejoCliError extends Schema.TaggedError<ForgejoCliError>()("ForgejoCliError", {
23 command: Schema.Literals(["fj", "tea"]),
24 cwd: Schema.String,
25 detail: Schema.String,
26 reason: Schema.optional(
27 Schema.Literals([
28 "missing-cli",
29 "authentication",
30 "forbidden",
31 "not-found",
32 "rate-limit",
33 "invalid-response",
34 ]),
35 ),
36 httpStatus: Schema.optional(Schema.Int),
37 cause: Schema.optional(Schema.Defect()),
38}) {}
39
40export const ForgejoLoginSchema = Schema.Struct({
41 name: Schema.String,
42 url: Schema.String,
43 ssh_host: Schema.optional(Schema.String),
44 valid: Schema.optional(Schema.String),
45 user: Schema.String,
46 default: Schema.String,
47});
48
49export function parseForgejoLogins(raw: string) {
50 const decoded = decodeJsonResult(Schema.Array(ForgejoLoginSchema))(raw);
51 return Result.isSuccess(decoded) ? decoded.success : [];
52}
53
54export const ForgejoKeysSchema = Schema.Struct({
55 hosts: Schema.Record(
56 Schema.String,
57 Schema.Struct({
58 type: Schema.Literals(["Application", "OAuth"]),
59 token: Schema.String,
60 }),
61 ),
62 aliases: Schema.optional(Schema.Record(Schema.String, Schema.String)),
63});
64
65const parseForgejoKeys = decodeJsonResult(ForgejoKeysSchema);
66
67/** Matches fj's directories::ProjectDirs, including its pre-0.6 organization name. */
68function forgejoKeysPaths(input: {
69 readonly platform: string;
70 readonly home: string;
71 readonly dataHome?: string;
72 readonly appData?: string;
73}) {
74 if (input.platform === "darwin")
75 return ["forgejo-cli", "Cyborus"].map((organization) =>
76 NodePath.join(
77 input.home,
78 "Library",
79 "Application Support",
80 `${organization}.forgejo-cli`,
81 "keys.json",
82 ),
83 );
84 if (input.platform === "win32")
85 return ["forgejo-cli", "Cyborus"].map((organization) =>
86 NodePath.win32.join(
87 input.appData || NodePath.win32.join(input.home, "AppData", "Roaming"),
88 organization,
89 "forgejo-cli",
90 "data",
91 "keys.json",
92 ),
93 );
94 return [
95 NodePath.join(
96 input.dataHome && NodePath.isAbsolute(input.dataHome)
97 ? input.dataHome
98 : NodePath.join(input.home, ".local", "share"),
99 "forgejo-cli",
100 "keys.json",
101 ),
102 ];
103}
104
105export interface ForgejoRepositoryInput {
106 readonly cwd: string;
107 readonly context?: SourceControlProviderContext;
108 readonly repository?: string;
109 readonly reference?: string;
110 readonly host?: string;
111}
112
113export interface ForgejoRepository {
114 readonly command?: "fj" | "tea";
115 readonly login: string;
116 readonly repository: string;
117 readonly baseUrl: string;
118}
119
120export interface ForgejoApiInput extends ForgejoRepositoryInput {
121 readonly path: string;
122 readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
123 readonly body?: unknown;
124}
125
126export class ForgejoCli extends Context.Service<
127 ForgejoCli,
128 {
129 readonly execute: (input: {
130 readonly command?: "fj" | "tea";
131 readonly cwd: string;
132 readonly args: ReadonlyArray<string>;
133 readonly stdin?: string;
134 readonly timeoutMs?: number;
135 readonly maxOutputBytes?: number;
136 }) => Effect.Effect<SourceControlHost.SourceControlProcessOutput, ForgejoCliError>;
137 readonly listLogins?: (input: {
138 readonly cwd: string;
139 readonly command: "fj" | "tea";
140 readonly remoteUrl?: string;
141 }) => Effect.Effect<ReturnType<typeof parseForgejoLogins>, ForgejoCliError>;
142 readonly getAccount?: (input: {
143 readonly cwd: string;
144 readonly baseUrl: string;
145 }) => Effect.Effect<string, ForgejoCliError>;
146 readonly resolveRepository: (
147 input: ForgejoRepositoryInput,
148 ) => Effect.Effect<ForgejoRepository, ForgejoCliError>;
149 readonly api: (
150 input: ForgejoApiInput,
151 ) => Effect.Effect<SourceControlHost.SourceControlProcessOutput, ForgejoCliError>;
152 }
153>()("@t3tools/source-control-forgejo/server/ForgejoCli") {}
154
155export function parseForgejoRemote(value: string) {
156 if (/^(?:https?|ssh):\/\//i.test(value)) {
157 try {
158 const url = new URL(value);
159 return {
160 host: url.host.toLowerCase(),
161 hostname: url.hostname.toLowerCase(),
162 ssh: url.protocol === "ssh:",
163 path: url.pathname.replace(/^\/+|\/+$/g, "").replace(/\.git$/, ""),
164 };
165 } catch {
166 return null;
167 }
168 }
169 // SCP remotes may omit the username; URL treats these as a custom scheme.
170 const ssh = /^(?:[^@/]+@)?([^:/]+):([^/].*)$/.exec(value);
171 return ssh?.[1] && ssh[2]
172 ? {
173 host: ssh[1].toLowerCase(),
174 hostname: ssh[1].toLowerCase(),
175 ssh: true,
176 path: ssh[2].replace(/\.git$/, ""),
177 }
178 : null;
179}
180
181export function matchForgejoLogin(
182 logins: ReturnType<typeof parseForgejoLogins>,
183 remote: NonNullable<ReturnType<typeof parseForgejoRemote>>,
184 requestedHost?: string,
185 hostOnly = false,
186) {
187 const matches = [
188 ...new Map(
189 logins
190 .filter((login) => {
191 const url = parseForgejoRemote(login.url);
192 if (!url) return false;
193 if (requestedHost !== undefined && url.host !== requestedHost.toLowerCase()) return false;
194 return remote.ssh
195 ? login.ssh_host?.toLowerCase() === remote.host ||
196 login.ssh_host?.toLowerCase() === remote.hostname ||
197 url.hostname === remote.hostname
198 : url.host === remote.host &&
199 ((hostOnly && !remote.path) ||
200 !url.path ||
201 remote.path === url.path ||
202 remote.path.startsWith(`${url.path}/`));
203 })
204 .map((login) => [login.name, login]),
205 ).values(),
206 ];
207 return matches.length === 1
208 ? matches[0]
209 : new Set(matches.map((login) => login.url)).size === 1
210 ? matches.find((login) => login.default === "true")
211 : undefined;
212}
213
214export const make = Effect.gen(function* () {
215 const { process } = yield* SourceControlHost.SourceControlHost;
216 const fileSystem = yield* FileSystem.FileSystem;
217 const httpClient = yield* HttpClient.HttpClient;
218 const authLock = yield* Semaphore.make(1);
219 const authenticated = new Map<string, { token: string; time: number }>();
220 const execute: ForgejoCli["Service"]["execute"] = (input) =>
221 process
222 .run({
223 ...input,
224 operation: "ForgejoCli.execute",
225 command: input.command ?? "tea",
226 timeoutMs: input.timeoutMs ?? 30_000,
227 })
228 .pipe(
229 Effect.mapError(
230 (cause) =>
231 new ForgejoCliError({
232 command: input.command ?? "tea",
233 cwd: input.cwd,
234 ...(input.command === "fj" ? {} : { cause }),
235 ...(cause._tag === "VcsProcessSpawnError"
236 ? { reason: "missing-cli" as const }
237 : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication"
238 ? { reason: "authentication" as const }
239 : {}),
240 detail:
241 cause._tag === "VcsProcessSpawnError"
242 ? "Install Forgejo CLI (`fj` 0.6 or later) or Gitea CLI (`tea` 0.16 or later) and retry."
243 : cause._tag === "VcsProcessExitError" && cause.failureKind === "authentication"
244 ? "Authenticate this server with `fj auth login`, `fj auth add-token`, or `tea login add`."
245 : "Forgejo CLI command failed.",
246 }),
247 ),
248 );
249
250 const readKeys = Effect.fn("ForgejoCli.readKeys")(function* (cwd: string) {
251 for (const path of forgejoKeysPaths({
252 platform: yield* HostProcessPlatform,
253 home: NodeOS.homedir(),
254 ...(globalThis.process.env.XDG_DATA_HOME
255 ? { dataHome: globalThis.process.env.XDG_DATA_HOME }
256 : {}),
257 ...(globalThis.process.env.APPDATA ? { appData: globalThis.process.env.APPDATA } : {}),
258 })) {
259 const exists = yield* fileSystem.exists(path).pipe(
260 Effect.mapError(
261 () =>
262 new ForgejoCliError({
263 command: "fj",
264 cwd,
265 reason: "authentication",
266 detail: "Could not read fj authentication storage.",
267 }),
268 ),
269 );
270 if (!exists) continue;
271 const raw = yield* fileSystem.readFileString(path).pipe(
272 Effect.mapError(
273 () =>
274 new ForgejoCliError({
275 command: "fj",
276 cwd,
277 reason: "authentication",
278 detail: "Could not read fj authentication storage.",
279 }),
280 ),
281 );
282 const decoded = parseForgejoKeys(raw);
283 if (Result.isFailure(decoded))
284 return yield* new ForgejoCliError({
285 command: "fj",
286 cwd,
287 reason: "authentication",
288 detail: "fj authentication storage is invalid. Authenticate again with fj.",
289 });
290 return decoded.success;
291 }
292 const empty: typeof ForgejoKeysSchema.Type = { hosts: {}, aliases: {} };
293 return empty;
294 });
295
296 const publicLogins = (
297 keys: typeof ForgejoKeysSchema.Type,
298 remoteUrl?: string,
299 ): ReturnType<typeof parseForgejoLogins> => {
300 const remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null;
301 return Object.keys(keys.hosts).flatMap((host) => {
302 const url = parseForgejoRemote(`https://${host}`);
303 // fj 0.6 drops URL mounts during whoami and OAuth renewal; tea supports them.
304 if (!url || url.path) return [];
305 // fj omits the scheme in storage. Only an explicit matching HTTP remote opts into HTTP.
306 const scheme =
307 remote && !remote.ssh && remote.host === url.host && /^http:\/\//i.test(remoteUrl ?? "")
308 ? "http"
309 : "https";
310 const login = { name: host, url: `${scheme}://${host}`, user: "", default: "false" };
311 const aliases = Object.entries(keys.aliases ?? {})
312 .filter(([, target]) => target === host)
313 .map(([alias]) => ({ ...login, ssh_host: alias }));
314 return aliases.length ? aliases : [login];
315 });
316 };
317
318 const listLogins: NonNullable<ForgejoCli["Service"]["listLogins"]> = Effect.fn(
319 "ForgejoCli.listLogins",
320 )(function* (input) {
321 if (input.command === "fj") {
322 const keys = yield* readKeys(input.cwd).pipe(Effect.result);
323 if (Result.isFailure(keys)) {
324 // Stale credentials from an uninstalled fj must not disable an available tea login.
325 const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe(
326 Effect.result,
327 );
328 if (Result.isFailure(available) && available.failure.reason === "missing-cli") return [];
329 return yield* keys.failure;
330 }
331 return publicLogins(keys.success, input.remoteUrl);
332 }
333 return parseForgejoLogins(
334 (yield* execute({ cwd: input.cwd, args: ["login", "list", "--output", "json"] })).stdout,
335 );
336 });
337
338 const requestFj = Effect.fn("ForgejoCli.requestFj")(
339 function* (input: {
340 readonly cwd: string;
341 readonly baseUrl: string;
342 readonly token: string;
343 readonly path: string;
344 readonly method?: ForgejoApiInput["method"];
345 readonly body?: string;
346 }) {
347 const base = new URL(`${input.baseUrl}/api/v1/`);
348 const url = new URL(input.path, base);
349 if (
350 url.origin !== base.origin ||
351 !url.pathname.startsWith(base.pathname) ||
352 url.username ||
353 url.password
354 )
355 return yield* new ForgejoCliError({
356 command: "fj",
357 cwd: input.cwd,
358 detail: "Invalid Forgejo API path.",
359 });
360 let request = HttpClientRequest.make(input.method ?? "GET")(url.toString()).pipe(
361 HttpClientRequest.setHeader("Authorization", `token ${input.token}`),
362 );
363 if (input.body !== undefined)
364 request = request.pipe(HttpClientRequest.bodyText(input.body, "application/json"));
365 const response = yield* httpClient
366 .execute(request)
367 .pipe(Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }));
368 const status = response.status;
369 const body =
370 status === 204 || status === 205
371 ? { text: "", truncated: false, invalidUtf8: false }
372 : yield* collectUint8StreamText({
373 stream: response.stream,
374 maxBytes: 8 * 1024 * 1024,
375 });
376 if (body.truncated || body.invalidUtf8)
377 return yield* new ForgejoCliError({
378 command: "fj",
379 cwd: input.cwd,
380 reason: "invalid-response",
381 detail: "Forgejo returned an oversized or invalid response.",
382 });
383 if (status < 200 || status >= 300) {
384 const detail =
385 status === 404
386 ? "Forgejo repository or pull request was not found."
387 : body.text
388 ? `Forgejo API request failed (HTTP ${status}): ${body.text}`
389 : `Forgejo API request failed (HTTP ${status}). Check this server's fj credentials and permissions.`;
390 return yield* new ForgejoCliError({
391 command: "fj",
392 cwd: input.cwd,
393 httpStatus: status,
394 ...(status === 401
395 ? { reason: "authentication" as const }
396 : status === 403
397 ? { reason: "forbidden" as const }
398 : status === 404
399 ? { reason: "not-found" as const }
400 : status === 429
401 ? { reason: "rate-limit" as const }
402 : {}),
403 detail,
404 });
405 }
406 return {
407 exitCode: ChildProcessSpawner.ExitCode(0),
408 stdout: body.text,
409 stderr: `HTTP/1.1 ${status}\n${response.headers.link ? `link: ${response.headers.link}\n` : ""}`,
410 stdoutTruncated: false,
411 stderrTruncated: false,
412 };
413 },
414 (effect, input) =>
415 effect.pipe(
416 Effect.timeout(30_000),
417 Effect.mapError((error) =>
418 error._tag === "ForgejoCliError"
419 ? error
420 : new ForgejoCliError({
421 command: "fj",
422 cwd: input.cwd,
423 detail: "Forgejo API request failed or timed out.",
424 }),
425 ),
426 ),
427 );
428
429 const authenticateFj = Effect.fn("ForgejoCli.authenticateFj")(function* (
430 cwd: string,
431 login: typeof ForgejoLoginSchema.Type,
432 ) {
433 const keys = yield* readKeys(cwd);
434 const token = keys.hosts[login.name]?.token;
435 const now = yield* Clock.currentTimeMillis;
436 const cached = authenticated.get(login.url);
437 if (token && cached?.token === token && now - cached.time < 30_000) return token;
438 yield* execute({ command: "fj", cwd, args: ["--host", login.url, "whoami"] });
439 // fj owns OAuth renewal. Re-read the file after it has refreshed an expired token.
440 const refreshed = (yield* readKeys(cwd)).hosts[login.name]?.token;
441 if (!refreshed)
442 return yield* new ForgejoCliError({
443 command: "fj",
444 cwd,
445 reason: "authentication",
446 detail: "fj has no credentials for this server. Authenticate again with fj.",
447 });
448 authenticated.set(login.url, { token: refreshed, time: now });
449 return refreshed;
450 }, authLock.withPermits(1));
451
452 const getAccount: NonNullable<ForgejoCli["Service"]["getAccount"]> = Effect.fn(
453 "ForgejoCli.getAccount",
454 )(function* (input) {
455 const logins = yield* listLogins({ cwd: input.cwd, command: "fj", remoteUrl: input.baseUrl });
456 const login = logins.find(
457 (item) => item.url.replace(/\/+$/, "") === input.baseUrl.replace(/\/+$/, ""),
458 );
459 if (!login)
460 return yield* new ForgejoCliError({
461 command: "fj",
462 cwd: input.cwd,
463 reason: "authentication",
464 detail: "fj has no credentials for this server.",
465 });
466 const token = yield* authenticateFj(input.cwd, login);
467 const currentUser = yield* requestFj({
468 cwd: input.cwd,
469 baseUrl: login.url.replace(/\/+$/, ""),
470 token,
471 path: "user",
472 });
473 const user = decodeJsonResult(Schema.Struct({ login: Schema.String }))(currentUser.stdout);
474 if (Result.isFailure(user) || !user.success.login.trim())
475 return yield* new ForgejoCliError({
476 command: "fj",
477 cwd: input.cwd,
478 reason: "invalid-response",
479 detail: "Forgejo returned an invalid account response.",
480 });
481 return user.success.login;
482 });
483
484 const resolveTarget = Effect.fn("ForgejoCli.resolveTarget")(function* (
485 input: ForgejoRepositoryInput,
486 hostOnly = false,
487 ) {
488 const referenceRemote = input.reference ? parseForgejoRemote(input.reference) : null;
489 let remoteUrl = [input.reference, input.repository, input.context?.remoteUrl].find(
490 (value) => value && parseForgejoRemote(value),
491 );
492 let remote =
493 referenceRemote ??
494 (input.repository ? parseForgejoRemote(input.repository) : null) ??
495 (input.context ? parseForgejoRemote(input.context.remoteUrl) : null);
496 if (!remote && (!input.repository || input.host)) {
497 const result = yield* process
498 .run({
499 operation: "ForgejoCli.remote",
500 command: "git",
501 args: input.host ? ["remote", "-v"] : ["remote", "get-url", "origin"],
502 cwd: input.cwd,
503 allowNonZeroExit: true,
504 })
505 .pipe(
506 Effect.mapError(
507 (cause) =>
508 new ForgejoCliError({
509 command: "tea",
510 cwd: input.cwd,
511 detail: "Could not resolve the Forgejo repository remote.",
512 cause,
513 }),
514 ),
515 );
516 if (input.host) {
517 const matchingUrls = [
518 ...new Set(
519 result.stdout.split("\n").flatMap((line) => {
520 const url = /^\S+\s+(https?:\/\/\S+)\s+\(fetch\)$/.exec(line.trim())?.[1];
521 return url && parseForgejoRemote(url)?.host === input.host?.toLowerCase()
522 ? [url]
523 : [];
524 }),
525 ),
526 ];
527 const origins = [...new Set(matchingUrls.map((url) => new URL(url).origin))];
528 remoteUrl =
529 matchingUrls.length === 1
530 ? matchingUrls[0]
531 : origins.length === 1
532 ? origins[0]
533 : undefined;
534 } else {
535 remoteUrl = result.stdout.trim();
536 }
537 remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null;
538 }
539 if (
540 input.host &&
541 !remote?.ssh &&
542 remote?.host !== input.host.toLowerCase() &&
543 remote?.hostname !== input.host.toLowerCase()
544 )
545 remote = {
546 host: input.host.toLowerCase(),
547 hostname: input.host.split(":")[0] ?? input.host,
548 ssh: false,
549 path: remote?.path ?? "",
550 };
551 const schemeRemoteUrl = remote?.ssh ? input.context?.provider.baseUrl : remoteUrl;
552 const fjLogins = yield* listLogins({
553 cwd: input.cwd,
554 command: "fj",
555 ...(schemeRemoteUrl ? { remoteUrl: schemeRemoteUrl } : {}),
556 });
557 const requestedHost = input.host ?? input.context?.requestedHost;
558 const matchHostOnly = hostOnly || (!!input.host && !remote?.path);
559 const selectLogin = (logins: ReturnType<typeof parseForgejoLogins>) =>
560 remote
561 ? matchForgejoLogin(logins, remote, remote.ssh ? requestedHost : undefined, matchHostOnly)
562 : (logins.find((item) => item.default === "true") ??
563 (new Set(logins.map((item) => item.name)).size === 1 ? logins[0] : undefined));
564 let login = selectLogin(fjLogins);
565 let command: "fj" | "tea" = "fj";
566 if (
567 !login &&
568 fjLogins.some(
569 (item) =>
570 !remote ||
571 matchForgejoLogin([item], remote, remote.ssh ? requestedHost : undefined, matchHostOnly),
572 )
573 ) {
574 const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe(
575 Effect.result,
576 );
577 if (Result.isSuccess(available))
578 return yield* new ForgejoCliError({
579 command: "fj",
580 cwd: input.cwd,
581 reason: "authentication",
582 detail: "Multiple fj logins match this repository. Specify its full server URL.",
583 });
584 if (available.failure.reason !== "missing-cli") return yield* available.failure;
585 }
586 if (login) {
587 const auth = yield* authenticateFj(input.cwd, login).pipe(Effect.result);
588 if (Result.isFailure(auth)) {
589 if (auth.failure.reason === "missing-cli") login = undefined;
590 else return yield* auth.failure;
591 }
592 }
593 if (!login) {
594 command = "tea";
595 login = selectLogin(yield* listLogins({ cwd: input.cwd, command: "tea" }));
596 }
597 if (!login)
598 return yield* new ForgejoCliError({
599 command: "tea",
600 cwd: input.cwd,
601 reason: "authentication",
602 detail:
603 "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.",
604 });
605 if (hostOnly)
606 return { command, login: login.name, repository: "", baseUrl: login.url.replace(/\/+$/, "") };
607 const path =
608 referenceRemote?.path ??
609 (input.repository && !parseForgejoRemote(input.repository)
610 ? input.repository
611 : remote?.path) ??
612 "";
613 const basePath = new URL(login.url).pathname.replace(/^\/+|\/+$/g, "");
614 const relativePath =
615 basePath && path.split("/").length > 2 && path.startsWith(`${basePath}/`)
616 ? path.slice(basePath.length + 1)
617 : path;
618 const repositoryPath = relativePath.replace(/\/pulls\/\d+.*$/, "").replace(/\.git$/, "");
619 if (command === "fj" && !repositoryPath.includes("/")) {
620 login = { ...login, user: yield* getAccount({ cwd: input.cwd, baseUrl: login.url }) };
621 }
622 const repository = repositoryPath.includes("/")
623 ? repositoryPath
624 : `${login.user}/${repositoryPath}`;
625 if (!/^[^/\s]+\/[^/\s]+$/.test(repository))
626 return yield* new ForgejoCliError({
627 command,
628 cwd: input.cwd,
629 detail: "Specify a Forgejo repository as owner/repository or its full server URL.",
630 });
631 return { command, login: login.name, repository, baseUrl: login.url.replace(/\/+$/, "") };
632 });
633 const resolveRepository = (input: ForgejoRepositoryInput) => resolveTarget(input);
634 const api = Effect.fn("ForgejoCli.api")(function* (input: ForgejoApiInput) {
635 const repository = yield* resolveTarget(
636 input,
637 input.path.replace(/^\/+/, "") === "user" && (!input.method || input.method === "GET"),
638 );
639 const stdin =
640 input.body === undefined
641 ? undefined
642 : yield* encodeApiBody(input.body).pipe(
643 Effect.mapError(
644 (cause) =>
645 new ForgejoCliError({
646 command: "tea",
647 cwd: input.cwd,
648 detail: "Could not encode the Forgejo request body.",
649 cause,
650 }),
651 ),
652 );
653 let path = input.path.replace(/^\/+/, "");
654 if (input.repository && input.repository !== repository.repository) {
655 // Repository identities retain the server mount path; API routes do not.
656 const prefix = `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`;
657 if (path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}?`)) {
658 path = `repos/${repository.repository.split("/").map(encodeURIComponent).join("/")}${path.slice(prefix.length)}`;
659 }
660 }
661 if (repository.command === "fj") {
662 const token = (yield* readKeys(input.cwd)).hosts[repository.login]?.token;
663 if (!token)
664 return yield* new ForgejoCliError({
665 command: "fj",
666 cwd: input.cwd,
667 reason: "authentication",
668 detail: "fj has no credentials for this server.",
669 });
670 return yield* requestFj({
671 cwd: input.cwd,
672 baseUrl: repository.baseUrl,
673 token,
674 path,
675 ...(input.method === undefined ? {} : { method: input.method }),
676 ...(stdin === undefined ? {} : { body: stdin }),
677 });
678 }
679 const result = yield* execute({
680 cwd: input.cwd,
681 args: [
682 "api",
683 "--include",
684 "--login",
685 repository.login,
686 ...(repository.repository ? ["--repo", repository.repository] : []),
687 "--method",
688 input.method ?? "GET",
689 ...(input.body === undefined ? [] : ["--data", "@-"]),
690 `${repository.baseUrl}/api/v1/${path}`,
691 ],
692 ...(stdin === undefined ? {} : { stdin }),
693 });
694 // tea reports HTTP failures with exit code zero; use its response status.
695 const status = Number(/^HTTP\/\S+ (\d{3})/m.exec(result.stderr)?.[1]);
696 if (!status || status >= 400)
697 return yield* new ForgejoCliError({
698 command: "tea",
699 cwd: input.cwd,
700 ...(status ? { httpStatus: status } : {}),
701 ...(status === 401
702 ? { reason: "authentication" as const }
703 : status === 403
704 ? { reason: "forbidden" as const }
705 : status === 404
706 ? { reason: "not-found" as const }
707 : status === 429
708 ? { reason: "rate-limit" as const }
709 : {}),
710 detail:
711 status === 401 || status === 403
712 ? "Forgejo denied access. Check this server's `tea login` credentials and permissions."
713 : status === 404
714 ? "Forgejo repository or pull request was not found."
715 : status === 429
716 ? "Forgejo API rate limit exceeded."
717 : `Forgejo API request failed${status ? ` (HTTP ${status})` : " without an HTTP status"}.`,
718 });
719 return result;
720 });
721 return ForgejoCli.of({ execute, listLogins, getAccount, resolveRepository, api });
722});
723
724export const layer = Layer.effect(ForgejoCli, make);
packages/source-control-forgejo/src/server/ForgejoPullRequestProvider.ts created+614
......@@ -0,0 +1,614 @@
1import * as Effect from "effect/Effect";
2import * as Schema from "effect/Schema";
3import * as Result from "effect/Result";
4import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts";
5import { decodeJsonResult } from "@t3tools/shared/schemaJson";
6import * as ForgejoCli from "./ForgejoCli.ts";
7import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions";
8import {
9 PullRequestProviderError,
10 type ProviderChangeRequestDetail,
11 type ProviderRepositoryRef,
12 type PullRequestProviderApi,
13} from "@t3tools/source-control-core/server/PullRequestProvider";
14import {
15 ForgejoPullRequest,
16 ForgejoRepository,
17 ForgejoUser,
18 ForgejoComment,
19 ForgejoReview,
20 ForgejoReviewComment,
21 ForgejoCommit,
22 ForgejoStatus,
23 ForgejoLabel,
24 ForgejoReaction,
25 FORGEJO_REACTIONS,
26 forgejoChangeRequest,
27 forgejoActor,
28 forgejoComment,
29 forgejoReview,
30 forgejoReviewThread,
31 forgejoCommit,
32 forgejoChecks,
33 forgejoReactions,
34} from "./forgejoPullRequestJson.ts";
35
36const CAPABILITIES: PullRequestCapabilities = {
37 diff: true,
38 viewedFiles: "environment",
39 comment: true,
40 actions: ["merge", "close", "reopen", "update-branch"],
41 mergeMethods: ["merge", "squash", "rebase"],
42 updateMethods: ["merge", "rebase"],
43 search: false,
44 reactions: true,
45 labels: true,
46 // Forgejo's public API has no thread replies/resolution or draft conversion endpoint.
47 review: {
48 inlineComment: true,
49 reply: false,
50 resolve: false,
51 verdicts: ["comment", "approve", "request-changes"],
52 },
53 reviewers: { request: true, listCandidates: true },
54 edit: { changeRequest: true, comment: true },
55};
56const repoPath = (input: ProviderRepositoryRef) =>
57 `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`;
58const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) =>
59 `${repoPath(input)}/pulls/${input.number}`;
60const issuePath = (input: ProviderRepositoryRef & { readonly number: number }) =>
61 `${repoPath(input)}/issues/${input.number}`;
62// Review IDs differ from the issue-comment IDs used by Forgejo's reactions API.
63const reviewCommentId = (review: typeof ForgejoReview.Type) =>
64 /#issuecomment-([1-9]\d*)$/.exec(review.html_url ?? "")?.[1];
65
66export const make = Effect.gen(function* () {
67 const cli = yield* ForgejoCli.ForgejoCli;
68 const failure = (operation: string, detail: string, cause?: unknown) =>
69 new PullRequestProviderError({
70 provider: "forgejo",
71 operation,
72 reason: "failed",
73 detail,
74 ...(cause === undefined ? {} : { cause }),
75 });
76 const request = (input: ForgejoCli.ForgejoApiInput) =>
77 cli.api(input).pipe(
78 Effect.mapError(
79 (error) =>
80 new PullRequestProviderError({
81 provider: "forgejo",
82 operation: input.path,
83 detail: error.detail,
84 cause: error,
85 reason:
86 error.reason === "missing-cli"
87 ? "missing-tool"
88 : error.reason === "authentication"
89 ? "unauthenticated"
90 : error.reason === "rate-limit"
91 ? "rate-limited"
92 : error.reason === "not-found"
93 ? "not-found"
94 : "failed",
95 }),
96 ),
97 );
98 const read = Effect.fn("ForgejoPullRequestProvider.read")(function* <A>(
99 input: ForgejoCli.ForgejoApiInput,
100 schema: Schema.Codec<A, unknown, never, never>,
101 ) {
102 const result = yield* request(input);
103 if (result.stdoutTruncated)
104 return yield* failure(input.path, "Forgejo response exceeded the output limit.");
105 const decoded = decodeJsonResult(schema)(result.stdout);
106 return Result.isSuccess(decoded)
107 ? decoded.success
108 : yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure);
109 });
110 const readArray = <A>(
111 input: ForgejoCli.ForgejoApiInput,
112 schema: Schema.Codec<A, unknown, never, never>,
113 ) => read(input, Schema.NullOr(Schema.Array(schema))).pipe(Effect.map((rows) => rows ?? []));
114 const readPage = Effect.fn("ForgejoPullRequestProvider.readPage")(function* <A>(
115 input: ForgejoCli.ForgejoApiInput,
116 schema: Schema.Codec<A, unknown, never, never>,
117 index: number,
118 ) {
119 const result = yield* request({
120 ...input,
121 path: `${input.path}${input.path.includes("?") ? "&" : "?"}limit=50&page=${index}`,
122 });
123 if (result.stdoutTruncated)
124 return yield* failure(input.path, "Forgejo response exceeded the output limit.");
125 const decoded = decodeJsonResult(Schema.NullOr(Schema.Array(schema)))(result.stdout);
126 if (Result.isFailure(decoded))
127 return yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure);
128 const rows = decoded.success ?? [];
129 const links = /^link:\s*(.*)$/im.exec(result.stderr)?.[1];
130 return {
131 rows,
132 more: rows.length > 0 && (links === undefined || /rel="?next"?/i.test(links)),
133 };
134 });
135 // Use a stable, small page size that also works with Forgejo's default maximum of 50.
136 const page = Effect.fn("ForgejoPullRequestProvider.page")(function* <A>(
137 input: ForgejoCli.ForgejoApiInput,
138 schema: Schema.Codec<A, unknown, never, never>,
139 limit = 500,
140 ) {
141 const items: A[] = [];
142 for (let index = 1; items.length < limit; index++) {
143 const { rows, more } = yield* readPage(input, schema, index);
144 items.push(...rows);
145 if (!more) return { items, truncated: false };
146 }
147 return { items, truncated: true };
148 });
149 const write = (input: ForgejoCli.ForgejoApiInput) => request(input).pipe(Effect.asVoid);
150 const getPull = (input: ProviderRepositoryRef & { readonly number: number }) =>
151 read(
152 {
153 cwd: input.cwd,
154 repository: input.repository,
155 host: input.host,
156 path: pullPath(input),
157 },
158 ForgejoPullRequest,
159 );
160 const getRepo = (input: ProviderRepositoryRef) =>
161 read({ ...input, path: repoPath(input) }, ForgejoRepository);
162 const getViewer = (input: {
163 readonly cwd: string;
164 readonly repository?: string;
165 readonly host?: string;
166 }) => read({ ...input, path: "user" }, ForgejoUser).pipe(Effect.map((user) => user.login));
167 const permissions = (
168 repo: typeof ForgejoRepository.Type,
169 pr: typeof ForgejoPullRequest.Type,
170 viewer: string,
171 ): PullRequestViewerPermissions => {
172 const canWrite = repo.permissions?.push ?? false;
173 const canEdit = canWrite || pr.user?.login === viewer;
174 const active = !repo.archived;
175 return {
176 actions: active
177 ? CAPABILITIES.actions.filter((action) =>
178 action === "merge" || action === "update-branch" ? canWrite : canEdit,
179 )
180 : [],
181 comment: active && (!pr.is_locked || canWrite),
182 resolve: false,
183 verdicts: active
184 ? pr.user?.login === viewer
185 ? ["comment"]
186 : CAPABILITIES.review.verdicts
187 : [],
188 requestReviewers: active && canEdit,
189 labels: active && canWrite,
190 updateMethods:
191 active && canWrite ? (repo.allow_rebase_update ? ["merge", "rebase"] : ["merge"]) : [],
192 };
193 };
194 const getPermissions = Effect.fn("ForgejoPullRequestProvider.getPermissions")(function* (
195 input: ProviderRepositoryRef & { readonly number: number },
196 ) {
197 const [repo, pr, viewer] = yield* Effect.all(
198 [getRepo(input), getPull(input), getViewer(input)],
199 { concurrency: 3 },
200 );
201 return permissions(repo, pr, viewer);
202 });
203 const unsupported = (operation: string) =>
204 Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`));
205 const provider: PullRequestProviderApi = {
206 kind: "forgejo",
207 capabilities: CAPABILITIES,
208 getViewer,
209 listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")(
210 function* (input) {
211 const offset = input.cursor?.delivered ?? 0;
212 const items: ReturnType<typeof forgejoChangeRequest>[] = [];
213 const state = input.state === "merged" ? "closed" : input.state;
214 const query = {
215 ...input,
216 path: `${repoPath(input)}/pulls?state=${state}&sort=recentupdate`,
217 };
218 // Self-hosted servers may cap pages below 50. Establish their actual page size before
219 // translating the service's row offset into an API page number.
220 const first = yield* readPage(query, Schema.NullOr(ForgejoPullRequest), 1);
221 const pageSize = first.rows.length || 50;
222 const firstIndex = Math.floor(offset / pageSize) + 1;
223 let consumed = 0;
224 let more = true;
225 for (let index = firstIndex; more && consumed < input.limit; index++) {
226 const result =
227 index === 1 ? first : yield* readPage(query, Schema.NullOr(ForgejoPullRequest), index);
228 const rows = result.rows;
229 const start = index === firstIndex ? offset % pageSize : 0;
230 const countBefore = consumed;
231 for (const row of rows.slice(start)) {
232 if (consumed >= input.limit) break;
233 consumed++;
234 if (row) items.push(forgejoChangeRequest(row));
235 }
236 more = result.more || rows.length - start > consumed - countBefore;
237 }
238 return { items, truncated: more, continues: true, cursorAdvance: consumed };
239 },
240 ),
241 getChangeRequestSummary: (input) => getPull(input).pipe(Effect.map(forgejoChangeRequest)),
242 getChangeRequestChecks: Effect.fn("ForgejoPullRequestProvider.getChangeRequestChecks")(
243 function* (input) {
244 const pr = yield* getPull(input);
245 const statuses = yield* page(
246 {
247 ...input,
248 path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`,
249 },
250 ForgejoStatus,
251 );
252 return { state: forgejoChangeRequest(pr).state, checks: forgejoChecks(statuses.items) };
253 },
254 ),
255 getChangeRequest: Effect.fn("ForgejoPullRequestProvider.getChangeRequest")(function* (input) {
256 const [pr, repo, viewer] = yield* Effect.all(
257 [getPull(input), getRepo(input), getViewer(input)],
258 { concurrency: 3 },
259 );
260 const statuses = yield* page(
261 {
262 ...input,
263 path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`,
264 },
265 ForgejoStatus,
266 );
267 return {
268 ...forgejoChangeRequest(pr),
269 body: pr.body ?? "",
270 changedFiles: pr.changed_files ?? 0,
271 reviewers: (pr.requested_reviewers ?? []).flatMap((user) => {
272 const actor = forgejoActor(user);
273 return actor ? [actor] : [];
274 }),
275 checks: forgejoChecks(statuses.items),
276 baseComparison: !pr.merge_base
277 ? "unknown"
278 : pr.merge_base === pr.base.sha
279 ? "up-to-date"
280 : "behind",
281 viewerPermissions: permissions(repo, pr, viewer),
282 mergeCapabilities: {
283 merge: repo.allow_merge_commits ?? true,
284 squash: repo.allow_squash_merge ?? true,
285 rebase: repo.allow_rebase ?? true,
286 },
287 } satisfies ProviderChangeRequestDetail;
288 }),
289 getViewerPermissions: getPermissions,
290 getChangeRequestActivity: Effect.fn("ForgejoPullRequestProvider.getChangeRequestActivity")(
291 function* (input) {
292 const [comments, reviews, commits, reactions, viewer] = yield* Effect.all(
293 [
294 // Issue comments ignore page/limit; fetch this unpaginated endpoint once.
295 readArray({ ...input, path: `${issuePath(input)}/comments` }, ForgejoComment).pipe(
296 Effect.map((items) => ({
297 items: items.slice(0, 500),
298 truncated: items.length > 500,
299 })),
300 ),
301 page({ ...input, path: `${pullPath(input)}/reviews` }, ForgejoReview),
302 page({ ...input, path: `${pullPath(input)}/commits` }, ForgejoCommit),
303 page({ ...input, path: `${issuePath(input)}/reactions` }, ForgejoReaction),
304 getViewer(input),
305 ],
306 { concurrency: 5 },
307 );
308 const reviewComments = yield* Effect.forEach(
309 reviews.items.filter((review) => review.comments_count > 0 && review.state !== "PENDING"),
310 (review) =>
311 readArray(
312 { ...input, path: `${pullPath(input)}/reviews/${review.id}/comments` },
313 ForgejoReviewComment,
314 ),
315 { concurrency: 4 },
316 );
317 const allInline = reviewComments.flat();
318 const inline = allInline.slice(0, 500);
319 const entries = [
320 ...comments.items.map((comment) => ({
321 comment: forgejoComment(comment),
322 reactionId: String(comment.id),
323 })),
324 ...reviews.items
325 .filter((review) => review.state !== "PENDING" && review.state !== "REQUEST_REVIEW")
326 .map((review) => ({
327 comment: forgejoReview(review),
328 reactionId: reviewCommentId(review),
329 })),
330 ...inline.map((comment) => ({
331 comment: {
332 ...forgejoComment(comment),
333 kind: "review-comment" as const,
334 path: comment.path,
335 },
336 reactionId: String(comment.id),
337 })),
338 ];
339 const enriched = yield* Effect.forEach(
340 entries,
341 ({ comment, reactionId }) =>
342 (reactionId === undefined
343 ? Effect.succeed([])
344 : readArray(
345 { ...input, path: `${repoPath(input)}/issues/comments/${reactionId}/reactions` },
346 ForgejoReaction,
347 )
348 ).pipe(
349 Effect.map((rows) => ({ ...comment, reactions: forgejoReactions(rows, viewer) })),
350 ),
351 { concurrency: 4 },
352 );
353 const byId = new Map(enriched.map((comment) => [comment.id, comment]));
354 const timeline = enriched.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
355 return {
356 comments: timeline,
357 commentCount: timeline.length,
358 commentsTruncated:
359 comments.truncated || reviews.truncated || allInline.length > inline.length,
360 reviewThreads: inline.map((comment) => ({
361 ...forgejoReviewThread(comment),
362 comments: [byId.get(String(comment.id)) ?? forgejoComment(comment)],
363 })),
364 commits: commits.items.map(forgejoCommit),
365 reactions: forgejoReactions(reactions.items, viewer),
366 };
367 },
368 ),
369 getFileRevisions: Effect.fn("ForgejoPullRequestProvider.getFileRevisions")(function* (input) {
370 const result = yield* request({ ...input, path: `${pullPath(input)}.diff` });
371 if (result.stdoutTruncated) {
372 return yield* failure("getFileRevisions", "Forgejo diff exceeded the output limit.");
373 }
374 const revisions = new Map(parseDiffFileRevisions(result.stdout));
375 for (const path of input.paths) {
376 if (!revisions.has(path)) revisions.set(path, "");
377 }
378 return { revisions, complete: true };
379 }),
380 getDiff: (input) =>
381 request({
382 ...input,
383 path: input.commit
384 ? `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}.diff`
385 : `${pullPath(input)}.diff`,
386 }).pipe(
387 Effect.map((result) => ({
388 patch: result.stdout,
389 truncated: result.stdoutTruncated,
390 nextCursor: null,
391 })),
392 ),
393 getDiffFileContents: Effect.fn("ForgejoPullRequestProvider.getDiffFileContents")(
394 function* (input) {
395 const pr = yield* getPull(input);
396 const commit = input.commit
397 ? yield* read(
398 {
399 ...input,
400 path: `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}`,
401 },
402 ForgejoCommit,
403 )
404 : null;
405 const oldRef = commit ? commit.parents[0]?.sha : pr.merge_base || pr.base.sha;
406 const newRef = input.commit ?? pr.head.sha;
407 const content = (repository: string, ref: string, path: string) =>
408 read(
409 {
410 ...input,
411 repository,
412 path: `${repoPath({ ...input, repository })}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`,
413 },
414 Schema.Struct({ content: Schema.String, encoding: Schema.Literal("base64") }),
415 ).pipe(Effect.map((file) => Buffer.from(file.content, "base64").toString("utf8")));
416 const [oldContents, newContents] = yield* Effect.all(
417 [
418 input.changeType === "new" || !oldRef
419 ? Effect.succeed("")
420 : content(input.repository, oldRef, input.oldPath),
421 input.changeType === "deleted"
422 ? Effect.succeed("")
423 : content(pr.head.repo?.full_name ?? input.repository, newRef, input.newPath),
424 ],
425 { concurrency: 2 },
426 );
427 return { oldContents, newContents };
428 },
429 ),
430 runAction: (input) => {
431 switch (input.action) {
432 case "merge":
433 return write({
434 ...input,
435 path: `${pullPath(input)}/merge`,
436 method: "POST",
437 body: { Do: input.mergeMethod ?? "merge" },
438 });
439 case "close":
440 case "reopen":
441 return write({
442 ...input,
443 path: pullPath(input),
444 method: "PATCH",
445 body: { state: input.action === "close" ? "closed" : "open" },
446 });
447 case "update-branch":
448 return write({
449 ...input,
450 path: `${pullPath(input)}/update?style=${input.updateMethod ?? "merge"}`,
451 method: "POST",
452 });
453 default:
454 return unsupported(input.action);
455 }
456 },
457 updateChangeRequest: (input) =>
458 write({
459 ...input,
460 path: pullPath(input),
461 method: "PATCH",
462 body: {
463 ...(input.title === undefined ? {} : { title: input.title }),
464 ...(input.body === undefined ? {} : { body: input.body }),
465 },
466 }),
467 comment: (input) =>
468 write({
469 ...input,
470 path: `${issuePath(input)}/comments`,
471 method: "POST",
472 body: { body: input.body },
473 }),
474 updateComment: (input) =>
475 write({
476 ...input,
477 path: `${repoPath(input)}/issues/comments/${encodeURIComponent(input.commentId)}`,
478 method: "PATCH",
479 body: { body: input.body },
480 }),
481 submitReview: Effect.fn("ForgejoPullRequestProvider.submitReview")(function* (input) {
482 const pr = yield* getPull(input);
483 yield* write({
484 ...input,
485 path: `${pullPath(input)}/reviews`,
486 method: "POST",
487 body: {
488 event:
489 input.verdict === "approve"
490 ? "APPROVED"
491 : input.verdict === "request-changes"
492 ? "REQUEST_CHANGES"
493 : "COMMENT",
494 body: input.body,
495 commit_id: pr.head.sha,
496 comments: input.comments.map((comment) => {
497 const position = comment.position;
498 const old =
499 position.kind === "deleted" ||
500 (position.kind === "context" && position.side === "left");
501 return {
502 path: old ? (comment.oldPath ?? comment.path) : comment.path,
503 body: comment.body,
504 old_position: old ? position.oldLine : 0,
505 new_position: old ? 0 : position.newLine,
506 };
507 }),
508 },
509 });
510 }),
511 listReviewerCandidates: Effect.fn("ForgejoPullRequestProvider.listReviewerCandidates")(
512 function* (input) {
513 const [pr, users] = yield* Effect.all(
514 [getPull(input), page({ ...input, path: `${repoPath(input)}/assignees` }, ForgejoUser)],
515 { concurrency: 2 },
516 );
517 return {
518 candidates: users.items
519 .filter((user) => user.login !== pr.user?.login)
520 .flatMap((user) => {
521 const actor = forgejoActor(user);
522 return actor
523 ? [
524 {
525 ...actor,
526 id: user.login,
527 kind: "user" as const,
528 isRequested:
529 pr.requested_reviewers?.some((reviewer) => reviewer.login === user.login) ??
530 false,
531 },
532 ]
533 : [];
534 }),
535 truncated: users.truncated,
536 };
537 },
538 ),
539 setReviewerRequest: (input) =>
540 write({
541 ...input,
542 path: `${pullPath(input)}/requested_reviewers`,
543 method: input.requested ? "POST" : "DELETE",
544 body: { reviewers: input.reviewers.map((reviewer) => reviewer.id) },
545 }),
546 listLabelCandidates: Effect.fn("ForgejoPullRequestProvider.listLabelCandidates")(
547 function* (input) {
548 const [pr, labels] = yield* Effect.all(
549 [getPull(input), page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel)],
550 { concurrency: 2 },
551 );
552 return {
553 candidates: labels.items.map((label) => ({
554 name: label.name,
555 color: label.color ?? null,
556 description: label.description ?? null,
557 isApplied: pr.labels?.some((applied) => applied.id === label.id) ?? false,
558 })),
559 truncated: labels.truncated,
560 };
561 },
562 ),
563 setLabels: Effect.fn("ForgejoPullRequestProvider.setLabels")(function* (input) {
564 const labels = yield* page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel);
565 const selected = labels.items.filter((label) => input.labels.includes(label.name));
566 if (selected.length !== input.labels.length)
567 return yield* failure("setLabels", "One or more requested labels could not be found.");
568 if (input.applied)
569 yield* write({
570 ...input,
571 path: `${issuePath(input)}/labels`,
572 method: "POST",
573 body: { labels: selected.map((label) => label.id) },
574 });
575 else
576 yield* Effect.forEach(
577 selected,
578 (label) =>
579 write({ ...input, path: `${issuePath(input)}/labels/${label.id}`, method: "DELETE" }),
580 { concurrency: 1 },
581 );
582 }),
583 setReaction: Effect.fn("ForgejoPullRequestProvider.setReaction")(function* (input) {
584 let commentId = input.subjectId;
585 if (commentId?.startsWith("review:")) {
586 const reviewId = /^review:([1-9]\d*)$/.exec(commentId)?.[1];
587 if (!reviewId) return yield* failure("setReaction", "Invalid Forgejo review ID.");
588 const review = yield* read(
589 { ...input, path: `${pullPath(input)}/reviews/${reviewId}` },
590 ForgejoReview,
591 );
592 commentId = reviewCommentId(review);
593 if (!commentId)
594 return yield* failure(
595 "setReaction",
596 "Forgejo did not return a comment ID for this review.",
597 );
598 }
599 if (commentId && !/^[1-9]\d*$/.test(commentId))
600 return yield* failure("setReaction", "Invalid Forgejo comment ID.");
601 yield* write({
602 ...input,
603 path: commentId
604 ? `${repoPath(input)}/issues/comments/${commentId}/reactions`
605 : `${issuePath(input)}/reactions`,
606 method: input.reacted ? "POST" : "DELETE",
607 body: { content: FORGEJO_REACTIONS[input.content] },
608 });
609 }),
610 replyToThread: () => unsupported("thread replies"),
611 setThreadResolution: () => unsupported("thread resolution"),
612 };
613 return provider;
614});
packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.test.ts created+1283
......@@ -0,0 +1,1283 @@
1import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import type * as Context from "effect/Context";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";
7import * as Schema from "effect/Schema";
8import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
9import * as FetchHttpClient from "effect/http/FetchHttpClient";
10import * as HttpClient from "effect/http/HttpClient";
11import * as HttpClientResponse from "effect/http/HttpClientResponse";
12import { VcsProcessSpawnError } from "@t3tools/contracts";
13import { firstNonEmptyLine } from "@t3tools/source-control-core/server/discovery";
14import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
15
16import * as ForgejoCli from "./ForgejoCli.ts";
17import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
18import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
19
20const processOutput = TestSourceControlHost.processOutput;
21
22const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
23const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
24
25it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => {
26 const methods: string[] = [];
27 const fetchReview = async (
28 ...[input, init]: Parameters<Context.Service.Shape<typeof FetchHttpClient.Fetch>>
29 ) => {
30 const request = new Request(input instanceof Request ? input.url : String(input), {
31 ...(init?.method === undefined ? {} : { method: init.method }),
32 ...(init?.headers === undefined ? {} : { headers: init.headers }),
33 ...(init?.body === undefined ? {} : { body: init.body }),
34 });
35 methods.push(request.method);
36 if (request.method === "GET") {
37 assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42");
38 return new Response(
39 encodeJson({
40 number: 42,
41 title: "Review target",
42 body: "",
43 html_url: "https://forgejo.test/maria/project/pulls/42",
44 user: { login: "maria" },
45 state: "open",
46 merged: false,
47 head: { ref: "feature", sha: "head", repo: null },
48 base: { ref: "main", sha: "base", repo: null },
49 created_at: "2026-09-13T00:00:00Z",
50 updated_at: "2026-09-13T00:00:00Z",
51 closed_at: null,
52 merged_at: null,
53 labels: [],
54 }),
55 );
56 }
57 assert.strictEqual(request.method, "POST");
58 assert.strictEqual(
59 request.url,
60 "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews",
61 );
62 assert.deepStrictEqual(JSON.parse(await request.text()), {
63 event: "COMMENT",
64 body: "Review summary",
65 commit_id: "head",
66 comments: [],
67 });
68 return new Response('{"id":1}', { status: 200 });
69 };
70 return Effect.gen(function* () {
71 const cli = yield* ForgejoCli.make;
72 const provider = yield* ForgejoPullRequestProvider.make.pipe(
73 Effect.provideService(ForgejoCli.ForgejoCli, cli),
74 );
75 yield* provider.submitReview({
76 cwd: "/repo",
77 repository: "maria/project",
78 host: "forgejo.test",
79 number: 42,
80 verdict: "comment",
81 body: "Review summary",
82 comments: [],
83 });
84 assert.deepStrictEqual(methods, ["GET", "POST"]);
85 }).pipe(
86 Effect.provideService(
87 FetchHttpClient.Fetch,
88 Object.assign(fetchReview, { preconnect: () => undefined }),
89 ),
90 Effect.provide(FetchHttpClient.layer),
91 Effect.provideService(
92 FileSystem.FileSystem,
93 FileSystem.makeNoop({
94 exists: () => Effect.succeed(true),
95 readFileString: () =>
96 Effect.succeed(
97 encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }),
98 ),
99 }),
100 ),
101 Effect.provide(
102 TestSourceControlHost.layer({
103 process: {
104 run: (input) => {
105 if (input.command === "git") {
106 assert.deepStrictEqual(input.args, ["remote", "-v"]);
107 return Effect.succeed(
108 processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"),
109 );
110 }
111 assert.strictEqual(input.command, "fj");
112 assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]);
113 return Effect.succeed(processOutput(""));
114 },
115 },
116 }),
117 ),
118 );
119});
120
121it.effect("reads Forgejo checks without repository or viewer requests", () => {
122 const paths: string[] = [];
123 return Effect.gen(function* () {
124 const provider = yield* ForgejoPullRequestProvider.make;
125 const read = provider.getChangeRequestChecks;
126 if (read === undefined) return yield* Effect.die("checks read missing");
127 const result = yield* read({
128 cwd: "/repo",
129 repository: "acme/web",
130 host: "forgejo.test",
131 number: 1,
132 });
133 assert.strictEqual(result.state, "open");
134 assert.strictEqual(result.checks[0]?.status, "failure");
135 assert.deepStrictEqual(paths, [
136 "repos/acme/web/pulls/1",
137 "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=1",
138 "repos/acme/web/statuses/head?sort=recentupdate&limit=50&page=2",
139 ]);
140 }).pipe(
141 Effect.provide(
142 Layer.mock(ForgejoCli.ForgejoCli)({
143 api: (input) => {
144 paths.push(input.path);
145 assert.match(input.path, /^repos\/acme\/web\/(pulls\/1|statuses\/head)/);
146 return Effect.succeed(
147 processOutput(
148 input.path.endsWith("pulls/1")
149 ? `{"number":1,"title":"Checks","body":"","html_url":"https://forgejo.test/acme/web/pulls/1", "user":null,"state":"open","merged":false,
150 "head":{"ref":"feature","sha":"head","repo":null},"base":{"ref":"main","sha":"base","repo":null},
151 "created_at":"2026-09-16T00:00:00Z","updated_at":"2026-09-16T00:00:00Z","closed_at":null,"merged_at":null,"labels":[]}`
152 : input.path.endsWith("page=1")
153 ? `[{"context":"build","status":"failure","description":null,"target_url":null,"updated_at":"2026-09-16T00:00:00Z"}]`
154 : "[]",
155 ),
156 );
157 },
158 }),
159 ),
160 );
161});
162
163it.effect.each([
164 ["Ready", false, true, false, "mergeable"],
165 ["Draft", true, false, true, "unknown"],
166 ["Blocked", false, false, false, "unknown"],
167 ["Unchecked", false, undefined, false, "unknown"],
168 ["WIP: Legacy draft", undefined, false, true, "unknown"],
169 ["[WIP] Legacy draft", undefined, false, true, "unknown"],
170 ["WIP: Explicitly ready", false, false, false, "unknown"],
171] as const)(
172 "reads Forgejo mergeability for %s (draft=%s, mergeable=%s) across list, summary and detail",
173 ([title, draft, mergeable, isDraft, mergeability]) => {
174 const pr = {
175 number: 42,
176 title,
177 body: "",
178 html_url: "https://forgejo.test/maria/project/pulls/42",
179 user: { login: "maria" },
180 state: "open",
181 merged: false,
182 ...(draft === undefined ? {} : { draft }),
183 ...(mergeable === undefined ? {} : { mergeable }),
184 head: { ref: "feature", sha: "head", repo: null },
185 base: { ref: "main", sha: "base", repo: null },
186 created_at: "2026-09-16T00:00:00Z",
187 updated_at: "2026-09-16T00:00:00Z",
188 closed_at: null,
189 merged_at: null,
190 labels: [],
191 };
192 return Effect.gen(function* () {
193 const provider = yield* ForgejoPullRequestProvider.make;
194 const readSummary = provider.getChangeRequestSummary;
195 if (readSummary === undefined) return yield* Effect.die("summary read missing");
196 const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 42 };
197 const list = yield* provider.listChangeRequests({
198 ...input,
199 state: "open",
200 involvement: "all",
201 viewer: "maria",
202 limit: 10,
203 });
204 const summary = yield* readSummary(input);
205 const detail = yield* provider.getChangeRequest(input);
206 assert.strictEqual(list.items.length, 1);
207 for (const result of [list.items[0]!, summary, detail]) {
208 assert.strictEqual(result.mergeability, mergeability);
209 assert.strictEqual(result.isDraft, isDraft);
210 }
211 }).pipe(
212 Effect.provide(
213 Layer.mock(ForgejoCli.ForgejoCli)({
214 api: (input) => {
215 const [path, query] = input.path.split("?");
216 let response: unknown;
217 switch (path) {
218 case "repos/maria/project/pulls":
219 response = new URLSearchParams(query).get("page") === "1" ? [pr] : [];
220 break;
221 case "repos/maria/project/pulls/42":
222 response = pr;
223 break;
224 case "repos/maria/project":
225 response = { full_name: "maria/project", permissions: { push: true, admin: true } };
226 break;
227 case "user":
228 response = pr.user;
229 break;
230 case "repos/maria/project/statuses/head":
231 response = [];
232 break;
233 default:
234 return Effect.die(`Unexpected Forgejo request: ${input.path}`);
235 }
236 return encodeJsonEffect(response).pipe(Effect.orDie, Effect.map(processOutput));
237 },
238 }),
239 ),
240 );
241 },
242);
243
244it.effect("loads Forgejo pull request references from files and commits views", () =>
245 Effect.gen(function* () {
246 const provider = yield* ForgejoSourceControlProvider.make;
247 for (const reference of [
248 "42",
249 "#42",
250 "https://forgejo.test/maria/project/pulls/42",
251 "https://forgejo.test/maria/project/pulls/42/",
252 "https://forgejo.test/maria/project/pulls/42/files?w=1#diff-1",
253 "http://forgejo.test:3000/git/maria/project/pulls/42/commits",
254 ]) {
255 const result = yield* provider.getChangeRequest({ cwd: "/repo", reference });
256 assert.strictEqual(result.number, 42);
257 assert.strictEqual(result.title, "Forgejo view reference");
258 }
259 const invalid = yield* provider
260 .getChangeRequest({
261 cwd: "/repo",
262 reference: "https://forgejo.test/maria/project/pulls/42invalid/files",
263 })
264 .pipe(Effect.result);
265 assert.strictEqual(invalid._tag, "Failure");
266 }).pipe(
267 Effect.provide(
268 Layer.mergeAll(
269 Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})),
270 TestSourceControlHost.layer({ process: {} }),
271 Layer.mock(ForgejoCli.ForgejoCli)({
272 resolveRepository: () =>
273 Effect.succeed({
274 login: "work",
275 repository: "maria/project",
276 baseUrl: "https://forgejo.test",
277 }),
278 api: (input) => {
279 assert.strictEqual(input.path, "repos/maria/project/pulls/42");
280 return encodeJsonEffect({
281 number: 42,
282 title: "Forgejo view reference",
283 html_url: "https://forgejo.test/maria/project/pulls/42",
284 state: "open",
285 merged: false,
286 base: { ref: "main", sha: "base", repo: null },
287 head: { ref: "feature", sha: "head", repo: null },
288 }).pipe(Effect.orDie, Effect.map(processOutput));
289 },
290 }),
291 ),
292 ),
293 ),
294);
295
296it.effect(
297 "loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations",
298 () => {
299 const user = { login: "maria" };
300 const review = {
301 id: 8,
302 body: "review body",
303 user,
304 state: "COMMENT",
305 submitted_at: "2026-09-12T00:00:00Z",
306 html_url: "https://forgejo.test/maria/project/pulls/2#issuecomment-37",
307 comments_count: 1,
308 };
309 const comment = { id: 12, body: "ordinary", user, created_at: review.submitted_at };
310 const responses: Record<string, unknown> = {
311 user,
312 "repos/maria/project/issues/2/comments": [comment],
313 "repos/maria/project/pulls/2/reviews": [review],
314 "repos/maria/project/pulls/2/reviews/8": review,
315 "repos/maria/project/pulls/2/reviews/9": { ...review, id: 9, html_url: "" },
316 "repos/maria/project/pulls/2/commits": [],
317 "repos/maria/project/issues/2/reactions": [],
318 "repos/maria/project/pulls/2/reviews/8/comments": [
319 {
320 ...comment,
321 id: 38,
322 body: "inline",
323 path: "file.ts",
324 position: 1,
325 original_position: 1,
326 commit_id: "head",
327 original_commit_id: "head",
328 resolver: null,
329 },
330 ],
331 "repos/maria/project/issues/comments/12/reactions": [{ content: "+1", user }],
332 "repos/maria/project/issues/comments/37/reactions": [{ content: "heart", user }],
333 "repos/maria/project/issues/comments/38/reactions": [
334 { content: "rocket", user: { login: "reviewer" } },
335 ],
336 };
337 const writes: ForgejoCli.ForgejoApiInput[] = [];
338 let reactionReads = 0;
339 return Effect.gen(function* () {
340 const provider = yield* ForgejoPullRequestProvider.make;
341 const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 2 };
342 const activity = yield* provider.getChangeRequestActivity(input);
343 assert.deepStrictEqual(
344 activity.comments.map((entry) => ({
345 id: entry.id,
346 kind: entry.kind,
347 reactions: entry.reactions,
348 })),
349 [
350 {
351 id: "12",
352 kind: "issue-comment",
353 reactions: [{ content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }],
354 },
355 {
356 id: "review:8",
357 kind: "review",
358 reactions: [{ content: "heart", count: 1, actors: [], viewerHasReacted: true }],
359 },
360 {
361 id: "38",
362 kind: "review-comment",
363 reactions: [
364 { content: "rocket", count: 1, actors: ["reviewer"], viewerHasReacted: false },
365 ],
366 },
367 ],
368 );
369 const inlineComment = activity.comments[2];
370 assert.ok(inlineComment);
371 assert.deepStrictEqual(activity.reviewThreads[0]?.comments, [inlineComment]);
372 for (const reacted of [true, false]) {
373 yield* provider.setReaction({ ...input, subjectId: "review:8", content: "heart", reacted });
374 yield* provider.setReaction({ ...input, subjectId: "38", content: "rocket", reacted });
375 }
376 assert.deepStrictEqual(
377 writes.map(({ path, method, body }) => ({ path, method, body })),
378 [
379 {
380 path: "repos/maria/project/issues/comments/37/reactions",
381 method: "POST",
382 body: { content: "heart" },
383 },
384 {
385 path: "repos/maria/project/issues/comments/38/reactions",
386 method: "POST",
387 body: { content: "rocket" },
388 },
389 {
390 path: "repos/maria/project/issues/comments/37/reactions",
391 method: "DELETE",
392 body: { content: "heart" },
393 },
394 {
395 path: "repos/maria/project/issues/comments/38/reactions",
396 method: "DELETE",
397 body: { content: "rocket" },
398 },
399 ],
400 );
401 const missing = yield* provider
402 .setReaction({ ...input, subjectId: "review:9", content: "heart", reacted: true })
403 .pipe(Effect.result);
404 assert.strictEqual(missing._tag, "Failure");
405 if (missing._tag === "Failure") assert.include(missing.failure.detail, "comment ID");
406 assert.strictEqual(writes.length, 4);
407 responses["repos/maria/project/pulls/2/reviews/8/comments"] = Array.from(
408 { length: 501 },
409 (_, index) => ({
410 ...comment,
411 id: 1000 + index,
412 path: "file.ts",
413 position: 1,
414 original_position: 1,
415 commit_id: "head",
416 original_commit_id: "head",
417 resolver: null,
418 }),
419 );
420 for (let index = 0; index < 500; index++) {
421 responses[`repos/maria/project/issues/comments/${1000 + index}/reactions`] = [];
422 }
423 reactionReads = 0;
424 const bounded = yield* provider.getChangeRequestActivity(input);
425 assert.strictEqual(bounded.reviewThreads.length, 500);
426 assert.strictEqual(
427 bounded.comments.filter((entry) => entry.kind === "review-comment").length,
428 500,
429 );
430 assert.strictEqual(bounded.commentsTruncated, true);
431 assert.strictEqual(reactionReads, 502);
432 }).pipe(
433 Effect.provide(
434 Layer.mock(ForgejoCli.ForgejoCli)({
435 api: (input) => {
436 if (input.method) {
437 writes.push(input);
438 return Effect.succeed(processOutput("{}"));
439 }
440 const path = input.path.split("?")[0]!;
441 if (/\/issues\/comments\/\d+\/reactions$/.test(path)) reactionReads++;
442 assert.ok(Object.hasOwn(responses, path), `Unexpected Forgejo request: ${path}`);
443 const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page"));
444 return encodeJsonEffect(page > 1 ? [] : responses[path]).pipe(
445 Effect.orDie,
446 Effect.map(processOutput),
447 );
448 },
449 }),
450 ),
451 );
452 },
453);
454
455it.effect("discovers Forgejo accounts and retains the server port", () =>
456 Effect.gen(function* () {
457 const auth = ForgejoSourceControlProvider.discovery.parseAuth(
458 processOutput(
459 yield* encodeJsonEffect([
460 {
461 name: "work",
462 url: "http://forgejo.local:3000",
463 ssh_host: "git.forgejo.local",
464 user: "maria",
465 default: "true",
466 valid: "true",
467 },
468 ]),
469 ),
470 );
471 assert.deepStrictEqual(
472 firstNonEmptyLine("\u001b[1mtea version 0.16.0\u001b[0m\n"),
473 Option.some("tea version 0.16.0"),
474 );
475 assert.strictEqual(auth.status, "authenticated");
476 assert.deepStrictEqual(auth.account, Option.some("maria"));
477 assert.deepStrictEqual(auth.host, Option.some("forgejo.local:3000"));
478 const revoked = ForgejoSourceControlProvider.discovery.parseAuth(
479 processOutput(
480 encodeJson([
481 {
482 name: "work",
483 url: "http://forgejo.local:3000",
484 user: "maria",
485 default: "true",
486 valid: "false",
487 },
488 ]),
489 ),
490 );
491 assert.strictEqual(revoked.status, "unauthenticated");
492 const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
493 cwd: "/repo",
494 context: {
495 provider: {
496 kind: "unknown",
497 name: "git.forgejo.local",
498 baseUrl: "https://git.forgejo.local",
499 },
500 remoteName: "origin",
501 remoteUrl: "git@git.forgejo.local:maria/project.git",
502 },
503 auth: processOutput(
504 yield* encodeJsonEffect([
505 {
506 name: "work",
507 url: "http://forgejo.local:3000",
508 ssh_host: "git.forgejo.local",
509 user: "maria",
510 default: "true",
511 },
512 ]),
513 ),
514 });
515 assert.deepStrictEqual(refined, {
516 kind: "forgejo",
517 name: "Forgejo / Gitea",
518 baseUrl: "http://forgejo.local:3000",
519 });
520 }),
521);
522
523it.effect("does not choose a default Forgejo login across ambiguous SSH server ports", () =>
524 Effect.gen(function* () {
525 const logins = ForgejoCli.parseForgejoLogins(
526 yield* encodeJsonEffect([
527 {
528 name: "one",
529 url: "http://forgejo.local:3000",
530 ssh_host: "forgejo.local",
531 user: "maria",
532 default: "true",
533 },
534 {
535 name: "two",
536 url: "http://forgejo.local:4000",
537 ssh_host: "forgejo.local",
538 user: "maria",
539 default: "false",
540 },
541 ]),
542 );
543 const remote = ForgejoCli.parseForgejoRemote("git@forgejo.local:maria/project.git");
544 assert.isNotNull(remote);
545 assert.deepStrictEqual(
546 ForgejoCli.parseForgejoRemote("forgejo.local:maria/project.git"),
547 remote,
548 );
549 assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!));
550 assert.strictEqual(
551 ForgejoCli.matchForgejoLogin(logins, remote!, "forgejo.local:4000")?.name,
552 "two",
553 );
554 assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!, "other.local:4000"));
555 const alias = ForgejoCli.parseForgejoRemote("git@ssh.forgejo.local:maria/project.git");
556 assert.isNotNull(alias);
557 assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, alias!, "forgejo.local:4000"));
558 const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
559 cwd: "/repo",
560 context: {
561 provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" },
562 remoteName: "origin",
563 remoteUrl: "git@forgejo.local:maria/project.git",
564 requestedHost: "forgejo.local:4000",
565 },
566 auth: processOutput(yield* encodeJsonEffect(logins)),
567 });
568 assert.strictEqual(refined?.baseUrl, "http://forgejo.local:4000");
569 const https = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000/maria/project.git");
570 assert.isNotNull(https);
571 assert.strictEqual(ForgejoCli.matchForgejoLogin(logins, https!)?.name, "two");
572 const hostOnly = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000");
573 assert.strictEqual(
574 ForgejoCli.matchForgejoLogin(logins, hostOnly!, undefined, true)?.name,
575 "two",
576 );
577 const mounted = logins.map((login) => ({
578 ...login,
579 url: `http://forgejo.local:4000/${login.name}`,
580 }));
581 assert.isUndefined(ForgejoCli.matchForgejoLogin(mounted, hostOnly!, undefined, true));
582 }),
583);
584
585it.effect("rejects HTTP failures even when tea exits successfully", () =>
586 Effect.gen(function* () {
587 const cli = yield* ForgejoCli.make;
588 const result = yield* cli
589 .api({
590 cwd: "/repo",
591 repository: "http://forgejo.local:3000/maria/project",
592 path: "repos/maria/project/pulls/42",
593 method: "PATCH",
594 body: { state: "closed" },
595 })
596 .pipe(Effect.result);
597 assert.strictEqual(result._tag, "Failure");
598 if (result._tag === "Failure")
599 assert.strictEqual(
600 result.failure.detail,
601 "Forgejo repository or pull request was not found.",
602 );
603 }).pipe(
604 Effect.provideService(
605 FileSystem.FileSystem,
606 FileSystem.makeNoop({
607 exists: () => Effect.succeed(false),
608 }),
609 ),
610 Effect.provideService(
611 HttpClient.HttpClient,
612 HttpClient.make(() => {
613 throw new Error("tea must handle its own HTTP request");
614 }),
615 ),
616 Effect.provide(
617 TestSourceControlHost.layer({
618 process: {
619 run: (input) => {
620 if (input.args[0] === "api") {
621 assert.strictEqual(input.stdin, '{"state":"closed"}');
622 assert.include(input.args, "work");
623 assert.include(
624 input.args,
625 "http://forgejo.local:3000/api/v1/repos/maria/project/pulls/42",
626 );
627 }
628 return Effect.succeed(
629 input.args[0] === "login"
630 ? processOutput(
631 encodeJson([
632 {
633 name: "work",
634 url: "http://forgejo.local:3000",
635 ssh_host: "forgejo.local",
636 user: "maria",
637 default: "true",
638 },
639 ]),
640 )
641 : processOutput('{"message":"not found"}', { stderr: "HTTP/1.1 404 Not Found\n" }),
642 );
643 },
644 },
645 }),
646 ),
647 ),
648);
649
650it.effect("routes mounted Forgejo repositories without repeating the mount in API paths", () =>
651 Effect.gen(function* () {
652 const cli = yield* ForgejoCli.make;
653 const viewer = yield* cli.api({ cwd: "/upstream-only", host: "code.test", path: "user" });
654 assert.strictEqual(viewer.stdout, "[]");
655 const mountedRepository = yield* cli.resolveRepository({
656 cwd: "/upstream-only",
657 host: "code.test",
658 repository: "maria/project",
659 });
660 assert.strictEqual(mountedRepository.baseUrl, "https://code.test/forgejo");
661 assert.strictEqual(mountedRepository.repository, "maria/project");
662 for (const path of [
663 "repos/forgejo/maria/project/pulls?state=open",
664 "repos/forgejo/maria/project",
665 "repos/reviewer/project/contents/file.ts",
666 ]) {
667 const result = yield* cli.api({
668 cwd: "/repo",
669 repository: "forgejo/maria/project",
670 context: {
671 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" },
672 remoteName: "origin",
673 remoteUrl: "https://code.test/forgejo/maria/project.git",
674 },
675 path,
676 });
677 assert.strictEqual(result.stdout, "[]");
678 }
679 const sameOwnerAsMount = yield* cli.resolveRepository({
680 cwd: "/repo",
681 repository: "forgejo/project",
682 context: {
683 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" },
684 remoteName: "origin",
685 remoteUrl: "ssh://git@code.test/forgejo/project.git",
686 },
687 });
688 assert.strictEqual(sameOwnerAsMount.command, "tea");
689 assert.strictEqual(sameOwnerAsMount.repository, "forgejo/project");
690 }).pipe(
691 Effect.provideService(
692 FileSystem.FileSystem,
693 FileSystem.makeNoop({
694 exists: () => Effect.succeed(true),
695 readFileString: () =>
696 Effect.succeed(
697 encodeJson({
698 hosts: { "code.test/forgejo": { type: "Application", token: "test-token" } },
699 }),
700 ),
701 }),
702 ),
703 Effect.provideService(
704 HttpClient.HttpClient,
705 HttpClient.make(() => {
706 throw new Error("tea must handle its own HTTP request");
707 }),
708 ),
709 Effect.provide(
710 TestSourceControlHost.layer({
711 process: {
712 run: (input) => {
713 if (input.command === "git")
714 return Effect.succeed(
715 processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }),
716 );
717 if (input.args[0] === "login")
718 return Effect.succeed(
719 processOutput(
720 encodeJson([
721 {
722 name: "mounted",
723 url: "https://code.test/forgejo",
724 ssh_host: "code.test",
725 user: "maria",
726 default: "true",
727 },
728 ]),
729 ),
730 );
731 const supported = [
732 "https://code.test/forgejo/api/v1/user",
733 "https://code.test/forgejo/api/v1/repos/maria/project/pulls?state=open",
734 "https://code.test/forgejo/api/v1/repos/maria/project",
735 "https://code.test/forgejo/api/v1/repos/reviewer/project/contents/file.ts",
736 ];
737 if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo");
738 assert.strictEqual(input.command, "tea");
739 return Effect.succeed(
740 supported.includes(input.args.at(-1) ?? "")
741 ? processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" })
742 : processOutput("{}", { stderr: "HTTP/1.1 404 Not Found\n" }),
743 );
744 },
745 },
746 }),
747 ),
748 ),
749);
750
751it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => {
752 const commands: string[] = [];
753 const requests: string[] = [];
754 return Effect.gen(function* () {
755 const cli = yield* ForgejoCli.make;
756 for (const remoteUrl of [
757 "http://forgejo.local:3000/maria/project.git",
758 "ssh://git@ssh.forgejo.local:2222/maria/project.git",
759 "ssh://git@forgejo.local:2222/maria/project.git",
760 ]) {
761 const result = yield* cli.api({
762 cwd: "/repo",
763 repository: "maria/project",
764 context: {
765 provider: {
766 kind: "forgejo",
767 name: "Forgejo",
768 baseUrl: "http://forgejo.local:3000",
769 },
770 remoteName: "origin",
771 remoteUrl,
772 requestedHost: "forgejo.local:3000",
773 },
774 path: "repos/maria/project/issues/42/comments",
775 method: "POST",
776 body: { body: "verified through fj" },
777 });
778 assert.strictEqual(result.stdout, '{"id":99}');
779 }
780 assert.deepStrictEqual(commands, ["fj"]);
781 assert.deepStrictEqual(requests, [
782 "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
783 "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
784 "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
785 ]);
786 const viewer = yield* cli.api({
787 cwd: "/no-remotes",
788 host: "forgejo.local:3000",
789 path: "user",
790 });
791 assert.strictEqual(viewer.stdout, '{"login":"maria"}');
792 assert.strictEqual(requests.at(-1), "https://forgejo.local:3000/api/v1/user");
793 const upstreamViewer = yield* cli.api({
794 cwd: "/upstream-only",
795 host: "forgejo.local:3000",
796 path: "user",
797 });
798 assert.strictEqual(upstreamViewer.stdout, '{"login":"maria"}');
799 assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user");
800 const upstreamRepository = yield* cli.resolveRepository({
801 cwd: "/upstream-only",
802 host: "forgejo.local:3000",
803 repository: "maria/project",
804 });
805 assert.strictEqual(upstreamRepository.baseUrl, "http://forgejo.local:3000");
806 assert.strictEqual(upstreamRepository.repository, "maria/project");
807 const httpViewer = yield* cli.api({ cwd: "/repo", host: "forgejo.local:3000", path: "user" });
808 assert.strictEqual(httpViewer.stdout, '{"login":"maria"}');
809 assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user");
810 }).pipe(
811 Effect.provideService(
812 FileSystem.FileSystem,
813 FileSystem.makeNoop({
814 exists: () => Effect.succeed(true),
815 readFileString: () =>
816 Effect.succeed(
817 encodeJson({
818 hosts: {
819 "forgejo.local:3000": { type: "Application", token: "test-token" },
820 "forgejo.local:4000": { type: "Application", token: "other-token" },
821 },
822 aliases: { "ssh.forgejo.local:2222": "forgejo.local:3000" },
823 }),
824 ),
825 }),
826 ),
827 Effect.provideService(
828 HttpClient.HttpClient,
829 HttpClient.make((request) => {
830 requests.push(request.url);
831 if (request.url.endsWith("/user")) {
832 assert.strictEqual(request.method, "GET");
833 assert.strictEqual(request.headers.authorization, "token test-token");
834 return Effect.succeed(
835 HttpClientResponse.fromWeb(request, new Response('{"login":"maria"}')),
836 );
837 }
838 assert.strictEqual(request.method, "POST");
839 assert.strictEqual(request.headers.authorization, "token test-token");
840 assert.strictEqual(request.body._tag, "Uint8Array");
841 if (request.body._tag === "Uint8Array")
842 assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), {
843 body: "verified through fj",
844 });
845 return Effect.succeed(
846 HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })),
847 );
848 }),
849 ),
850 Effect.provide(
851 TestSourceControlHost.layer({
852 process: {
853 run: (input) => {
854 commands.push(input.command);
855 if (input.command === "git")
856 return Effect.succeed(
857 input.cwd === "/no-remotes"
858 ? processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) })
859 : processOutput(
860 `${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)`,
861 ),
862 );
863 assert.strictEqual(input.command, "fj");
864 assert.deepStrictEqual(input.args, [
865 "--host",
866 input.cwd === "/no-remotes"
867 ? "https://forgejo.local:3000"
868 : "http://forgejo.local:3000",
869 "whoami",
870 ]);
871 return Effect.succeed(processOutput(""));
872 },
873 },
874 }),
875 ),
876 );
877});
878
879it.effect("loads later fj review pages when the server caps pages below the requested size", () => {
880 const pages: number[] = [];
881 let issueCommentRequests = 0;
882 return Effect.gen(function* () {
883 const cli = yield* ForgejoCli.make;
884 const provider = yield* ForgejoPullRequestProvider.make.pipe(
885 Effect.provideService(ForgejoCli.ForgejoCli, cli),
886 );
887 const activity = yield* provider.getChangeRequestActivity({
888 cwd: "/repo",
889 repository: "maria/project",
890 host: "forgejo.test",
891 number: 42,
892 });
893 assert.strictEqual(activity.commentCount, 42);
894 assert.strictEqual(activity.comments.at(-1)?.id, "review:41");
895 assert.strictEqual(activity.commentsTruncated, false);
896 assert.deepStrictEqual(pages, [1, 2, 3]);
897 assert.strictEqual(issueCommentRequests, 1);
898 }).pipe(
899 Effect.provideService(
900 FileSystem.FileSystem,
901 FileSystem.makeNoop({
902 exists: () => Effect.succeed(true),
903 readFileString: () =>
904 Effect.succeed(
905 encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }),
906 ),
907 }),
908 ),
909 Effect.provideService(
910 HttpClient.HttpClient,
911 HttpClient.make((request) => {
912 const url = new URL(request.url);
913 if (url.pathname === "/api/v1/repos/maria/project/issues/42/comments") {
914 issueCommentRequests++;
915 return Effect.succeed(
916 HttpClientResponse.fromWeb(
917 request,
918 new Response(
919 encodeJson([
920 {
921 id: 100,
922 body: "Unpaginated issue comment",
923 user: { login: "maria" },
924 created_at: "2026-09-13T00:00:00Z",
925 },
926 ]),
927 ),
928 ),
929 );
930 }
931 if (url.pathname.endsWith("/reviews")) {
932 const page = Number(url.searchParams.get("page"));
933 pages.push(page);
934 assert.ok(page >= 1 && page <= 3);
935 const reviews = Array.from({ length: page < 3 ? 20 : 1 }, (_, index) => ({
936 id: (page - 1) * 20 + index + 1,
937 body: "Review from a capped page",
938 user: { login: "maria" },
939 state: "COMMENT",
940 submitted_at: "2026-09-13T00:00:00Z",
941 comments_count: 0,
942 }));
943 return Effect.succeed(
944 HttpClientResponse.fromWeb(
945 request,
946 new Response(encodeJson(reviews), {
947 headers:
948 page === 2
949 ? {}
950 : {
951 Link:
952 page === 1
953 ? `<${url.origin}${url.pathname}?limit=50&page=2>; rel="next"`
954 : `<${url.origin}${url.pathname}?limit=50&page=1>; rel="prev"`,
955 },
956 }),
957 ),
958 );
959 }
960 return Effect.succeed(
961 HttpClientResponse.fromWeb(
962 request,
963 new Response(encodeJson(url.pathname === "/api/v1/user" ? { login: "maria" } : [])),
964 ),
965 );
966 }),
967 ),
968 Effect.provide(
969 TestSourceControlHost.layer({
970 process: {
971 run: (input) => {
972 if (input.command === "git") {
973 assert.deepStrictEqual(input.args, ["remote", "-v"]);
974 return Effect.succeed(
975 processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"),
976 );
977 }
978 assert.strictEqual(input.command, "fj");
979 assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]);
980 return Effect.succeed(processOutput(""));
981 },
982 },
983 }),
984 ),
985 );
986});
987
988it.effect("falls back to tea when fj is missing or has no account for this server", () =>
989 Effect.gen(function* () {
990 for (const scenario of ["missing-cli", "missing-account", "stale-invalid-storage"] as const) {
991 const commands: string[] = [];
992 yield* Effect.gen(function* () {
993 const cli = yield* ForgejoCli.make;
994 const result = yield* cli.api({
995 cwd: "/repo",
996 repository: "https://forgejo.local:3000/maria/project",
997 path: "repos/maria/project/pulls",
998 });
999 assert.strictEqual(result.stdout, "[]");
1000 assert.deepStrictEqual(
1001 commands,
1002 scenario === "missing-account" ? ["tea", "tea"] : ["fj", "tea", "tea"],
1003 );
1004 const viewer = yield* cli.api({
1005 cwd: "/upstream-only",
1006 host: "forgejo.local:3000",
1007 path: "user",
1008 });
1009 assert.strictEqual(viewer.stdout, "[]");
1010 }).pipe(
1011 Effect.provideService(
1012 FileSystem.FileSystem,
1013 FileSystem.makeNoop({
1014 exists: () => Effect.succeed(true),
1015 readFileString: () =>
1016 Effect.succeed(
1017 scenario === "stale-invalid-storage"
1018 ? "invalid json"
1019 : encodeJson({
1020 hosts: {
1021 [scenario === "missing-cli" ? "forgejo.local:3000" : "other.local"]: {
1022 type: "Application",
1023 token: "test-token",
1024 },
1025 },
1026 }),
1027 ),
1028 }),
1029 ),
1030 Effect.provideService(
1031 HttpClient.HttpClient,
1032 HttpClient.make(() => {
1033 throw new Error("tea must handle its own HTTP request");
1034 }),
1035 ),
1036 Effect.provide(
1037 TestSourceControlHost.layer({
1038 process: {
1039 run: (input) => {
1040 commands.push(input.command);
1041 if (input.command === "git")
1042 return Effect.succeed(
1043 processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }),
1044 );
1045 if (input.command === "fj")
1046 return Effect.fail(
1047 new VcsProcessSpawnError({
1048 operation: input.operation,
1049 command: input.command,
1050 cwd: input.cwd,
1051 cause: new Error("fj not found"),
1052 }),
1053 );
1054 assert.strictEqual(input.command, "tea");
1055 if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo");
1056 return Effect.succeed(
1057 input.args[0] === "login"
1058 ? processOutput(
1059 encodeJson([
1060 {
1061 name: "work",
1062 url: "https://forgejo.local:3000",
1063 user: "maria",
1064 default: "true",
1065 valid: "true",
1066 },
1067 ]),
1068 )
1069 : processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }),
1070 );
1071 },
1072 },
1073 }),
1074 ),
1075 );
1076 }
1077 }),
1078);
1079
1080it.effect("handles fj mutation statuses without retrying failures or reading absent bodies", () =>
1081 Effect.gen(function* () {
1082 for (const status of [204, 205, 302, 401, 403, 404, 429, 500]) {
1083 let writes = 0;
1084 yield* Effect.gen(function* () {
1085 const cli = yield* ForgejoCli.make;
1086 const result = yield* cli
1087 .api({
1088 cwd: "/repo",
1089 repository: "https://forgejo.local/maria/project",
1090 path: "repos/maria/project/issues/42/comments",
1091 method: "POST",
1092 body: { body: "only once" },
1093 })
1094 .pipe(Effect.result);
1095 assert.strictEqual(result._tag, status < 300 ? "Success" : "Failure");
1096 if (result._tag === "Success") assert.strictEqual(result.success.stdout, "");
1097 if (result._tag === "Failure") {
1098 assert.strictEqual(result.failure.command, "fj");
1099 assert.strictEqual(result.failure.httpStatus, status);
1100 }
1101 assert.strictEqual(writes, 1);
1102 }).pipe(
1103 Effect.provideService(
1104 FileSystem.FileSystem,
1105 FileSystem.makeNoop({
1106 exists: () => Effect.succeed(true),
1107 readFileString: () =>
1108 Effect.succeed(
1109 encodeJson({
1110 hosts: {
1111 "forgejo.local": { type: "Application", token: "test-token" },
1112 },
1113 }),
1114 ),
1115 }),
1116 ),
1117 Effect.provideService(
1118 HttpClient.HttpClient,
1119 HttpClient.make((request) => {
1120 writes++;
1121 assert.strictEqual(
1122 request.url,
1123 "https://forgejo.local/api/v1/repos/maria/project/issues/42/comments",
1124 );
1125 return Effect.succeed(
1126 HttpClientResponse.fromWeb(
1127 request,
1128 new Response(status < 300 ? null : "", {
1129 status,
1130 headers: { location: "https://other.local/" },
1131 }),
1132 ),
1133 );
1134 }),
1135 ),
1136 Effect.provide(
1137 TestSourceControlHost.layer({
1138 process: {
1139 run: (input) => {
1140 assert.strictEqual(
1141 input.command,
1142 "fj",
1143 "a failed mutation must never switch accounts or CLI",
1144 );
1145 return Effect.succeed(processOutput(""));
1146 },
1147 },
1148 }),
1149 ),
1150 );
1151 }
1152 }),
1153);
1154
1155it.effect(
1156 "discovers fj first and retains configured authentication failures instead of switching accounts",
1157 () =>
1158 Effect.gen(function* () {
1159 for (const scenario of ["authenticated", "revoked", "missing", "invalid-storage"] as const) {
1160 const commands: string[] = [];
1161 yield* Effect.gen(function* () {
1162 const spec = yield* ForgejoSourceControlProvider.makeDiscovery;
1163 assert.strictEqual(spec.type, "managed-cli");
1164 if (spec.type !== "managed-cli") return;
1165 const result = yield* spec.probe("/repo");
1166 assert.strictEqual(result.executable, scenario === "missing" ? "tea" : "fj");
1167 assert.strictEqual(
1168 result.auth.status,
1169 scenario === "revoked"
1170 ? "unauthenticated"
1171 : scenario === "invalid-storage"
1172 ? "unknown"
1173 : "authenticated",
1174 );
1175 if (scenario !== "revoked" && scenario !== "invalid-storage")
1176 assert.deepStrictEqual(result.auth.host, Option.some("forgejo.local:3000"));
1177 assert.deepStrictEqual(
1178 result.auth.account,
1179 scenario === "authenticated" || scenario === "missing"
1180 ? Option.some("maria")
1181 : Option.none(),
1182 );
1183 assert.strictEqual(
1184 commands.some((command) => command.startsWith("tea ")),
1185 scenario === "missing",
1186 );
1187 assert.include(commands, "fj version");
1188 }).pipe(
1189 Effect.provide(
1190 Layer.mergeAll(
1191 Layer.mock(ForgejoCli.ForgejoCli)({
1192 getAccount: (input) => {
1193 assert.strictEqual(scenario, "authenticated");
1194 assert.deepStrictEqual(input, {
1195 cwd: "/repo",
1196 baseUrl: "http://forgejo.local:3000",
1197 });
1198 return Effect.succeed("maria");
1199 },
1200 listLogins: (input) => {
1201 assert.strictEqual(
1202 input.remoteUrl,
1203 "http://forgejo.local:3000/maria/project.git",
1204 );
1205 if (scenario === "invalid-storage")
1206 return Effect.fail(
1207 new ForgejoCli.ForgejoCliError({
1208 command: "fj",
1209 cwd: input.cwd,
1210 reason: "authentication",
1211 detail: "fj authentication storage is invalid.",
1212 }),
1213 );
1214 return Effect.succeed([
1215 {
1216 name: "forgejo.local:3000",
1217 url: "http://forgejo.local:3000",
1218 user: "",
1219 default: "false",
1220 },
1221 ]);
1222 },
1223 }),
1224 TestSourceControlHost.layer({
1225 process: {
1226 run: (input) => {
1227 commands.push(`${input.command} ${input.args.join(" ")}`);
1228 if (input.command === "git")
1229 return Effect.succeed(
1230 processOutput("http://forgejo.local:3000/maria/project.git\n"),
1231 );
1232 if (input.command === "fj") {
1233 if (scenario === "missing")
1234 return Effect.fail(
1235 new VcsProcessSpawnError({
1236 operation: input.operation,
1237 command: input.command,
1238 cwd: input.cwd,
1239 cause: new Error("fj not found"),
1240 }),
1241 );
1242 if (input.args[0] === "version")
1243 return Effect.succeed(processOutput("fj 0.10.0"));
1244 if (scenario === "invalid-storage") {
1245 assert.deepStrictEqual(input.args, ["auth", "list"]);
1246 return Effect.succeed(processOutput(""));
1247 }
1248 assert.deepStrictEqual(input.args, [
1249 "--host",
1250 "http://forgejo.local:3000",
1251 "whoami",
1252 ]);
1253 return Effect.succeed(
1254 processOutput("", {
1255 exitCode: ChildProcessSpawner.ExitCode(scenario === "revoked" ? 1 : 0),
1256 }),
1257 );
1258 }
1259 assert.strictEqual(input.command, "tea");
1260 return Effect.succeed(
1261 input.args[0] === "--version"
1262 ? processOutput("tea version 0.16.0")
1263 : processOutput(
1264 encodeJson([
1265 {
1266 name: "work",
1267 url: "http://forgejo.local:3000",
1268 user: "maria",
1269 default: "true",
1270 valid: "true",
1271 },
1272 ]),
1273 ),
1274 );
1275 },
1276 },
1277 }),
1278 ),
1279 ),
1280 );
1281 }
1282 }),
1283);
packages/source-control-forgejo/src/server/ForgejoSourceControlProvider.ts created+387
......@@ -0,0 +1,387 @@
1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Schema from "effect/Schema";
4import * as Result from "effect/Result";
5import { SourceControlProviderError } from "@t3tools/contracts";
6import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
7import * as ForgejoCli from "./ForgejoCli.ts";
8import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
9import {
10 providerAuth,
11 probeSourceControlProvider,
12 type SourceControlCliDiscoverySpec,
13 type SourceControlManagedCliDiscoverySpec,
14} from "@t3tools/source-control-core/server/discovery";
15import { ForgejoPullRequestSchema, toForgejoChangeRequest } from "./forgejoPullRequests.ts";
16
17const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError);
18
19export const discovery = {
20 type: "cli",
21 kind: "forgejo",
22 label: "Forgejo / Gitea",
23 executable: "tea",
24 versionArgs: ["--version"],
25 authArgs: ["login", "status", "--output", "json"],
26 remoteRefinementArgs: ["login", "list", "--output", "json"],
27 parseAuth: (input) => {
28 const logins = ForgejoCli.parseForgejoLogins(input.stdout);
29 const login = logins.find((entry) => entry.default === "true") ?? logins[0];
30 return login
31 ? providerAuth({
32 status: login.valid === "true" ? "authenticated" : "unauthenticated",
33 account: login.user,
34 host: ForgejoCli.parseForgejoRemote(login.url)?.host,
35 })
36 : providerAuth({
37 status: "unauthenticated",
38 detail: "Run `tea login add` to authenticate a Forgejo or Gitea server.",
39 });
40 },
41 refineUnknownRemote: (input) => {
42 const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl);
43 const login =
44 remote &&
45 ForgejoCli.matchForgejoLogin(
46 ForgejoCli.parseForgejoLogins(input.auth.stdout),
47 remote,
48 input.context.requestedHost,
49 );
50 return login ? { kind: "forgejo", name: "Forgejo / Gitea", baseUrl: login.url } : null;
51 },
52 installHint:
53 "Install `fj` 0.6 or later from https://codeberg.org/forgejo-contrib/forgejo-cli and run `fj --host <server-url> 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.",
54} satisfies SourceControlCliDiscoverySpec;
55
56export const makeDiscovery = Effect.gen(function* () {
57 const cli = yield* ForgejoCli.ForgejoCli;
58 const { process } = yield* SourceControlHost.SourceControlHost;
59 const listLogins = cli.listLogins;
60 if (!listLogins) return discovery;
61 return {
62 type: "managed-cli",
63 kind: "forgejo",
64 label: discovery.label,
65 installHint: discovery.installHint,
66 probe: Effect.fn("ForgejoSourceControlProvider.discovery")(function* (cwd: string) {
67 const remoteUrl = yield* process
68 .run({
69 operation: "source-control.discovery.remote",
70 command: "git",
71 args: ["remote", "get-url", "origin"],
72 cwd,
73 allowNonZeroExit: true,
74 timeoutMs: 5_000,
75 maxOutputBytes: 8_000,
76 })
77 .pipe(
78 Effect.map((result) => result.stdout.trim()),
79 Effect.orElseSucceed(() => ""),
80 );
81 const credentials = yield* Effect.result(listLogins({ cwd, command: "fj", remoteUrl }));
82 const logins = Result.isSuccess(credentials) ? credentials.success : [];
83 const remote = ForgejoCli.parseForgejoRemote(remoteUrl);
84 const login =
85 (remote && ForgejoCli.matchForgejoLogin(logins, remote)) ||
86 logins.find((entry) => entry.default === "true") ||
87 logins[0];
88 const fj = yield* probeSourceControlProvider({
89 cwd,
90 process,
91 spec: {
92 ...discovery,
93 executable: "fj",
94 versionArgs: ["version"],
95 authArgs: login ? ["--host", login.url, "whoami"] : ["auth", "list"],
96 parseAuth: (result) =>
97 Result.isFailure(credentials)
98 ? providerAuth({
99 status: "unknown",
100 detail: "Could not read fj authentication storage. Authenticate again with fj.",
101 })
102 : login && result.exitCode === 0
103 ? providerAuth({
104 status: "authenticated",
105 account: login.user,
106 host: ForgejoCli.parseForgejoRemote(login.url)?.host,
107 })
108 : providerAuth({
109 status: "unauthenticated",
110 detail:
111 "Authenticate this server with `fj --host <server-url> auth add-token`.",
112 }),
113 },
114 });
115 // A configured fj account owns its requests, including authentication errors.
116 if (fj.status === "available" && (login || Result.isFailure(credentials))) {
117 if (login && fj.auth.status === "authenticated" && cli.getAccount) {
118 const account = yield* cli.getAccount({ cwd, baseUrl: login.url }).pipe(Effect.result);
119 return {
120 ...fj,
121 auth: Result.isSuccess(account)
122 ? providerAuth({
123 status: "authenticated",
124 account: account.success,
125 host: ForgejoCli.parseForgejoRemote(login.url)?.host,
126 })
127 : providerAuth({
128 status: "unknown",
129 detail: account.failure.detail,
130 host: ForgejoCli.parseForgejoRemote(login.url)?.host,
131 }),
132 };
133 }
134 return fj;
135 }
136 const tea = yield* probeSourceControlProvider({ cwd, process, spec: discovery });
137 return tea.status === "available" || fj.status === "missing" ? tea : fj;
138 }),
139 refineUnknownRemote: Effect.fn("ForgejoSourceControlProvider.refineUnknownRemote")(
140 function* (input: {
141 readonly cwd: string;
142 readonly context: SourceControlProvider.SourceControlProviderContext;
143 }) {
144 const remote = ForgejoCli.parseForgejoRemote(input.context.remoteUrl);
145 if (!remote) return null;
146 for (const command of ["fj", "tea"] as const) {
147 const logins = yield* listLogins({
148 cwd: input.cwd,
149 command,
150 remoteUrl: input.context.remoteUrl,
151 }).pipe(Effect.orElseSucceed(() => []));
152 const login = ForgejoCli.matchForgejoLogin(logins, remote, input.context.requestedHost);
153 if (login) return { kind: "forgejo" as const, name: discovery.label, baseUrl: login.url };
154 }
155 return null;
156 },
157 ),
158 } satisfies SourceControlManagedCliDiscoverySpec;
159});
160
161const RepositorySchema = Schema.Struct({
162 full_name: Schema.String,
163 clone_url: Schema.String,
164 ssh_url: Schema.String,
165 default_branch: Schema.optional(Schema.NullOr(Schema.String)),
166});
167const cloneUrls = (raw: typeof RepositorySchema.Type) => ({
168 nameWithOwner: raw.full_name,
169 url: raw.clone_url,
170 sshUrl: raw.ssh_url,
171});
172const repositoryPath = (repository: string) =>
173 `repos/${repository.split("/").map(encodeURIComponent).join("/")}`;
174
175export const make = Effect.gen(function* () {
176 const cli = yield* ForgejoCli.ForgejoCli;
177 const fs = yield* FileSystem.FileSystem;
178 const { process } = yield* SourceControlHost.SourceControlHost;
179 const request = <S extends Schema.Codec<unknown, unknown, never, never>>(
180 input: ForgejoCli.ForgejoApiInput,
181 schema: S,
182 ) =>
183 cli.api(input).pipe(
184 Effect.flatMap((result) =>
185 Schema.decodeEffect(Schema.fromJsonString(schema))(result.stdout).pipe(
186 Effect.mapError(
187 (cause) =>
188 new ForgejoCli.ForgejoCliError({
189 command: "tea",
190 cwd: input.cwd,
191 detail: "Forgejo API returned an invalid response.",
192 reason: "invalid-response",
193 cause,
194 }),
195 ),
196 ),
197 ),
198 );
199 const mapError = (operation: string, cwd: string) =>
200 Effect.mapError(
201 (cause: unknown) =>
202 new SourceControlProviderError({
203 provider: "forgejo",
204 operation,
205 cwd,
206 ...(isForgejoCliError(cause) ? { command: cause.command } : {}),
207 detail: isForgejoCliError(cause) ? cause.detail : "Forgejo operation failed.",
208 cause,
209 }),
210 );
211 const getPull = Effect.fn("ForgejoSourceControlProvider.getPull")(function* (
212 input: Parameters<
213 SourceControlProvider.SourceControlProvider["Service"]["getChangeRequest"]
214 >[0],
215 ) {
216 const repo = yield* cli.resolveRepository(input);
217 const number = /(?:^#?|\/pulls\/)(\d+)(?:\/[^?#]*)?(?:[?#].*)?$/.exec(input.reference)?.[1];
218 if (!number)
219 return yield* new ForgejoCli.ForgejoCliError({
220 command: "tea",
221 cwd: input.cwd,
222 detail: "Specify a pull request number or Forgejo pull request URL.",
223 });
224 return yield* request(
225 { ...input, path: `${repositoryPath(repo.repository)}/pulls/${number}` },
226 ForgejoPullRequestSchema,
227 );
228 });
229 return SourceControlProvider.SourceControlProvider.of({
230 kind: "forgejo",
231 listChangeRequests: (input) =>
232 Effect.gen(function* () {
233 const repo = yield* cli.resolveRepository(input);
234 const source = SourceControlProvider.sourceControlRefFromInput(input);
235 const branch = SourceControlProvider.sourceBranch(input);
236 const results: ReturnType<typeof toForgejoChangeRequest>[] = [];
237 const limit = input.limit ?? 20;
238 for (let page = 1; results.length < limit; page++) {
239 const items = yield* request(
240 {
241 ...input,
242 path: `${repositoryPath(repo.repository)}/pulls?state=${input.state === "merged" ? "closed" : input.state}&sort=recentupdate&limit=50&page=${page}`,
243 },
244 Schema.Array(ForgejoPullRequestSchema),
245 );
246 for (const item of items) {
247 if (
248 item.head.ref !== branch ||
249 (source?.repository && item.head.repo?.full_name !== source.repository) ||
250 (source?.owner && item.head.repo?.owner.login !== source.owner)
251 )
252 continue;
253 const normalized = toForgejoChangeRequest(item);
254 if (input.state === "all" || normalized.state === input.state) results.push(normalized);
255 }
256 if (items.length === 0) break;
257 }
258 return results.slice(0, limit);
259 }).pipe(mapError("listChangeRequests", input.cwd)),
260 getChangeRequest: (input) =>
261 getPull(input).pipe(
262 Effect.map(toForgejoChangeRequest),
263 mapError("getChangeRequest", input.cwd),
264 ),
265 createChangeRequest: (input) =>
266 Effect.gen(function* () {
267 const repo = yield* cli.resolveRepository(input);
268 const source = SourceControlProvider.sourceControlRefFromInput(input);
269 const owner = source?.owner ?? source?.repository?.split("/")[0];
270 const head = SourceControlProvider.sourceBranch(input);
271 yield* cli.api({
272 ...input,
273 path: `${repositoryPath(input.target?.repository ?? repo.repository)}/pulls`,
274 method: "POST",
275 body: {
276 base: input.target?.refName ?? input.baseRefName,
277 head: owner ? `${owner}:${head}` : head,
278 title: input.title,
279 body: yield* fs.readFileString(input.bodyFile),
280 },
281 });
282 }).pipe(mapError("createChangeRequest", input.cwd)),
283 getRepositoryCloneUrls: (input) =>
284 Effect.gen(function* () {
285 const repo = yield* cli.resolveRepository(input);
286 return cloneUrls(
287 yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema),
288 );
289 }).pipe(mapError("getRepositoryCloneUrls", input.cwd)),
290 createRepository: (input) =>
291 Effect.gen(function* () {
292 const repo = yield* cli.resolveRepository(input);
293 const user = yield* request(
294 { ...input, path: "user" },
295 Schema.Struct({ login: Schema.String }),
296 );
297 const [owner, name] = repo.repository.split("/");
298 return cloneUrls(
299 yield* request(
300 {
301 ...input,
302 path:
303 owner === user.login
304 ? "user/repos"
305 : `orgs/${encodeURIComponent(owner ?? "")}/repos`,
306 method: "POST",
307 body: { name, private: input.visibility === "private", auto_init: false },
308 },
309 RepositorySchema,
310 ),
311 );
312 }).pipe(mapError("createRepository", input.cwd)),
313 getDefaultBranch: (input) =>
314 Effect.gen(function* () {
315 const repo = yield* cli.resolveRepository(input);
316 return (
317 (yield* request({ ...input, path: repositoryPath(repo.repository) }, RepositorySchema))
318 .default_branch ?? null
319 );
320 }).pipe(mapError("getDefaultBranch", input.cwd)),
321 checkoutChangeRequest: (input) =>
322 Effect.gen(function* () {
323 const repo = yield* cli.resolveRepository(input);
324 const pull = yield* getPull(input);
325 if (repo.command === "fj") {
326 // fj checkout cannot target a repository outside the local remotes.
327 const urls = yield* request(
328 { ...input, path: repositoryPath(repo.repository) },
329 RepositorySchema,
330 );
331 const remote = input.context?.remoteUrl;
332 const useSsh = remote && ForgejoCli.parseForgejoRemote(remote)?.ssh;
333 yield* process.run({
334 operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
335 command: "git",
336 cwd: input.cwd,
337 args: [
338 "fetch",
339 "--",
340 useSsh ? urls.ssh_url : urls.clone_url,
341 `refs/pull/${pull.number}/head`,
342 ],
343 });
344 const branch = `pulls/${pull.number}`;
345 const existing = yield* process.run({
346 operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
347 command: "git",
348 cwd: input.cwd,
349 args: ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
350 allowNonZeroExit: true,
351 });
352 yield* process.run({
353 operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
354 command: "git",
355 cwd: input.cwd,
356 args:
357 existing.exitCode === 0
358 ? ["checkout", branch]
359 : ["checkout", "-b", branch, "FETCH_HEAD"],
360 });
361 } else
362 yield* cli.execute({
363 cwd: input.cwd,
364 args: [
365 "pulls",
366 "checkout",
367 "--login",
368 repo.login,
369 "--repo",
370 repo.repository,
371 "--branch",
372 String(pull.number),
373 ],
374 });
375 if (input.force) {
376 // tea leaves an existing PR branch at its old tip. Keep dirty files safe
377 // while bringing the selected branch to the PR revision we fetched.
378 yield* process.run({
379 operation: "ForgejoSourceControlProvider.checkoutChangeRequest",
380 command: "git",
381 cwd: input.cwd,
382 args: ["reset", "--keep", pull.head.sha],
383 });
384 }
385 }).pipe(mapError("checkoutChangeRequest", input.cwd)),
386 });
387});
packages/source-control-forgejo/src/server/driver.ts created+20
......@@ -0,0 +1,20 @@
1/**
2 * The Forgejo / Gitea driver: repository operations, discovery, and pull requests through
3 * `fj` or `tea`.
4 *
5 * @module source-control-forgejo/server/driver
6 */
7import { defineSourceControlDriver } from "@t3tools/source-control-core/server/driver";
8import * as Effect from "effect/Effect";
9
10import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
11import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
12
13export const driver = defineSourceControlDriver({
14 kind: "forgejo",
15 make: Effect.all({
16 sourceControl: ForgejoSourceControlProvider.make,
17 discovery: ForgejoSourceControlProvider.makeDiscovery,
18 pullRequests: ForgejoPullRequestProvider.make,
19 }),
20});
packages/source-control-forgejo/src/server/forgejoPullRequestJson.ts created+262
......@@ -0,0 +1,262 @@
1import * as Schema from "effect/Schema";
2import * as DateTime from "effect/DateTime";
3import * as Option from "effect/Option";
4import type {
5 PullRequestActor,
6 PullRequestCheck,
7 PullRequestComment,
8 PullRequestCommit,
9 PullRequestReaction,
10 PullRequestReactionContent,
11 PullRequestReviewThread,
12} from "@t3tools/contracts";
13import type { ProviderChangeRequest } from "@t3tools/source-control-core/server/PullRequestProvider";
14import { dedupeChecks } from "@t3tools/source-control-core/server/pullRequestChecks";
15
16export const ForgejoUser = Schema.Struct({
17 login: Schema.String,
18 full_name: Schema.optional(Schema.NullOr(Schema.String)),
19 avatar_url: Schema.optional(Schema.NullOr(Schema.String)),
20});
21export const ForgejoLabel = Schema.Struct({
22 id: Schema.Int,
23 name: Schema.String,
24 color: Schema.optional(Schema.NullOr(Schema.String)),
25 description: Schema.optional(Schema.NullOr(Schema.String)),
26});
27export const ForgejoRepository = Schema.Struct({
28 full_name: Schema.String,
29 permissions: Schema.optional(Schema.Struct({ push: Schema.Boolean, admin: Schema.Boolean })),
30 archived: Schema.optional(Schema.Boolean),
31 allow_merge_commits: Schema.optional(Schema.Boolean),
32 allow_squash_merge: Schema.optional(Schema.Boolean),
33 allow_rebase: Schema.optional(Schema.Boolean),
34 allow_rebase_update: Schema.optional(Schema.Boolean),
35});
36const Branch = Schema.Struct({
37 ref: Schema.String,
38 sha: Schema.String,
39 repo: Schema.NullOr(ForgejoRepository),
40});
41export const ForgejoPullRequest = Schema.Struct({
42 number: Schema.Int,
43 title: Schema.String,
44 body: Schema.NullOr(Schema.String),
45 html_url: Schema.String,
46 user: Schema.NullOr(ForgejoUser),
47 state: Schema.String,
48 draft: Schema.optional(Schema.Boolean),
49 merged: Schema.Boolean,
50 mergeable: Schema.optional(Schema.Boolean),
51 is_locked: Schema.optional(Schema.Boolean),
52 head: Branch,
53 base: Branch,
54 merge_base: Schema.optional(Schema.String),
55 created_at: Schema.String,
56 updated_at: Schema.String,
57 closed_at: Schema.NullOr(Schema.String),
58 merged_at: Schema.NullOr(Schema.String),
59 additions: Schema.optional(Schema.NullOr(Schema.Int)),
60 deletions: Schema.optional(Schema.NullOr(Schema.Int)),
61 changed_files: Schema.optional(Schema.NullOr(Schema.Int)),
62 comments: Schema.optional(Schema.Int),
63 labels: Schema.NullOr(Schema.Array(ForgejoLabel)),
64 requested_reviewers: Schema.optional(Schema.NullOr(Schema.Array(ForgejoUser))),
65});
66export const ForgejoComment = Schema.Struct({
67 id: Schema.Int,
68 body: Schema.String,
69 user: Schema.NullOr(ForgejoUser),
70 created_at: Schema.String,
71 html_url: Schema.optional(Schema.String),
72});
73export const ForgejoReview = Schema.Struct({
74 id: Schema.Int,
75 body: Schema.String,
76 user: Schema.NullOr(ForgejoUser),
77 state: Schema.String,
78 submitted_at: Schema.String,
79 html_url: Schema.optional(Schema.String),
80 comments_count: Schema.Int,
81});
82export const ForgejoReviewComment = Schema.Struct({
83 ...ForgejoComment.fields,
84 path: Schema.String,
85 position: Schema.Int,
86 original_position: Schema.Int,
87 commit_id: Schema.String,
88 original_commit_id: Schema.String,
89 resolver: Schema.NullOr(ForgejoUser),
90});
91export const ForgejoCommit = Schema.Struct({
92 sha: Schema.String,
93 author: Schema.NullOr(ForgejoUser),
94 commit: Schema.Struct({
95 message: Schema.String,
96 committer: Schema.Struct({ date: Schema.String }),
97 }),
98 parents: Schema.Array(Schema.Struct({ sha: Schema.String })),
99 stats: Schema.optional(
100 Schema.NullOr(Schema.Struct({ additions: Schema.Int, deletions: Schema.Int })),
101 ),
102});
103export const ForgejoStatus = Schema.Struct({
104 context: Schema.String,
105 status: Schema.String,
106 description: Schema.NullOr(Schema.String),
107 target_url: Schema.NullOr(Schema.String),
108 updated_at: Schema.String,
109});
110export const ForgejoReaction = Schema.Struct({
111 content: Schema.String,
112 user: Schema.NullOr(ForgejoUser),
113});
114
115export function forgejoActor(
116 user: typeof ForgejoUser.Type | null | undefined,
117): PullRequestActor | null {
118 return user?.login
119 ? { login: user.login, name: user.full_name || null, avatarUrl: user.avatar_url || null }
120 : null;
121}
122
123function toIsoUtc(value: string): string {
124 return Option.match(DateTime.make(value), { onNone: () => value, onSome: DateTime.formatIso });
125}
126
127export function forgejoChangeRequest(pr: typeof ForgejoPullRequest.Type) {
128 return {
129 number: pr.number,
130 title: pr.title,
131 url: pr.html_url,
132 author: forgejoActor(pr.user),
133 headBranch: pr.head.ref,
134 baseBranch: pr.base.ref,
135 headRepositoryNameWithOwner: pr.head.repo?.full_name ?? null,
136 state: pr.merged ? "merged" : pr.state === "closed" ? "closed" : "open",
137 isDraft: pr.draft ?? /^(?:\[WIP\]|WIP:)/i.test(pr.title),
138 // A false value also covers drafts and unfinished or failed conflict checks.
139 mergeability: pr.mergeable === true ? "mergeable" : "unknown",
140 additions: pr.additions ?? 0,
141 deletions: pr.deletions ?? 0,
142 createdAt: toIsoUtc(pr.created_at),
143 updatedAt: toIsoUtc(pr.updated_at),
144 closedAt: pr.closed_at === null ? null : toIsoUtc(pr.closed_at),
145 mergedAt: pr.merged_at === null ? null : toIsoUtc(pr.merged_at),
146 reviewRequestLogins: (pr.requested_reviewers ?? []).map((user) => user.login),
147 labels: (pr.labels ?? []).map((label) => ({ name: label.name, color: label.color ?? null })),
148 } satisfies ProviderChangeRequest;
149}
150
151export function forgejoComment(comment: typeof ForgejoComment.Type): PullRequestComment {
152 return {
153 id: String(comment.id),
154 kind: "issue-comment",
155 author: forgejoActor(comment.user),
156 body: comment.body,
157 createdAt: toIsoUtc(comment.created_at),
158 url: comment.html_url || null,
159 path: null,
160 reviewState: null,
161 };
162}
163
164export function forgejoReview(review: typeof ForgejoReview.Type): PullRequestComment {
165 return {
166 id: `review:${review.id}`,
167 kind: "review",
168 author: forgejoActor(review.user),
169 body: review.body,
170 createdAt: toIsoUtc(review.submitted_at),
171 url: review.html_url || null,
172 path: null,
173 reviewState:
174 review.state === "REQUEST_CHANGES"
175 ? "CHANGES_REQUESTED"
176 : review.state === "COMMENT"
177 ? "COMMENTED"
178 : review.state,
179 };
180}
181
182export function forgejoReviewThread(
183 comment: typeof ForgejoReviewComment.Type,
184): PullRequestReviewThread {
185 const oldSide = comment.position === 0 && comment.original_position > 0;
186 const line = oldSide ? comment.original_position : comment.position;
187 return {
188 id: String(comment.id),
189 path: comment.path,
190 line: line > 0 ? line : null,
191 side: oldSide ? "left" : "right",
192 isResolved: comment.resolver !== null,
193 isOutdated: false,
194 comments: [forgejoComment(comment)],
195 };
196}
197
198export function forgejoCommit(commit: typeof ForgejoCommit.Type): PullRequestCommit {
199 const author = forgejoActor(commit.author);
200 return {
201 oid: commit.sha,
202 messageHeadline: commit.commit.message.split("\n")[0] ?? "",
203 committedDate: toIsoUtc(commit.commit.committer.date),
204 authors: author ? [author] : [],
205 ...(commit.stats
206 ? { additions: commit.stats.additions, deletions: commit.stats.deletions }
207 : {}),
208 };
209}
210
211export function forgejoChecks(
212 statuses: ReadonlyArray<typeof ForgejoStatus.Type>,
213): ReadonlyArray<PullRequestCheck> {
214 return dedupeChecks(
215 statuses.map((status) => ({
216 workflowName: null,
217 at: status.updated_at,
218 check: {
219 name: status.context || "check",
220 description: status.description || null,
221 url: status.target_url || null,
222 status:
223 status.status === "success"
224 ? "success"
225 : status.status === "failure" || status.status === "error"
226 ? "failure"
227 : "pending",
228 },
229 })),
230 );
231}
232
233export const FORGEJO_REACTIONS: Record<PullRequestReactionContent, string> = {
234 "thumbs-up": "+1",
235 "thumbs-down": "-1",
236 laugh: "laugh",
237 hooray: "hooray",
238 confused: "confused",
239 heart: "heart",
240 rocket: "rocket",
241 eyes: "eyes",
242};
243export function forgejoReactions(
244 reactions: ReadonlyArray<typeof ForgejoReaction.Type>,
245 viewer: string,
246): ReadonlyArray<PullRequestReaction> {
247 return Object.entries(FORGEJO_REACTIONS).flatMap(([content, emoji]) => {
248 const matching = reactions.filter((reaction) => reaction.content === emoji);
249 return matching.length === 0
250 ? []
251 : [
252 {
253 content: content as PullRequestReactionContent,
254 count: matching.length,
255 actors: matching.flatMap((reaction) =>
256 reaction.user?.login && reaction.user.login !== viewer ? [reaction.user.login] : [],
257 ),
258 viewerHasReacted: matching.some((reaction) => reaction.user?.login === viewer),
259 },
260 ];
261 });
262}
packages/source-control-forgejo/src/server/forgejoPullRequests.ts created+47
......@@ -0,0 +1,47 @@
1import * as Schema from "effect/Schema";
2import * as Option from "effect/Option";
3import type { ChangeRequest } from "@t3tools/contracts";
4
5const Repository = Schema.Struct({
6 full_name: Schema.String,
7 owner: Schema.Struct({ login: Schema.String }),
8});
9const Branch = Schema.Struct({
10 ref: Schema.String,
11 sha: Schema.String,
12 repo: Schema.NullOr(Repository),
13});
14export const ForgejoPullRequestSchema = Schema.Struct({
15 number: Schema.Int,
16 title: Schema.String,
17 html_url: Schema.String,
18 state: Schema.String,
19 merged: Schema.Boolean,
20 draft: Schema.optional(Schema.Boolean),
21 base: Branch,
22 head: Branch,
23 closed_at: Schema.optional(Schema.NullOr(Schema.String)),
24 merged_at: Schema.optional(Schema.NullOr(Schema.String)),
25 updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)),
26});
27export function toForgejoChangeRequest(raw: typeof ForgejoPullRequestSchema.Type): ChangeRequest {
28 return {
29 provider: "forgejo",
30 number: raw.number,
31 title: raw.title,
32 url: raw.html_url,
33 state: raw.merged ? "merged" : raw.state === "closed" ? "closed" : "open",
34 isDraft: raw.draft ?? /^(?:\[WIP\]|WIP:)/i.test(raw.title),
35 baseRefName: raw.base.ref,
36 headRefName: raw.head.ref,
37 closedAt: raw.closed_at ?? null,
38 mergedAt: raw.merged_at ?? null,
39 updatedAt: raw.updated_at ?? Option.none(),
40 isCrossRepository:
41 raw.head.repo !== null &&
42 raw.base.repo !== null &&
43 raw.head.repo.full_name !== raw.base.repo.full_name,
44 headRepositoryNameWithOwner: raw.head.repo?.full_name ?? null,
45 headRepositoryOwnerLogin: raw.head.repo?.owner.login ?? null,
46 };
47}
packages/source-control-forgejo/tsconfig.json created+7
......@@ -0,0 +1,7 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"]
5 },
6 "include": ["src"]
7}
packages/source-control-testing/package.json created+22
......@@ -0,0 +1,22 @@
1{
2 "name": "@t3tools/source-control-testing",
3 "private": true,
4 "type": "module",
5 "exports": {
6 "./TestSourceControlHost": {
7 "types": "./src/TestSourceControlHost.ts",
8 "import": "./src/TestSourceControlHost.ts"
9 }
10 },
11 "scripts": {
12 "typecheck": "tsc --noEmit"
13 },
14 "dependencies": {
15 "@t3tools/contracts": "workspace:*",
16 "@t3tools/source-control-core": "workspace:*",
17 "effect": "catalog:"
18 },
19 "devDependencies": {
20 "@types/node": "catalog:"
21 }
22}
packages/source-control-testing/src/TestSourceControlHost.ts created+47
......@@ -0,0 +1,47 @@
1/**
2 * A `SourceControlHost.SourceControlHost` for provider tests. Settings are fixed unless the test
3 * supplies its own, and every process run goes to the test's `run`, which fails by default so an
4 * unexpected CLI call is visible.
5 *
6 * @module source-control-testing/TestSourceControlHost
7 */
8import { DEFAULT_SERVER_SETTINGS, type ServerSettings } from "@t3tools/contracts";
9import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
10import * as Effect from "effect/Effect";
11import * as Layer from "effect/Layer";
12import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
13
14export interface TestSourceControlHostOptions {
15 readonly settings?: ServerSettings;
16 readonly process?: Partial<SourceControlHost.SourceControlHost["Service"]["process"]>;
17}
18
19export const layer = (
20 options: TestSourceControlHostOptions = {},
21): Layer.Layer<SourceControlHost.SourceControlHost> =>
22 Layer.succeed(
23 SourceControlHost.SourceControlHost,
24 SourceControlHost.SourceControlHost.of({
25 settings: { get: Effect.succeed(options.settings ?? DEFAULT_SERVER_SETTINGS) },
26 process: {
27 run:
28 options.process?.run ??
29 ((input) => Effect.die(`Unexpected ${input.command} run in ${input.operation}.`)),
30 },
31 }),
32 );
33
34/** A successful run's output, for tests that script CLI responses. */
35export const processOutput = (
36 stdout: string,
37 options?: {
38 readonly stderr?: string;
39 readonly exitCode?: ChildProcessSpawner.ExitCode;
40 },
41): SourceControlHost.SourceControlProcessOutput => ({
42 exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0),
43 stdout,
44 stderr: options?.stderr ?? "",
45 stdoutTruncated: false,
46 stderrTruncated: false,
47});
packages/source-control-testing/tsconfig.json created+7
......@@ -0,0 +1,7 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"]
5 },
6 "include": ["src"]
7}
pnpm-lock.yaml+53
......@@ -651,6 +651,9 @@ importers:
651651 '@t3tools/source-control-core':
652652 specifier: workspace:*
653653 version: link:../../packages/source-control-core
654 '@t3tools/source-control-forgejo':
655 specifier: workspace:*
656 version: link:../../packages/source-control-forgejo
654657 diff:
655658 specifier: 8.0.3
656659 version: 8.0.3
......@@ -1518,6 +1521,9 @@ importers:
15181521 '@t3tools/contracts':
15191522 specifier: workspace:*
15201523 version: link:../contracts
1524 '@t3tools/shared':
1525 specifier: workspace:*
1526 version: link:../shared
15211527 effect:
15221528 specifier: 4.0.2
15231529 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
......@@ -1532,6 +1538,53 @@ importers:
15321538 specifier: 1.0.0
15331539 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)
15341540
1541 packages/source-control-forgejo:
1542 dependencies:
1543 '@t3tools/contracts':
1544 specifier: workspace:*
1545 version: link:../contracts
1546 '@t3tools/provider-core':
1547 specifier: workspace:*
1548 version: link:../provider-core
1549 '@t3tools/shared':
1550 specifier: workspace:*
1551 version: link:../shared
1552 '@t3tools/source-control-core':
1553 specifier: workspace:*
1554 version: link:../source-control-core
1555 effect:
1556 specifier: 4.0.2
1557 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
1558 devDependencies:
1559 '@effect/vitest':
1560 specifier: 4.0.2
1561 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)
1562 '@t3tools/source-control-testing':
1563 specifier: workspace:*
1564 version: link:../source-control-testing
1565 '@types/node':
1566 specifier: 24.12.4
1567 version: 24.12.4
1568 vite-plus:
1569 specifier: 1.0.0
1570 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)
1571
1572 packages/source-control-testing:
1573 dependencies:
1574 '@t3tools/contracts':
1575 specifier: workspace:*
1576 version: link:../contracts
1577 '@t3tools/source-control-core':
1578 specifier: workspace:*
1579 version: link:../source-control-core
1580 effect:
1581 specifier: 4.0.2
1582 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
1583 devDependencies:
1584 '@types/node':
1585 specifier: 24.12.4
1586 version: 24.12.4
1587
15351588 packages/ssh:
15361589 dependencies:
15371590 '@t3tools/contracts':
scripts/release-smoke.ts+2
......@@ -38,6 +38,8 @@ const workspaceFiles = [
3838 "packages/provider-muse/package.json",
3939 "packages/provider-opencode/package.json",
4040 "packages/source-control-core/package.json",
41 "packages/source-control-forgejo/package.json",
42 "packages/source-control-testing/package.json",
4143 "packages/effect-acp/package.json",
4244 "packages/effect-codex-app-server/package.json",
4345 "scripts/package.json",