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 @@...@@ -46,6 +46,7 @@
46 "@t3tools/provider-opencode": "workspace:*",46 "@t3tools/provider-opencode": "workspace:*",
47 "@t3tools/provider-pi": "workspace:*",47 "@t3tools/provider-pi": "workspace:*",
48 "@t3tools/source-control-core": "workspace:*",48 "@t3tools/source-control-core": "workspace:*",
49 "@t3tools/source-control-forgejo": "workspace:*",
49 "diff": "8.0.3",50 "diff": "8.0.3",
50 "effect": "catalog:",51 "effect": "catalog:",
51 "jose": "catalog:",52 "jose": "catalog:",
apps/server/scripts/evaluate-thread-titles.ts+2-4
...@@ -23,10 +23,9 @@ import {...@@ -23,10 +23,9 @@ import {
23} from "@t3tools/provider-core/server/threadTitleContext";23} from "@t3tools/provider-core/server/threadTitleContext";
24import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";24import * as ThreadTitleLinks from "../src/textGeneration/ThreadTitleLinks.ts";
25import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";25import * as SourceControlProviderRegistry from "../src/sourceControl/SourceControlProviderRegistry.ts";
26import * as ServerSourceControlHost from "../src/sourceControl/ServerSourceControlHost.ts";
27import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";26import * as GitHubApi from "../src/sourceControl/GitHubApi.ts";
28import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";27import * as GitLabCli from "../src/sourceControl/GitLabCli.ts";
29import * as ForgejoCli from "../src/sourceControl/ForgejoCli.ts";28import * as SourceControlBuiltInDrivers from "../src/sourceControl/builtInDrivers.ts";
30import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";29import * as AzureDevOpsCli from "../src/sourceControl/AzureDevOpsCli.ts";
31import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";30import * as BitbucketApi from "../src/sourceControl/BitbucketApi.ts";
32import * as ServerSettings from "../src/serverSettings.ts";31import * as ServerSettings from "../src/serverSettings.ts";
...@@ -155,11 +154,10 @@ await Effect.runPromise(...@@ -155,11 +154,10 @@ await Effect.runPromise(
155 Layer.mergeAll(154 Layer.mergeAll(
156 GitHubApi.layerWithDependencies,155 GitHubApi.layerWithDependencies,
157 GitLabCli.layer,156 GitLabCli.layer,
158 ForgejoCli.layer,157 SourceControlBuiltInDrivers.layer,
159 AzureDevOpsCli.layer,158 AzureDevOpsCli.layer,
160 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.159 // No saved credentials here; Bitbucket falls back to T3CODE_BITBUCKET_* variables.
161 BitbucketApi.layer,160 BitbucketApi.layer,
162 ServerSourceControlHost.layer,
163 ),161 ),
164 ),162 ),
165 // Default settings: no saved Bitbucket token, gh's own GitHub account choice.163 // 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...@@ -57,7 +57,7 @@ import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceContr
57import {57import {
58 ForgejoPullRequestSchema,58 ForgejoPullRequestSchema,
59 toForgejoChangeRequest,59 toForgejoChangeRequest,
60} from "../sourceControl/forgejoPullRequests.ts";60} from "@t3tools/source-control-forgejo/server/forgejoPullRequests";
61import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider";61import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider";
62import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";62import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
63import * as ServerConfig from "../config.ts";63import * as ServerConfig from "../config.ts";
apps/server/src/pullRequest/BitbucketPullRequestApi.ts+1-1
...@@ -22,7 +22,7 @@ import type {...@@ -22,7 +22,7 @@ import type {
22} from "@t3tools/contracts";22} from "@t3tools/contracts";
2323
24import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";24import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
25import { parseDiffFileRevisions } from "./bitbucketDiffRevisions.ts";25import { parseDiffFileRevisions } from "@t3tools/source-control-core/server/diffFileRevisions";
26import {26import {
27 buildReviewThreads,27 buildReviewThreads,
28 decodeCommentsJson,28 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";...@@ -7,8 +7,6 @@ import * as AzureDevOpsCli from "../sourceControl/AzureDevOpsCli.ts";
7import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";7import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
8import * as GitHubApi from "../sourceControl/GitHubApi.ts";8import * as GitHubApi from "../sourceControl/GitHubApi.ts";
9import * as GitLabCli from "../sourceControl/GitLabCli.ts";9import * as GitLabCli from "../sourceControl/GitLabCli.ts";
10import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
11import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
12import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";10import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
13import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";11import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";
14import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";12import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
...@@ -17,6 +15,7 @@ import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";...@@ -17,6 +15,7 @@ import * as GitHubPullRequestApi from "./GitHubPullRequestApi.ts";
17import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";15import * as GitHubPullRequestProvider from "./GitHubPullRequestProvider.ts";
18import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";16import * as GitLabPullRequestCli from "./GitLabPullRequestCli.ts";
19import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";17import * as GitLabPullRequestProvider from "./GitLabPullRequestProvider.ts";
18import * as BuiltInDrivers from "../sourceControl/builtInDrivers.ts";
20import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider";19import type { PullRequestProviderApi } from "@t3tools/source-control-core/server/PullRequestProvider";
2120
22export class PullRequestProviderRegistry extends Context.Service<21export class PullRequestProviderRegistry extends Context.Service<
...@@ -45,16 +44,21 @@ export function fromProviders(...@@ -45,16 +44,21 @@ export function fromProviders(
45 *44 *
46 * @public Service construction is part of the canonical Effect module API.45 * @public Service construction is part of the canonical Effect module API.
47 */46 */
48export const make = Effect.map(47export const make = Effect.gen(function* () {
49 Effect.all([48 const providers = yield* Effect.all([
50 GitHubPullRequestProvider.make,49 GitHubPullRequestProvider.make,
51 GitLabPullRequestProvider.make,50 GitLabPullRequestProvider.make,
52 ForgejoPullRequestProvider.make,
53 BitbucketPullRequestProvider.make,51 BitbucketPullRequestProvider.make,
54 AzureDevOpsPullRequestProvider.make,52 AzureDevOpsPullRequestProvider.make,
55 ]),53 ]);
56 fromProviders,54 const drivers = yield* Effect.forEach(BuiltInDrivers.BUILT_IN_SOURCE_CONTROL_DRIVERS, (driver) =>
57);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
59export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(63export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
60 Layer.provide(64 Layer.provide(
...@@ -66,7 +70,7 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(...@@ -66,7 +70,7 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
66 ),70 ),
67 ),71 ),
68 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),72 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),
69 Layer.provide(ForgejoCli.layer),73 Layer.provide(BuiltInDrivers.layer),
70 Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),74 Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),
71 Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))),75 Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))),
72);76);
apps/server/src/pullRequest/PullRequestService.test.ts+2-2
...@@ -30,8 +30,8 @@ import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.t...@@ -30,8 +30,8 @@ import * as PullRequestFilesViewed from "../persistence/PullRequestFilesViewed.t
30import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";30import * as RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts";
31import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";31import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
32import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";32import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
33import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";33import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
34import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";34import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
35import {35import {
36 PullRequestProviderError,36 PullRequestProviderError,
37 type ProviderChangeRequest,37 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";...@@ -56,7 +56,8 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
56import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";56import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
57import * as GitHubApi from "./sourceControl/GitHubApi.ts";57import * as GitHubApi from "./sourceControl/GitHubApi.ts";
58import * as GitLabCli from "./sourceControl/GitLabCli.ts";58import * 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";
60import * as TextGeneration from "./textGeneration/TextGeneration.ts";61import * as TextGeneration from "./textGeneration/TextGeneration.ts";
61import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts";62import * as ProviderInstanceRegistryHydration from "./provider/ProviderInstanceRegistryHydration.ts";
62import * as TerminalManager from "./terminal/Manager.ts";63import * as TerminalManager from "./terminal/Manager.ts";
...@@ -104,7 +105,6 @@ import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";...@@ -104,7 +105,6 @@ import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
104import * as GitWorkflowService from "./git/GitWorkflowService.ts";105import * as GitWorkflowService from "./git/GitWorkflowService.ts";
105import * as ReviewService from "./review/ReviewService.ts";106import * as ReviewService from "./review/ReviewService.ts";
106import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";107import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
107import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts";
108import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts";108import * as PullRequestReadCache from "./pullRequest/PullRequestReadCache.ts";
109import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";109import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
110import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";110import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts";
...@@ -305,8 +305,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p...@@ -305,8 +305,7 @@ const layerSourceControlProviderRegistry = SourceControlProviderRegistry.layer.p
305 BitbucketApi.layer,305 BitbucketApi.layer,
306 GitHubApi.layerWithDependencies,306 GitHubApi.layerWithDependencies,
307 GitLabCli.layer,307 GitLabCli.layer,
308 ForgejoCli.layer,308 SourceControlBuiltInDrivers.layer,
309 ServerSourceControlHost.layer,
310 ),309 ),
311 ),310 ),
312 Layer.provideMerge(GitVcsDriver.layer),311 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";...@@ -20,13 +20,13 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
20import * as BitbucketApi from "./BitbucketApi.ts";20import * as BitbucketApi from "./BitbucketApi.ts";
21import * as GitHubApi from "./GitHubApi.ts";21import * as GitHubApi from "./GitHubApi.ts";
22import * as GitLabCli from "./GitLabCli.ts";22import * as GitLabCli from "./GitLabCli.ts";
23import * as ForgejoCli from "./ForgejoCli.ts";23import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
24import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";24import * as ForgejoSourceControlProvider from "@t3tools/source-control-forgejo/server/ForgejoSourceControlProvider";
25import * as ForgejoPullRequestProvider from "../pullRequest/ForgejoPullRequestProvider.ts";25import * as ForgejoPullRequestProvider from "@t3tools/source-control-forgejo/server/ForgejoPullRequestProvider";
26import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";26import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
27import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";27import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
28import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";28import * 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
31const layerSourceControlProviderRegistryTest = (input: {31const layerSourceControlProviderRegistryTest = (input: {
32 readonly bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>;32 readonly bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>;
...@@ -76,434 +76,6 @@ const processOutput = (...@@ -76,434 +76,6 @@ const processOutput = (
76const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));76const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
77const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));77const 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
507it.effect("reports implemented tools separately from locally available executables", () => {79it.effect("reports implemented tools separately from locally available executables", () => {
508 const processMock = {80 const processMock = {
509 run: (input: VcsProcess.VcsProcessInput) => {81 run: (input: VcsProcess.VcsProcessInput) => {
...@@ -764,820 +336,6 @@ Logged in to gitlab.com as gitlab-user...@@ -764,820 +336,6 @@ Logged in to gitlab.com as gitlab-user
764 }).pipe(Effect.provide(layerTest));336 }).pipe(Effect.provide(layerTest));
765});337});
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
1581it.effect(339it.effect(
1582 "checks out fj pull refs and preserves existing branches and dirty files until forced",340 "checks out fj pull refs and preserves existing branches and dirty files until forced",
1583 () =>341 () =>
...@@ -1627,22 +385,26 @@ it.effect(...@@ -1627,22 +385,26 @@ it.effect(
1627 args: ["rev-parse", "HEAD"],385 args: ["rev-parse", "HEAD"],
1628 })).stdout.trim();386 })).stdout.trim();
1629 const fetched: string[] = [];387 const fetched: string[] = [];
388 const host = yield* SourceControlHost.SourceControlHost;
1630 const provider = yield* ForgejoSourceControlProvider.make.pipe(389 const provider = yield* ForgejoSourceControlProvider.make.pipe(
1631 Effect.provideService(390 Effect.provideService(
1632 VcsProcess.VcsProcess,391 SourceControlHost.SourceControlHost,
1633 VcsProcess.VcsProcess.of({392 SourceControlHost.SourceControlHost.of({
1634 run: (input) => {393 settings: host.settings,
1635 if (input.args[0] !== "fetch") return git.run(input);394 process: {
1636 const url = input.args[2];395 run: (input) => {
1637 assert.isDefined(url);396 if (input.args[0] !== "fetch") return git.run(input);
1638 fetched.push(url!);397 const url = input.args[2];
1639 // Only SSH transport is substituted; both paths fetch the real pull ref.398 assert.isDefined(url);
1640 return git.run({399 fetched.push(url!);
1641 ...input,400 // Only SSH transport is substituted; both paths fetch the real pull ref.
1642 args: input.args.map((arg) =>401 return git.run({
1643 arg === "git@forgejo.test:reviewer/project.git" ? source : arg,402 ...input,
1644 ),403 args: input.args.map((arg) =>
1645 });404 arg === "git@forgejo.test:reviewer/project.git" ? source : arg,
405 ),
406 });
407 },
1646 },408 },
1647 }),409 }),
1648 ),410 ),
...@@ -1759,6 +521,12 @@ it.effect(...@@ -1759,6 +521,12 @@ it.effect(
1759 assert.deepStrictEqual(fetched, [source, source, "git@forgejo.test:reviewer/project.git"]);521 assert.deepStrictEqual(fetched, [source, source, "git@forgejo.test:reviewer/project.git"]);
1760 }).pipe(522 }).pipe(
1761 Effect.scoped,523 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 ),
1763 ),531 ),
1764);532);
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+1-1
...@@ -19,7 +19,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";...@@ -19,7 +19,7 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
19import * as GitHubApi from "./GitHubApi.ts";19import * as GitHubApi from "./GitHubApi.ts";
20import * as GitHubCredentials from "./GitHubCredentials.ts";20import * as GitHubCredentials from "./GitHubCredentials.ts";
21import * as GitLabCli from "./GitLabCli.ts";21import * as GitLabCli from "./GitLabCli.ts";
22import * as ForgejoCli from "./ForgejoCli.ts";22import * as ForgejoCli from "@t3tools/source-control-forgejo/server/ForgejoCli";
23import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";23import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
24import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";24import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
2525
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+11-4
...@@ -15,7 +15,7 @@ import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlPro...@@ -15,7 +15,7 @@ import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlPro
15import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";15import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
16import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";16import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
17import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";17import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
18import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";18import * as BuiltInDrivers from "./builtInDrivers.ts";
19import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";19import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
20import {20import {
21 probeSourceControlProvider,21 probeSourceControlProvider,
...@@ -308,11 +308,18 @@ export const make = Effect.gen(function* () {...@@ -308,11 +308,18 @@ export const make = Effect.gen(function* () {
308 const github = yield* GitHubSourceControlProvider.make;308 const github = yield* GitHubSourceControlProvider.make;
309 const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery;309 const githubDiscovery = yield* GitHubSourceControlProvider.makeDiscovery;
310 const gitlab = yield* GitLabSourceControlProvider.make;310 const gitlab = yield* GitLabSourceControlProvider.make;
311 const forgejo = yield* ForgejoSourceControlProvider.make;
312 const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery;
313 const bitbucket = yield* BitbucketSourceControlProvider.make;311 const bitbucket = yield* BitbucketSourceControlProvider.make;
314 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;312 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;
315 const azureDevOps = yield* AzureDevOpsSourceControlProvider.make;313 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 );
316 return yield* makeWithProviders([323 return yield* makeWithProviders([
317 {324 {
318 kind: "github",325 kind: "github",
...@@ -334,7 +341,7 @@ export const make = Effect.gen(function* () {...@@ -334,7 +341,7 @@ export const make = Effect.gen(function* () {
334 provider: bitbucket,341 provider: bitbucket,
335 discovery: bitbucketDiscovery,342 discovery: bitbucketDiscovery,
336 },343 },
337 { kind: "forgejo", provider: forgejo, discovery: forgejoDiscovery },344 ...drivers,
338 ]);345 ]);
339});346});
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";...@@ -233,9 +233,8 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
233import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";233import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
234import * as GitHubApi from "./sourceControl/GitHubApi.ts";234import * as GitHubApi from "./sourceControl/GitHubApi.ts";
235import * as GitLabCli from "./sourceControl/GitLabCli.ts";235import * as GitLabCli from "./sourceControl/GitLabCli.ts";
236import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";236import * as SourceControlBuiltInDrivers from "./sourceControl/builtInDrivers.ts";
237import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";237import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
238import * as ServerSourceControlHost from "./sourceControl/ServerSourceControlHost.ts";
239import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";238import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
240import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";239import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
241import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";240import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
...@@ -3209,8 +3208,7 @@ export const layer = Layer.unwrap(...@@ -3209,8 +3208,7 @@ export const layer = Layer.unwrap(
3209 BitbucketApi.layer,3208 BitbucketApi.layer,
3210 GitHubApi.layerWithDependencies,3209 GitHubApi.layerWithDependencies,
3211 GitLabCli.layer,3210 GitLabCli.layer,
3212 ForgejoCli.layer,3211 SourceControlBuiltInDrivers.layer,
3213 ServerSourceControlHost.layer,
3214 ),3212 ),
3215 ),3213 ),
3216 Layer.provideMerge(GitVcsDriver.layer),3214 Layer.provideMerge(GitVcsDriver.layer),
package.json+1-1
...@@ -28,7 +28,7 @@...@@ -28,7 +28,7 @@
28 "tc": "vp run -r --concurrency-limit 2 typecheck",28 "tc": "vp run -r --concurrency-limit 2 typecheck",
29 "lint": "vp lint --report-unused-disable-directives",29 "lint": "vp lint --report-unused-disable-directives",
30 "knip": "knip --preprocessor ./scripts/knip-schemas.ts",30 "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",
32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",32 "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts",
33 "lint:mobile": "node scripts/mobile-native-static-check.ts",33 "lint:mobile": "node scripts/mobile-native-static-check.ts",
34 "test": "vp run -r test",34 "test": "vp run -r test",
packages/source-control-core/package.json+9
...@@ -3,10 +3,18 @@...@@ -3,10 +3,18 @@
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "exports": {5 "exports": {
6 "./server/diffFileRevisions": {
7 "types": "./src/server/diffFileRevisions.ts",
8 "import": "./src/server/diffFileRevisions.ts"
9 },
6 "./server/discovery": {10 "./server/discovery": {
7 "types": "./src/server/discovery.ts",11 "types": "./src/server/discovery.ts",
8 "import": "./src/server/discovery.ts"12 "import": "./src/server/discovery.ts"
9 },13 },
14 "./server/driver": {
15 "types": "./src/server/driver.ts",
16 "import": "./src/server/driver.ts"
17 },
10 "./server/mergeMessage": {18 "./server/mergeMessage": {
11 "types": "./src/server/mergeMessage.ts",19 "types": "./src/server/mergeMessage.ts",
12 "import": "./src/server/mergeMessage.ts"20 "import": "./src/server/mergeMessage.ts"
...@@ -38,6 +46,7 @@...@@ -38,6 +46,7 @@
38 },46 },
39 "dependencies": {47 "dependencies": {
40 "@t3tools/contracts": "workspace:*",48 "@t3tools/contracts": "workspace:*",
49 "@t3tools/shared": "workspace:*",
41 "effect": "catalog:"50 "effect": "catalog:"
42 },51 },
43 "devDependencies": {52 "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:...@@ -651,6 +651,9 @@ importers:
651 '@t3tools/source-control-core':651 '@t3tools/source-control-core':
652 specifier: workspace:*652 specifier: workspace:*
653 version: link:../../packages/source-control-core653 version: link:../../packages/source-control-core
654 '@t3tools/source-control-forgejo':
655 specifier: workspace:*
656 version: link:../../packages/source-control-forgejo
654 diff:657 diff:
655 specifier: 8.0.3658 specifier: 8.0.3
656 version: 8.0.3659 version: 8.0.3
...@@ -1518,6 +1521,9 @@ importers:...@@ -1518,6 +1521,9 @@ importers:
1518 '@t3tools/contracts':1521 '@t3tools/contracts':
1519 specifier: workspace:*1522 specifier: workspace:*
1520 version: link:../contracts1523 version: link:../contracts
1524 '@t3tools/shared':
1525 specifier: workspace:*
1526 version: link:../shared
1521 effect:1527 effect:
1522 specifier: 4.0.21528 specifier: 4.0.2
1523 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)1529 version: 4.0.2(patch_hash=ec61ee307eaec3e17f757e0e6a16296ea4b7a8c3188df0c6ec5e66301fd78688)
...@@ -1532,6 +1538,53 @@ importers:...@@ -1532,6 +1538,53 @@ importers:
1532 specifier: 1.0.01538 specifier: 1.0.0
1533 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)1539 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
1535 packages/ssh:1588 packages/ssh:
1536 dependencies:1589 dependencies:
1537 '@t3tools/contracts':1590 '@t3tools/contracts':
scripts/release-smoke.ts+2
...@@ -38,6 +38,8 @@ const workspaceFiles = [...@@ -38,6 +38,8 @@ const workspaceFiles = [
38 "packages/provider-muse/package.json",38 "packages/provider-muse/package.json",
39 "packages/provider-opencode/package.json",39 "packages/provider-opencode/package.json",
40 "packages/source-control-core/package.json",40 "packages/source-control-core/package.json",
41 "packages/source-control-forgejo/package.json",
42 "packages/source-control-testing/package.json",
41 "packages/effect-acp/package.json",43 "packages/effect-acp/package.json",
42 "packages/effect-codex-app-server/package.json",44 "packages/effect-codex-app-server/package.json",
43 "scripts/package.json",45 "scripts/package.json",