authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-13 00:02:42-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 00:02:42-03:00
log6fd68f5c3e7c81a9650ba36df32f2241912e00ba
tree882f1bc0bffb35875b5fc1a674794f92d6f84400
parentc29976458a7dbbb61c83b3d06ea894869b718607
signature Signed by PGP key B5690EEEBB952194

feat(source-control): support Forgejo and Gitea with fj and tea (#11436)


66 files changed, 4545 insertions(+), 102 deletions(-)

apps/mobile/src/components/SourceControlIcon.tsx+15-2
......@@ -1,9 +1,9 @@
1import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg";
1import Svg, { Circle, Defs, G, LinearGradient, Path, Stop } from "react-native-svg";
22import { withUniwind } from "uniwind";
33
44const ThemedSvg = withUniwind(Svg);
55
6export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops";
6export type SourceControlIconKind = "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops";
77
88export function SourceControlIcon(props: {
99 readonly kind: SourceControlIconKind;
......@@ -14,6 +14,19 @@ export function SourceControlIcon(props: {
1414 const size = props.size ?? 18;
1515
1616 switch (props.kind) {
17 case "forgejo":
18 // Official two-color mark from https://forgejo.org/favicon.svg.
19 return (
20 <Svg width={size} height={size} viewBox="0 0 212 212">
21 <G transform="translate(6 6)" fill="none">
22 <Path d="M58 168 v-98 a50 50 0 0 1 50-50 h20" stroke="#ff6600" strokeWidth={25} />
23 <Path d="M58 168 v-30 a50 50 0 0 1 50-50 h20" stroke="#d40000" strokeWidth={25} />
24 <Circle cx={142} cy={20} r={18} stroke="#ff6600" strokeWidth={15} />
25 <Circle cx={142} cy={88} r={18} stroke="#d40000" strokeWidth={15} />
26 <Circle cx={58} cy={180} r={18} stroke="#d40000" strokeWidth={15} />
27 </G>
28 </Svg>
29 );
1730 case "github":
1831 return (
1932 <ThemedSvg
apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx+1
......@@ -17,6 +17,7 @@ export function AddProjectRepositoryRoute({
1717 const title =
1818 source === "github" ||
1919 source === "gitlab" ||
20 source === "forgejo" ||
2021 source === "bitbucket" ||
2122 source === "azure-devops"
2223 ? addProjectRemoteSourceLabel(source)
apps/mobile/src/features/projects/AddProjectScreen.tsx+1
......@@ -110,6 +110,7 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote
110110 source === "url" ||
111111 source === "github" ||
112112 source === "gitlab" ||
113 source === "forgejo" ||
113114 source === "bitbucket" ||
114115 source === "azure-devops"
115116 ) {
apps/server/src/git/GitManager.test.ts+68
......@@ -37,6 +37,10 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
3737import * as VcsProcess from "../vcs/VcsProcess.ts";
3838import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
3939import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";
40import {
41 ForgejoPullRequestSchema,
42 toForgejoChangeRequest,
43} from "../sourceControl/forgejoPullRequests.ts";
4044import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts";
4145import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
4246import * as ServerConfig from "../config.ts";
......@@ -46,6 +50,7 @@ import * as ServerSettings from "../serverSettings.ts";
4650import * as GitManager from "./GitManager.ts";
4751
4852const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
53const decodeForgejoPullRequest = Schema.decodeEffect(ForgejoPullRequestSchema);
4954
5055interface FakeGhScenario {
5156 prListSequence?: string[];
......@@ -3851,6 +3856,69 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
38513856 20_000,
38523857 );
38533858
3859 it.effect("matches mounted Forgejo heads without confusing forks sharing a branch", () =>
3860 Effect.gen(function* () {
3861 for (const owner of ["maria", "reviewer"]) {
3862 const mapped = toForgejoChangeRequest(
3863 yield* decodeForgejoPullRequest({
3864 number: 42,
3865 title: "Greeting",
3866 html_url: "https://forgejo.example/forgejo/maria/project/pulls/42",
3867 state: "open",
3868 merged: false,
3869 base: {
3870 ref: "main",
3871 sha: "base",
3872 repo: { full_name: "maria/project", owner: { login: "maria" } },
3873 },
3874 head: {
3875 ref: "greeting",
3876 sha: "head",
3877 repo: { full_name: `${owner}/project`, owner: { login: owner } },
3878 },
3879 }),
3880 );
3881 const pr = {
3882 ...mapped,
3883 isDraft: mapped.isDraft ?? false,
3884 closedAt: mapped.closedAt ?? null,
3885 mergedAt: mapped.mergedAt ?? null,
3886 };
3887 const repository = GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
3888 `https://forgejo.example/forgejo/${owner}/project.git`,
3889 "forgejo",
3890 );
3891 expect(repository).toBe(`${owner}/project`);
3892 const context = {
3893 headBranch: "greeting",
3894 headRepositoryNameWithOwner: repository,
3895 headRepositoryOwnerLogin: repository?.split("/")[0] ?? null,
3896 isCrossRepository: owner !== "maria",
3897 };
3898 expect(GitManager.matchesBranchHeadContext(pr, context)).toBe(true);
3899 expect(
3900 GitManager.matchesBranchHeadContext(pr, {
3901 ...context,
3902 headRepositoryNameWithOwner: "other/project",
3903 headRepositoryOwnerLogin: "other",
3904 }),
3905 ).toBe(false);
3906 }
3907 expect(
3908 GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
3909 "git@forgejo.example:maria/project.git",
3910 "forgejo",
3911 ),
3912 ).toBe("maria/project");
3913 expect(
3914 GitManager.parseRepositoryNameWithOwnerFromRemoteUrl(
3915 "https://gitlab.example/group/maria/project.git",
3916 "gitlab",
3917 ),
3918 ).toBe("group/maria/project");
3919 }),
3920 );
3921
38543922 it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () =>
38553923 Effect.sync(() => {
38563924 const headContext = {
apps/server/src/git/GitManager.ts+35-3
......@@ -277,7 +277,10 @@ function resolvePullRequestWorktreeLocalBranchName(
277277 return `t3code/pr-${pullRequest.number}/${suffix}`;
278278}
279279
280function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
280export function parseRepositoryNameWithOwnerFromRemoteUrl(
281 url: string | null,
282 providerKind?: ChangeRequest["provider"],
283): string | null {
281284 const trimmed = url?.trim() ?? "";
282285 if (trimmed.length === 0) {
283286 return null;
......@@ -288,6 +291,12 @@ function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string |
288291 trimmed,
289292 );
290293 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";
294 // Forgejo HTTP paths can include an installation mount; its API always names owner/repo.
295 if (providerKind === "forgejo" && /^https?:\/\//iu.test(trimmed)) {
296 return repositoryNameWithOwner.length > 0
297 ? repositoryNameWithOwner.split("/").slice(-2).join("/")
298 : null;
299 }
291300 return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null;
292301}
293302
......@@ -1291,7 +1300,15 @@ export const make = Effect.gen(function* () {
12911300 (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??
12921301 (yield* readConfigValueNullable(cwd, "remote.origin.url"));
12931302
1294 return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null;
1303 const provider = remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null;
1304 if (!remoteUrl || provider?.kind !== "unknown") return provider;
1305 const handle = yield* sourceControlProviders
1306 .resolveHandle({
1307 cwd,
1308 context: { provider, remoteName: preferredRemoteName, remoteUrl },
1309 })
1310 .pipe(Effect.orElseSucceed(() => null));
1311 return handle?.context?.provider ?? provider;
12951312 });
12961313
12971314 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
......@@ -1307,7 +1324,22 @@ export const make = Effect.gen(function* () {
13071324 }
13081325
13091326 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
1310 const repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
1327 let repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
1328 if (
1329 remoteUrl !== null &&
1330 /^https?:\/\//iu.test(remoteUrl) &&
1331 (repositoryNameWithOwner?.split("/").length ?? 0) > 2
1332 ) {
1333 const detected = detectSourceControlProviderFromGitRemoteUrl(remoteUrl);
1334 const kind =
1335 detected?.kind === "unknown"
1336 ? yield* sourceControlProvider(cwd).pipe(
1337 Effect.map((provider) => provider.kind),
1338 Effect.orElseSucceed(() => undefined),
1339 )
1340 : detected?.kind;
1341 repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl, kind);
1342 }
13111343 return {
13121344 remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null,
13131345 repositoryNameWithOwner,
apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts+46
......@@ -265,6 +265,40 @@ describe("pull request toolkit handlers", () => {
265265 }),
266266 );
267267
268 it.effect("links a numeric Forgejo reference with its remote's web origin and mount path", () =>
269 Effect.gen(function* () {
270 const harness = yield* makeHarness({
271 project: makeProject({
272 canonicalKey: "forge.example/git/owner/repo",
273 locator: {
274 source: "git-remote",
275 remoteName: "origin",
276 remoteUrl: "http://forge.example:3000/git/owner/repo.git",
277 },
278 provider: "forgejo",
279 displayName: "git/owner/repo",
280 }),
281 });
282 const result = yield* harness.call("link_pull_request", {
283 repository: "git/owner/repo",
284 number: 42,
285 });
286 expect(result).toEqual({
287 host: "forge.example:3000",
288 repository: "git/owner/repo",
289 number: 42,
290 url: "http://forge.example:3000/git/owner/repo/pulls/42",
291 alreadyLinked: false,
292 });
293 const other = yield* harness.call("link_pull_request", {
294 host: "other.example",
295 repository: "owner/repo",
296 number: 42,
297 });
298 expect(other.url).toBe("https://other.example/owner/repo/pull/42");
299 }),
300 );
301
268302 it.effect("rejects a target that names neither a URL nor repository and number", () =>
269303 Effect.gen(function* () {
270304 const harness = yield* makeHarness();
......@@ -334,6 +368,18 @@ describe("pull request toolkit handlers", () => {
334368 }),
335369 );
336370
371 it("reports an older Forgejo link's HTTP port when listing thread links", () => {
372 const result = listThreadPullRequests(
373 makeThread([
374 makeLink(42, {
375 host: "forge.example",
376 url: "http://forge.example:3000/t3tools/t3code/pulls/42",
377 }),
378 ]),
379 );
380 expect(result.pullRequests[0]?.host).toBe("forge.example:3000");
381 });
382
337383 it.effect("fails cleanly when the token's thread no longer exists", () =>
338384 Effect.gen(function* () {
339385 const harness = yield* makeHarness({ thread: null });
apps/server/src/mcp/toolkits/pullRequests/handlers.ts+8-3
......@@ -9,6 +9,7 @@ import {
99} from "@t3tools/contracts";
1010import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
1111import {
12 normalizeThreadPullRequestKey,
1213 resolveThreadPullRequestChains,
1314 threadPullRequestKeyOf,
1415 visibleThreadPullRequests,
......@@ -71,7 +72,7 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (
7172 if (parsed === null) {
7273 return yield* new PullRequestUrlInvalidError({});
7374 }
74 return { ...parsed, url: input.url } satisfies ResolvedTarget;
75 return { ...normalizeThreadPullRequestKey(parsed), url: input.url } satisfies ResolvedTarget;
7576 }
7677 if (input.repository === undefined || input.number === undefined) {
7778 return yield* new PullRequestTargetIncompleteError({});
......@@ -89,8 +90,12 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (
8990 host,
9091 repository,
9192 input.number,
93 project?.repositoryIdentity?.locator.remoteUrl,
9294 ) ?? `https://${host}/${repository}/pull/${input.number}`;
93 return { host, repository, number: input.number, url } satisfies ResolvedTarget;
95 return {
96 ...normalizeThreadPullRequestKey({ host, repository, number: input.number, url }),
97 url,
98 } satisfies ResolvedTarget;
9499});
95100
96101function entryOf(
......@@ -108,7 +113,7 @@ function entryOf(
108113 }
109114 }
110115 return {
111 host: link.host,
116 host: normalizeThreadPullRequestKey(link).host,
112117 repository: link.repository,
113118 number: link.number,
114119 url: link.url,
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+33
......@@ -889,6 +889,39 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull
889889 assert.deepEqual(yield* readLinks(), []);
890890 assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:05.000Z" }]);
891891
892 // Older Forgejo rows stored a portless host; unlink by their URL's authority.
893 yield* eventStore.append({
894 ...base("2026-01-01T00:00:05.100Z"),
895 type: "thread.pull-request-linked",
896 payload: {
897 threadId,
898 link: {
899 host: "forge.example",
900 repository: "team/repo",
901 number: 42,
902 url: "http://forge.example:3000/team/repo/pulls/42",
903 source: "agent",
904 linkedAt: "2026-01-01T00:00:05.100Z",
905 snapshot: null,
906 stack: null,
907 },
908 updatedAt: "2026-01-01T00:00:05.100Z",
909 },
910 });
911 yield* eventStore.append({
912 ...base("2026-01-01T00:00:05.200Z"),
913 type: "thread.pull-request-unlinked",
914 payload: {
915 threadId,
916 host: "forge.example:3000",
917 repository: "team/repo",
918 number: 42,
919 updatedAt: "2026-01-01T00:00:05.200Z",
920 },
921 });
922 yield* projectionPipeline.bootstrap;
923 assert.deepEqual(yield* readLinks(), []);
924
892925 // Deleting the thread clears whatever links it still had.
893926 yield* eventStore.append({
894927 ...base("2026-01-01T00:00:06.000Z"),
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+12-4
......@@ -880,12 +880,20 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
880880 if (Option.isNone(existingRow)) {
881881 return;
882882 }
883 yield* projectionThreadPullRequestRepository.delete({
883 const links = yield* projectionThreadPullRequestRepository.listByThreadId({
884884 threadId: event.payload.threadId,
885 host: event.payload.host.toLowerCase(),
886 repository: event.payload.repository.toLowerCase(),
887 number: event.payload.number,
888885 });
886 const link = links.find((candidate) =>
887 threadPullRequestKeysEqual(candidate, event.payload),
888 );
889 if (link !== undefined) {
890 yield* projectionThreadPullRequestRepository.delete({
891 threadId: event.payload.threadId,
892 host: link.host,
893 repository: link.repository,
894 number: link.number,
895 });
896 }
889897 yield* projectionThreadRepository.upsert({
890898 ...existingRow.value,
891899 updatedAt: event.payload.updatedAt,
apps/server/src/orchestration/PullRequestSyncReactor.test.ts+25
......@@ -420,6 +420,31 @@ describe("PullRequestSyncReactor", () => {
420420 ),
421421 );
422422
423 it.effect("recovers the HTTP port when syncing an older Forgejo link", () =>
424 Effect.scoped(
425 Effect.gen(function* () {
426 const fixture = yield* makeHarness({
427 snapshot: makeSnapshot([
428 makeThread("one", {
429 pullRequests: [
430 makeLink(42, null, {
431 host: "forge.example",
432 url: "http://forge.example:3000/owner/repository/pulls/42",
433 }),
434 ],
435 }),
436 ]),
437 summary: (input) => Effect.succeed(makeSummary(input)),
438 });
439 yield* Effect.gen(function* () {
440 yield* startAndSweep(fixture);
441 assert.strictEqual((yield* Ref.get(fixture.summaryCalls))[0]?.host, "forge.example:3000");
442 assert.strictEqual((yield* Ref.get(fixture.syncCommands))[0]?.host, "forge.example:3000");
443 }).pipe(Effect.provide(fixture.layer));
444 }),
445 ),
446 );
447
423448 it.effect("dispatches nothing when the host snapshot is unchanged", () =>
424449 Effect.scoped(
425450 Effect.gen(function* () {
apps/server/src/orchestration/PullRequestSyncReactor.ts+8-3
......@@ -11,6 +11,7 @@ import {
1111import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
1212import {
1313 threadPullRequestKeyOf,
14 normalizeThreadPullRequestKey,
1415 threadPullRequestKeysEqual,
1516 visibleThreadPullRequests,
1617} from "@t3tools/shared/threadPullRequests";
......@@ -191,7 +192,7 @@ export const make = Effect.gen(function* () {
191192 type: "thread.pull-request-link.sync",
192193 commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`),
193194 threadId: thread.id,
194 host: link.host,
195 host: normalizeThreadPullRequestKey(link).host,
195196 repository: link.repository,
196197 number: link.number,
197198 snapshot: { ...fields, syncedAt: nowIso },
......@@ -200,7 +201,11 @@ export const make = Effect.gen(function* () {
200201 }
201202 if (fetchedStack === null || fetchedStack.stack === null) return;
202203 for (const layer of fetchedStack.stack.layers) {
203 const layerKey = { host: link.host, repository: link.repository, number: layer.number };
204 const layerKey = {
205 host: normalizeThreadPullRequestKey(link).host,
206 repository: link.repository,
207 number: layer.number,
208 };
204209 const dedupeKey = `${thread.id}:${threadPullRequestKeyOf(layerKey)}`;
205210 if (linkedThisSweep.has(dedupeKey)) continue;
206211 // Tombstones count as present: a dismissed layer is never re-added.
......@@ -240,7 +245,7 @@ export const make = Effect.gen(function* () {
240245 const first = entries[0]!;
241246 const ref = {
242247 projectId: first.thread.projectId,
243 host: first.link.host,
248 host: normalizeThreadPullRequestKey(first.link).host,
244249 repository: first.link.repository,
245250 number: first.link.number,
246251 };
apps/server/src/orchestration/decider.pullRequests.test.ts+43
......@@ -115,6 +115,49 @@ const snapshot: ThreadPullRequestSnapshot = {
115115};
116116
117117it.layer(NodeServices.layer)("pull request link decider", (it) => {
118 it.effect("links the same Forgejo number on two ports and unlinks an older portless record", () =>
119 Effect.gen(function* () {
120 const existing = makeLink({
121 host: "forge.example",
122 url: "http://forge.example:3000/t3tools/t3code/pulls/42",
123 });
124 let model = makeReadModel([existing]);
125 const command = yield* decodeCommand({
126 type: "thread.pull-request.link",
127 commandId: "link-other-port",
128 threadId: THREAD_ID,
129 host: "forge.example",
130 repository: "t3tools/t3code",
131 number: 42,
132 url: "http://forge.example:4000/t3tools/t3code/pulls/42",
133 source: "manual",
134 });
135 const linked = expectSingleEvent(
136 yield* decideOrchestrationCommand({ readModel: model, command }),
137 "thread.pull-request-linked",
138 );
139 expect(linked.payload.link.host).toBe("forge.example:4000");
140 model = yield* projectEvent(model, { ...linked, sequence: 1 });
141 expect(model.threads[0]!.pullRequests).toHaveLength(2);
142 const unlink = yield* decodeCommand({
143 type: "thread.pull-request.unlink",
144 commandId: "unlink-old-port",
145 threadId: THREAD_ID,
146 host: "forge.example:3000",
147 repository: "t3tools/t3code",
148 number: 42,
149 });
150 const unlinked = expectSingleEvent(
151 yield* decideOrchestrationCommand({ readModel: model, command: unlink }),
152 "thread.pull-request-unlinked",
153 );
154 model = yield* projectEvent(model, { ...unlinked, sequence: 2 });
155 expect(model.threads[0]!.pullRequests.map((link) => link.url)).toEqual([
156 linked.payload.link.url,
157 ]);
158 }),
159 );
160
118161 it.effect("legacy unlink cannot remove a newer cross-host link", () =>
119162 Effect.gen(function* () {
120163 const own = makeLink();
apps/server/src/project/RepositoryIdentityResolver.test.ts+41-2
......@@ -1,6 +1,7 @@
11// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not.
22import * as NodeFS from "node:fs";
33import * as NodeServices from "@effect/platform-node/NodeServices";
4import { SourceControlProviderError } from "@t3tools/contracts";
45import { expect, it } from "@effect/vitest";
56import * as Duration from "effect/Duration";
67import * as Effect from "effect/Effect";
......@@ -41,6 +42,9 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
4142 it.effect("refreshes the Git root only when requested", () => {
4243 const calls: Array<ReadonlyArray<string>> = [];
4344 let rootPath = "/repo";
45 let remoteUrl = "git@github.com:T3Tools/t3code.git";
46 let refinements = 0;
47 let refinementFails = false;
4448 const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
4549 run: (input) =>
4650 Effect.sync(() => {
......@@ -48,7 +52,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
4852 return {
4953 stdout: input.args.includes("rev-parse")
5054 ? `${rootPath}\n`
51 : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n",
55 : `origin\t${remoteUrl} (fetch)\n`,
5256 stderr: "",
5357 code: ChildProcessSpawner.ExitCode(0),
5458 timedOut: false,
......@@ -61,7 +65,29 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
6165 });
6266 const resolverLayer = Layer.effect(
6367 RepositoryIdentityResolver.RepositoryIdentityResolver,
64 RepositoryIdentityResolver.make(),
68 RepositoryIdentityResolver.make({
69 refine: (identity) => {
70 refinements++;
71 if (refinementFails)
72 return Effect.fail(
73 new SourceControlProviderError({
74 provider: "forgejo",
75 operation: "detectProvider",
76 cwd: rootPath,
77 detail: "account unavailable",
78 }),
79 );
80 return Effect.succeed(
81 identity.canonicalKey.startsWith("ssh.forge.test/")
82 ? {
83 ...identity,
84 provider: "forgejo",
85 webUrl: "http://forge.test:3000/git/team/repo",
86 }
87 : identity,
88 );
89 },
90 }),
6591 ).pipe(Layer.provide(processRunner));
6692
6793 return Effect.gen(function* () {
......@@ -72,6 +98,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
7298
7399 expect(first?.canonicalKey).toBe("github.com/t3tools/t3code");
74100 expect(second).toEqual(first);
101 expect(refinements).toBe(1);
75102 expect(calls).toEqual([
76103 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
77104 ["-C", "/repo", "remote", "-v"],
......@@ -84,6 +111,18 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
84111 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
85112 ["-C", "/repo/packages/web", "remote", "-v"],
86113 ]);
114 remoteUrl = "git@ssh.forge.test:team/repo.git";
115 const forgejo = yield* resolver.resolve(rootPath, { refresh: true });
116 expect(forgejo?.webUrl).toBe("http://forge.test:3000/git/team/repo");
117 expect(forgejo?.provider).toBe("forgejo");
118 expect(forgejo?.canonicalKey).toBe("ssh.forge.test/team/repo");
119 expect(forgejo?.locator.remoteUrl).toBe(remoteUrl);
120 expect(yield* resolver.resolve(rootPath)).toEqual(forgejo);
121 expect(refinements).toBe(3);
122 refinementFails = true;
123 const unavailable = yield* resolver.resolve(rootPath, { refresh: true });
124 expect(unavailable?.webUrl).toBeUndefined();
125 expect(unavailable?.canonicalKey).toBe("ssh.forge.test/team/repo");
87126 }).pipe(Effect.provide(resolverLayer));
88127 });
89128
apps/server/src/project/RepositoryIdentityResolver.ts+9-1
......@@ -1,4 +1,4 @@
1import type { RepositoryIdentity } from "@t3tools/contracts";
1import type { RepositoryIdentity, SourceControlProviderError } from "@t3tools/contracts";
22import {
33 detectSourceControlProviderFromGitRemoteUrl,
44 normalizeGitRemoteUrl,
......@@ -20,6 +20,9 @@ export interface RepositoryIdentityResolverOptions {
2020 readonly cacheCapacity?: number;
2121 readonly positiveCacheTtl?: Duration.Input;
2222 readonly negativeCacheTtl?: Duration.Input;
23 readonly refine?: (
24 identity: RepositoryIdentity,
25 ) => Effect.Effect<RepositoryIdentity, SourceControlProviderError>;
2326}
2427
2528export class RepositoryIdentityResolver extends Context.Service<
......@@ -158,6 +161,11 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
158161 (cacheKey) =>
159162 resolveRepositoryIdentityFromCacheKey(cacheKey).pipe(
160163 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),
164 Effect.flatMap((identity) =>
165 identity !== null && options.refine
166 ? options.refine(identity).pipe(Effect.catch(() => Effect.succeed(identity)))
167 : Effect.succeed(identity),
168 ),
161169 ),
162170 {
163171 capacity: cacheCapacity,
apps/server/src/pullRequest/ForgejoPullRequestProvider.ts created+584
......@@ -0,0 +1,584 @@
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 { ForgejoCli, type ForgejoApiInput } from "../sourceControl/ForgejoCli.ts";
7import {
8 PullRequestProviderError,
9 type ProviderChangeRequestDetail,
10 type ProviderRepositoryRef,
11 type PullRequestProviderApi,
12} from "./PullRequestProvider.ts";
13import {
14 ForgejoPullRequest,
15 ForgejoRepository,
16 ForgejoUser,
17 ForgejoComment,
18 ForgejoReview,
19 ForgejoReviewComment,
20 ForgejoCommit,
21 ForgejoStatus,
22 ForgejoLabel,
23 ForgejoReaction,
24 FORGEJO_REACTIONS,
25 forgejoChangeRequest,
26 forgejoActor,
27 forgejoComment,
28 forgejoReview,
29 forgejoReviewThread,
30 forgejoCommit,
31 forgejoChecks,
32 forgejoReactions,
33} from "./forgejoPullRequestJson.ts";
34
35const CAPABILITIES: PullRequestCapabilities = {
36 diff: true,
37 comment: true,
38 actions: ["merge", "close", "reopen", "update-branch"],
39 mergeMethods: ["merge", "squash", "rebase"],
40 updateMethods: ["merge", "rebase"],
41 search: false,
42 reactions: true,
43 labels: true,
44 // Forgejo's public API has no thread replies/resolution or draft conversion endpoint.
45 review: {
46 inlineComment: true,
47 reply: false,
48 resolve: false,
49 verdicts: ["comment", "approve", "request-changes"],
50 },
51 reviewers: { request: true, listCandidates: true },
52 edit: { changeRequest: true, comment: true },
53};
54const repoPath = (input: ProviderRepositoryRef) =>
55 `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`;
56const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) =>
57 `${repoPath(input)}/pulls/${input.number}`;
58const issuePath = (input: ProviderRepositoryRef & { readonly number: number }) =>
59 `${repoPath(input)}/issues/${input.number}`;
60// Review IDs differ from the issue-comment IDs used by Forgejo's reactions API.
61const reviewCommentId = (review: typeof ForgejoReview.Type) =>
62 /#issuecomment-([1-9]\d*)$/.exec(review.html_url ?? "")?.[1];
63
64export const make = Effect.gen(function* () {
65 const cli = yield* ForgejoCli;
66 const failure = (operation: string, detail: string, cause?: unknown) =>
67 new PullRequestProviderError({
68 provider: "forgejo",
69 operation,
70 reason: "failed",
71 detail,
72 ...(cause === undefined ? {} : { cause }),
73 });
74 const request = (input: ForgejoApiInput) =>
75 cli.api(input).pipe(
76 Effect.mapError(
77 (error) =>
78 new PullRequestProviderError({
79 provider: "forgejo",
80 operation: input.path,
81 detail: error.detail,
82 cause: error,
83 reason:
84 error.reason === "missing-cli"
85 ? "missing-tool"
86 : error.reason === "authentication"
87 ? "unauthenticated"
88 : error.reason === "rate-limit"
89 ? "rate-limited"
90 : "failed",
91 }),
92 ),
93 );
94 const read = Effect.fn("ForgejoPullRequestProvider.read")(function* <A>(
95 input: ForgejoApiInput,
96 schema: Schema.Codec<A, unknown, never, never>,
97 ) {
98 const result = yield* request(input);
99 if (result.stdoutTruncated)
100 return yield* failure(input.path, "Forgejo response exceeded the output limit.");
101 const decoded = decodeJsonResult(schema)(result.stdout);
102 return Result.isSuccess(decoded)
103 ? decoded.success
104 : yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure);
105 });
106 const readArray = <A>(input: ForgejoApiInput, schema: Schema.Codec<A, unknown, never, never>) =>
107 read(input, Schema.NullOr(Schema.Array(schema))).pipe(Effect.map((rows) => rows ?? []));
108 const readPage = Effect.fn("ForgejoPullRequestProvider.readPage")(function* <A>(
109 input: ForgejoApiInput,
110 schema: Schema.Codec<A, unknown, never, never>,
111 index: number,
112 ) {
113 const result = yield* request({
114 ...input,
115 path: `${input.path}${input.path.includes("?") ? "&" : "?"}limit=50&page=${index}`,
116 });
117 if (result.stdoutTruncated)
118 return yield* failure(input.path, "Forgejo response exceeded the output limit.");
119 const decoded = decodeJsonResult(Schema.NullOr(Schema.Array(schema)))(result.stdout);
120 if (Result.isFailure(decoded))
121 return yield* failure(input.path, "Forgejo returned an invalid response.", decoded.failure);
122 const rows = decoded.success ?? [];
123 const links = /^link:\s*(.*)$/im.exec(result.stderr)?.[1];
124 return {
125 rows,
126 more: rows.length > 0 && (links === undefined || /rel="?next"?/i.test(links)),
127 };
128 });
129 // Use a stable, small page size that also works with Forgejo's default maximum of 50.
130 const page = Effect.fn("ForgejoPullRequestProvider.page")(function* <A>(
131 input: ForgejoApiInput,
132 schema: Schema.Codec<A, unknown, never, never>,
133 limit = 500,
134 ) {
135 const items: A[] = [];
136 for (let index = 1; items.length < limit; index++) {
137 const { rows, more } = yield* readPage(input, schema, index);
138 items.push(...rows);
139 if (!more) return { items, truncated: false };
140 }
141 return { items, truncated: true };
142 });
143 const write = (input: ForgejoApiInput) => request(input).pipe(Effect.asVoid);
144 const getPull = (input: ProviderRepositoryRef & { readonly number: number }) =>
145 read(
146 {
147 cwd: input.cwd,
148 repository: input.repository,
149 host: input.host,
150 path: pullPath(input),
151 },
152 ForgejoPullRequest,
153 );
154 const getRepo = (input: ProviderRepositoryRef) =>
155 read({ ...input, path: repoPath(input) }, ForgejoRepository);
156 const getViewer = (input: {
157 readonly cwd: string;
158 readonly repository?: string;
159 readonly host?: string;
160 }) => read({ ...input, path: "user" }, ForgejoUser).pipe(Effect.map((user) => user.login));
161 const permissions = (
162 repo: typeof ForgejoRepository.Type,
163 pr: typeof ForgejoPullRequest.Type,
164 viewer: string,
165 ): PullRequestViewerPermissions => {
166 const canWrite = repo.permissions?.push ?? false;
167 const canEdit = canWrite || pr.user?.login === viewer;
168 const active = !repo.archived;
169 return {
170 actions: active
171 ? CAPABILITIES.actions.filter((action) =>
172 action === "merge" || action === "update-branch" ? canWrite : canEdit,
173 )
174 : [],
175 comment: active && (!pr.is_locked || canWrite),
176 resolve: false,
177 verdicts: active
178 ? pr.user?.login === viewer
179 ? ["comment"]
180 : CAPABILITIES.review.verdicts
181 : [],
182 requestReviewers: active && canEdit,
183 labels: active && canWrite,
184 updateMethods:
185 active && canWrite ? (repo.allow_rebase_update ? ["merge", "rebase"] : ["merge"]) : [],
186 };
187 };
188 const getPermissions = Effect.fn("ForgejoPullRequestProvider.getPermissions")(function* (
189 input: ProviderRepositoryRef & { readonly number: number },
190 ) {
191 const [repo, pr, viewer] = yield* Effect.all(
192 [getRepo(input), getPull(input), getViewer(input)],
193 { concurrency: 3 },
194 );
195 return permissions(repo, pr, viewer);
196 });
197 const unsupported = (operation: string) =>
198 Effect.fail(failure(operation, `Forgejo does not expose ${operation} through its API.`));
199 const provider: PullRequestProviderApi = {
200 kind: "forgejo",
201 capabilities: CAPABILITIES,
202 getViewer,
203 listChangeRequests: Effect.fn("ForgejoPullRequestProvider.listChangeRequests")(
204 function* (input) {
205 const offset = input.cursor?.delivered ?? 0;
206 const items: ReturnType<typeof forgejoChangeRequest>[] = [];
207 const state = input.state === "merged" ? "closed" : input.state;
208 const query = {
209 ...input,
210 path: `${repoPath(input)}/pulls?state=${state}&sort=recentupdate`,
211 };
212 // Self-hosted servers may cap pages below 50. Establish their actual page size before
213 // translating the service's row offset into an API page number.
214 const first = yield* readPage(query, Schema.NullOr(ForgejoPullRequest), 1);
215 const pageSize = first.rows.length || 50;
216 const firstIndex = Math.floor(offset / pageSize) + 1;
217 let consumed = 0;
218 let more = true;
219 for (let index = firstIndex; more && consumed < input.limit; index++) {
220 const result =
221 index === 1 ? first : yield* readPage(query, Schema.NullOr(ForgejoPullRequest), index);
222 const rows = result.rows;
223 const start = index === firstIndex ? offset % pageSize : 0;
224 const countBefore = consumed;
225 for (const row of rows.slice(start)) {
226 if (consumed >= input.limit) break;
227 consumed++;
228 if (row) items.push(forgejoChangeRequest(row));
229 }
230 more = result.more || rows.length - start > consumed - countBefore;
231 }
232 return { items, truncated: more, continues: true, cursorAdvance: consumed };
233 },
234 ),
235 getChangeRequestSummary: (input) => getPull(input).pipe(Effect.map(forgejoChangeRequest)),
236 getChangeRequest: Effect.fn("ForgejoPullRequestProvider.getChangeRequest")(function* (input) {
237 const [pr, repo, viewer] = yield* Effect.all(
238 [getPull(input), getRepo(input), getViewer(input)],
239 { concurrency: 3 },
240 );
241 const statuses = yield* page(
242 {
243 ...input,
244 path: `${repoPath(input)}/statuses/${encodeURIComponent(pr.head.sha)}?sort=recentupdate`,
245 },
246 ForgejoStatus,
247 );
248 return {
249 ...forgejoChangeRequest(pr),
250 body: pr.body ?? "",
251 changedFiles: pr.changed_files ?? 0,
252 reviewers: (pr.requested_reviewers ?? []).flatMap((user) => {
253 const actor = forgejoActor(user);
254 return actor ? [actor] : [];
255 }),
256 checks: forgejoChecks(statuses.items),
257 baseComparison: !pr.merge_base
258 ? "unknown"
259 : pr.merge_base === pr.base.sha
260 ? "up-to-date"
261 : "behind",
262 viewerPermissions: permissions(repo, pr, viewer),
263 mergeCapabilities: {
264 merge: repo.allow_merge_commits ?? true,
265 squash: repo.allow_squash_merge ?? true,
266 rebase: repo.allow_rebase ?? true,
267 },
268 } satisfies ProviderChangeRequestDetail;
269 }),
270 getViewerPermissions: getPermissions,
271 getChangeRequestActivity: Effect.fn("ForgejoPullRequestProvider.getChangeRequestActivity")(
272 function* (input) {
273 const [comments, reviews, commits, reactions, viewer] = yield* Effect.all(
274 [
275 // Issue comments ignore page/limit; fetch this unpaginated endpoint once.
276 readArray({ ...input, path: `${issuePath(input)}/comments` }, ForgejoComment).pipe(
277 Effect.map((items) => ({
278 items: items.slice(0, 500),
279 truncated: items.length > 500,
280 })),
281 ),
282 page({ ...input, path: `${pullPath(input)}/reviews` }, ForgejoReview),
283 page({ ...input, path: `${pullPath(input)}/commits` }, ForgejoCommit),
284 page({ ...input, path: `${issuePath(input)}/reactions` }, ForgejoReaction),
285 getViewer(input),
286 ],
287 { concurrency: 5 },
288 );
289 const reviewComments = yield* Effect.forEach(
290 reviews.items.filter((review) => review.comments_count > 0 && review.state !== "PENDING"),
291 (review) =>
292 readArray(
293 { ...input, path: `${pullPath(input)}/reviews/${review.id}/comments` },
294 ForgejoReviewComment,
295 ),
296 { concurrency: 4 },
297 );
298 const allInline = reviewComments.flat();
299 const inline = allInline.slice(0, 500);
300 const entries = [
301 ...comments.items.map((comment) => ({
302 comment: forgejoComment(comment),
303 reactionId: String(comment.id),
304 })),
305 ...reviews.items
306 .filter((review) => review.state !== "PENDING" && review.state !== "REQUEST_REVIEW")
307 .map((review) => ({
308 comment: forgejoReview(review),
309 reactionId: reviewCommentId(review),
310 })),
311 ...inline.map((comment) => ({
312 comment: {
313 ...forgejoComment(comment),
314 kind: "review-comment" as const,
315 path: comment.path,
316 },
317 reactionId: String(comment.id),
318 })),
319 ];
320 const enriched = yield* Effect.forEach(
321 entries,
322 ({ comment, reactionId }) =>
323 (reactionId === undefined
324 ? Effect.succeed([])
325 : readArray(
326 { ...input, path: `${repoPath(input)}/issues/comments/${reactionId}/reactions` },
327 ForgejoReaction,
328 )
329 ).pipe(
330 Effect.map((rows) => ({ ...comment, reactions: forgejoReactions(rows, viewer) })),
331 ),
332 { concurrency: 4 },
333 );
334 const byId = new Map(enriched.map((comment) => [comment.id, comment]));
335 const timeline = enriched.toSorted((a, b) => a.createdAt.localeCompare(b.createdAt));
336 return {
337 comments: timeline,
338 commentCount: timeline.length,
339 commentsTruncated:
340 comments.truncated || reviews.truncated || allInline.length > inline.length,
341 reviewThreads: inline.map((comment) => ({
342 ...forgejoReviewThread(comment),
343 comments: [byId.get(String(comment.id)) ?? forgejoComment(comment)],
344 })),
345 commits: commits.items.map(forgejoCommit),
346 reactions: forgejoReactions(reactions.items, viewer),
347 };
348 },
349 ),
350 getDiff: (input) =>
351 request({
352 ...input,
353 path: input.commit
354 ? `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}.diff`
355 : `${pullPath(input)}.diff`,
356 }).pipe(
357 Effect.map((result) => ({
358 patch: result.stdout,
359 truncated: result.stdoutTruncated,
360 nextCursor: null,
361 })),
362 ),
363 getDiffFileContents: Effect.fn("ForgejoPullRequestProvider.getDiffFileContents")(
364 function* (input) {
365 const pr = yield* getPull(input);
366 const commit = input.commit
367 ? yield* read(
368 {
369 ...input,
370 path: `${repoPath(input)}/git/commits/${encodeURIComponent(input.commit)}`,
371 },
372 ForgejoCommit,
373 )
374 : null;
375 const oldRef = commit ? commit.parents[0]?.sha : pr.merge_base || pr.base.sha;
376 const newRef = input.commit ?? pr.head.sha;
377 const content = (repository: string, ref: string, path: string) =>
378 read(
379 {
380 ...input,
381 repository,
382 path: `${repoPath({ ...input, repository })}/contents/${path.split("/").map(encodeURIComponent).join("/")}?ref=${encodeURIComponent(ref)}`,
383 },
384 Schema.Struct({ content: Schema.String, encoding: Schema.Literal("base64") }),
385 ).pipe(Effect.map((file) => Buffer.from(file.content, "base64").toString("utf8")));
386 const [oldContents, newContents] = yield* Effect.all(
387 [
388 input.changeType === "new" || !oldRef
389 ? Effect.succeed("")
390 : content(input.repository, oldRef, input.oldPath),
391 input.changeType === "deleted"
392 ? Effect.succeed("")
393 : content(pr.head.repo?.full_name ?? input.repository, newRef, input.newPath),
394 ],
395 { concurrency: 2 },
396 );
397 return { oldContents, newContents };
398 },
399 ),
400 runAction: (input) => {
401 switch (input.action) {
402 case "merge":
403 return write({
404 ...input,
405 path: `${pullPath(input)}/merge`,
406 method: "POST",
407 body: { Do: input.mergeMethod ?? "merge" },
408 });
409 case "close":
410 case "reopen":
411 return write({
412 ...input,
413 path: pullPath(input),
414 method: "PATCH",
415 body: { state: input.action === "close" ? "closed" : "open" },
416 });
417 case "update-branch":
418 return write({
419 ...input,
420 path: `${pullPath(input)}/update?style=${input.updateMethod ?? "merge"}`,
421 method: "POST",
422 });
423 default:
424 return unsupported(input.action);
425 }
426 },
427 updateChangeRequest: (input) =>
428 write({
429 ...input,
430 path: pullPath(input),
431 method: "PATCH",
432 body: {
433 ...(input.title === undefined ? {} : { title: input.title }),
434 ...(input.body === undefined ? {} : { body: input.body }),
435 },
436 }),
437 comment: (input) =>
438 write({
439 ...input,
440 path: `${issuePath(input)}/comments`,
441 method: "POST",
442 body: { body: input.body },
443 }),
444 updateComment: (input) =>
445 write({
446 ...input,
447 path: `${repoPath(input)}/issues/comments/${encodeURIComponent(input.commentId)}`,
448 method: "PATCH",
449 body: { body: input.body },
450 }),
451 submitReview: Effect.fn("ForgejoPullRequestProvider.submitReview")(function* (input) {
452 const pr = yield* getPull(input);
453 yield* write({
454 ...input,
455 path: `${pullPath(input)}/reviews`,
456 method: "POST",
457 body: {
458 event:
459 input.verdict === "approve"
460 ? "APPROVED"
461 : input.verdict === "request-changes"
462 ? "REQUEST_CHANGES"
463 : "COMMENT",
464 body: input.body,
465 commit_id: pr.head.sha,
466 comments: input.comments.map((comment) => {
467 const position = comment.position;
468 const old =
469 position.kind === "deleted" ||
470 (position.kind === "context" && position.side === "left");
471 return {
472 path: old ? (comment.oldPath ?? comment.path) : comment.path,
473 body: comment.body,
474 old_position: old ? position.oldLine : 0,
475 new_position: old ? 0 : position.newLine,
476 };
477 }),
478 },
479 });
480 }),
481 listReviewerCandidates: Effect.fn("ForgejoPullRequestProvider.listReviewerCandidates")(
482 function* (input) {
483 const [pr, users] = yield* Effect.all(
484 [getPull(input), page({ ...input, path: `${repoPath(input)}/assignees` }, ForgejoUser)],
485 { concurrency: 2 },
486 );
487 return {
488 candidates: users.items
489 .filter((user) => user.login !== pr.user?.login)
490 .flatMap((user) => {
491 const actor = forgejoActor(user);
492 return actor
493 ? [
494 {
495 ...actor,
496 id: user.login,
497 kind: "user" as const,
498 isRequested:
499 pr.requested_reviewers?.some((reviewer) => reviewer.login === user.login) ??
500 false,
501 },
502 ]
503 : [];
504 }),
505 truncated: users.truncated,
506 };
507 },
508 ),
509 setReviewerRequest: (input) =>
510 write({
511 ...input,
512 path: `${pullPath(input)}/requested_reviewers`,
513 method: input.requested ? "POST" : "DELETE",
514 body: { reviewers: input.reviewers.map((reviewer) => reviewer.id) },
515 }),
516 listLabelCandidates: Effect.fn("ForgejoPullRequestProvider.listLabelCandidates")(
517 function* (input) {
518 const [pr, labels] = yield* Effect.all(
519 [getPull(input), page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel)],
520 { concurrency: 2 },
521 );
522 return {
523 candidates: labels.items.map((label) => ({
524 name: label.name,
525 color: label.color ?? null,
526 description: label.description ?? null,
527 isApplied: pr.labels?.some((applied) => applied.id === label.id) ?? false,
528 })),
529 truncated: labels.truncated,
530 };
531 },
532 ),
533 setLabels: Effect.fn("ForgejoPullRequestProvider.setLabels")(function* (input) {
534 const labels = yield* page({ ...input, path: `${repoPath(input)}/labels` }, ForgejoLabel);
535 const selected = labels.items.filter((label) => input.labels.includes(label.name));
536 if (selected.length !== input.labels.length)
537 return yield* failure("setLabels", "One or more requested labels could not be found.");
538 if (input.applied)
539 yield* write({
540 ...input,
541 path: `${issuePath(input)}/labels`,
542 method: "POST",
543 body: { labels: selected.map((label) => label.id) },
544 });
545 else
546 yield* Effect.forEach(
547 selected,
548 (label) =>
549 write({ ...input, path: `${issuePath(input)}/labels/${label.id}`, method: "DELETE" }),
550 { concurrency: 1 },
551 );
552 }),
553 setReaction: Effect.fn("ForgejoPullRequestProvider.setReaction")(function* (input) {
554 let commentId = input.subjectId;
555 if (commentId?.startsWith("review:")) {
556 const reviewId = /^review:([1-9]\d*)$/.exec(commentId)?.[1];
557 if (!reviewId) return yield* failure("setReaction", "Invalid Forgejo review ID.");
558 const review = yield* read(
559 { ...input, path: `${pullPath(input)}/reviews/${reviewId}` },
560 ForgejoReview,
561 );
562 commentId = reviewCommentId(review);
563 if (!commentId)
564 return yield* failure(
565 "setReaction",
566 "Forgejo did not return a comment ID for this review.",
567 );
568 }
569 if (commentId && !/^[1-9]\d*$/.test(commentId))
570 return yield* failure("setReaction", "Invalid Forgejo comment ID.");
571 yield* write({
572 ...input,
573 path: commentId
574 ? `${repoPath(input)}/issues/comments/${commentId}/reactions`
575 : `${issuePath(input)}/reactions`,
576 method: input.reacted ? "POST" : "DELETE",
577 body: { content: FORGEJO_REACTIONS[input.content] },
578 });
579 }),
580 replyToThread: () => unsupported("thread replies"),
581 setThreadResolution: () => unsupported("thread resolution"),
582 };
583 return provider;
584});
apps/server/src/pullRequest/PullRequestProvider.ts+1
......@@ -282,6 +282,7 @@ export interface PullRequestProviderApi {
282282 /** The signed-in account, which is what involvement filtering compares against. */
283283 readonly getViewer: (input: {
284284 readonly cwd: string;
285 readonly host?: string;
285286 }) => Effect.Effect<string, PullRequestProviderError>;
286287
287288 readonly listChangeRequests: (
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+4
......@@ -8,6 +8,8 @@ import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
88import * as GitHubCli from "../sourceControl/GitHubCli.ts";
99import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
1010import * as GitLabCli from "../sourceControl/GitLabCli.ts";
11import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
12import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
1113import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
1214import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";
1315import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
......@@ -48,6 +50,7 @@ export const make = Effect.map(
4850 Effect.all([
4951 GitHubPullRequestProvider.make,
5052 GitLabPullRequestProvider.make,
53 ForgejoPullRequestProvider.make,
5154 BitbucketPullRequestProvider.make,
5255 AzureDevOpsPullRequestProvider.make,
5356 ]),
......@@ -62,6 +65,7 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
6265 ),
6366 ),
6467 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),
68 Layer.provide(ForgejoCli.layer),
6569 Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),
6670 Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))),
6771);
apps/server/src/pullRequest/PullRequestService.test.ts+112
......@@ -1659,6 +1659,118 @@ it.effect("reads a host-native stack through the provider and null where it has
16591659 }),
16601660);
16611661
1662it.effect("routes explicit Forgejo HTTP authorities through SSH checkouts after refinement", () =>
1663 Effect.gen(function* () {
1664 for (const provider of ["forgejo", "unknown"] as const) {
1665 const seen: string[] = [];
1666 const viewers: Array<string | undefined> = [];
1667 const service = yield* makeService({
1668 projects: [
1669 project({
1670 id: "ssh",
1671 title: "ssh",
1672 workspaceRoot: "/ssh",
1673 repository: "team/repo",
1674 provider,
1675 host: "ssh.code.example",
1676 remoteUrl: "git@ssh.code.example:team/repo.git",
1677 }),
1678 ],
1679 providers: [
1680 fakeProvider("forgejo", {
1681 getViewer: (input) => {
1682 viewers.push(input.host);
1683 assert.strictEqual(input.host, "code.example:3000");
1684 return Effect.succeed("bilal");
1685 },
1686 listChangeRequests: (input) => {
1687 assert.strictEqual(input.host, "code.example:3000");
1688 return Effect.succeed({ items: [], truncated: false, continues: true });
1689 },
1690 getChangeRequest: (input) => {
1691 assert.strictEqual(input.host, "code.example:3000");
1692 return Effect.succeed({ ...hostedChangeRequest("Forgejo detail"), number: 42 });
1693 },
1694 getChangeRequestSummary: (input) =>
1695 Effect.sync(() => {
1696 seen.push(input.host);
1697 return changeRequest(42, "2026-07-02T00:00:00Z");
1698 }),
1699 }),
1700 ],
1701 resolveHandle: ({ context }) => {
1702 if (context?.requestedHost === undefined) {
1703 return Effect.succeed({ context: context!, provider: undefined as never });
1704 }
1705 assert.strictEqual(context.requestedHost, "code.example:3000");
1706 return Effect.succeed({
1707 context: {
1708 ...context,
1709 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "http://code.example:3000" },
1710 },
1711 provider: undefined as never,
1712 });
1713 },
1714 });
1715 yield* service.summary(
1716 {
1717 projectId: "ssh" as ProjectId,
1718 host: "code.example:3000",
1719 repository: "team/repo",
1720 number: 42,
1721 },
1722 { recoverTransientFailure: false },
1723 );
1724 assert.deepStrictEqual(seen, ["code.example:3000"]);
1725 const listed = yield* service.list({
1726 projectId: "ssh" as ProjectId,
1727 host: "code.example:3000",
1728 state: "open",
1729 });
1730 assert.strictEqual(listed.viewers["code.example:3000"], "bilal");
1731 const detail = yield* service.detail({
1732 projectId: "ssh" as ProjectId,
1733 host: "code.example:3000",
1734 repository: "team/repo",
1735 number: 42,
1736 });
1737 assert.strictEqual(detail.body, "Forgejo detail");
1738 assert.deepStrictEqual(viewers, ["code.example:3000"]);
1739 }
1740 }),
1741);
1742
1743it.effect("rejects a different Forgejo HTTP port for an HTTP checkout", () =>
1744 Effect.gen(function* () {
1745 const service = yield* makeService({
1746 projects: [
1747 project({
1748 id: "http",
1749 title: "http",
1750 workspaceRoot: "/http",
1751 repository: "team/repo",
1752 provider: "forgejo",
1753 host: "code.example",
1754 remoteUrl: "http://code.example:4000/team/repo.git",
1755 }),
1756 ],
1757 providers: [fakeProvider("forgejo")],
1758 });
1759 const failure = yield* service
1760 .summary(
1761 {
1762 projectId: "http" as ProjectId,
1763 host: "code.example:3000",
1764 repository: "team/repo",
1765 number: 42,
1766 },
1767 { recoverTransientFailure: false },
1768 )
1769 .pipe(Effect.flip);
1770 assert.strictEqual(failure._tag, "PullRequestUnavailableError");
1771 }),
1772);
1773
16621774it.effect("routes a hosted reference to another repository through a project on that host", () =>
16631775 Effect.gen(function* () {
16641776 const seen: Array<{ cwd: string; repository: string; host: string }> = [];
apps/server/src/pullRequest/PullRequestService.ts+41-15
......@@ -1,5 +1,6 @@
11import {
22 canonicalRepositoryKey,
3 isSshRemoteUrl,
34 sourceControlRepositorySelector,
45} from "@t3tools/shared/sourceControl";
56import * as Cache from "effect/Cache";
......@@ -553,14 +554,21 @@ export const make = Effect.gen(function* () {
553554 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;
554555 const identity = project.repositoryIdentity;
555556 if (
556 identity?.provider !== "unknown" ||
557 (identity?.provider !== "unknown" &&
558 !(identity?.provider === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl))) ||
557559 sourceControlRepositorySelector(project.repositoryIdentity) === null
558560 )
559561 continue;
560562 const host = pullRequestHostOf(identity, "unknown");
561563 // A legacy identity has no canonical host until its provider is refined, so it must reach
562564 // the refinement before a host filter can decide whether it belongs in the result.
563 if (filter.host !== undefined && host !== "unknown" && host !== filter.host.toLowerCase()) {
565 if (
566 filter.host !== undefined &&
567 host !== "unknown" &&
568 host !== filter.host.toLowerCase() &&
569 pullRequestHostOf(identity, "forgejo") !== filter.host.toLowerCase() &&
570 !isSshRemoteUrl(identity.locator.remoteUrl)
571 ) {
564572 continue;
565573 }
566574 const { remoteName, remoteUrl } = identity.locator;
......@@ -581,20 +589,28 @@ export const make = Effect.gen(function* () {
581589 Effect.suspend(() =>
582590 sourceControlProviders.resolveHandle({
583591 cwd: project.workspaceRoot,
584 context: { provider, remoteName, remoteUrl },
592 context: {
593 provider:
594 provider.kind === "forgejo" ? { ...provider, kind: "unknown" } : provider,
595 remoteName,
596 remoteUrl,
597 ...(filter.host !== undefined && isSshRemoteUrl(remoteUrl)
598 ? { requestedHost: filter.host }
599 : {}),
600 },
585601 }),
586602 ).pipe(
587603 Effect.flatMap((handle) => {
588 const kind = handle.context?.provider.kind;
589 return kind === undefined || kind === "unknown"
604 const refined = handle.context?.provider;
605 return refined === undefined || refined.kind === "unknown"
590606 ? Effect.fail(undefined)
591 : Effect.succeed(kind);
607 : Effect.succeed(refined);
592608 }),
593609 ),
594610 ),
595611 ).pipe(
596 Effect.map((kind) => [baseUrl, kind] as const),
597 Effect.orElseSucceed(() => [baseUrl, "unknown"] as const),
612 Effect.map((provider) => [baseUrl, provider] as const),
613 Effect.orElseSucceed(() => [baseUrl, null] as const),
598614 ),
599615 { concurrency: REPOSITORY_CONCURRENCY },
600616 ).pipe(Effect.map((resolved) => new Map(resolved)));
......@@ -617,10 +633,10 @@ export const make = Effect.gen(function* () {
617633 ),
618634 Effect.flatMap((projects) =>
619635 refineUnknownProjectKinds(projects, filter).pipe(
620 Effect.map((refinedKinds) => ({ refinedKinds, projects })),
636 Effect.map((refinedProviders) => ({ refinedProviders, projects })),
621637 ),
622638 ),
623 Effect.map(({ refinedKinds, projects }) => {
639 Effect.map(({ refinedProviders, projects }) => {
624640 const supported: SupportedProject[] = [];
625641 const unimplemented = new Map<
626642 string,
......@@ -638,12 +654,22 @@ export const make = Effect.gen(function* () {
638654 // Worktrees of one repository are separate projects; reading the remote once keeps
639655 // the page from repeating every change request per local checkout. The host is part
640656 // of the key, so the same `owner/repo` on two hosts stays two repositories.
641 if (kind === "unknown") {
657 let refinedProvider: SourceControlProviderInfo | null | undefined;
658 if (
659 kind === "unknown" ||
660 (kind === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl))
661 ) {
642662 const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl);
643 kind = provider === null ? kind : (refinedKinds.get(provider.baseUrl) ?? kind);
663 refinedProvider = provider === null ? null : refinedProviders.get(provider.baseUrl);
664 kind = refinedProvider?.kind ?? kind;
665 }
666 const host =
667 refinedProvider?.kind === "forgejo"
668 ? new URL(refinedProvider.baseUrl).host.toLowerCase()
669 : pullRequestHostOf(identity, kind);
670 if (filter.host !== undefined && host !== filter.host.toLowerCase()) {
671 continue;
644672 }
645 const host = pullRequestHostOf(identity, kind);
646 if (filter.host !== undefined && host !== filter.host.toLowerCase()) continue;
647673 const api = registry.get(kind);
648674 // Recorded before the de-duplication below, so the viewer lookup keeps the alternates
649675 // the listing is about to drop.
......@@ -816,7 +842,7 @@ export const make = Effect.gen(function* () {
816842 return Effect.die(new Error(`Missing pull request provider: ${kind}`));
817843 }
818844 const api = withRateLimitBackoff(registered, host, rateLimits);
819 return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd }))).pipe(
845 return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd, host }))).pipe(
820846 Effect.map((viewer) => ({
821847 host,
822848 kind,
apps/server/src/pullRequest/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 "./PullRequestProvider.ts";
14import { dedupeChecks } from "./pullRequestChecks.ts";
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 mergeability:
139 pr.mergeable === undefined ? "unknown" : pr.mergeable ? "mergeable" : "conflicting",
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/pullRequest/linkedThreads.test.ts+31-1
......@@ -17,6 +17,22 @@ it.effect(
1717 VALUES ('project-1', 'Project', '/tmp/project', '[]', ${createdAt}, ${createdAt})
1818 `;
1919 const fixtures = [
20 {
21 id: "forgejo-old",
22 host: "forge.example",
23 repository: "acme/web",
24 number: 7,
25 source: "manual",
26 url: "http://forge.example:3000/acme/web/pulls/7",
27 },
28 {
29 id: "forgejo-other-port",
30 host: "forge.example:4000",
31 repository: "acme/web",
32 number: 7,
33 source: "manual",
34 url: "http://forge.example:4000/acme/web/pulls/7",
35 },
2036 {
2137 id: "azure",
2238 host: "dev.azure.com",
......@@ -83,10 +99,24 @@ it.effect(
8399 yield* sql`
84100 INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at)
85101 VALUES (${fixture.id}, ${fixture.host}, ${fixture.repository}, ${fixture.number},
86 'https://github.com/acme/web/pull/7', ${fixture.source}, ${createdAt})
102 ${fixture.url ?? "https://github.com/acme/web/pull/7"}, ${fixture.source}, ${createdAt})
87103 `;
88104 }
89105
106 expect(
107 (yield* listLinkedPullRequestThreads({
108 host: "forge.example:3000",
109 repository: "acme/web",
110 number: 7,
111 })).threads.map((thread) => thread.id),
112 ).toEqual(["forgejo-old"]);
113 expect(
114 (yield* listLinkedPullRequestThreads({
115 host: "forge.example:4000",
116 repository: "acme/web",
117 number: 7,
118 })).threads.map((thread) => thread.id),
119 ).toEqual(["forgejo-other-port"]);
90120 expect(
91121 (yield* listLinkedPullRequestThreads({
92122 host: "org.visualstudio.com",
apps/server/src/pullRequest/linkedThreads.ts+20-4
......@@ -1,4 +1,7 @@
1import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
1import {
2 normalizeThreadPullRequestKey,
3 threadPullRequestKeysEqual,
4} from "@t3tools/shared/threadPullRequests";
25import {
36 PullRequestLinkedThreadsResult,
47 PullRequestOperationError,
......@@ -13,19 +16,32 @@ const decodeLinkedThreads = Schema.decodeUnknownEffect(PullRequestLinkedThreadsR
1316export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThreads")(
1417 function* (input: ThreadPullRequestKey) {
1518 const key = normalizeThreadPullRequestKey(input);
19 const hostname = key.host.replace(/:\d+$/u, "");
1620 const sql = yield* SqlClient.SqlClient;
17 const threads = yield* sql`
21 const rows = yield* sql<{
22 id: string;
23 projectId: string;
24 title: string;
25 archivedAt: string | null;
26 host: string;
27 repository: string;
28 number: number;
29 url: string;
30 }>`
1831 SELECT t.thread_id AS id, t.project_id AS "projectId", t.title,
19 t.archived_at AS "archivedAt"
32 t.archived_at AS "archivedAt", link.host, link.repository, link.number, link.url
2033 FROM projection_thread_pull_requests AS link
2134 JOIN projection_threads AS t ON t.thread_id = link.thread_id
22 WHERE link.host = ${key.host.toLowerCase()}
35 WHERE (link.host = ${key.host} OR link.host = ${hostname})
2336 AND link.repository = ${key.repository.toLowerCase()}
2437 AND link.number = ${key.number}
2538 AND link.source != 'stack-dismissed'
2639 AND t.deleted_at IS NULL
2740 ORDER BY t.updated_at DESC, t.thread_id ASC
2841 `;
42 const threads = rows
43 .filter((row) => threadPullRequestKeysEqual(row, key))
44 .map(({ id, projectId, title, archivedAt }) => ({ id, projectId, title, archivedAt }));
2945 return yield* decodeLinkedThreads({ threads });
3046 },
3147 Effect.mapError(
apps/server/src/server.ts+50-3
......@@ -1,4 +1,8 @@
1import { EnvironmentHttpApi, ProviderDriverKind } from "@t3tools/contracts";
1import {
2 EnvironmentHttpApi,
3 ProviderDriverKind,
4 type RepositoryIdentity,
5} from "@t3tools/contracts";
26import * as Cause from "effect/Cause";
37import * as Duration from "effect/Duration";
48import * as Deferred from "effect/Deferred";
......@@ -51,6 +55,7 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
5155import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
5256import * as GitHubCli from "./sourceControl/GitHubCli.ts";
5357import * as GitLabCli from "./sourceControl/GitLabCli.ts";
58import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
5459import * as TextGeneration from "./textGeneration/TextGeneration.ts";
5560import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
5661import * as TerminalManager from "./terminal/Manager.ts";
......@@ -313,12 +318,54 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe(
313318
314319const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe(
315320 Layer.provide(
316 Layer.mergeAll(AzureDevOpsCli.layer, BitbucketApi.layer, GitHubCli.layer, GitLabCli.layer),
321 Layer.mergeAll(
322 AzureDevOpsCli.layer,
323 BitbucketApi.layer,
324 GitHubCli.layer,
325 GitLabCli.layer,
326 ForgejoCli.layer,
327 ),
317328 ),
318329 Layer.provideMerge(GitVcsDriver.layer),
319330 Layer.provideMerge(VcsDriverRegistryLayerLive),
320331);
321332
333const RepositoryIdentityResolverLayerLive = Layer.effect(
334 RepositoryIdentityResolver.RepositoryIdentityResolver,
335 Effect.gen(function* () {
336 const registry = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
337 return yield* RepositoryIdentityResolver.make({
338 refine: Effect.fn(function* (identity: RepositoryIdentity) {
339 const remote = ForgejoCli.parseForgejoRemote(identity.locator.remoteUrl);
340 if (
341 !remote ||
342 !identity.rootPath ||
343 (identity.provider !== undefined &&
344 identity.provider !== "unknown" &&
345 identity.provider !== "forgejo")
346 )
347 return identity;
348 const handle = yield* registry.resolveHandle({
349 cwd: identity.rootPath,
350 context: {
351 provider: { kind: "unknown", name: "Unknown", baseUrl: "" },
352 remoteName: identity.locator.remoteName,
353 remoteUrl: identity.locator.remoteUrl,
354 },
355 });
356 if (handle.context?.provider.kind !== "forgejo") return identity;
357 const baseUrl = handle.context.provider.baseUrl.replace(/\/+$/, "");
358 const basePath = new URL(baseUrl).pathname.replace(/^\/+|\/+$/g, "");
359 const path =
360 !remote.ssh && basePath && remote.path.startsWith(`${basePath}/`)
361 ? remote.path.slice(basePath.length + 1)
362 : remote.path;
363 return { ...identity, provider: "forgejo", webUrl: `${baseUrl}/${path}` };
364 }),
365 });
366 }),
367).pipe(Layer.provide(SourceControlProviderRegistryLayerLive), Layer.provide(ProcessRunner.layer));
368
322369const PullRequestServiceLive = PullRequestService.layer.pipe(
323370 Layer.provide(PullRequestProviderRegistry.layer),
324371 Layer.provide(PullRequestReadCache.layer),
......@@ -512,7 +559,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
512559 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
513560 Layer.provideMerge(WorkspaceLayerLive),
514561 Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, ProjectFaviconResolverLayerLive)),
515 Layer.provideMerge(RepositoryIdentityResolver.layer),
562 Layer.provideMerge(RepositoryIdentityResolverLayerLive),
516563 Layer.provideMerge(ServerEnvironmentLayerLive),
517564 Layer.provideMerge(AuthLayerLive),
518565 Layer.provideMerge(ServerSecretStore.layer),
apps/server/src/sourceControl/ForgejoCli.ts created+720
......@@ -0,0 +1,720 @@
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/unstable/http";
13import { ChildProcessSpawner } from "effect/unstable/process";
14import { decodeJsonResult } from "@t3tools/shared/schemaJson";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import * as VcsProcess from "../vcs/VcsProcess.ts";
17import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts";
18import type { SourceControlProviderContext } from "./SourceControlProvider.ts";
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 if (status < 200 || status >= 300)
370 return yield* new ForgejoCliError({
371 command: "fj",
372 cwd: input.cwd,
373 httpStatus: status,
374 ...(status === 401
375 ? { reason: "authentication" as const }
376 : status === 403
377 ? { reason: "forbidden" as const }
378 : status === 404
379 ? { reason: "not-found" as const }
380 : status === 429
381 ? { reason: "rate-limit" as const }
382 : {}),
383 detail:
384 status === 404
385 ? "Forgejo repository or pull request was not found."
386 : `Forgejo API request failed (HTTP ${status}). Check this server's fj credentials and permissions.`,
387 });
388 const body =
389 status === 204 || status === 205
390 ? { text: "", truncated: false, invalidUtf8: false }
391 : yield* collectUint8StreamText({
392 stream: response.stream,
393 maxBytes: 8 * 1024 * 1024,
394 });
395 if (body.truncated || body.invalidUtf8)
396 return yield* new ForgejoCliError({
397 command: "fj",
398 cwd: input.cwd,
399 reason: "invalid-response",
400 detail: "Forgejo returned an oversized or invalid response.",
401 });
402 return {
403 exitCode: ChildProcessSpawner.ExitCode(0),
404 stdout: body.text,
405 stderr: `HTTP/1.1 ${status}\n${response.headers.link ? `link: ${response.headers.link}\n` : ""}`,
406 stdoutTruncated: false,
407 stderrTruncated: false,
408 };
409 },
410 (effect, input) =>
411 effect.pipe(
412 Effect.timeout(30_000),
413 Effect.mapError((error) =>
414 error._tag === "ForgejoCliError"
415 ? error
416 : new ForgejoCliError({
417 command: "fj",
418 cwd: input.cwd,
419 detail: "Forgejo API request failed or timed out.",
420 }),
421 ),
422 ),
423 );
424
425 const authenticateFj = Effect.fn("ForgejoCli.authenticateFj")(function* (
426 cwd: string,
427 login: typeof ForgejoLoginSchema.Type,
428 ) {
429 const keys = yield* readKeys(cwd);
430 const token = keys.hosts[login.name]?.token;
431 const now = yield* Clock.currentTimeMillis;
432 const cached = authenticated.get(login.url);
433 if (token && cached?.token === token && now - cached.time < 30_000) return token;
434 yield* execute({ command: "fj", cwd, args: ["--host", login.url, "whoami"] });
435 // fj owns OAuth renewal. Re-read the file after it has refreshed an expired token.
436 const refreshed = (yield* readKeys(cwd)).hosts[login.name]?.token;
437 if (!refreshed)
438 return yield* new ForgejoCliError({
439 command: "fj",
440 cwd,
441 reason: "authentication",
442 detail: "fj has no credentials for this server. Authenticate again with fj.",
443 });
444 authenticated.set(login.url, { token: refreshed, time: now });
445 return refreshed;
446 }, authLock.withPermits(1));
447
448 const getAccount: NonNullable<ForgejoCli["Service"]["getAccount"]> = Effect.fn(
449 "ForgejoCli.getAccount",
450 )(function* (input) {
451 const logins = yield* listLogins({ cwd: input.cwd, command: "fj", remoteUrl: input.baseUrl });
452 const login = logins.find(
453 (item) => item.url.replace(/\/+$/, "") === input.baseUrl.replace(/\/+$/, ""),
454 );
455 if (!login)
456 return yield* new ForgejoCliError({
457 command: "fj",
458 cwd: input.cwd,
459 reason: "authentication",
460 detail: "fj has no credentials for this server.",
461 });
462 const token = yield* authenticateFj(input.cwd, login);
463 const currentUser = yield* requestFj({
464 cwd: input.cwd,
465 baseUrl: login.url.replace(/\/+$/, ""),
466 token,
467 path: "user",
468 });
469 const user = decodeJsonResult(Schema.Struct({ login: Schema.String }))(currentUser.stdout);
470 if (Result.isFailure(user) || !user.success.login.trim())
471 return yield* new ForgejoCliError({
472 command: "fj",
473 cwd: input.cwd,
474 reason: "invalid-response",
475 detail: "Forgejo returned an invalid account response.",
476 });
477 return user.success.login;
478 });
479
480 const resolveTarget = Effect.fn("ForgejoCli.resolveTarget")(function* (
481 input: ForgejoRepositoryInput,
482 hostOnly = false,
483 ) {
484 const referenceRemote = input.reference ? parseForgejoRemote(input.reference) : null;
485 let remoteUrl = [input.reference, input.repository, input.context?.remoteUrl].find(
486 (value) => value && parseForgejoRemote(value),
487 );
488 let remote =
489 referenceRemote ??
490 (input.repository ? parseForgejoRemote(input.repository) : null) ??
491 (input.context ? parseForgejoRemote(input.context.remoteUrl) : null);
492 if (!remote && (!input.repository || input.host)) {
493 const result = yield* process
494 .run({
495 operation: "ForgejoCli.remote",
496 command: "git",
497 args: input.host ? ["remote", "-v"] : ["remote", "get-url", "origin"],
498 cwd: input.cwd,
499 allowNonZeroExit: true,
500 })
501 .pipe(
502 Effect.mapError(
503 (cause) =>
504 new ForgejoCliError({
505 command: "tea",
506 cwd: input.cwd,
507 detail: "Could not resolve the Forgejo repository remote.",
508 cause,
509 }),
510 ),
511 );
512 if (input.host) {
513 const matchingUrls = [
514 ...new Set(
515 result.stdout.split("\n").flatMap((line) => {
516 const url = /^\S+\s+(https?:\/\/\S+)\s+\(fetch\)$/.exec(line.trim())?.[1];
517 return url && parseForgejoRemote(url)?.host === input.host?.toLowerCase()
518 ? [url]
519 : [];
520 }),
521 ),
522 ];
523 const origins = [...new Set(matchingUrls.map((url) => new URL(url).origin))];
524 remoteUrl =
525 matchingUrls.length === 1
526 ? matchingUrls[0]
527 : origins.length === 1
528 ? origins[0]
529 : undefined;
530 } else {
531 remoteUrl = result.stdout.trim();
532 }
533 remote = remoteUrl ? parseForgejoRemote(remoteUrl) : null;
534 }
535 if (
536 input.host &&
537 !remote?.ssh &&
538 remote?.host !== input.host.toLowerCase() &&
539 remote?.hostname !== input.host.toLowerCase()
540 )
541 remote = {
542 host: input.host.toLowerCase(),
543 hostname: input.host.split(":")[0] ?? input.host,
544 ssh: false,
545 path: remote?.path ?? "",
546 };
547 const schemeRemoteUrl = remote?.ssh ? input.context?.provider.baseUrl : remoteUrl;
548 const fjLogins = yield* listLogins({
549 cwd: input.cwd,
550 command: "fj",
551 ...(schemeRemoteUrl ? { remoteUrl: schemeRemoteUrl } : {}),
552 });
553 const requestedHost = input.host ?? input.context?.requestedHost;
554 const matchHostOnly = hostOnly || (!!input.host && !remote?.path);
555 const selectLogin = (logins: ReturnType<typeof parseForgejoLogins>) =>
556 remote
557 ? matchForgejoLogin(logins, remote, remote.ssh ? requestedHost : undefined, matchHostOnly)
558 : (logins.find((item) => item.default === "true") ??
559 (new Set(logins.map((item) => item.name)).size === 1 ? logins[0] : undefined));
560 let login = selectLogin(fjLogins);
561 let command: "fj" | "tea" = "fj";
562 if (
563 !login &&
564 fjLogins.some(
565 (item) =>
566 !remote ||
567 matchForgejoLogin([item], remote, remote.ssh ? requestedHost : undefined, matchHostOnly),
568 )
569 ) {
570 const available = yield* execute({ command: "fj", cwd: input.cwd, args: ["version"] }).pipe(
571 Effect.result,
572 );
573 if (Result.isSuccess(available))
574 return yield* new ForgejoCliError({
575 command: "fj",
576 cwd: input.cwd,
577 reason: "authentication",
578 detail: "Multiple fj logins match this repository. Specify its full server URL.",
579 });
580 if (available.failure.reason !== "missing-cli") return yield* available.failure;
581 }
582 if (login) {
583 const auth = yield* authenticateFj(input.cwd, login).pipe(Effect.result);
584 if (Result.isFailure(auth)) {
585 if (auth.failure.reason === "missing-cli") login = undefined;
586 else return yield* auth.failure;
587 }
588 }
589 if (!login) {
590 command = "tea";
591 login = selectLogin(yield* listLogins({ cwd: input.cwd, command: "tea" }));
592 }
593 if (!login)
594 return yield* new ForgejoCliError({
595 command: "tea",
596 cwd: input.cwd,
597 reason: "authentication",
598 detail:
599 "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.",
600 });
601 if (hostOnly)
602 return { command, login: login.name, repository: "", baseUrl: login.url.replace(/\/+$/, "") };
603 const path =
604 referenceRemote?.path ??
605 (input.repository && !parseForgejoRemote(input.repository)
606 ? input.repository
607 : remote?.path) ??
608 "";
609 const basePath = new URL(login.url).pathname.replace(/^\/+|\/+$/g, "");
610 const relativePath =
611 basePath && path.split("/").length > 2 && path.startsWith(`${basePath}/`)
612 ? path.slice(basePath.length + 1)
613 : path;
614 const repositoryPath = relativePath.replace(/\/pulls\/\d+.*$/, "").replace(/\.git$/, "");
615 if (command === "fj" && !repositoryPath.includes("/")) {
616 login = { ...login, user: yield* getAccount({ cwd: input.cwd, baseUrl: login.url }) };
617 }
618 const repository = repositoryPath.includes("/")
619 ? repositoryPath
620 : `${login.user}/${repositoryPath}`;
621 if (!/^[^/\s]+\/[^/\s]+$/.test(repository))
622 return yield* new ForgejoCliError({
623 command,
624 cwd: input.cwd,
625 detail: "Specify a Forgejo repository as owner/repository or its full server URL.",
626 });
627 return { command, login: login.name, repository, baseUrl: login.url.replace(/\/+$/, "") };
628 });
629 const resolveRepository = (input: ForgejoRepositoryInput) => resolveTarget(input);
630 const api = Effect.fn("ForgejoCli.api")(function* (input: ForgejoApiInput) {
631 const repository = yield* resolveTarget(
632 input,
633 input.path.replace(/^\/+/, "") === "user" && (!input.method || input.method === "GET"),
634 );
635 const stdin =
636 input.body === undefined
637 ? undefined
638 : yield* encodeApiBody(input.body).pipe(
639 Effect.mapError(
640 (cause) =>
641 new ForgejoCliError({
642 command: "tea",
643 cwd: input.cwd,
644 detail: "Could not encode the Forgejo request body.",
645 cause,
646 }),
647 ),
648 );
649 let path = input.path.replace(/^\/+/, "");
650 if (input.repository && input.repository !== repository.repository) {
651 // Repository identities retain the server mount path; API routes do not.
652 const prefix = `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`;
653 if (path === prefix || path.startsWith(`${prefix}/`) || path.startsWith(`${prefix}?`)) {
654 path = `repos/${repository.repository.split("/").map(encodeURIComponent).join("/")}${path.slice(prefix.length)}`;
655 }
656 }
657 if (repository.command === "fj") {
658 const token = (yield* readKeys(input.cwd)).hosts[repository.login]?.token;
659 if (!token)
660 return yield* new ForgejoCliError({
661 command: "fj",
662 cwd: input.cwd,
663 reason: "authentication",
664 detail: "fj has no credentials for this server.",
665 });
666 return yield* requestFj({
667 cwd: input.cwd,
668 baseUrl: repository.baseUrl,
669 token,
670 path,
671 ...(input.method === undefined ? {} : { method: input.method }),
672 ...(stdin === undefined ? {} : { body: stdin }),
673 });
674 }
675 const result = yield* execute({
676 cwd: input.cwd,
677 args: [
678 "api",
679 "--include",
680 "--login",
681 repository.login,
682 ...(repository.repository ? ["--repo", repository.repository] : []),
683 "--method",
684 input.method ?? "GET",
685 ...(input.body === undefined ? [] : ["--data", "@-"]),
686 `${repository.baseUrl}/api/v1/${path}`,
687 ],
688 ...(stdin === undefined ? {} : { stdin }),
689 });
690 // tea reports HTTP failures with exit code zero; use its response status.
691 const status = Number(/^HTTP\/\S+ (\d{3})/m.exec(result.stderr)?.[1]);
692 if (!status || status >= 400)
693 return yield* new ForgejoCliError({
694 command: "tea",
695 cwd: input.cwd,
696 ...(status ? { httpStatus: status } : {}),
697 ...(status === 401
698 ? { reason: "authentication" as const }
699 : status === 403
700 ? { reason: "forbidden" as const }
701 : status === 404
702 ? { reason: "not-found" as const }
703 : status === 429
704 ? { reason: "rate-limit" as const }
705 : {}),
706 detail:
707 status === 401 || status === 403
708 ? "Forgejo denied access. Check this server's `tea login` credentials and permissions."
709 : status === 404
710 ? "Forgejo repository or pull request was not found."
711 : status === 429
712 ? "Forgejo API rate limit exceeded."
713 : `Forgejo API request failed${status ? ` (HTTP ${status})` : " without an HTTP status"}.`,
714 });
715 return result;
716 });
717 return ForgejoCli.of({ execute, listLogins, getAccount, resolveRepository, api });
718});
719
720export const layer = Layer.effect(ForgejoCli, make);
apps/server/src/sourceControl/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 VcsProcess from "../vcs/VcsProcess.ts";
7import * as ForgejoCli from "./ForgejoCli.ts";
8import * as SourceControlProvider from "./SourceControlProvider.ts";
9import {
10 providerAuth,
11 probeSourceControlProvider,
12 type SourceControlCliDiscoverySpec,
13 type SourceControlManagedCliDiscoverySpec,
14} from "./SourceControlProviderDiscovery.ts";
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+1347-2
......@@ -1,9 +1,14 @@
11import { assert, it } from "@effect/vitest";
22import * as NodeServices from "@effect/platform-node/NodeServices";
33import * as Effect from "effect/Effect";
4import type * as Context from "effect/Context";
5import * as FileSystem from "effect/FileSystem";
46import * as Layer from "effect/Layer";
57import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
610import { ChildProcessSpawner } from "effect/unstable/process";
11import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http";
712import { VcsProcessSpawnError } from "@t3tools/contracts";
813
914import * as ServerConfig from "../config.ts";
......@@ -13,8 +18,12 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
1318import * as BitbucketApi from "./BitbucketApi.ts";
1419import * as GitHubCli from "./GitHubCli.ts";
1520import * as GitLabCli from "./GitLabCli.ts";
21import * as ForgejoCli from "./ForgejoCli.ts";
22import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
23import * as ForgejoPullRequestProvider from "../pullRequest/ForgejoPullRequestProvider.ts";
1624import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
1725import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
26import { firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts";
1827
1928const sourceControlProviderRegistryTestLayer = (input: {
2029 readonly bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>;
......@@ -30,6 +39,7 @@ const sourceControlProviderRegistryTestLayer = (input: {
3039 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
3140 Layer.mock(GitHubCli.GitHubCli)({}),
3241 Layer.mock(GitLabCli.GitLabCli)({}),
42 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
3343 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
3444 Layer.mock(VcsProcess.VcsProcess)(input.process),
3545 ),
......@@ -50,6 +60,314 @@ const processOutput = (
5060 stderrTruncated: false,
5161});
5262
63const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
64const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown));
65
66it.effect("submits a Forgejo review without sending its summary in the preliminary GET", () => {
67 const methods: string[] = [];
68 const fetchReview = async (
69 ...[input, init]: Parameters<Context.Service.Shape<typeof FetchHttpClient.Fetch>>
70 ) => {
71 const request = new Request(input instanceof Request ? input.url : String(input), {
72 ...(init?.method === undefined ? {} : { method: init.method }),
73 ...(init?.headers === undefined ? {} : { headers: init.headers }),
74 ...(init?.body === undefined ? {} : { body: init.body }),
75 });
76 methods.push(request.method);
77 if (request.method === "GET") {
78 assert.strictEqual(request.url, "https://forgejo.test/api/v1/repos/maria/project/pulls/42");
79 return new Response(
80 encodeJson({
81 number: 42,
82 title: "Review target",
83 body: "",
84 html_url: "https://forgejo.test/maria/project/pulls/42",
85 user: { login: "maria" },
86 state: "open",
87 merged: false,
88 head: { ref: "feature", sha: "head", repo: null },
89 base: { ref: "main", sha: "base", repo: null },
90 created_at: "2026-09-13T00:00:00Z",
91 updated_at: "2026-09-13T00:00:00Z",
92 closed_at: null,
93 merged_at: null,
94 labels: [],
95 }),
96 );
97 }
98 assert.strictEqual(request.method, "POST");
99 assert.strictEqual(
100 request.url,
101 "https://forgejo.test/api/v1/repos/maria/project/pulls/42/reviews",
102 );
103 assert.deepStrictEqual(JSON.parse(await request.text()), {
104 event: "COMMENT",
105 body: "Review summary",
106 commit_id: "head",
107 comments: [],
108 });
109 return new Response('{"id":1}', { status: 200 });
110 };
111 return Effect.gen(function* () {
112 const cli = yield* ForgejoCli.make;
113 const provider = yield* ForgejoPullRequestProvider.make.pipe(
114 Effect.provideService(ForgejoCli.ForgejoCli, cli),
115 );
116 yield* provider.submitReview({
117 cwd: "/repo",
118 repository: "maria/project",
119 host: "forgejo.test",
120 number: 42,
121 verdict: "comment",
122 body: "Review summary",
123 comments: [],
124 });
125 assert.deepStrictEqual(methods, ["GET", "POST"]);
126 }).pipe(
127 Effect.provideService(
128 FetchHttpClient.Fetch,
129 Object.assign(fetchReview, { preconnect: () => undefined }),
130 ),
131 Effect.provide(FetchHttpClient.layer),
132 Effect.provideService(
133 FileSystem.FileSystem,
134 FileSystem.makeNoop({
135 exists: () => Effect.succeed(true),
136 readFileString: () =>
137 Effect.succeed(
138 encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }),
139 ),
140 }),
141 ),
142 Effect.provide(
143 Layer.mock(VcsProcess.VcsProcess)({
144 run: (input) => {
145 if (input.command === "git") {
146 assert.deepStrictEqual(input.args, ["remote", "-v"]);
147 return Effect.succeed(
148 processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"),
149 );
150 }
151 assert.strictEqual(input.command, "fj");
152 assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]);
153 return Effect.succeed(processOutput(""));
154 },
155 }),
156 ),
157 );
158});
159
160it.effect("loads Forgejo pull request references from files and commits views", () =>
161 Effect.gen(function* () {
162 const provider = yield* ForgejoSourceControlProvider.make;
163 for (const reference of [
164 "42",
165 "#42",
166 "https://forgejo.test/maria/project/pulls/42",
167 "https://forgejo.test/maria/project/pulls/42/",
168 "https://forgejo.test/maria/project/pulls/42/files?w=1#diff-1",
169 "http://forgejo.test:3000/git/maria/project/pulls/42/commits",
170 ]) {
171 const result = yield* provider.getChangeRequest({ cwd: "/repo", reference });
172 assert.strictEqual(result.number, 42);
173 assert.strictEqual(result.title, "Forgejo view reference");
174 }
175 const invalid = yield* provider
176 .getChangeRequest({
177 cwd: "/repo",
178 reference: "https://forgejo.test/maria/project/pulls/42invalid/files",
179 })
180 .pipe(Effect.result);
181 assert.strictEqual(invalid._tag, "Failure");
182 }).pipe(
183 Effect.provide(
184 Layer.mergeAll(
185 Layer.succeed(FileSystem.FileSystem, FileSystem.makeNoop({})),
186 Layer.mock(VcsProcess.VcsProcess)({}),
187 Layer.mock(ForgejoCli.ForgejoCli)({
188 resolveRepository: () =>
189 Effect.succeed({
190 login: "work",
191 repository: "maria/project",
192 baseUrl: "https://forgejo.test",
193 }),
194 api: (input) => {
195 assert.strictEqual(input.path, "repos/maria/project/pulls/42");
196 return encodeJsonEffect({
197 number: 42,
198 title: "Forgejo view reference",
199 html_url: "https://forgejo.test/maria/project/pulls/42",
200 state: "open",
201 merged: false,
202 base: { ref: "main", sha: "base", repo: null },
203 head: { ref: "feature", sha: "head", repo: null },
204 }).pipe(Effect.orDie, Effect.map(processOutput));
205 },
206 }),
207 ),
208 ),
209 ),
210);
211
212it.effect(
213 "loads Forgejo reactions on comments, reviews and inline threads and resolves review mutations",
214 () => {
215 const user = { login: "maria" };
216 const review = {
217 id: 8,
218 body: "review body",
219 user,
220 state: "COMMENT",
221 submitted_at: "2026-09-12T00:00:00Z",
222 html_url: "https://forgejo.test/maria/project/pulls/2#issuecomment-37",
223 comments_count: 1,
224 };
225 const comment = { id: 12, body: "ordinary", user, created_at: review.submitted_at };
226 const responses: Record<string, unknown> = {
227 user,
228 "repos/maria/project/issues/2/comments": [comment],
229 "repos/maria/project/pulls/2/reviews": [review],
230 "repos/maria/project/pulls/2/reviews/8": review,
231 "repos/maria/project/pulls/2/reviews/9": { ...review, id: 9, html_url: "" },
232 "repos/maria/project/pulls/2/commits": [],
233 "repos/maria/project/issues/2/reactions": [],
234 "repos/maria/project/pulls/2/reviews/8/comments": [
235 {
236 ...comment,
237 id: 38,
238 body: "inline",
239 path: "file.ts",
240 position: 1,
241 original_position: 1,
242 commit_id: "head",
243 original_commit_id: "head",
244 resolver: null,
245 },
246 ],
247 "repos/maria/project/issues/comments/12/reactions": [{ content: "+1", user }],
248 "repos/maria/project/issues/comments/37/reactions": [{ content: "heart", user }],
249 "repos/maria/project/issues/comments/38/reactions": [
250 { content: "rocket", user: { login: "reviewer" } },
251 ],
252 };
253 const writes: ForgejoCli.ForgejoApiInput[] = [];
254 let reactionReads = 0;
255 return Effect.gen(function* () {
256 const provider = yield* ForgejoPullRequestProvider.make;
257 const input = { cwd: "/repo", repository: "maria/project", host: "forgejo.test", number: 2 };
258 const activity = yield* provider.getChangeRequestActivity(input);
259 assert.deepStrictEqual(
260 activity.comments.map((entry) => ({
261 id: entry.id,
262 kind: entry.kind,
263 reactions: entry.reactions,
264 })),
265 [
266 {
267 id: "12",
268 kind: "issue-comment",
269 reactions: [{ content: "thumbs-up", count: 1, actors: [], viewerHasReacted: true }],
270 },
271 {
272 id: "review:8",
273 kind: "review",
274 reactions: [{ content: "heart", count: 1, actors: [], viewerHasReacted: true }],
275 },
276 {
277 id: "38",
278 kind: "review-comment",
279 reactions: [
280 { content: "rocket", count: 1, actors: ["reviewer"], viewerHasReacted: false },
281 ],
282 },
283 ],
284 );
285 const inlineComment = activity.comments[2];
286 assert.ok(inlineComment);
287 assert.deepStrictEqual(activity.reviewThreads[0]?.comments, [inlineComment]);
288 for (const reacted of [true, false]) {
289 yield* provider.setReaction({ ...input, subjectId: "review:8", content: "heart", reacted });
290 yield* provider.setReaction({ ...input, subjectId: "38", content: "rocket", reacted });
291 }
292 assert.deepStrictEqual(
293 writes.map(({ path, method, body }) => ({ path, method, body })),
294 [
295 {
296 path: "repos/maria/project/issues/comments/37/reactions",
297 method: "POST",
298 body: { content: "heart" },
299 },
300 {
301 path: "repos/maria/project/issues/comments/38/reactions",
302 method: "POST",
303 body: { content: "rocket" },
304 },
305 {
306 path: "repos/maria/project/issues/comments/37/reactions",
307 method: "DELETE",
308 body: { content: "heart" },
309 },
310 {
311 path: "repos/maria/project/issues/comments/38/reactions",
312 method: "DELETE",
313 body: { content: "rocket" },
314 },
315 ],
316 );
317 const missing = yield* provider
318 .setReaction({ ...input, subjectId: "review:9", content: "heart", reacted: true })
319 .pipe(Effect.result);
320 assert.strictEqual(missing._tag, "Failure");
321 if (missing._tag === "Failure") assert.include(missing.failure.detail, "comment ID");
322 assert.strictEqual(writes.length, 4);
323 responses["repos/maria/project/pulls/2/reviews/8/comments"] = Array.from(
324 { length: 501 },
325 (_, index) => ({
326 ...comment,
327 id: 1000 + index,
328 path: "file.ts",
329 position: 1,
330 original_position: 1,
331 commit_id: "head",
332 original_commit_id: "head",
333 resolver: null,
334 }),
335 );
336 for (let index = 0; index < 500; index++) {
337 responses[`repos/maria/project/issues/comments/${1000 + index}/reactions`] = [];
338 }
339 reactionReads = 0;
340 const bounded = yield* provider.getChangeRequestActivity(input);
341 assert.strictEqual(bounded.reviewThreads.length, 500);
342 assert.strictEqual(
343 bounded.comments.filter((entry) => entry.kind === "review-comment").length,
344 500,
345 );
346 assert.strictEqual(bounded.commentsTruncated, true);
347 assert.strictEqual(reactionReads, 502);
348 }).pipe(
349 Effect.provide(
350 Layer.mock(ForgejoCli.ForgejoCli)({
351 api: (input) => {
352 if (input.method) {
353 writes.push(input);
354 return Effect.succeed(processOutput("{}"));
355 }
356 const path = input.path.split("?")[0]!;
357 if (/\/issues\/comments\/\d+\/reactions$/.test(path)) reactionReads++;
358 assert.ok(Object.hasOwn(responses, path), `Unexpected Forgejo request: ${path}`);
359 const page = Number(new URLSearchParams(input.path.split("?")[1]).get("page"));
360 return encodeJsonEffect(page > 1 ? [] : responses[path]).pipe(
361 Effect.orDie,
362 Effect.map(processOutput),
363 );
364 },
365 }),
366 ),
367 );
368 },
369);
370
53371it.effect("reports implemented tools separately from locally available executables", () => {
54372 const processMock = {
55373 run: (input: VcsProcess.VcsProcessInput) => {
......@@ -62,7 +380,7 @@ it.effect("reports implemented tools separately from locally available executabl
62380 if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {
63381 return Effect.succeed(
64382 processOutput(
65 JSON.stringify({
383 encodeJson({
66384 hosts: {
67385 "github.com": [
68386 {
......@@ -161,6 +479,12 @@ it.effect("reports implemented tools separately from locally available executabl
161479 auth: "unauthenticated",
162480 account: Option.none(),
163481 },
482 {
483 kind: "forgejo",
484 status: "missing",
485 auth: "unknown",
486 account: Option.none(),
487 },
164488 ],
165489 );
166490 const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket");
......@@ -178,7 +502,7 @@ it.effect("probes provider authentication without exposing token details", () =>
178502 if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {
179503 return Effect.succeed(
180504 processOutput(
181 JSON.stringify({
505 encodeJson({
182506 hosts: {
183507 "github.com": [
184508 {
......@@ -202,6 +526,22 @@ Logged in to gitlab.com as gitlab-user
202526`),
203527 );
204528 }
529 if (input.command === "tea" && input.args[0] === "login") {
530 return Effect.succeed(
531 processOutput(
532 encodeJson([
533 {
534 name: "forgejo",
535 url: "https://forgejo.example.com",
536 ssh_host: "forgejo.example.com",
537 user: "forgejo-user",
538 valid: "true",
539 default: "true",
540 },
541 ]),
542 ),
543 );
544 }
205545 if (
206546 input.command === "az" &&
207547 input.args.join(" ") === "account show --query user.name -o tsv"
......@@ -277,7 +617,1012 @@ Logged in to gitlab.com as gitlab-user
277617 account: Option.some("bitbucket-user"),
278618 detail: Option.none(),
279619 },
620 {
621 kind: "forgejo",
622 auth: "authenticated",
623 account: Option.some("forgejo-user"),
624 detail: Option.none(),
625 },
280626 ],
281627 );
282628 }).pipe(Effect.provide(testLayer));
283629});
630
631it.effect("discovers Forgejo accounts and retains the server port", () =>
632 Effect.gen(function* () {
633 const auth = ForgejoSourceControlProvider.discovery.parseAuth(
634 processOutput(
635 yield* encodeJsonEffect([
636 {
637 name: "work",
638 url: "http://forgejo.local:3000",
639 ssh_host: "git.forgejo.local",
640 user: "maria",
641 default: "true",
642 valid: "true",
643 },
644 ]),
645 ),
646 );
647 assert.deepStrictEqual(
648 firstNonEmptyLine("\u001b[1mtea version 0.16.0\u001b[0m\n"),
649 Option.some("tea version 0.16.0"),
650 );
651 assert.strictEqual(auth.status, "authenticated");
652 assert.deepStrictEqual(auth.account, Option.some("maria"));
653 assert.deepStrictEqual(auth.host, Option.some("forgejo.local:3000"));
654 const revoked = ForgejoSourceControlProvider.discovery.parseAuth(
655 processOutput(
656 encodeJson([
657 {
658 name: "work",
659 url: "http://forgejo.local:3000",
660 user: "maria",
661 default: "true",
662 valid: "false",
663 },
664 ]),
665 ),
666 );
667 assert.strictEqual(revoked.status, "unauthenticated");
668 const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
669 cwd: "/repo",
670 context: {
671 provider: {
672 kind: "unknown",
673 name: "git.forgejo.local",
674 baseUrl: "https://git.forgejo.local",
675 },
676 remoteName: "origin",
677 remoteUrl: "git@git.forgejo.local:maria/project.git",
678 },
679 auth: processOutput(
680 yield* encodeJsonEffect([
681 {
682 name: "work",
683 url: "http://forgejo.local:3000",
684 ssh_host: "git.forgejo.local",
685 user: "maria",
686 default: "true",
687 },
688 ]),
689 ),
690 });
691 assert.deepStrictEqual(refined, {
692 kind: "forgejo",
693 name: "Forgejo / Gitea",
694 baseUrl: "http://forgejo.local:3000",
695 });
696 }),
697);
698
699it.effect("does not choose a default Forgejo login across ambiguous SSH server ports", () =>
700 Effect.gen(function* () {
701 const logins = ForgejoCli.parseForgejoLogins(
702 yield* encodeJsonEffect([
703 {
704 name: "one",
705 url: "http://forgejo.local:3000",
706 ssh_host: "forgejo.local",
707 user: "maria",
708 default: "true",
709 },
710 {
711 name: "two",
712 url: "http://forgejo.local:4000",
713 ssh_host: "forgejo.local",
714 user: "maria",
715 default: "false",
716 },
717 ]),
718 );
719 const remote = ForgejoCli.parseForgejoRemote("git@forgejo.local:maria/project.git");
720 assert.isNotNull(remote);
721 assert.deepStrictEqual(
722 ForgejoCli.parseForgejoRemote("forgejo.local:maria/project.git"),
723 remote,
724 );
725 assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!));
726 assert.strictEqual(
727 ForgejoCli.matchForgejoLogin(logins, remote!, "forgejo.local:4000")?.name,
728 "two",
729 );
730 assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, remote!, "other.local:4000"));
731 const alias = ForgejoCli.parseForgejoRemote("git@ssh.forgejo.local:maria/project.git");
732 assert.isNotNull(alias);
733 assert.isUndefined(ForgejoCli.matchForgejoLogin(logins, alias!, "forgejo.local:4000"));
734 const refined = ForgejoSourceControlProvider.discovery.refineUnknownRemote({
735 cwd: "/repo",
736 context: {
737 provider: { kind: "unknown", name: "Forgejo", baseUrl: "https://forgejo.local" },
738 remoteName: "origin",
739 remoteUrl: "git@forgejo.local:maria/project.git",
740 requestedHost: "forgejo.local:4000",
741 },
742 auth: processOutput(yield* encodeJsonEffect(logins)),
743 });
744 assert.strictEqual(refined?.baseUrl, "http://forgejo.local:4000");
745 const https = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000/maria/project.git");
746 assert.isNotNull(https);
747 assert.strictEqual(ForgejoCli.matchForgejoLogin(logins, https!)?.name, "two");
748 const hostOnly = ForgejoCli.parseForgejoRemote("http://forgejo.local:4000");
749 assert.strictEqual(
750 ForgejoCli.matchForgejoLogin(logins, hostOnly!, undefined, true)?.name,
751 "two",
752 );
753 const mounted = logins.map((login) => ({
754 ...login,
755 url: `http://forgejo.local:4000/${login.name}`,
756 }));
757 assert.isUndefined(ForgejoCli.matchForgejoLogin(mounted, hostOnly!, undefined, true));
758 }),
759);
760
761it.effect("rejects HTTP failures even when tea exits successfully", () =>
762 Effect.gen(function* () {
763 const cli = yield* ForgejoCli.make;
764 const result = yield* cli
765 .api({
766 cwd: "/repo",
767 repository: "http://forgejo.local:3000/maria/project",
768 path: "repos/maria/project/pulls/42",
769 method: "PATCH",
770 body: { state: "closed" },
771 })
772 .pipe(Effect.result);
773 assert.strictEqual(result._tag, "Failure");
774 if (result._tag === "Failure")
775 assert.strictEqual(
776 result.failure.detail,
777 "Forgejo repository or pull request was not found.",
778 );
779 }).pipe(
780 Effect.provideService(
781 FileSystem.FileSystem,
782 FileSystem.makeNoop({
783 exists: () => Effect.succeed(false),
784 }),
785 ),
786 Effect.provideService(
787 HttpClient.HttpClient,
788 HttpClient.make(() => {
789 throw new Error("tea must handle its own HTTP request");
790 }),
791 ),
792 Effect.provide(
793 Layer.mock(VcsProcess.VcsProcess)({
794 run: (input) => {
795 if (input.args[0] === "api") {
796 assert.strictEqual(input.stdin, '{"state":"closed"}');
797 assert.include(input.args, "work");
798 assert.include(
799 input.args,
800 "http://forgejo.local:3000/api/v1/repos/maria/project/pulls/42",
801 );
802 }
803 return Effect.succeed(
804 input.args[0] === "login"
805 ? processOutput(
806 encodeJson([
807 {
808 name: "work",
809 url: "http://forgejo.local:3000",
810 ssh_host: "forgejo.local",
811 user: "maria",
812 default: "true",
813 },
814 ]),
815 )
816 : processOutput('{"message":"not found"}', { stderr: "HTTP/1.1 404 Not Found\n" }),
817 );
818 },
819 }),
820 ),
821 ),
822);
823
824it.effect("routes mounted Forgejo repositories without repeating the mount in API paths", () =>
825 Effect.gen(function* () {
826 const cli = yield* ForgejoCli.make;
827 const viewer = yield* cli.api({ cwd: "/upstream-only", host: "code.test", path: "user" });
828 assert.strictEqual(viewer.stdout, "[]");
829 const mountedRepository = yield* cli.resolveRepository({
830 cwd: "/upstream-only",
831 host: "code.test",
832 repository: "maria/project",
833 });
834 assert.strictEqual(mountedRepository.baseUrl, "https://code.test/forgejo");
835 assert.strictEqual(mountedRepository.repository, "maria/project");
836 for (const path of [
837 "repos/forgejo/maria/project/pulls?state=open",
838 "repos/forgejo/maria/project",
839 "repos/reviewer/project/contents/file.ts",
840 ]) {
841 const result = yield* cli.api({
842 cwd: "/repo",
843 repository: "forgejo/maria/project",
844 context: {
845 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" },
846 remoteName: "origin",
847 remoteUrl: "https://code.test/forgejo/maria/project.git",
848 },
849 path,
850 });
851 assert.strictEqual(result.stdout, "[]");
852 }
853 const sameOwnerAsMount = yield* cli.resolveRepository({
854 cwd: "/repo",
855 repository: "forgejo/project",
856 context: {
857 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://code.test/forgejo" },
858 remoteName: "origin",
859 remoteUrl: "ssh://git@code.test/forgejo/project.git",
860 },
861 });
862 assert.strictEqual(sameOwnerAsMount.command, "tea");
863 assert.strictEqual(sameOwnerAsMount.repository, "forgejo/project");
864 }).pipe(
865 Effect.provideService(
866 FileSystem.FileSystem,
867 FileSystem.makeNoop({
868 exists: () => Effect.succeed(true),
869 readFileString: () =>
870 Effect.succeed(
871 encodeJson({
872 hosts: { "code.test/forgejo": { type: "Application", token: "test-token" } },
873 }),
874 ),
875 }),
876 ),
877 Effect.provideService(
878 HttpClient.HttpClient,
879 HttpClient.make(() => {
880 throw new Error("tea must handle its own HTTP request");
881 }),
882 ),
883 Effect.provide(
884 Layer.mock(VcsProcess.VcsProcess)({
885 run: (input) => {
886 if (input.command === "git")
887 return Effect.succeed(processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }));
888 if (input.args[0] === "login")
889 return Effect.succeed(
890 processOutput(
891 encodeJson([
892 {
893 name: "mounted",
894 url: "https://code.test/forgejo",
895 ssh_host: "code.test",
896 user: "maria",
897 default: "true",
898 },
899 ]),
900 ),
901 );
902 const supported = [
903 "https://code.test/forgejo/api/v1/user",
904 "https://code.test/forgejo/api/v1/repos/maria/project/pulls?state=open",
905 "https://code.test/forgejo/api/v1/repos/maria/project",
906 "https://code.test/forgejo/api/v1/repos/reviewer/project/contents/file.ts",
907 ];
908 if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo");
909 assert.strictEqual(input.command, "tea");
910 return Effect.succeed(
911 supported.includes(input.args.at(-1) ?? "")
912 ? processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" })
913 : processOutput("{}", { stderr: "HTTP/1.1 404 Not Found\n" }),
914 );
915 },
916 }),
917 ),
918 ),
919);
920
921it.effect("prefers fj for HTTP and ported SSH aliases on root servers", () => {
922 const commands: string[] = [];
923 const requests: string[] = [];
924 return Effect.gen(function* () {
925 const cli = yield* ForgejoCli.make;
926 for (const remoteUrl of [
927 "http://forgejo.local:3000/maria/project.git",
928 "ssh://git@ssh.forgejo.local:2222/maria/project.git",
929 "ssh://git@forgejo.local:2222/maria/project.git",
930 ]) {
931 const result = yield* cli.api({
932 cwd: "/repo",
933 repository: "maria/project",
934 context: {
935 provider: {
936 kind: "forgejo",
937 name: "Forgejo",
938 baseUrl: "http://forgejo.local:3000",
939 },
940 remoteName: "origin",
941 remoteUrl,
942 requestedHost: "forgejo.local:3000",
943 },
944 path: "repos/maria/project/issues/42/comments",
945 method: "POST",
946 body: { body: "verified through fj" },
947 });
948 assert.strictEqual(result.stdout, '{"id":99}');
949 }
950 assert.deepStrictEqual(commands, ["fj"]);
951 assert.deepStrictEqual(requests, [
952 "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
953 "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
954 "http://forgejo.local:3000/api/v1/repos/maria/project/issues/42/comments",
955 ]);
956 const viewer = yield* cli.api({
957 cwd: "/no-remotes",
958 host: "forgejo.local:3000",
959 path: "user",
960 });
961 assert.strictEqual(viewer.stdout, '{"login":"maria"}');
962 assert.strictEqual(requests.at(-1), "https://forgejo.local:3000/api/v1/user");
963 const upstreamViewer = yield* cli.api({
964 cwd: "/upstream-only",
965 host: "forgejo.local:3000",
966 path: "user",
967 });
968 assert.strictEqual(upstreamViewer.stdout, '{"login":"maria"}');
969 assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user");
970 const upstreamRepository = yield* cli.resolveRepository({
971 cwd: "/upstream-only",
972 host: "forgejo.local:3000",
973 repository: "maria/project",
974 });
975 assert.strictEqual(upstreamRepository.baseUrl, "http://forgejo.local:3000");
976 assert.strictEqual(upstreamRepository.repository, "maria/project");
977 const httpViewer = yield* cli.api({ cwd: "/repo", host: "forgejo.local:3000", path: "user" });
978 assert.strictEqual(httpViewer.stdout, '{"login":"maria"}');
979 assert.strictEqual(requests.at(-1), "http://forgejo.local:3000/api/v1/user");
980 }).pipe(
981 Effect.provideService(
982 FileSystem.FileSystem,
983 FileSystem.makeNoop({
984 exists: () => Effect.succeed(true),
985 readFileString: () =>
986 Effect.succeed(
987 encodeJson({
988 hosts: {
989 "forgejo.local:3000": { type: "Application", token: "test-token" },
990 "forgejo.local:4000": { type: "Application", token: "other-token" },
991 },
992 aliases: { "ssh.forgejo.local:2222": "forgejo.local:3000" },
993 }),
994 ),
995 }),
996 ),
997 Effect.provideService(
998 HttpClient.HttpClient,
999 HttpClient.make((request) => {
1000 requests.push(request.url);
1001 if (request.url.endsWith("/user")) {
1002 assert.strictEqual(request.method, "GET");
1003 assert.strictEqual(request.headers.authorization, "token test-token");
1004 return Effect.succeed(
1005 HttpClientResponse.fromWeb(request, new Response('{"login":"maria"}')),
1006 );
1007 }
1008 assert.strictEqual(request.method, "POST");
1009 assert.strictEqual(request.headers.authorization, "token test-token");
1010 assert.strictEqual(request.body._tag, "Uint8Array");
1011 if (request.body._tag === "Uint8Array")
1012 assert.deepStrictEqual(JSON.parse(new TextDecoder().decode(request.body.body)), {
1013 body: "verified through fj",
1014 });
1015 return Effect.succeed(
1016 HttpClientResponse.fromWeb(request, new Response('{"id":99}', { status: 201 })),
1017 );
1018 }),
1019 ),
1020 Effect.provide(
1021 Layer.mock(VcsProcess.VcsProcess)({
1022 run: (input) => {
1023 commands.push(input.command);
1024 if (input.command === "git")
1025 return Effect.succeed(
1026 input.cwd === "/no-remotes"
1027 ? processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) })
1028 : processOutput(
1029 `${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)`,
1030 ),
1031 );
1032 assert.strictEqual(input.command, "fj");
1033 assert.deepStrictEqual(input.args, [
1034 "--host",
1035 input.cwd === "/no-remotes"
1036 ? "https://forgejo.local:3000"
1037 : "http://forgejo.local:3000",
1038 "whoami",
1039 ]);
1040 return Effect.succeed(processOutput(""));
1041 },
1042 }),
1043 ),
1044 );
1045});
1046
1047it.effect("loads later fj review pages when the server caps pages below the requested size", () => {
1048 const pages: number[] = [];
1049 let issueCommentRequests = 0;
1050 return Effect.gen(function* () {
1051 const cli = yield* ForgejoCli.make;
1052 const provider = yield* ForgejoPullRequestProvider.make.pipe(
1053 Effect.provideService(ForgejoCli.ForgejoCli, cli),
1054 );
1055 const activity = yield* provider.getChangeRequestActivity({
1056 cwd: "/repo",
1057 repository: "maria/project",
1058 host: "forgejo.test",
1059 number: 42,
1060 });
1061 assert.strictEqual(activity.commentCount, 42);
1062 assert.strictEqual(activity.comments.at(-1)?.id, "review:41");
1063 assert.strictEqual(activity.commentsTruncated, false);
1064 assert.deepStrictEqual(pages, [1, 2, 3]);
1065 assert.strictEqual(issueCommentRequests, 1);
1066 }).pipe(
1067 Effect.provideService(
1068 FileSystem.FileSystem,
1069 FileSystem.makeNoop({
1070 exists: () => Effect.succeed(true),
1071 readFileString: () =>
1072 Effect.succeed(
1073 encodeJson({ hosts: { "forgejo.test": { type: "Application", token: "test-token" } } }),
1074 ),
1075 }),
1076 ),
1077 Effect.provideService(
1078 HttpClient.HttpClient,
1079 HttpClient.make((request) => {
1080 const url = new URL(request.url);
1081 if (url.pathname === "/api/v1/repos/maria/project/issues/42/comments") {
1082 issueCommentRequests++;
1083 return Effect.succeed(
1084 HttpClientResponse.fromWeb(
1085 request,
1086 new Response(
1087 encodeJson([
1088 {
1089 id: 100,
1090 body: "Unpaginated issue comment",
1091 user: { login: "maria" },
1092 created_at: "2026-09-13T00:00:00Z",
1093 },
1094 ]),
1095 ),
1096 ),
1097 );
1098 }
1099 if (url.pathname.endsWith("/reviews")) {
1100 const page = Number(url.searchParams.get("page"));
1101 pages.push(page);
1102 assert.ok(page >= 1 && page <= 3);
1103 const reviews = Array.from({ length: page < 3 ? 20 : 1 }, (_, index) => ({
1104 id: (page - 1) * 20 + index + 1,
1105 body: "Review from a capped page",
1106 user: { login: "maria" },
1107 state: "COMMENT",
1108 submitted_at: "2026-09-13T00:00:00Z",
1109 comments_count: 0,
1110 }));
1111 return Effect.succeed(
1112 HttpClientResponse.fromWeb(
1113 request,
1114 new Response(encodeJson(reviews), {
1115 headers:
1116 page === 2
1117 ? {}
1118 : {
1119 Link:
1120 page === 1
1121 ? `<${url.origin}${url.pathname}?limit=50&page=2>; rel="next"`
1122 : `<${url.origin}${url.pathname}?limit=50&page=1>; rel="prev"`,
1123 },
1124 }),
1125 ),
1126 );
1127 }
1128 return Effect.succeed(
1129 HttpClientResponse.fromWeb(
1130 request,
1131 new Response(encodeJson(url.pathname === "/api/v1/user" ? { login: "maria" } : [])),
1132 ),
1133 );
1134 }),
1135 ),
1136 Effect.provide(
1137 Layer.mock(VcsProcess.VcsProcess)({
1138 run: (input) => {
1139 if (input.command === "git") {
1140 assert.deepStrictEqual(input.args, ["remote", "-v"]);
1141 return Effect.succeed(
1142 processOutput("origin\thttps://forgejo.test/maria/project.git (fetch)"),
1143 );
1144 }
1145 assert.strictEqual(input.command, "fj");
1146 assert.deepStrictEqual(input.args, ["--host", "https://forgejo.test", "whoami"]);
1147 return Effect.succeed(processOutput(""));
1148 },
1149 }),
1150 ),
1151 );
1152});
1153
1154it.effect("falls back to tea when fj is missing or has no account for this server", () =>
1155 Effect.gen(function* () {
1156 for (const scenario of ["missing-cli", "missing-account", "stale-invalid-storage"] as const) {
1157 const commands: string[] = [];
1158 yield* Effect.gen(function* () {
1159 const cli = yield* ForgejoCli.make;
1160 const result = yield* cli.api({
1161 cwd: "/repo",
1162 repository: "https://forgejo.local:3000/maria/project",
1163 path: "repos/maria/project/pulls",
1164 });
1165 assert.strictEqual(result.stdout, "[]");
1166 assert.deepStrictEqual(
1167 commands,
1168 scenario === "missing-account" ? ["tea", "tea"] : ["fj", "tea", "tea"],
1169 );
1170 const viewer = yield* cli.api({
1171 cwd: "/upstream-only",
1172 host: "forgejo.local:3000",
1173 path: "user",
1174 });
1175 assert.strictEqual(viewer.stdout, "[]");
1176 }).pipe(
1177 Effect.provideService(
1178 FileSystem.FileSystem,
1179 FileSystem.makeNoop({
1180 exists: () => Effect.succeed(true),
1181 readFileString: () =>
1182 Effect.succeed(
1183 scenario === "stale-invalid-storage"
1184 ? "invalid json"
1185 : encodeJson({
1186 hosts: {
1187 [scenario === "missing-cli" ? "forgejo.local:3000" : "other.local"]: {
1188 type: "Application",
1189 token: "test-token",
1190 },
1191 },
1192 }),
1193 ),
1194 }),
1195 ),
1196 Effect.provideService(
1197 HttpClient.HttpClient,
1198 HttpClient.make(() => {
1199 throw new Error("tea must handle its own HTTP request");
1200 }),
1201 ),
1202 Effect.provide(
1203 Layer.mock(VcsProcess.VcsProcess)({
1204 run: (input) => {
1205 commands.push(input.command);
1206 if (input.command === "git")
1207 return Effect.succeed(
1208 processOutput("", { exitCode: ChildProcessSpawner.ExitCode(2) }),
1209 );
1210 if (input.command === "fj")
1211 return Effect.fail(
1212 new VcsProcessSpawnError({
1213 operation: input.operation,
1214 command: input.command,
1215 cwd: input.cwd,
1216 cause: new Error("fj not found"),
1217 }),
1218 );
1219 assert.strictEqual(input.command, "tea");
1220 if (input.args.at(-1)?.endsWith("/user")) assert.notInclude(input.args, "--repo");
1221 return Effect.succeed(
1222 input.args[0] === "login"
1223 ? processOutput(
1224 encodeJson([
1225 {
1226 name: "work",
1227 url: "https://forgejo.local:3000",
1228 user: "maria",
1229 default: "true",
1230 valid: "true",
1231 },
1232 ]),
1233 )
1234 : processOutput("[]", { stderr: "HTTP/1.1 200 OK\n" }),
1235 );
1236 },
1237 }),
1238 ),
1239 );
1240 }
1241 }),
1242);
1243
1244it.effect("handles fj mutation statuses without retrying failures or reading absent bodies", () =>
1245 Effect.gen(function* () {
1246 for (const status of [204, 205, 302, 401, 403, 404, 429, 500]) {
1247 let writes = 0;
1248 yield* Effect.gen(function* () {
1249 const cli = yield* ForgejoCli.make;
1250 const result = yield* cli
1251 .api({
1252 cwd: "/repo",
1253 repository: "https://forgejo.local/maria/project",
1254 path: "repos/maria/project/issues/42/comments",
1255 method: "POST",
1256 body: { body: "only once" },
1257 })
1258 .pipe(Effect.result);
1259 assert.strictEqual(result._tag, status < 300 ? "Success" : "Failure");
1260 if (result._tag === "Success") assert.strictEqual(result.success.stdout, "");
1261 if (result._tag === "Failure") {
1262 assert.strictEqual(result.failure.command, "fj");
1263 assert.strictEqual(result.failure.httpStatus, status);
1264 }
1265 assert.strictEqual(writes, 1);
1266 }).pipe(
1267 Effect.provideService(
1268 FileSystem.FileSystem,
1269 FileSystem.makeNoop({
1270 exists: () => Effect.succeed(true),
1271 readFileString: () =>
1272 Effect.succeed(
1273 encodeJson({
1274 hosts: {
1275 "forgejo.local": { type: "Application", token: "test-token" },
1276 },
1277 }),
1278 ),
1279 }),
1280 ),
1281 Effect.provideService(
1282 HttpClient.HttpClient,
1283 HttpClient.make((request) => {
1284 writes++;
1285 assert.strictEqual(
1286 request.url,
1287 "https://forgejo.local/api/v1/repos/maria/project/issues/42/comments",
1288 );
1289 return Effect.succeed(
1290 HttpClientResponse.fromWeb(
1291 request,
1292 new Response(status < 300 ? null : "", {
1293 status,
1294 headers: { location: "https://other.local/" },
1295 }),
1296 ),
1297 );
1298 }),
1299 ),
1300 Effect.provide(
1301 Layer.mock(VcsProcess.VcsProcess)({
1302 run: (input) => {
1303 assert.strictEqual(
1304 input.command,
1305 "fj",
1306 "a failed mutation must never switch accounts or CLI",
1307 );
1308 return Effect.succeed(processOutput(""));
1309 },
1310 }),
1311 ),
1312 );
1313 }
1314 }),
1315);
1316
1317it.effect(
1318 "discovers fj first and retains configured authentication failures instead of switching accounts",
1319 () =>
1320 Effect.gen(function* () {
1321 for (const scenario of ["authenticated", "revoked", "missing", "invalid-storage"] as const) {
1322 const commands: string[] = [];
1323 yield* Effect.gen(function* () {
1324 const spec = yield* ForgejoSourceControlProvider.makeDiscovery;
1325 assert.strictEqual(spec.type, "managed-cli");
1326 if (spec.type !== "managed-cli") return;
1327 const result = yield* spec.probe("/repo");
1328 assert.strictEqual(result.executable, scenario === "missing" ? "tea" : "fj");
1329 assert.strictEqual(
1330 result.auth.status,
1331 scenario === "revoked"
1332 ? "unauthenticated"
1333 : scenario === "invalid-storage"
1334 ? "unknown"
1335 : "authenticated",
1336 );
1337 if (scenario !== "revoked" && scenario !== "invalid-storage")
1338 assert.deepStrictEqual(result.auth.host, Option.some("forgejo.local:3000"));
1339 assert.deepStrictEqual(
1340 result.auth.account,
1341 scenario === "authenticated" || scenario === "missing"
1342 ? Option.some("maria")
1343 : Option.none(),
1344 );
1345 assert.strictEqual(
1346 commands.some((command) => command.startsWith("tea ")),
1347 scenario === "missing",
1348 );
1349 assert.include(commands, "fj version");
1350 }).pipe(
1351 Effect.provide(
1352 Layer.mergeAll(
1353 Layer.mock(ForgejoCli.ForgejoCli)({
1354 getAccount: (input) => {
1355 assert.strictEqual(scenario, "authenticated");
1356 assert.deepStrictEqual(input, {
1357 cwd: "/repo",
1358 baseUrl: "http://forgejo.local:3000",
1359 });
1360 return Effect.succeed("maria");
1361 },
1362 listLogins: (input) => {
1363 assert.strictEqual(
1364 input.remoteUrl,
1365 "http://forgejo.local:3000/maria/project.git",
1366 );
1367 if (scenario === "invalid-storage")
1368 return Effect.fail(
1369 new ForgejoCli.ForgejoCliError({
1370 command: "fj",
1371 cwd: input.cwd,
1372 reason: "authentication",
1373 detail: "fj authentication storage is invalid.",
1374 }),
1375 );
1376 return Effect.succeed([
1377 {
1378 name: "forgejo.local:3000",
1379 url: "http://forgejo.local:3000",
1380 user: "",
1381 default: "false",
1382 },
1383 ]);
1384 },
1385 }),
1386 Layer.mock(VcsProcess.VcsProcess)({
1387 run: (input) => {
1388 commands.push(`${input.command} ${input.args.join(" ")}`);
1389 if (input.command === "git")
1390 return Effect.succeed(
1391 processOutput("http://forgejo.local:3000/maria/project.git\n"),
1392 );
1393 if (input.command === "fj") {
1394 if (scenario === "missing")
1395 return Effect.fail(
1396 new VcsProcessSpawnError({
1397 operation: input.operation,
1398 command: input.command,
1399 cwd: input.cwd,
1400 cause: new Error("fj not found"),
1401 }),
1402 );
1403 if (input.args[0] === "version")
1404 return Effect.succeed(processOutput("fj 0.10.0"));
1405 if (scenario === "invalid-storage") {
1406 assert.deepStrictEqual(input.args, ["auth", "list"]);
1407 return Effect.succeed(processOutput(""));
1408 }
1409 assert.deepStrictEqual(input.args, [
1410 "--host",
1411 "http://forgejo.local:3000",
1412 "whoami",
1413 ]);
1414 return Effect.succeed(
1415 processOutput("", {
1416 exitCode: ChildProcessSpawner.ExitCode(scenario === "revoked" ? 1 : 0),
1417 }),
1418 );
1419 }
1420 assert.strictEqual(input.command, "tea");
1421 return Effect.succeed(
1422 input.args[0] === "--version"
1423 ? processOutput("tea version 0.16.0")
1424 : processOutput(
1425 encodeJson([
1426 {
1427 name: "work",
1428 url: "http://forgejo.local:3000",
1429 user: "maria",
1430 default: "true",
1431 valid: "true",
1432 },
1433 ]),
1434 ),
1435 );
1436 },
1437 }),
1438 ),
1439 ),
1440 );
1441 }
1442 }),
1443);
1444
1445it.effect(
1446 "checks out fj pull refs and preserves existing branches and dirty files until forced",
1447 () =>
1448 Effect.gen(function* () {
1449 const fs = yield* FileSystem.FileSystem;
1450 const git = yield* VcsProcess.VcsProcess;
1451 const path = yield* Path.Path;
1452 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fj-checkout-" });
1453 const source = path.join(root, "source");
1454 const cwd = path.join(root, "checkout");
1455 yield* fs.makeDirectory(source);
1456 for (const args of [
1457 ["init", "-b", "main"],
1458 ["config", "user.name", "Test"],
1459 ["config", "user.email", "test@example.com"],
1460 ])
1461 yield* git.run({ operation: "test.setup", command: "git", cwd: source, args });
1462 yield* fs.writeFileString(path.join(source, "base.txt"), "base\n");
1463 for (const args of [
1464 ["add", "base.txt"],
1465 ["commit", "-m", "base"],
1466 ])
1467 yield* git.run({ operation: "test.setup", command: "git", cwd: source, args });
1468 const base = (yield* git.run({
1469 operation: "test.setup",
1470 command: "git",
1471 cwd: source,
1472 args: ["rev-parse", "HEAD"],
1473 })).stdout.trim();
1474 yield* git.run({
1475 operation: "test.setup",
1476 command: "git",
1477 cwd: root,
1478 args: ["clone", source, cwd],
1479 });
1480 yield* fs.writeFileString(path.join(source, "feature.txt"), "pull request change\n");
1481 for (const args of [
1482 ["add", "feature.txt"],
1483 ["commit", "-m", "feature"],
1484 ["update-ref", "refs/pull/42/head", "HEAD"],
1485 ])
1486 yield* git.run({ operation: "test.setup", command: "git", cwd: source, args });
1487 const head = (yield* git.run({
1488 operation: "test.setup",
1489 command: "git",
1490 cwd: source,
1491 args: ["rev-parse", "HEAD"],
1492 })).stdout.trim();
1493 const fetched: string[] = [];
1494 const provider = yield* ForgejoSourceControlProvider.make.pipe(
1495 Effect.provideService(
1496 VcsProcess.VcsProcess,
1497 VcsProcess.VcsProcess.of({
1498 run: (input) => {
1499 if (input.args[0] !== "fetch") return git.run(input);
1500 const url = input.args[2];
1501 assert.isDefined(url);
1502 fetched.push(url!);
1503 // Only SSH transport is substituted; both paths fetch the real pull ref.
1504 return git.run({
1505 ...input,
1506 args: input.args.map((arg) =>
1507 arg === "git@forgejo.test:reviewer/project.git" ? source : arg,
1508 ),
1509 });
1510 },
1511 }),
1512 ),
1513 Effect.provide(
1514 Layer.mock(ForgejoCli.ForgejoCli)({
1515 resolveRepository: () =>
1516 Effect.succeed({
1517 command: "fj",
1518 login: "work",
1519 repository: "reviewer/project",
1520 baseUrl: "https://forgejo.test",
1521 }),
1522 api: (input) => {
1523 assert.include(
1524 ["repos/reviewer/project", "repos/reviewer/project/pulls/42"],
1525 input.path,
1526 );
1527 return Effect.succeed(
1528 processOutput(
1529 encodeJson(
1530 input.path.endsWith("/pulls/42")
1531 ? {
1532 number: 42,
1533 title: "Checkout",
1534 html_url: "https://forgejo.test/reviewer/project/pulls/42",
1535 state: "open",
1536 merged: false,
1537 base: { ref: "main", sha: base, repo: null },
1538 head: { ref: "feature", sha: head, repo: null },
1539 }
1540 : {
1541 full_name: "reviewer/project",
1542 clone_url: source,
1543 ssh_url: "git@forgejo.test:reviewer/project.git",
1544 default_branch: "main",
1545 },
1546 ),
1547 ),
1548 );
1549 },
1550 }),
1551 ),
1552 );
1553 yield* provider.checkoutChangeRequest({
1554 cwd,
1555 reference: "https://forgejo.test/reviewer/project/pulls/42",
1556 });
1557 assert.strictEqual(
1558 (yield* git.run({
1559 operation: "test.verify",
1560 command: "git",
1561 cwd,
1562 args: ["branch", "--show-current"],
1563 })).stdout.trim(),
1564 "pulls/42",
1565 );
1566 assert.strictEqual(
1567 (yield* git.run({
1568 operation: "test.verify",
1569 command: "git",
1570 cwd,
1571 args: ["rev-parse", "HEAD"],
1572 })).stdout.trim(),
1573 head,
1574 );
1575 assert.strictEqual(
1576 yield* fs.readFileString(path.join(cwd, "feature.txt")),
1577 "pull request change\n",
1578 );
1579 for (const args of [
1580 ["checkout", "main"],
1581 ["branch", "-f", "pulls/42", "main"],
1582 ])
1583 yield* git.run({ operation: "test.setup", command: "git", cwd, args });
1584 yield* fs.writeFileString(path.join(cwd, "base.txt"), "uncommitted work\n");
1585 yield* provider.checkoutChangeRequest({ cwd, reference: "42" });
1586 assert.strictEqual(
1587 (yield* git.run({
1588 operation: "test.verify",
1589 command: "git",
1590 cwd,
1591 args: ["rev-parse", "HEAD"],
1592 })).stdout.trim(),
1593 base,
1594 );
1595 assert.strictEqual(yield* fs.exists(path.join(cwd, "feature.txt")), false);
1596 yield* provider.checkoutChangeRequest({
1597 cwd,
1598 reference: "42",
1599 force: true,
1600 context: {
1601 provider: { kind: "forgejo", name: "Forgejo", baseUrl: "https://forgejo.test" },
1602 remoteName: "origin",
1603 remoteUrl: "git@forgejo.test:maria/project.git",
1604 },
1605 });
1606 assert.strictEqual(
1607 (yield* git.run({
1608 operation: "test.verify",
1609 command: "git",
1610 cwd,
1611 args: ["rev-parse", "HEAD"],
1612 })).stdout.trim(),
1613 head,
1614 );
1615 assert.strictEqual(
1616 yield* fs.readFileString(path.join(cwd, "base.txt")),
1617 "uncommitted work\n",
1618 );
1619 assert.strictEqual(
1620 yield* fs.readFileString(path.join(cwd, "feature.txt")),
1621 "pull request change\n",
1622 );
1623 assert.deepStrictEqual(fetched, [source, source, "git@forgejo.test:reviewer/project.git"]);
1624 }).pipe(
1625 Effect.scoped,
1626 Effect.provide(VcsProcess.layer.pipe(Layer.provideMerge(NodeServices.layer))),
1627 ),
1628);
apps/server/src/sourceControl/SourceControlProvider.ts+2
......@@ -14,6 +14,8 @@ export interface SourceControlProviderContext {
1414 readonly provider: SourceControlProviderInfo;
1515 readonly remoteName: string;
1616 readonly remoteUrl: string;
17 /** An explicit web authority can disambiguate Forgejo logins sharing an SSH alias. */
18 readonly requestedHost?: string;
1719}
1820
1921export interface SourceControlRefSelector {
apps/server/src/sourceControl/SourceControlProviderDiscovery.ts+23-6
......@@ -1,3 +1,4 @@
1import * as NodeUtil from "node:util";
12import type {
23 SourceControlProviderAuth,
34 SourceControlProviderDiscoveryItem,
......@@ -33,6 +34,7 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {
3334 readonly executable: string;
3435 readonly versionArgs: ReadonlyArray<string>;
3536 readonly authArgs: ReadonlyArray<string>;
37 readonly remoteRefinementArgs?: ReadonlyArray<string>;
3638 readonly probeTimeoutMs?: number;
3739 readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth;
3840 readonly refineUnknownRemote?: (
......@@ -45,8 +47,18 @@ export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & {
4547 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
4648};
4749
50export type SourceControlManagedCliDiscoverySpec = SourceControlDiscoverySpecBase & {
51 readonly type: "managed-cli";
52 readonly probe: (cwd: string) => Effect.Effect<SourceControlProviderDiscoveryItem>;
53 readonly refineUnknownRemote: (input: {
54 readonly cwd: string;
55 readonly context: SourceControlProvider.SourceControlProviderContext;
56 }) => Effect.Effect<SourceControlProviderInfo | null>;
57};
58
4859export type SourceControlProviderDiscoverySpec =
4960 | SourceControlCliDiscoverySpec
61 | SourceControlManagedCliDiscoverySpec
5062 | SourceControlApiDiscoverySpec;
5163
5264type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & {
......@@ -72,7 +84,7 @@ interface DiscoveryProbeResult {
7284}
7385
7486export function firstNonEmptyLine(text: string): Option.Option<string> {
75 const line = text
87 const line = NodeUtil.stripVTControlCharacters(text)
7688 .split(/\r?\n/)
7789 .map((entry) => entry.trim())
7890 .find((entry) => entry.length > 0);
......@@ -214,6 +226,7 @@ export function probeSourceControlProvider(input: {
214226 readonly process: VcsProcess.VcsProcess["Service"];
215227 readonly cwd: string;
216228}): Effect.Effect<SourceControlProviderDiscoveryItem> {
229 if (input.spec.type === "managed-cli") return input.spec.probe(input.cwd);
217230 if (input.spec.type === "api") {
218231 return input.spec.probeAuth.pipe(
219232 Effect.map(
......@@ -288,12 +301,16 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide
288301 }
289302 const context = input.context;
290303
291 const providers = yield* Effect.forEach(input.specs.filter(isCliRemoteRefinementSpec), (spec) =>
292 input.process
304 const providers = yield* Effect.forEach(input.specs, (spec) => {
305 if (spec.type === "managed-cli") {
306 return spec.refineUnknownRemote({ cwd: input.cwd, context });
307 }
308 if (!isCliRemoteRefinementSpec(spec)) return Effect.succeed(null);
309 return input.process
293310 .run({
294311 operation: "source-control.discovery.refine-unknown-remote",
295312 command: spec.executable,
296 args: spec.authArgs,
313 args: spec.remoteRefinementArgs ?? spec.authArgs,
297314 cwd: input.cwd,
298315 allowNonZeroExit: true,
299316 timeoutMs: probeTimeoutMs(spec),
......@@ -309,8 +326,8 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide
309326 }),
310327 ),
311328 Effect.orElseSucceed(() => null),
312 ),
313 );
329 );
330 });
314331 const provider = providers.find((candidate) => candidate !== null);
315332
316333 return provider ? { ...context, provider } : context;
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+3
......@@ -15,6 +15,7 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
1515import * as BitbucketApi from "./BitbucketApi.ts";
1616import * as GitHubCli from "./GitHubCli.ts";
1717import * as GitLabCli from "./GitLabCli.ts";
18import * as ForgejoCli from "./ForgejoCli.ts";
1819import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
1920
2021const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
......@@ -86,12 +87,14 @@ function makeRegistry(input: {
8687 return SourceControlProviderRegistry.make.pipe(
8788 Effect.provide(
8889 Layer.mergeAll(
90 NodeServices.layer,
8991 registryLayer,
9092 processLayer,
9193 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
9294 Layer.mock(BitbucketApi.BitbucketApi)({}),
9395 Layer.mock(GitHubCli.GitHubCli)({}),
9496 Layer.mock(GitLabCli.GitLabCli)({}),
97 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
9598 ServerConfig.layerTest(process.cwd(), {
9699 prefix: "t3-source-control-registry-test-",
97100 }).pipe(Layer.provide(NodeServices.layer)),
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+4
......@@ -15,6 +15,7 @@ import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlPro
1515import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
1616import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1717import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
18import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
1819import * as SourceControlProvider from "./SourceControlProvider.ts";
1920import {
2021 probeSourceControlProvider,
......@@ -296,6 +297,8 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
296297export const make = Effect.gen(function* () {
297298 const github = yield* GitHubSourceControlProvider.make;
298299 const gitlab = yield* GitLabSourceControlProvider.make;
300 const forgejo = yield* ForgejoSourceControlProvider.make;
301 const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery;
299302 const bitbucket = yield* BitbucketSourceControlProvider.make;
300303 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;
301304 const azureDevOps = yield* AzureDevOpsSourceControlProvider.make;
......@@ -320,6 +323,7 @@ export const make = Effect.gen(function* () {
320323 provider: bitbucket,
321324 discovery: bitbucketDiscovery,
322325 },
326 { kind: "forgejo", provider: forgejo, discovery: forgejoDiscovery },
323327 ]);
324328});
325329
apps/server/src/sourceControl/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}
apps/server/src/ws.ts+2
......@@ -155,6 +155,7 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
155155import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
156156import * as GitHubCli from "./sourceControl/GitHubCli.ts";
157157import * as GitLabCli from "./sourceControl/GitLabCli.ts";
158import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
158159import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
159160import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
160161import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
......@@ -3111,6 +3112,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
31113112 BitbucketApi.layer,
31123113 GitHubCli.layer,
31133114 GitLabCli.layer,
3115 ForgejoCli.layer,
31143116 ),
31153117 ),
31163118 Layer.provideMerge(GitVcsDriver.layer),
apps/web/src/components/CommandPalette.tsx+11-2
......@@ -150,7 +150,7 @@ import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sideb
150150import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
151151import { CommandPaletteContent } from "./CommandPaletteContent";
152152import { CommandPaletteResults } from "./CommandPaletteResults";
153import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";
153import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons";
154154import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
155155import { ProjectFavicon } from "./ProjectFavicon";
156156import { ProjectFilePicker } from "./files/ProjectFilePicker";
......@@ -244,7 +244,7 @@ interface AddProjectEnvironmentOption {
244244
245245type AddProjectRemoteProviderKind = Extract<
246246 SourceControlProviderKind,
247 "github" | "gitlab" | "bitbucket" | "azure-devops"
247 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"
248248>;
249249type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
250250
......@@ -267,12 +267,14 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [
267267 "url",
268268 "github",
269269 "gitlab",
270 "forgejo",
270271 "bitbucket",
271272 "azure-devops",
272273];
273274const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [
274275 "github",
275276 "gitlab",
277 "forgejo",
276278 "bitbucket",
277279 "azure-devops",
278280];
......@@ -281,6 +283,8 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {
281283 switch (source) {
282284 case "github":
283285 return "GitHub";
286 case "forgejo":
287 return "Forgejo / Gitea";
284288 case "gitlab":
285289 return "GitLab";
286290 case "bitbucket":
......@@ -294,6 +298,7 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {
294298
295299function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string {
296300 switch (source) {
301 case "forgejo":
297302 case "github":
298303 return "owner/repo";
299304 case "gitlab":
......@@ -317,6 +322,8 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri
317322 switch (source) {
318323 case "github":
319324 return <GitHubIcon className={className} />;
325 case "forgejo":
326 return <ForgejoIcon className={className} />;
320327 case "gitlab":
321328 return <GitLabIcon className={className} />;
322329 case "bitbucket":
......@@ -370,6 +377,7 @@ function buildAddProjectRemoteSourceReadiness(
370377 url: { ready: true, hint: null },
371378 github: unavailable,
372379 gitlab: unavailable,
380 forgejo: unavailable,
373381 bitbucket: unavailable,
374382 "azure-devops": unavailable,
375383 };
......@@ -1759,6 +1767,7 @@ function OpenCommandPaletteDialog(props: {
17591767 "git",
17601768 "github",
17611769 "gitlab",
1770 "forgejo",
17621771 "bitbucket",
17631772 "azure",
17641773 "devops",
apps/web/src/components/GitActionsControl.tsx+25-3
......@@ -39,7 +39,13 @@ import {
3939 GlobeIcon,
4040} from "lucide-react";
4141import { Radio as RadioPrimitive } from "@base-ui/react/radio";
42import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons";
42import {
43 AzureDevOpsIcon,
44 BitbucketIcon,
45 GitHubIcon,
46 GitLabIcon,
47 ForgejoIcon,
48} from "~/components/Icons";
4349import { RadioGroup } from "~/components/ui/radio-group";
4450import { Spinner } from "~/components/ui/spinner";
4551import { toggleVariants } from "~/components/ui/toggle";
......@@ -123,7 +129,7 @@ interface PendingDefaultBranchAction {
123129
124130type PublishProviderKind = Extract<
125131 SourceControlProviderKind,
126 "github" | "gitlab" | "bitbucket" | "azure-devops"
132 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"
127133>;
128134
129135type GitActionToastId = ReturnType<typeof toastManager.add>;
......@@ -171,6 +177,14 @@ function requestVcsStatusRefresh(
171177const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;
172178
173179const PUBLISH_PROVIDER_OPTIONS = [
180 {
181 value: "forgejo",
182 label: "Forgejo / Gitea",
183 description: "Your signed-in server",
184 host: "your server",
185 pathPlaceholder: "owner/repo",
186 Icon: ForgejoIcon,
187 },
174188 {
175189 value: "github",
176190 label: "GitHub",
......@@ -425,6 +439,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
425439 const accounts: Record<PublishProviderKind, string | null> = {
426440 github: null,
427441 gitlab: null,
442 forgejo: null,
428443 bitbucket: null,
429444 "azure-devops": null,
430445 };
......@@ -476,7 +491,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
476491 : "";
477492 const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill;
478493 const currentPublishProvider = publishProviderOption(publishProvider);
479 const publishHost = currentPublishProvider.host;
494 const publishHost =
495 publishProvider === "forgejo"
496 ? (Option.getOrNull(
497 sourceControlDiscovery.data?.sourceControlProviders.find(
498 (provider) => provider.kind === "forgejo",
499 )?.auth.host ?? Option.none(),
500 ) ?? currentPublishProvider.host)
501 : currentPublishProvider.host;
480502 const publishPathPlaceholder = currentPublishProvider.pathPlaceholder;
481503 const publishProviderLabel = currentPublishProvider.label;
482504 const publishWizardSteps = ["Provider", "Repository", "Summary"] as const;
apps/web/src/components/Icons.tsx+13
......@@ -768,3 +768,16 @@ export const PiAgentIcon: Icon = ({ className, ...props }) => (
768768 <path fill="#fff" d="M517.36 400H634.72V634.72H517.36Z" />
769769 </svg>
770770);
771
772// Official two-color mark from https://forgejo.org/favicon.svg.
773export const ForgejoIcon: Icon = (props) => (
774 <svg viewBox="0 0 212 212" aria-hidden="true" {...props}>
775 <g transform="translate(6 6)" fill="none">
776 <path d="M58 168 v-98 a50 50 0 0 1 50-50 h20" stroke="#ff6600" strokeWidth="25" />
777 <path d="M58 168 v-30 a50 50 0 0 1 50-50 h20" stroke="#d40000" strokeWidth="25" />
778 <circle cx="142" cy="20" r="18" stroke="#ff6600" strokeWidth="15" />
779 <circle cx="142" cy="88" r="18" stroke="#d40000" strokeWidth="15" />
780 <circle cx="58" cy="180" r="18" stroke="#d40000" strokeWidth="15" />
781 </g>
782 </svg>
783);
apps/web/src/components/ThreadStatusIndicators.tsx+3-1
......@@ -102,7 +102,9 @@ export function linkedPullRequestSnapshotStatus(
102102 ? "azure-devops"
103103 : link.url.includes("/pull-requests/")
104104 ? "bitbucket"
105 : "github";
105 : link.url.includes("/pulls/")
106 ? "forgejo"
107 : "github";
106108 return {
107109 pr: {
108110 number: link.number,
apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx+4-1
......@@ -144,7 +144,10 @@ function LinkPullRequestDialog({
144144 return {
145145 host,
146146 repository,
147 webUrl: (number: number) => changeRequestWebUrl(kind, host, repository, number),
147 webUrl: (number: number) =>
148 kind === "forgejo" && identity.webUrl
149 ? `${identity.webUrl.replace(/\/+$/, "")}/pulls/${number}`
150 : changeRequestWebUrl(kind, host, repository, number, identity.locator.remoteUrl),
148151 };
149152 }, [environmentProjects, projectId]);
150153 const linking = usePullRequestLinking(threadRef.environmentId);
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+1
......@@ -976,6 +976,7 @@ function PullRequestCodeTab({
976976 environmentId={environmentId}
977977 reference={reference}
978978 verdicts={review.verdicts}
979 requestChangesSummaryRequired={detail.provider === "forgejo"}
979980 onSubmitted={() => {
980981 onRefresh();
981982 setReviewOpen(false);
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+5-1
......@@ -727,6 +727,7 @@ export function PullRequestDetailPanel({
727727 detail.number,
728728 detail.headBranch,
729729 detail.headRepositoryNameWithOwner,
730 repositoryUrl,
730731 )
731732 : null;
732733 const branchRefsQuery = useEnvironmentQuery(
......@@ -784,11 +785,14 @@ export function PullRequestDetailPanel({
784785 if (!coreDetail) return;
785786 const next = { key: tabScopeKey, updatedAt: coreDetail.updatedAt };
786787 if (shouldRefreshPullRequestActivity(activityRevision.current, next)) {
788 // Let an existing read settle before revalidating the new revision. Interrupting a
789 // mutation's activity refresh can leave SWR displaying its previous value.
790 if (activityQuery.isPending) return;
787791 activityQuery.refresh();
788792 setRefreshToken((token) => token + 1);
789793 }
790794 activityRevision.current = next;
791 }, [activityQuery.refresh, coreDetail, tabScopeKey]);
795 }, [activityQuery.isPending, activityQuery.refresh, coreDetail, tabScopeKey]);
792796 // Reuse activity and diff until core detail reports a changed revision. Keyed by
793797 // the pull request rather than by the panel, because this one panel shows a different pull
794798 // request every time it is opened.
apps/web/src/components/pullRequest/PullRequestReviewBar.tsx+11-3
......@@ -50,11 +50,13 @@ export function PullRequestReviewBar({
5050 environmentId,
5151 reference,
5252 verdicts,
53 requestChangesSummaryRequired,
5354 onSubmitted,
5455}: {
5556 environmentId: EnvironmentId;
5657 reference: PullRequestRef;
5758 verdicts: ReadonlyArray<PullRequestReviewVerdict>;
59 requestChangesSummaryRequired: boolean;
5860 onSubmitted: () => void;
5961}) {
6062 const [pending, setPending] = useState(false);
......@@ -106,9 +108,11 @@ export function PullRequestReviewBar({
106108 onSubmitted();
107109 };
108110
109 // An approval needs no words; anything else does, unless it carries line comments instead.
111 // Forgejo requires a summary when requesting changes, even with inline comments.
110112 const canSubmit = (verdict: PullRequestReviewVerdict) =>
111 verdict === "approve" || body.trim().length > 0 || comments.length > 0;
113 verdict === "request-changes" && requestChangesSummaryRequired
114 ? body.trim().length > 0
115 : verdict === "approve" || body.trim().length > 0 || comments.length > 0;
112116
113117 return (
114118 <div className="px-4 py-3">
......@@ -128,7 +132,11 @@ export function PullRequestReviewBar({
128132 size="sm"
129133 className="mt-2"
130134 value={body}
131 placeholder="Summarize your review (optional)"
135 placeholder={
136 requestChangesSummaryRequired && verdicts.includes("request-changes")
137 ? "Summarize your review (required to request changes)"
138 : "Summarize your review (optional)"
139 }
132140 aria-label="Review summary"
133141 onChange={(event) => setSummary(reviewKey, event.target.value)}
134142 />
apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx+9-2
......@@ -7,6 +7,7 @@ import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-r
77import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
88
99import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";
10import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
1011import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities";
1112import { pullRequestEnvironment } from "~/state/pullRequests";
1213import { useEnvironmentQuery } from "~/state/query";
......@@ -56,7 +57,10 @@ function EnabledPullRequestThreadLinks({
5657 linking.mode === "multiple" && display !== "menu-item"
5758 ? pullRequestEnvironment.linkedThreads({
5859 environmentId,
59 input: parsed === null ? reference : { ...reference, ...parsed },
60 input:
61 parsed === null
62 ? reference
63 : { ...reference, ...normalizeThreadPullRequestKey(parsed) },
6064 })
6165 : null,
6266 );
......@@ -85,7 +89,10 @@ function EnabledPullRequestThreadLinks({
8589 }
8690 if (linking.mode === "multiple") {
8791 appAtomRegistry.refresh(
88 pullRequestEnvironment.linkedThreads({ environmentId, input: { ...reference, ...parsed } }),
92 pullRequestEnvironment.linkedThreads({
93 environmentId,
94 input: { ...reference, ...normalizeThreadPullRequestKey(parsed) },
95 }),
8996 );
9097 }
9198 onPickerOpenChange?.(false);
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+27
......@@ -55,6 +55,7 @@ describe("pull request checkout commands", () => {
5555 it.each([
5656 ["github", "feature", null, "gh pr checkout 42"],
5757 ["gitlab", "feature", null, "glab mr checkout 42"],
58 ["forgejo", "feature", null, null],
5859 ["azure-devops", "feature", null, "az repos pr checkout --id 42"],
5960 [
6061 "bitbucket",
......@@ -66,6 +67,32 @@ describe("pull request checkout commands", () => {
6667 ] as const)("builds the %s command", (provider, branch, repository, expected) => {
6768 expect(pullRequestCheckoutCommand(provider, 42, branch, repository)).toBe(expected);
6869 });
70 it("fetches Forgejo pull refs from the actual repository, including a mounted host and port", () => {
71 expect(
72 pullRequestCheckoutCommand(
73 "forgejo",
74 42,
75 "feature",
76 null,
77 "https://forgejo.local:3000/git/maria/repo",
78 ),
79 ).toBe(
80 "git fetch 'https://forgejo.local:3000/git/maria/repo' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD",
81 );
82 });
83 it("quotes shell metacharacters in Forgejo repository URLs", () => {
84 expect(
85 pullRequestCheckoutCommand(
86 "forgejo",
87 42,
88 "feature",
89 null,
90 "https://forgejo.local/maria/repo'$(echo nope)",
91 ),
92 ).toBe(
93 "git fetch 'https://forgejo.local/maria/repo'\\''$(echo nope)' refs/pull/42/head && git checkout -B pulls/42 FETCH_HEAD",
94 );
95 });
6996});
7097
7198const TIMELINE_SOURCE: Pick<
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+5
......@@ -104,12 +104,17 @@ export function pullRequestCheckoutCommand(
104104 number: number,
105105 headBranch: string,
106106 headRepositoryNameWithOwner?: string | null,
107 repositoryUrl?: string | null,
107108): string | null {
108109 switch (provider) {
109110 case "github":
110111 return `gh pr checkout ${number}`;
111112 case "gitlab":
112113 return `glab mr checkout ${number}`;
114 case "forgejo":
115 return repositoryUrl
116 ? `git fetch '${repositoryUrl.replaceAll("'", "'\\''")}' refs/pull/${number}/head && git checkout -B pulls/${number} FETCH_HEAD`
117 : null;
113118 case "azure-devops":
114119 return `az repos pr checkout --id ${number}`;
115120 case "bitbucket": {
apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts+1
......@@ -11,6 +11,7 @@ export type PullRequestLinkContextMenuAction = "copy-link" | "open-external";
1111const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = {
1212 github: "Open on GitHub",
1313 gitlab: "Open on GitLab",
14 forgejo: "Open on Forgejo",
1415 bitbucket: "Open on Bitbucket",
1516 "azure-devops": "Open on Azure DevOps",
1617};
apps/web/src/components/settings/SourceControlSettings.tsx+2
......@@ -52,6 +52,7 @@ import {
5252 GitHubIcon,
5353 GitIcon,
5454 GitLabIcon,
55 ForgejoIcon,
5556 JujutsuIcon,
5657 type Icon,
5758} from "../Icons";
......@@ -75,6 +76,7 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
7576const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = {
7677 github: GitHubIcon,
7778 gitlab: GitLabIcon,
79 forgejo: ForgejoIcon,
7880 "azure-devops": AzureDevOpsIcon,
7981 bitbucket: BitbucketIcon,
8082};
apps/web/src/components/settings/settingsSearch.ts+1-1
......@@ -567,7 +567,7 @@ export const SETTINGS_SEARCH_ITEMS = [
567567 to: "/settings/source-control",
568568 scope: "environment-defaults",
569569 searchTerms: [
570 "version control git github gitlab bitbucket azure devops hosting integrations credentials scan server environment",
570 "version control git github gitlab forgejo gitea tea codeberg bitbucket azure devops hosting integrations credentials scan server environment",
571571 ],
572572 },
573573 {
apps/web/src/lib/openPullRequestLink.test.ts+84
......@@ -170,6 +170,37 @@ describe("matchesLinkedPullRequestUrl", () => {
170170 ).toBe(true);
171171 });
172172
173 it.each([
174 ["http://forge.example:3000/git/team/repo/pulls/42/files", true],
175 ["http://forge.example:4000/git/team/repo/pulls/42", false],
176 ["http://forge.example/git/team/repo/pulls/42", false],
177 ])("matches Forgejo links by web authority: %s", (url, expected) => {
178 expect(
179 matchesLinkedPullRequestUrl(
180 { ...linkedPullRequest, url: "http://forge.example:3000/git/team/repo/pulls/42" },
181 url,
182 ),
183 ).toBe(expected);
184 });
185
186 it("keeps other providers' existing port normalization", () => {
187 expect(
188 matchesLinkedPullRequestUrl(
189 linkedPullRequest,
190 "https://github.com:8443/pingdotgg/t3code/pull/42",
191 ),
192 ).toBe(true);
193 });
194
195 it("keeps Forgejo and GitHub path shapes distinct on a GitHub-named host", () => {
196 expect(
197 matchesLinkedPullRequestUrl(
198 { ...linkedPullRequest, url: "https://github.internal/team/repo/pulls/42" },
199 "https://github.internal/team/repo/pull/42",
200 ),
201 ).toBe(false);
202 });
203
173204 it("rejects a different pull request or host", () => {
174205 expect(
175206 matchesLinkedPullRequestUrl(linkedPullRequest, "https://github.com/pingdotgg/t3code/pull/43"),
......@@ -350,6 +381,59 @@ describe("findProjectOnChangeRequestHost", () => {
350381 ).toBe(backend);
351382 });
352383
384 it("selects the Forgejo HTTP port for repository and host-level matches", () => {
385 const projects = [3000, 4000].map((port) =>
386 project(`forgejo-${port}`, {
387 canonicalKey: "forge.example/git/team/repo",
388 provider: "forgejo",
389 displayName: "git/team/repo",
390 locator: { remoteUrl: `http://forge.example:${port}/git/team/repo.git` },
391 }),
392 );
393 const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!;
394 expect(findProjectForChangeRequest(projects, reference)).toBe(projects[1]);
395 expect(findProjectOnChangeRequestHost(projects, reference)).toBe(projects[1]);
396 expect(
397 findProjectOnChangeRequestHost(projects, { ...reference, repository: "git/team/other" }),
398 ).toBe(projects[1]);
399 expect(findProjectForChangeRequest([projects[0]!], reference)).toBeUndefined();
400 expect(findProjectOnChangeRequestHost([projects[0]!], reference)).toBeUndefined();
401 });
402
403 it("lets tea resolve the web port for Forgejo SSH remotes", () => {
404 const checkout = project("forgejo-ssh", {
405 canonicalKey: "forge.example/git/team/repo",
406 provider: "forgejo",
407 displayName: "git/team/repo",
408 locator: { remoteUrl: "git@forge.example:git/team/repo.git" },
409 });
410 const reference = parseChangeRequestUrl("http://forge.example:4000/git/team/repo/pulls/42")!;
411 expect(findProjectForChangeRequest([checkout], reference)).toBe(checkout);
412 const aliased = project("forgejo-alias", {
413 canonicalKey: "ssh.forge.example/team/repo",
414 provider: "forgejo",
415 displayName: "team/repo",
416 locator: { remoteUrl: "git@ssh.forge.example:team/repo.git" },
417 webUrl: "http://forge.example:4000/git/team/repo",
418 });
419 expect(findProjectForChangeRequest([aliased], reference)).toBe(aliased);
420 expect(findProjectOnChangeRequestHost([aliased], reference)).toBe(aliased);
421 expect(
422 findProjectOnChangeRequestHost([aliased], { ...reference, repository: "git/team/other" }),
423 ).toBe(aliased);
424 for (const url of [
425 "http://other.example:4000/git/team/repo/pulls/42",
426 "http://forge.example:3000/git/team/repo/pulls/42",
427 ]) {
428 const other = parseChangeRequestUrl(url)!;
429 expect(findProjectForChangeRequest([aliased], other)).toBeUndefined();
430 expect(findProjectOnChangeRequestHost([aliased], other)).toBeUndefined();
431 }
432 const otherMount = parseChangeRequestUrl("http://forge.example:4000/other/team/repo/pulls/42")!;
433 expect(findProjectForChangeRequest([aliased], otherMount)).toBeUndefined();
434 expect(findProjectOnChangeRequestHost([aliased], otherMount)).toBeUndefined();
435 });
436
353437 it("lends any project on the host to a repository nobody has checked out", () => {
354438 expect(
355439 findProjectOnChangeRequestHost([frontend], {
apps/web/src/lib/openPullRequestLink.ts+55-13
......@@ -26,13 +26,34 @@ export {
2626 changeRequestRepositoryUrl,
2727} from "@t3tools/shared/changeRequestUrl";
2828
29/**
30 * Returns a click handler that opens a pull request URL in the system browser.
31 *
32 * Stops event propagation/default so activating the link does not also trigger
33 * an enclosing row or trigger (e.g. opening the branch dropdown), and surfaces a
34 * toast when the local API is unavailable or the open fails.
35 */
29function resolvedForgejoRepository(project: EnvironmentProject): URL | null {
30 const identity = project.repositoryIdentity;
31 if (identity?.provider !== "forgejo" || !identity.webUrl) return null;
32 try {
33 const url = new URL(identity.webUrl);
34 return url.protocol === "http:" || url.protocol === "https:" ? url : null;
35 } catch {
36 return null;
37 }
38}
39
40/** Keep Forgejo servers on different HTTP ports separate when selecting a project. */
41function matchesChangeRequestAuthority(
42 project: EnvironmentProject,
43 link: ChangeRequestLink,
44): boolean {
45 if (link.authority === undefined) return true;
46 try {
47 const remote = new URL(project.repositoryIdentity?.locator.remoteUrl ?? "");
48 if (remote.protocol === "http:" || remote.protocol === "https:") {
49 return remote.host.toLowerCase() === link.authority;
50 }
51 } catch {
52 // SSH remotes do not specify the server's HTTP port; tea resolves the configured login.
53 }
54 return true;
55}
56
3657/**
3758 * The project a link belongs to, or nothing. Matched the way the server matches: the repository
3859 * identity is the full path below the host where one was recorded — which is what nested GitLab
......@@ -45,9 +66,15 @@ export function findProjectForChangeRequest(
4566): EnvironmentProject | undefined {
4667 return projects.find((project) => {
4768 const identity = project.repositoryIdentity;
48 if (!identity) return false;
69 if (!identity || !matchesChangeRequestAuthority(project, link)) return false;
4970 const kind = identity.provider as SourceControlProviderKind | undefined;
5071 if (kind === undefined) return false;
72 const web = resolvedForgejoRepository(project);
73 if (web)
74 return (
75 web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() &&
76 web.pathname.replace(/^\/+|\/+$/g, "").toLowerCase() === link.repository.toLowerCase()
77 );
5178 if (kind === "azure-devops") {
5279 return (
5380 canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) ===
......@@ -60,7 +87,8 @@ export function findProjectForChangeRequest(
6087 return (
6188 repository !== null &&
6289 repository.toLowerCase() === link.repository.toLowerCase() &&
63 pullRequestHostOf(identity, kind) === link.host.toLowerCase()
90 (pullRequestHostOf(identity, kind) === link.host.toLowerCase() ||
91 pullRequestHostOf(identity, kind) === link.authority)
6492 );
6593 });
6694}
......@@ -86,11 +114,25 @@ export function findProjectOnChangeRequestHost(
86114 return projects.find((project) => {
87115 const identity = project.repositoryIdentity;
88116 const kind = identity?.provider as SourceControlProviderKind | undefined;
117 const web = resolvedForgejoRepository(project);
118 if (web) {
119 const mount = web.pathname
120 .replace(/^\/+|\/+$/g, "")
121 .split("/")
122 .slice(0, -2)
123 .join("/");
124 return (
125 web.host.toLowerCase() === (link.authority ?? link.host).toLowerCase() &&
126 (!mount || link.repository.toLowerCase().startsWith(`${mount.toLowerCase()}/`))
127 );
128 }
89129 return (
90130 identity != null &&
91131 kind !== undefined &&
92132 kind !== "azure-devops" &&
93 pullRequestHostOf(identity, kind) === link.host.toLowerCase()
133 matchesChangeRequestAuthority(project, link) &&
134 (pullRequestHostOf(identity, kind) === link.host.toLowerCase() ||
135 pullRequestHostOf(identity, kind) === link.authority)
94136 );
95137 });
96138}
......@@ -188,7 +230,7 @@ export function useOpenChangeRequestLink(
188230 projectId: project.id,
189231 ...(serverConfigs.get(project.environmentId)?.environment.capabilities
190232 .threadPullRequests === true
191 ? { host: parsed.host }
233 ? { host: parsed.authority ?? parsed.host }
192234 : {}),
193235 repository,
194236 url: targetUrl,
......@@ -203,7 +245,7 @@ export function useOpenChangeRequestLink(
203245 state: previous.state ?? "all",
204246 repository,
205247 number: parsed.number,
206 selectedHost: parsed.host,
248 selectedHost: parsed.authority ?? parsed.host,
207249 selectedProjectId: project.id,
208250 selectedEnvironmentId: project.environmentId,
209251 }),
......@@ -221,7 +263,7 @@ export function useOpenChangeRequestLink(
221263 state: "all",
222264 repository,
223265 number: parsed.number,
224 selectedHost: parsed.host,
266 selectedHost: parsed.authority ?? parsed.host,
225267 selectedProjectId: project.id,
226268 // Named so the page opens the right one of two servers holding this project.
227269 selectedEnvironmentId: project.environmentId,
apps/web/src/pullRequestReference.ts+5
......@@ -1,3 +1,6 @@
1const FORGEJO_PULL_REQUEST_URL_PATTERN =
2 /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i;
3const FORGEJO_CLI_PR_CHECKOUT_PATTERN = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i;
14const GITHUB_PULL_REQUEST_URL_PATTERN =
25 /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i;
36const GITLAB_MERGE_REQUEST_URL_PATTERN =
......@@ -32,6 +35,7 @@ export function parsePullRequestReference(input: string): string | null {
3235 const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed);
3336 const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);
3437 const normalizedInput =
38 FORGEJO_CLI_PR_CHECKOUT_PATTERN.exec(trimmed)?.[1]?.trim() ??
3539 ghCliCheckoutMatch?.[1]?.trim() ??
3640 glabCliCheckoutMatch?.[1]?.trim() ??
3741 (azureDevOpsCliCheckoutMatch?.[1]
......@@ -43,6 +47,7 @@ export function parsePullRequestReference(input: string): string | null {
4347 }
4448
4549 const urlMatch =
50 FORGEJO_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??
4651 GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??
4752 GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ??
4853 AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput);
apps/web/src/sourceControlPresentation.ts+13-1
......@@ -13,7 +13,13 @@ import {
1313 resolveChangeRequestPresentation,
1414 type ChangeRequestTerminology,
1515} from "@t3tools/shared/sourceControl";
16import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./components/Icons";
16import {
17 AzureDevOpsIcon,
18 BitbucketIcon,
19 GitHubIcon,
20 GitLabIcon,
21 ForgejoIcon,
22} from "./components/Icons";
1723
1824export interface SourceControlPresentation {
1925 readonly providerName: string;
......@@ -32,6 +38,12 @@ export function getSourceControlPresentation(
3238 terminology: getChangeRequestTerminology(provider),
3339 Icon: GitHubIcon,
3440 };
41 case "forgejo":
42 return {
43 providerName: provider?.name || presentation.providerName,
44 terminology: getChangeRequestTerminology(provider),
45 Icon: ForgejoIcon,
46 };
3547 case "gitlab":
3648 return {
3749 providerName: provider?.name || presentation.providerName,
apps/web/src/state/sourceControlActions.ts+1-1
......@@ -269,7 +269,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi
269269 );
270270 const action = useCallback(
271271 async (input: {
272 provider: "github" | "gitlab" | "bitbucket" | "azure-devops";
272 provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops";
273273 repository: string;
274274 visibility: SourceControlRepositoryVisibility;
275275 remoteName: string;
docs/user/source-control.md+21-1
......@@ -1,6 +1,6 @@
11# Source control
22
3T3 Code integrates with GitHub, GitLab, Bitbucket, and Azure DevOps to clone and publish
3T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish
44repositories, create pull requests, and review changes.
55
66## Connect an account
......@@ -17,6 +17,26 @@ Install [GitHub CLI](https://cli.github.com/) 2.81.0 or newer, then sign in:
1717gh auth login
1818```
1919
20### Forgejo and Gitea
21
22Install [Forgejo CLI (`fj`)](https://codeberg.org/forgejo-contrib/forgejo-cli) or
23[Gitea CLI (`tea`)](https://gitea.com/gitea/tea) 0.16 or later on your T3 Code server.
24Sign in with `fj --host https://your-server auth add-token` or `tea login add`.
25Repeat for each server you use, including Codeberg.
26
27T3 Code prefers a matching `fj` login and falls back to `tea` when `fj` is unavailable
28or has no login for that server. Once an account is selected, failed actions stay on that
29account. Settings shows the detected CLI. Forgejo and Gitea share one integration entry.
30Servers hosted under a URL subpath, such as `https://example.com/forgejo`, use `tea` because
31fj 0.6 does not preserve the subpath when checking its account.
32
33When cloning or publishing, use a full repository URL to select a specific server.
34You can use `owner/repo` when only one fj server is configured, or with your default `tea`
35login when fj is unavailable or unconfigured. With multiple fj servers, use the full URL.
36If you have multiple `tea` accounts on one server, select one with
37`tea login default <login-name>`. Git push and clone also need Git credentials or an SSH key
38for that server.
39
2040### GitLab
2141
2242Install [GitLab CLI](https://gitlab.com/gitlab-org/cli), then sign in:
packages/client-runtime/src/operations/projects.test.ts+7
......@@ -76,6 +76,13 @@ describe("add project shared logic", () => {
7676 sshUrl: "git@github.com:imputnet/helium.git",
7777 }),
7878 ).toBe("https://github.com/imputnet/helium");
79 expect(
80 getDefaultCloneUrl({
81 provider: "forgejo",
82 url: "https://forgejo.example.test:8443/owner/repo.git",
83 sshUrl: "ssh://git@forgejo.example.test:2222/owner/repo.git",
84 }),
85 ).toBe("https://forgejo.example.test:8443/owner/repo.git");
7986 });
8087
8188 it("preserves existing clone transport behavior for other providers", () => {
packages/client-runtime/src/operations/projects.ts+11-3
......@@ -25,7 +25,7 @@ import type { EnvironmentProject } from "../state/models.ts";
2525
2626export type AddProjectRemoteProviderKind = Extract<
2727 SourceControlProviderKind,
28 "github" | "gitlab" | "bitbucket" | "azure-devops"
28 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"
2929>;
3030export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
3131
......@@ -59,6 +59,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [
5959 "url",
6060 "github",
6161 "gitlab",
62 "forgejo",
6263 "bitbucket",
6364 "azure-devops",
6465];
......@@ -66,6 +67,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [
6667const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [
6768 "github",
6869 "gitlab",
70 "forgejo",
6971 "bitbucket",
7072 "azure-devops",
7173];
......@@ -74,6 +76,8 @@ export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): str
7476 switch (source) {
7577 case "github":
7678 return "GitHub";
79 case "forgejo":
80 return "Forgejo / Gitea";
7781 case "gitlab":
7882 return "GitLab";
7983 case "bitbucket":
......@@ -87,6 +91,7 @@ export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): str
8791
8892export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string {
8993 switch (source) {
94 case "forgejo":
9095 case "github":
9196 return "owner/repo";
9297 case "gitlab":
......@@ -117,11 +122,13 @@ export function normalizePastedCloneUrl(input: string): string {
117122 return `https://github.com/${repository}`;
118123}
119124
120/** GitHub defaults to HTTPS; other providers retain their existing SSH default. */
125/** GitHub and Forgejo default to HTTPS; other providers retain their existing SSH default. */
121126export function getDefaultCloneUrl(
122127 repository: Pick<SourceControlRepositoryInfo, "provider" | "url" | "sshUrl">,
123128): string {
124 return repository.provider === "github" ? repository.url : repository.sshUrl;
129 return repository.provider === "github" || repository.provider === "forgejo"
130 ? repository.url
131 : repository.sshUrl;
125132}
126133
127134export function sortAddProjectProviderSources(
......@@ -153,6 +160,7 @@ export function buildAddProjectRemoteSourceReadiness(
153160 url: { ready: true, hint: null },
154161 github: unavailable,
155162 gitlab: unavailable,
163 forgejo: unavailable,
156164 bitbucket: unavailable,
157165 "azure-devops": unavailable,
158166 };
packages/client-runtime/src/threadPullRequestCompatibility.test.ts+18
......@@ -19,6 +19,24 @@ const input = {
1919};
2020
2121describe("thread pull request capability negotiation", () => {
22 it.each([true, false])(
23 "preserves Forgejo ports in a multi-link mutation: linked=%s",
24 (linked) => {
25 const mutation = planThreadPullRequestMutation({
26 ...input,
27 linked,
28 capabilities: { threadPullRequests: true },
29 reference: {
30 host: "forge.example",
31 repository: "team/repo",
32 number: 7,
33 url: "http://forge.example:3000/team/repo/pulls/7",
34 },
35 });
36 expect(mutation).toMatchObject({ input: { host: "forge.example:3000", number: 7 } });
37 },
38 );
39
2240 it.each([undefined, {}, { threadPullRequests: false, threadPullRequestLinking: false }])(
2341 "does not dispatch when linking is unadvertised: %j",
2442 (capabilities) => {
packages/client-runtime/src/threadPullRequestCompatibility.ts+4-4
......@@ -4,6 +4,7 @@ import type {
44 ThreadId,
55 ThreadPullRequestKey,
66} from "@t3tools/contracts";
7import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
78
89type LinkingCapabilities = Pick<
910 ExecutionEnvironmentCapabilities,
......@@ -36,20 +37,19 @@ export function planThreadPullRequestMutation({
3637 legacyRepository?: string | undefined;
3738 linked: boolean;
3839}) {
40 const key = normalizeThreadPullRequestKey(reference);
3941 switch (threadPullRequestLinkMode(capabilities)) {
4042 case "multiple":
4143 return linked
4244 ? {
4345 type: "thread.pull-request.link" as const,
44 input: { threadId, ...reference, source: "manual" as const },
46 input: { threadId, ...reference, ...key, source: "manual" as const },
4547 }
4648 : {
4749 type: "thread.pull-request.unlink" as const,
4850 input: {
4951 threadId,
50 host: reference.host,
51 repository: reference.repository,
52 number: reference.number,
52 ...key,
5353 },
5454 };
5555 case "single":
packages/contracts/src/environment.ts+2
......@@ -192,6 +192,8 @@ export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type;
192192export const RepositoryIdentity = Schema.Struct({
193193 canonicalKey: TrimmedNonEmptyString,
194194 locator: RepositoryIdentityLocator,
195 /** Repository browser URL resolved from the server's configured hosting account. */
196 webUrl: Schema.optionalKey(TrimmedNonEmptyString),
195197 rootPath: Schema.optionalKey(TrimmedNonEmptyString),
196198 displayName: Schema.optionalKey(TrimmedNonEmptyString),
197199 provider: Schema.optionalKey(TrimmedNonEmptyString),
packages/contracts/src/pullRequest.test.ts+15
......@@ -7,6 +7,7 @@ import {
77 PullRequestListInput,
88 PullRequestListResult,
99 PullRequestReviewerRequestInput,
10 pullRequestHostOf,
1011 resolvePullRequestAuthorFilter,
1112} from "./pullRequest.ts";
1213
......@@ -65,6 +66,20 @@ const LIST_RESULT: PullRequestListResult = {
6566};
6667
6768describe("PullRequestListResult", () => {
69 it("separates Forgejo HTTP ports while preserving other provider host identities", () => {
70 const identity = {
71 canonicalKey: "forge.example/team/repo",
72 locator: { remoteUrl: "http://forge.example:3000/team/repo.git" },
73 };
74 expect(pullRequestHostOf(identity, "forgejo")).toBe("forge.example:3000");
75 expect(pullRequestHostOf(identity, "gitlab")).toBe("forge.example");
76 expect(
77 pullRequestHostOf(
78 { ...identity, locator: { remoteUrl: "ssh://git@forge.example:2222/team/repo.git" } },
79 "forgejo",
80 ),
81 ).toBe("forge.example");
82 });
6883 /**
6984 * The RPC builds this codec at call time, so a shape it cannot lower — an open-keyed record
7085 * with an optional value, for one — fails as an interrupted request rather than as a schema
packages/contracts/src/pullRequest.ts+22-1
......@@ -1134,6 +1134,12 @@ const PROVIDER_REQUIREMENT: Partial<
11341134 "GitHub CLI (`gh`) is required to browse change requests on this host. Install it from https://cli.github.com/ and reload.",
11351135 unauthenticated: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",
11361136 },
1137 forgejo: {
1138 missing:
1139 "Install Forgejo CLI (`fj` 0.6 or later) from https://codeberg.org/forgejo-contrib/forgejo-cli or Gitea CLI (`tea` 0.16 or later) from https://gitea.com/gitea/tea to browse Forgejo pull requests.",
1140 unauthenticated:
1141 "Authenticate your Forgejo or Gitea server with `fj --host <server-url> auth add-token` on the T3 Code server. If fj is missing or unconfigured for that server, use `tea login add`. A configured fj account must be repaired with fj.",
1142 },
11371143 gitlab: {
11381144 missing:
11391145 "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.",
......@@ -1161,9 +1167,24 @@ const PROVIDER_REQUIREMENT: Partial<
11611167 * knows its hosts before the listing answers, and the two must agree on what they are called.
11621168 */
11631169export function pullRequestHostOf(
1164 identity: { readonly canonicalKey?: string | undefined } | null | undefined,
1170 identity:
1171 | {
1172 readonly canonicalKey?: string | undefined;
1173 readonly locator?: { readonly remoteUrl: string } | undefined;
1174 }
1175 | null
1176 | undefined,
11651177 kind: SourceControlProviderKind,
11661178): string {
1179 if (kind === "forgejo") {
1180 try {
1181 const remote = new URL(identity?.locator?.remoteUrl ?? "");
1182 if (remote.protocol === "http:" || remote.protocol === "https:")
1183 return remote.host.toLowerCase();
1184 } catch {
1185 // SSH remotes retain their canonical host; the CLI resolves their web endpoint.
1186 }
1187 }
11671188 const host = identity?.canonicalKey?.split("/")[0]?.trim();
11681189 return host === undefined || host.length === 0 ? kind : host.toLowerCase();
11691190}
packages/contracts/src/sourceControl.ts+1
......@@ -5,6 +5,7 @@ import { VcsDriverKind } from "./vcs.ts";
55export const SourceControlProviderKind = Schema.Literals([
66 "github",
77 "gitlab",
8 "forgejo",
89 "azure-devops",
910 "bitbucket",
1011 "unknown",
packages/shared/src/changeRequestUrl.test.ts+48
......@@ -24,6 +24,15 @@ describe("parseChangeRequestUrl", () => {
2424 });
2525 });
2626
27 it("reads Forgejo URLs even when the hostname contains github", () => {
28 expect(parseChangeRequestUrl("https://github.internal/team/repo/pulls/7")).toEqual({
29 host: "github.internal",
30 authority: "github.internal",
31 repository: "team/repo",
32 number: 7,
33 });
34 });
35
2736 it("reads a supported GitHub host with a middle DNS label", () => {
2837 expect(parseChangeRequestUrl("https://code.github.example.com/acme/web/pull/42")).toEqual({
2938 host: "code.github.example.com",
......@@ -90,7 +99,23 @@ describe("parseChangeRequestUrl", () => {
9099});
91100
92101describe("siblingPullRequestUrl", () => {
102 it("recognizes Forgejo on custom HTTP hosts", () => {
103 expect(parseChangeRequestUrl("http://git.example.test:3000/team/repo/pulls/42/files")).toEqual({
104 host: "git.example.test",
105 authority: "git.example.test:3000",
106 repository: "team/repo",
107 number: 42,
108 });
109 });
93110 it.each([
111 [
112 "http://git.example.test:3000/team/repo/pulls/42/files",
113 "http://git.example.test:3000/team/repo/pulls/43",
114 ],
115 [
116 "https://git.example.test/forgejo/team/repo/pulls/42/files",
117 "https://git.example.test/forgejo/team/repo/pulls/43",
118 ],
94119 ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"],
95120 [
96121 "https://git.acme.test/team/merge_requests/1/repo/-/merge_requests/42/diffs",
......@@ -125,6 +150,29 @@ describe("siblingPullRequestUrl", () => {
125150});
126151
127152describe("changeRequestUrlFor", () => {
153 it("preserves the origin when the Forgejo host already contains its port", () => {
154 expect(
155 changeRequestUrlFor(
156 "forgejo",
157 "forge.example:3000",
158 "team/repo",
159 42,
160 "http://forge.example:3000/team/repo.git",
161 ),
162 ).toBe("http://forge.example:3000/team/repo/pulls/42");
163 });
164
165 it.each([
166 ["http://forge.example:3000/git/owner/repo.git", "http://forge.example:3000"],
167 ["https://forge.example:8443/git/owner/repo.git", "https://forge.example:8443"],
168 ["git@forge.example:git/owner/repo.git", "https://forge.example"],
169 ["http://other.example:3000/git/owner/repo.git", "https://forge.example"],
170 ])("preserves the matching Forgejo web origin from %s", (remoteUrl, origin) => {
171 expect(changeRequestUrlFor("forgejo", "forge.example", "git/owner/repo", 42, remoteUrl)).toBe(
172 `${origin}/git/owner/repo/pulls/42`,
173 );
174 });
175
128176 it.each([
129177 ["ssh.dev.azure.com", "v3/org/project/web"],
130178 ["vs-ssh.visualstudio.com", "v3/org/project/web"],
packages/shared/src/changeRequestUrl.ts+29-4
......@@ -13,6 +13,8 @@ export interface ChangeRequestLink {
1313 readonly host: string;
1414 readonly repository: string;
1515 readonly number: number;
16 /** Forgejo's HTTP host and port, separate from the portless repository identity. */
17 readonly authority?: string;
1618}
1719
1820/** The host itself, one of its subdomains, or an install named after the provider. */
......@@ -49,7 +51,13 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
4951 // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n}
5052 if (isHostOf(host, "github.com", "github")) {
5153 const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname);
52 return claim(host, match);
54 if (match) return claim(host, match);
55 }
56 // Forgejo and Gitea use /pulls/ on arbitrary self-hosted domains.
57 const forgejo = /^\/([^/]+(?:\/[^/]+)+)\/pulls\/(\d+)(?:\/|$)/u.exec(url.pathname);
58 if (forgejo) {
59 const link = claim(host, forgejo);
60 return link === null ? null : { ...link, authority: url.host.toLowerCase() };
5361 }
5462 // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/`
5563 // separator is GitLab's own, so the hostname is not asked about.
......@@ -83,10 +91,26 @@ export function changeRequestUrlFor(
8391 host: string,
8492 repository: string,
8593 number: number,
94 remoteUrl?: string,
8695): string | null {
8796 switch (kind) {
8897 case "github":
8998 return `https://${host}/${repository}/pull/${number}`;
99 case "forgejo": {
100 try {
101 const remote = new URL(remoteUrl ?? "");
102 if (
103 (remote.protocol === "http:" || remote.protocol === "https:") &&
104 (remote.hostname.toLowerCase() === host.toLowerCase() ||
105 remote.host.toLowerCase() === host.toLowerCase())
106 ) {
107 return `${remote.origin}/${repository}/pulls/${number}`;
108 }
109 } catch {
110 // SSH remotes do not specify the server's web origin.
111 }
112 return `https://${host}/${repository}/pulls/${number}`;
113 }
90114 case "gitlab":
91115 return `https://${host}/${repository}/-/merge_requests/${number}`;
92116 case "bitbucket":
......@@ -174,7 +198,8 @@ export function matchesLinkedPullRequestUrl(
174198 target !== null &&
175199 linked.host === target.host &&
176200 linked.repository === target.repository &&
177 linked.number === target.number
201 linked.number === target.number &&
202 linked.authority === target.authority
178203 );
179204}
180205
......@@ -185,7 +210,7 @@ export function changeRequestRepositoryUrl(targetUrl: string): string | null {
185210 const url = new URL(targetUrl);
186211 const repositoryPath =
187212 /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ??
188 /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec(
213 /^(.*?)(?:\/pulls?\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec(
189214 url.pathname,
190215 )?.[1];
191216 if (!repositoryPath) return null;
......@@ -199,7 +224,7 @@ export function siblingPullRequestUrl(url: string, number: number): string | nul
199224 const reference = parseChangeRequestUrl(url);
200225 if (reference === null || !Number.isSafeInteger(number) || number < 1) return null;
201226 const sibling = new URL(url);
202 const route = /^\/(-\/merge_requests|pull|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec(
227 const route = /^\/(-\/merge_requests|pulls?|pull-requests|pullrequest)\/\d+(?:\/|$)/u.exec(
203228 sibling.pathname.slice(reference.repository.length + 1),
204229 )?.[1];
205230 if (route === undefined) return null;
packages/shared/src/sourceControl.test.ts+16
......@@ -59,6 +59,22 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {
5959 ).toBe("bitbucket");
6060 });
6161
62 it("detects Forgejo and Gitea hosts while preserving HTTP origins", () => {
63 for (const host of ["codeberg.org", "forgejo.example.test", "gitea.example.test"]) {
64 expect(detectSourceControlProviderFromRemoteUrl(`http://${host}:3000/team/repo.git`)).toEqual(
65 {
66 kind: "forgejo",
67 name: "Forgejo",
68 baseUrl: `http://${host}:3000`,
69 },
70 );
71 }
72 expect(getChangeRequestTerminologyForKind("forgejo")).toEqual({
73 shortLabel: "PR",
74 singular: "pull request",
75 });
76 });
77
6278 it("detects Azure DevOps SSH remotes", () => {
6379 // The default Azure DevOps SSH clone URL uses the ssh.dev.azure.com host.
6480 expect(
packages/shared/src/sourceControl.ts+28-1
......@@ -5,7 +5,7 @@ import type {
55} from "@t3tools/contracts";
66
77export interface ChangeRequestPresentation {
8 readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request";
8 readonly icon: "github" | "gitlab" | "forgejo" | "azure-devops" | "bitbucket" | "change-request";
99 readonly providerName: string;
1010 readonly shortName: string;
1111 readonly longName: string;
......@@ -47,6 +47,17 @@ const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
4747 urlExample: "https://gitlab.com/group/project/-/merge_requests/42",
4848};
4949
50const FORGEJO_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
51 icon: "forgejo",
52 providerName: "Forgejo",
53 shortName: "PR",
54 longName: "pull request",
55 pluralLongName: "pull requests",
56 providerLongName: "Forgejo pull request",
57 checkoutCommandExample: "tea pr checkout 123",
58 urlExample: "https://codeberg.org/owner/repo/pulls/42",
59};
60
5061const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
5162 icon: "azure-devops",
5263 providerName: "Azure DevOps",
......@@ -87,6 +98,8 @@ export function resolveChangeRequestPresentation(
8798 return GITHUB_CHANGE_REQUEST_PRESENTATION;
8899 case "gitlab":
89100 return GITLAB_CHANGE_REQUEST_PRESENTATION;
101 case "forgejo":
102 return FORGEJO_CHANGE_REQUEST_PRESENTATION;
90103 case "azure-devops":
91104 return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION;
92105 case "bitbucket":
......@@ -200,6 +213,20 @@ export function detectSourceControlProviderFromRemoteUrl(
200213 }
201214 const hostname = parseHostName(host);
202215
216 if (
217 hostname === "codeberg.org" ||
218 hasDnsLabel(hostname, "forgejo") ||
219 hasDnsLabel(hostname, "gitea")
220 ) {
221 return {
222 kind: "forgejo",
223 name: "Forgejo",
224 baseUrl: /^https?:/iu.test(remoteUrl.trim())
225 ? new URL(remoteUrl.trim()).origin
226 : toBaseUrl(host),
227 };
228 }
229
203230 if (isGitHubHost(hostname)) {
204231 return {
205232 kind: "github",
packages/shared/src/threadPullRequests.test.ts+22
......@@ -62,6 +62,28 @@ function link(
6262}
6363
6464describe("threadPullRequestKeysEqual", () => {
65 it("recovers Forgejo ports from old stored URLs and keeps separate servers distinct", () => {
66 const old = link(1, {
67 host: "forge.example",
68 repository: "team/repo",
69 url: "http://forge.example:3000/team/repo/pulls/1",
70 });
71 expect(threadPullRequestKeysEqual(old, { ...old, host: "forge.example:3000" })).toBe(true);
72 expect(
73 threadPullRequestKeysEqual(old, {
74 host: "forge.example:3000",
75 repository: "team/repo",
76 number: 1,
77 }),
78 ).toBe(true);
79 expect(
80 threadPullRequestKeysEqual(old, {
81 ...old,
82 url: "http://forge.example:4000/team/repo/pulls/1",
83 }),
84 ).toBe(false);
85 });
86
6587 it("ignores host and repository case", () => {
6688 expect(
6789 threadPullRequestKeysEqual(
packages/shared/src/threadPullRequests.ts+40-10
......@@ -10,10 +10,23 @@ import { pullRequestHostOf } from "@t3tools/contracts";
1010import { parseChangeRequestUrl } from "./changeRequestUrl.ts";
1111import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts";
1212
13/** Normalize stored link identity, including Azure's SSH and browser host aliases. */
14export function normalizeThreadPullRequestKey(key: ThreadPullRequestKey): ThreadPullRequestKey {
13type ThreadPullRequestKeySource = ThreadPullRequestKey & {
14 readonly authority?: string;
15 readonly url?: string;
16};
17
18/** Normalize stored links, recovering Forgejo HTTP ports from old links' URLs. */
19export function normalizeThreadPullRequestKey(
20 key: ThreadPullRequestKeySource,
21): ThreadPullRequestKey {
22 const parsed = key.url === undefined ? null : parseChangeRequestUrl(key.url);
23 const authority =
24 key.authority ??
25 (parsed?.repository === key.repository.trim().toLowerCase() && parsed.number === key.number
26 ? parsed.authority
27 : undefined);
1528 const canonical = canonicalRepositoryKey(
16 `${key.host.trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`,
29 `${(authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`,
1730 );
1831 const separator = canonical.indexOf("/");
1932 return {
......@@ -31,7 +44,7 @@ export function legacyThreadPullRequestKey(
3144 const parsed = parseChangeRequestUrl(linked.url);
3245 if (parsed !== null && parsed.number === linked.number) {
3346 const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`);
34 if (canonical.startsWith("dev.azure.com/")) {
47 if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) {
3548 return normalizeThreadPullRequestKey(parsed);
3649 }
3750 }
......@@ -52,13 +65,13 @@ export function legacyThreadPullRequestKey(
5265
5366/** Identity comparison for links: host-level, case-insensitive on host and repository. */
5467export function threadPullRequestKeysEqual(
55 left: ThreadPullRequestKey,
56 right: ThreadPullRequestKey,
68 left: ThreadPullRequestKeySource,
69 right: ThreadPullRequestKeySource,
5770): boolean {
5871 return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right);
5972}
6073
61export function threadPullRequestKeyOf(key: ThreadPullRequestKey): string {
74export function threadPullRequestKeyOf(key: ThreadPullRequestKeySource): string {
6275 const normalized = normalizeThreadPullRequestKey(key);
6376 return `${normalized.host}/${normalized.repository}#${normalized.number}`;
6477}
......@@ -157,6 +170,20 @@ export function legacyLinkedPullRequestOf(
157170 const key = legacyThreadPullRequestKey(link, link.host);
158171 return canonicalRepositoryKey(`${key.host}/${key.repository}`) === azureKey;
159172 }
173 const parsed = parseChangeRequestUrl(link.url);
174 if (parsed?.authority !== undefined) {
175 try {
176 const remote = new URL(identity.locator.remoteUrl);
177 if (remote.protocol === "http:" || remote.protocol === "https:") {
178 return (
179 parsed.authority === remote.host && parsed.repository === repository.toLowerCase()
180 );
181 }
182 } catch {
183 // SSH web ports are resolved by the provider's configured login.
184 }
185 return parsed.host === host && parsed.repository === repository.toLowerCase();
186 }
160187 return (
161188 link.host.toLowerCase() === host.toLowerCase() &&
162189 link.repository.toLowerCase() === repository.toLowerCase()
......@@ -193,7 +220,8 @@ export function resolveThreadPullRequestChains(
193220 const nativeStacks = new Map<string, Array<ThreadPullRequestLink>>();
194221 for (const link of visible) {
195222 if (link.stack === null) continue;
196 const stackKey = `${link.host.toLowerCase()}/${link.repository.toLowerCase()}#stack:${link.stack.id}`;
223 const key = normalizeThreadPullRequestKey(link);
224 const stackKey = `${key.host}/${key.repository}#stack:${link.stack.id}`;
197225 const members = nativeStacks.get(stackKey) ?? [];
198226 members.push(link);
199227 nativeStacks.set(stackKey, members);
......@@ -206,8 +234,10 @@ export function resolveThreadPullRequestChains(
206234 }
207235
208236 const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link)));
209 const branchKey = (link: ThreadPullRequestLink, branch: string) =>
210 `${link.host.toLowerCase()}/${link.repository.toLowerCase()}:${branch}`;
237 const branchKey = (link: ThreadPullRequestLink, branch: string) => {
238 const key = normalizeThreadPullRequestKey(link);
239 return `${key.host}/${key.repository}:${branch}`;
240 };
211241 // Reused head names cannot identify a parent unambiguously.
212242 const byHead = new Map<string, ThreadPullRequestLink | null>();
213243 for (const link of remaining) {