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 @@...@@ -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";
2import { withUniwind } from "uniwind";2import { withUniwind } from "uniwind";
33
4const ThemedSvg = withUniwind(Svg);4const ThemedSvg = withUniwind(Svg);
55
6export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops";6export type SourceControlIconKind = "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops";
77
8export function SourceControlIcon(props: {8export function SourceControlIcon(props: {
9 readonly kind: SourceControlIconKind;9 readonly kind: SourceControlIconKind;
...@@ -14,6 +14,19 @@ export function SourceControlIcon(props: {...@@ -14,6 +14,19 @@ export function SourceControlIcon(props: {
14 const size = props.size ?? 18;14 const size = props.size ?? 18;
1515
16 switch (props.kind) {16 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 );
17 case "github":30 case "github":
18 return (31 return (
19 <ThemedSvg32 <ThemedSvg
apps/mobile/src/features/projects/AddProjectRepositoryRoute.tsx+1
...@@ -17,6 +17,7 @@ export function AddProjectRepositoryRoute({...@@ -17,6 +17,7 @@ export function AddProjectRepositoryRoute({
17 const title =17 const title =
18 source === "github" ||18 source === "github" ||
19 source === "gitlab" ||19 source === "gitlab" ||
20 source === "forgejo" ||
20 source === "bitbucket" ||21 source === "bitbucket" ||
21 source === "azure-devops"22 source === "azure-devops"
22 ? addProjectRemoteSourceLabel(source)23 ? addProjectRemoteSourceLabel(source)
apps/mobile/src/features/projects/AddProjectScreen.tsx+1
...@@ -110,6 +110,7 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote...@@ -110,6 +110,7 @@ function sourceFromParam(value: string | string[] | undefined): AddProjectRemote
110 source === "url" ||110 source === "url" ||
111 source === "github" ||111 source === "github" ||
112 source === "gitlab" ||112 source === "gitlab" ||
113 source === "forgejo" ||
113 source === "bitbucket" ||114 source === "bitbucket" ||
114 source === "azure-devops"115 source === "azure-devops"
115 ) {116 ) {
apps/server/src/git/GitManager.test.ts+68
...@@ -37,6 +37,10 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";...@@ -37,6 +37,10 @@ import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
37import * as VcsProcess from "../vcs/VcsProcess.ts";37import * as VcsProcess from "../vcs/VcsProcess.ts";
38import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";38import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
39import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";39import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts";
40import {
41 ForgejoPullRequestSchema,
42 toForgejoChangeRequest,
43} from "../sourceControl/forgejoPullRequests.ts";
40import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts";44import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts";
41import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";45import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
42import * as ServerConfig from "../config.ts";46import * as ServerConfig from "../config.ts";
...@@ -46,6 +50,7 @@ import * as ServerSettings from "../serverSettings.ts";...@@ -46,6 +50,7 @@ import * as ServerSettings from "../serverSettings.ts";
46import * as GitManager from "./GitManager.ts";50import * as GitManager from "./GitManager.ts";
4751
48const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));52const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
53const decodeForgejoPullRequest = Schema.decodeEffect(ForgejoPullRequestSchema);
4954
50interface FakeGhScenario {55interface FakeGhScenario {
51 prListSequence?: string[];56 prListSequence?: string[];
...@@ -3851,6 +3856,69 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {...@@ -3851,6 +3856,69 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
3851 20_000,3856 20_000,
3852 );3857 );
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
3854 it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () =>3922 it.effect("rejects same-repo PR metadata when matching a cross-repo head context", () =>
3855 Effect.sync(() => {3923 Effect.sync(() => {
3856 const headContext = {3924 const headContext = {
apps/server/src/git/GitManager.ts+35-3
...@@ -277,7 +277,10 @@ function resolvePullRequestWorktreeLocalBranchName(...@@ -277,7 +277,10 @@ function resolvePullRequestWorktreeLocalBranchName(
277 return `t3code/pr-${pullRequest.number}/${suffix}`;277 return `t3code/pr-${pullRequest.number}/${suffix}`;
278}278}
279279
280function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {280export function parseRepositoryNameWithOwnerFromRemoteUrl(
281 url: string | null,
282 providerKind?: ChangeRequest["provider"],
283): string | null {
281 const trimmed = url?.trim() ?? "";284 const trimmed = url?.trim() ?? "";
282 if (trimmed.length === 0) {285 if (trimmed.length === 0) {
283 return null;286 return null;
...@@ -288,6 +291,12 @@ function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string |...@@ -288,6 +291,12 @@ function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string |
288 trimmed,291 trimmed,
289 );292 );
290 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";293 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 }
291 return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null;300 return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null;
292}301}
293302
...@@ -1291,7 +1300,15 @@ export const make = Effect.gen(function* () {...@@ -1291,7 +1300,15 @@ export const make = Effect.gen(function* () {
1291 (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??1300 (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??
1292 (yield* readConfigValueNullable(cwd, "remote.origin.url"));1301 (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;
1295 });1312 });
12961313
1297 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (1314 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
...@@ -1307,7 +1324,22 @@ export const make = Effect.gen(function* () {...@@ -1307,7 +1324,22 @@ export const make = Effect.gen(function* () {
1307 }1324 }
13081325
1309 const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);1326 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 }
1311 return {1343 return {
1312 remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null,1344 remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null,
1313 repositoryNameWithOwner,1345 repositoryNameWithOwner,
apps/server/src/mcp/toolkits/pullRequests/handlers.test.ts+46
...@@ -265,6 +265,40 @@ describe("pull request toolkit handlers", () => {...@@ -265,6 +265,40 @@ describe("pull request toolkit handlers", () => {
265 }),265 }),
266 );266 );
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
268 it.effect("rejects a target that names neither a URL nor repository and number", () =>302 it.effect("rejects a target that names neither a URL nor repository and number", () =>
269 Effect.gen(function* () {303 Effect.gen(function* () {
270 const harness = yield* makeHarness();304 const harness = yield* makeHarness();
...@@ -334,6 +368,18 @@ describe("pull request toolkit handlers", () => {...@@ -334,6 +368,18 @@ describe("pull request toolkit handlers", () => {
334 }),368 }),
335 );369 );
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
337 it.effect("fails cleanly when the token's thread no longer exists", () =>383 it.effect("fails cleanly when the token's thread no longer exists", () =>
338 Effect.gen(function* () {384 Effect.gen(function* () {
339 const harness = yield* makeHarness({ thread: null });385 const harness = yield* makeHarness({ thread: null });
apps/server/src/mcp/toolkits/pullRequests/handlers.ts+8-3
...@@ -9,6 +9,7 @@ import {...@@ -9,6 +9,7 @@ import {
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";10import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl";
11import {11import {
12 normalizeThreadPullRequestKey,
12 resolveThreadPullRequestChains,13 resolveThreadPullRequestChains,
13 threadPullRequestKeyOf,14 threadPullRequestKeyOf,
14 visibleThreadPullRequests,15 visibleThreadPullRequests,
...@@ -71,7 +72,7 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (...@@ -71,7 +72,7 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (
71 if (parsed === null) {72 if (parsed === null) {
72 return yield* new PullRequestUrlInvalidError({});73 return yield* new PullRequestUrlInvalidError({});
73 }74 }
74 return { ...parsed, url: input.url } satisfies ResolvedTarget;75 return { ...normalizeThreadPullRequestKey(parsed), url: input.url } satisfies ResolvedTarget;
75 }76 }
76 if (input.repository === undefined || input.number === undefined) {77 if (input.repository === undefined || input.number === undefined) {
77 return yield* new PullRequestTargetIncompleteError({});78 return yield* new PullRequestTargetIncompleteError({});
...@@ -89,8 +90,12 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (...@@ -89,8 +90,12 @@ const resolveTarget = Effect.fn("PullRequestsToolkit.resolveTarget")(function* (
89 host,90 host,
90 repository,91 repository,
91 input.number,92 input.number,
93 project?.repositoryIdentity?.locator.remoteUrl,
92 ) ?? `https://${host}/${repository}/pull/${input.number}`;94 ) ?? `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;
94});99});
95100
96function entryOf(101function entryOf(
...@@ -108,7 +113,7 @@ function entryOf(...@@ -108,7 +113,7 @@ function entryOf(
108 }113 }
109 }114 }
110 return {115 return {
111 host: link.host,116 host: normalizeThreadPullRequestKey(link).host,
112 repository: link.repository,117 repository: link.repository,
113 number: link.number,118 number: link.number,
114 url: link.url,119 url: link.url,
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+33
...@@ -889,6 +889,39 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull...@@ -889,6 +889,39 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-pull
889 assert.deepEqual(yield* readLinks(), []);889 assert.deepEqual(yield* readLinks(), []);
890 assert.deepEqual(yield* readThreadUpdatedAt(), [{ updatedAt: "2026-01-01T00:00:05.000Z" }]);890 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
892 // Deleting the thread clears whatever links it still had.925 // Deleting the thread clears whatever links it still had.
893 yield* eventStore.append({926 yield* eventStore.append({
894 ...base("2026-01-01T00:00:06.000Z"),927 ...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...@@ -880,12 +880,20 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti
880 if (Option.isNone(existingRow)) {880 if (Option.isNone(existingRow)) {
881 return;881 return;
882 }882 }
883 yield* projectionThreadPullRequestRepository.delete({883 const links = yield* projectionThreadPullRequestRepository.listByThreadId({
884 threadId: event.payload.threadId,884 threadId: event.payload.threadId,
885 host: event.payload.host.toLowerCase(),
886 repository: event.payload.repository.toLowerCase(),
887 number: event.payload.number,
888 });885 });
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 }
889 yield* projectionThreadRepository.upsert({897 yield* projectionThreadRepository.upsert({
890 ...existingRow.value,898 ...existingRow.value,
891 updatedAt: event.payload.updatedAt,899 updatedAt: event.payload.updatedAt,
apps/server/src/orchestration/PullRequestSyncReactor.test.ts+25
...@@ -420,6 +420,31 @@ describe("PullRequestSyncReactor", () => {...@@ -420,6 +420,31 @@ describe("PullRequestSyncReactor", () => {
420 ),420 ),
421 );421 );
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
423 it.effect("dispatches nothing when the host snapshot is unchanged", () =>448 it.effect("dispatches nothing when the host snapshot is unchanged", () =>
424 Effect.scoped(449 Effect.scoped(
425 Effect.gen(function* () {450 Effect.gen(function* () {
apps/server/src/orchestration/PullRequestSyncReactor.ts+8-3
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";11import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
12import {12import {
13 threadPullRequestKeyOf,13 threadPullRequestKeyOf,
14 normalizeThreadPullRequestKey,
14 threadPullRequestKeysEqual,15 threadPullRequestKeysEqual,
15 visibleThreadPullRequests,16 visibleThreadPullRequests,
16} from "@t3tools/shared/threadPullRequests";17} from "@t3tools/shared/threadPullRequests";
...@@ -191,7 +192,7 @@ export const make = Effect.gen(function* () {...@@ -191,7 +192,7 @@ export const make = Effect.gen(function* () {
191 type: "thread.pull-request-link.sync",192 type: "thread.pull-request-link.sync",
192 commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`),193 commandId: CommandId.make(`server:pr-sync:${thread.id}:${uuid}`),
193 threadId: thread.id,194 threadId: thread.id,
194 host: link.host,195 host: normalizeThreadPullRequestKey(link).host,
195 repository: link.repository,196 repository: link.repository,
196 number: link.number,197 number: link.number,
197 snapshot: { ...fields, syncedAt: nowIso },198 snapshot: { ...fields, syncedAt: nowIso },
...@@ -200,7 +201,11 @@ export const make = Effect.gen(function* () {...@@ -200,7 +201,11 @@ export const make = Effect.gen(function* () {
200 }201 }
201 if (fetchedStack === null || fetchedStack.stack === null) return;202 if (fetchedStack === null || fetchedStack.stack === null) return;
202 for (const layer of fetchedStack.stack.layers) {203 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 };
204 const dedupeKey = `${thread.id}:${threadPullRequestKeyOf(layerKey)}`;209 const dedupeKey = `${thread.id}:${threadPullRequestKeyOf(layerKey)}`;
205 if (linkedThisSweep.has(dedupeKey)) continue;210 if (linkedThisSweep.has(dedupeKey)) continue;
206 // Tombstones count as present: a dismissed layer is never re-added.211 // Tombstones count as present: a dismissed layer is never re-added.
...@@ -240,7 +245,7 @@ export const make = Effect.gen(function* () {...@@ -240,7 +245,7 @@ export const make = Effect.gen(function* () {
240 const first = entries[0]!;245 const first = entries[0]!;
241 const ref = {246 const ref = {
242 projectId: first.thread.projectId,247 projectId: first.thread.projectId,
243 host: first.link.host,248 host: normalizeThreadPullRequestKey(first.link).host,
244 repository: first.link.repository,249 repository: first.link.repository,
245 number: first.link.number,250 number: first.link.number,
246 };251 };
apps/server/src/orchestration/decider.pullRequests.test.ts+43
...@@ -115,6 +115,49 @@ const snapshot: ThreadPullRequestSnapshot = {...@@ -115,6 +115,49 @@ const snapshot: ThreadPullRequestSnapshot = {
115};115};
116116
117it.layer(NodeServices.layer)("pull request link decider", (it) => {117it.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
118 it.effect("legacy unlink cannot remove a newer cross-host link", () =>161 it.effect("legacy unlink cannot remove a newer cross-host link", () =>
119 Effect.gen(function* () {162 Effect.gen(function* () {
120 const own = makeLink();163 const own = makeLink();
apps/server/src/project/RepositoryIdentityResolver.test.ts+41-2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not.1// @effect-diagnostics nodeBuiltinImport:off - realpathSync.native resolves Windows 8.3 short names, which the Effect realPath does not.
2import * as NodeFS from "node:fs";2import * as NodeFS from "node:fs";
3import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { SourceControlProviderError } from "@t3tools/contracts";
4import { expect, it } from "@effect/vitest";5import { expect, it } from "@effect/vitest";
5import * as Duration from "effect/Duration";6import * as Duration from "effect/Duration";
6import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
...@@ -41,6 +42,9 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {...@@ -41,6 +42,9 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
41 it.effect("refreshes the Git root only when requested", () => {42 it.effect("refreshes the Git root only when requested", () => {
42 const calls: Array<ReadonlyArray<string>> = [];43 const calls: Array<ReadonlyArray<string>> = [];
43 let rootPath = "/repo";44 let rootPath = "/repo";
45 let remoteUrl = "git@github.com:T3Tools/t3code.git";
46 let refinements = 0;
47 let refinementFails = false;
44 const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, {48 const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
45 run: (input) =>49 run: (input) =>
46 Effect.sync(() => {50 Effect.sync(() => {
...@@ -48,7 +52,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {...@@ -48,7 +52,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
48 return {52 return {
49 stdout: input.args.includes("rev-parse")53 stdout: input.args.includes("rev-parse")
50 ? `${rootPath}\n`54 ? `${rootPath}\n`
51 : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n",55 : `origin\t${remoteUrl} (fetch)\n`,
52 stderr: "",56 stderr: "",
53 code: ChildProcessSpawner.ExitCode(0),57 code: ChildProcessSpawner.ExitCode(0),
54 timedOut: false,58 timedOut: false,
...@@ -61,7 +65,29 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {...@@ -61,7 +65,29 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
61 });65 });
62 const resolverLayer = Layer.effect(66 const resolverLayer = Layer.effect(
63 RepositoryIdentityResolver.RepositoryIdentityResolver,67 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 }),
65 ).pipe(Layer.provide(processRunner));91 ).pipe(Layer.provide(processRunner));
6692
67 return Effect.gen(function* () {93 return Effect.gen(function* () {
...@@ -72,6 +98,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {...@@ -72,6 +98,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
7298
73 expect(first?.canonicalKey).toBe("github.com/t3tools/t3code");99 expect(first?.canonicalKey).toBe("github.com/t3tools/t3code");
74 expect(second).toEqual(first);100 expect(second).toEqual(first);
101 expect(refinements).toBe(1);
75 expect(calls).toEqual([102 expect(calls).toEqual([
76 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],103 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
77 ["-C", "/repo", "remote", "-v"],104 ["-C", "/repo", "remote", "-v"],
...@@ -84,6 +111,18 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {...@@ -84,6 +111,18 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
84 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],111 ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"],
85 ["-C", "/repo/packages/web", "remote", "-v"],112 ["-C", "/repo/packages/web", "remote", "-v"],
86 ]);113 ]);
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");
87 }).pipe(Effect.provide(resolverLayer));126 }).pipe(Effect.provide(resolverLayer));
88 });127 });
89128
apps/server/src/project/RepositoryIdentityResolver.ts+9-1
...@@ -1,4 +1,4 @@...@@ -1,4 +1,4 @@
1import type { RepositoryIdentity } from "@t3tools/contracts";1import type { RepositoryIdentity, SourceControlProviderError } from "@t3tools/contracts";
2import {2import {
3 detectSourceControlProviderFromGitRemoteUrl,3 detectSourceControlProviderFromGitRemoteUrl,
4 normalizeGitRemoteUrl,4 normalizeGitRemoteUrl,
...@@ -20,6 +20,9 @@ export interface RepositoryIdentityResolverOptions {...@@ -20,6 +20,9 @@ export interface RepositoryIdentityResolverOptions {
20 readonly cacheCapacity?: number;20 readonly cacheCapacity?: number;
21 readonly positiveCacheTtl?: Duration.Input;21 readonly positiveCacheTtl?: Duration.Input;
22 readonly negativeCacheTtl?: Duration.Input;22 readonly negativeCacheTtl?: Duration.Input;
23 readonly refine?: (
24 identity: RepositoryIdentity,
25 ) => Effect.Effect<RepositoryIdentity, SourceControlProviderError>;
23}26}
2427
25export class RepositoryIdentityResolver extends Context.Service<28export class RepositoryIdentityResolver extends Context.Service<
...@@ -158,6 +161,11 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (...@@ -158,6 +161,11 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
158 (cacheKey) =>161 (cacheKey) =>
159 resolveRepositoryIdentityFromCacheKey(cacheKey).pipe(162 resolveRepositoryIdentityFromCacheKey(cacheKey).pipe(
160 Effect.provideService(ProcessRunner.ProcessRunner, processRunner),163 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 ),
161 ),169 ),
162 {170 {
163 capacity: cacheCapacity,171 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 {...@@ -282,6 +282,7 @@ export interface PullRequestProviderApi {
282 /** The signed-in account, which is what involvement filtering compares against. */282 /** The signed-in account, which is what involvement filtering compares against. */
283 readonly getViewer: (input: {283 readonly getViewer: (input: {
284 readonly cwd: string;284 readonly cwd: string;
285 readonly host?: string;
285 }) => Effect.Effect<string, PullRequestProviderError>;286 }) => Effect.Effect<string, PullRequestProviderError>;
286287
287 readonly listChangeRequests: (288 readonly listChangeRequests: (
apps/server/src/pullRequest/PullRequestProviderRegistry.ts+4
...@@ -8,6 +8,8 @@ import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";...@@ -8,6 +8,8 @@ import * as BitbucketApi from "../sourceControl/BitbucketApi.ts";
8import * as GitHubCli from "../sourceControl/GitHubCli.ts";8import * as GitHubCli from "../sourceControl/GitHubCli.ts";
9import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";9import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts";
10import * as GitLabCli from "../sourceControl/GitLabCli.ts";10import * as GitLabCli from "../sourceControl/GitLabCli.ts";
11import * as ForgejoCli from "../sourceControl/ForgejoCli.ts";
12import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts";
11import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";13import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts";
12import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";14import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts";
13import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";15import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts";
...@@ -48,6 +50,7 @@ export const make = Effect.map(...@@ -48,6 +50,7 @@ export const make = Effect.map(
48 Effect.all([50 Effect.all([
49 GitHubPullRequestProvider.make,51 GitHubPullRequestProvider.make,
50 GitLabPullRequestProvider.make,52 GitLabPullRequestProvider.make,
53 ForgejoPullRequestProvider.make,
51 BitbucketPullRequestProvider.make,54 BitbucketPullRequestProvider.make,
52 AzureDevOpsPullRequestProvider.make,55 AzureDevOpsPullRequestProvider.make,
53 ]),56 ]),
...@@ -62,6 +65,7 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(...@@ -62,6 +65,7 @@ export const layer = Layer.effect(PullRequestProviderRegistry, make).pipe(
62 ),65 ),
63 ),66 ),
64 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),67 Layer.provide(GitLabPullRequestCli.layer.pipe(Layer.provide(GitLabCli.layer))),
68 Layer.provide(ForgejoCli.layer),
65 Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),69 Layer.provide(BitbucketPullRequestApi.layer.pipe(Layer.provide(BitbucketApi.layer))),
66 Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))),70 Layer.provide(AzureDevOpsPullRequestCli.layer.pipe(Layer.provide(AzureDevOpsCli.layer))),
67);71);
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...@@ -1659,6 +1659,118 @@ it.effect("reads a host-native stack through the provider and null where it has
1659 }),1659 }),
1660);1660);
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
1662it.effect("routes a hosted reference to another repository through a project on that host", () =>1774it.effect("routes a hosted reference to another repository through a project on that host", () =>
1663 Effect.gen(function* () {1775 Effect.gen(function* () {
1664 const seen: Array<{ cwd: string; repository: string; host: string }> = [];1776 const seen: Array<{ cwd: string; repository: string; host: string }> = [];
apps/server/src/pullRequest/PullRequestService.ts+41-15
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 canonicalRepositoryKey,2 canonicalRepositoryKey,
3 isSshRemoteUrl,
3 sourceControlRepositorySelector,4 sourceControlRepositorySelector,
4} from "@t3tools/shared/sourceControl";5} from "@t3tools/shared/sourceControl";
5import * as Cache from "effect/Cache";6import * as Cache from "effect/Cache";
...@@ -553,14 +554,21 @@ export const make = Effect.gen(function* () {...@@ -553,14 +554,21 @@ export const make = Effect.gen(function* () {
553 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;554 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;
554 const identity = project.repositoryIdentity;555 const identity = project.repositoryIdentity;
555 if (556 if (
556 identity?.provider !== "unknown" ||557 (identity?.provider !== "unknown" &&
558 !(identity?.provider === "forgejo" && isSshRemoteUrl(identity.locator.remoteUrl))) ||
557 sourceControlRepositorySelector(project.repositoryIdentity) === null559 sourceControlRepositorySelector(project.repositoryIdentity) === null
558 )560 )
559 continue;561 continue;
560 const host = pullRequestHostOf(identity, "unknown");562 const host = pullRequestHostOf(identity, "unknown");
561 // A legacy identity has no canonical host until its provider is refined, so it must reach563 // A legacy identity has no canonical host until its provider is refined, so it must reach
562 // the refinement before a host filter can decide whether it belongs in the result.564 // 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 ) {
564 continue;572 continue;
565 }573 }
566 const { remoteName, remoteUrl } = identity.locator;574 const { remoteName, remoteUrl } = identity.locator;
...@@ -581,20 +589,28 @@ export const make = Effect.gen(function* () {...@@ -581,20 +589,28 @@ export const make = Effect.gen(function* () {
581 Effect.suspend(() =>589 Effect.suspend(() =>
582 sourceControlProviders.resolveHandle({590 sourceControlProviders.resolveHandle({
583 cwd: project.workspaceRoot,591 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 },
585 }),601 }),
586 ).pipe(602 ).pipe(
587 Effect.flatMap((handle) => {603 Effect.flatMap((handle) => {
588 const kind = handle.context?.provider.kind;604 const refined = handle.context?.provider;
589 return kind === undefined || kind === "unknown"605 return refined === undefined || refined.kind === "unknown"
590 ? Effect.fail(undefined)606 ? Effect.fail(undefined)
591 : Effect.succeed(kind);607 : Effect.succeed(refined);
592 }),608 }),
593 ),609 ),
594 ),610 ),
595 ).pipe(611 ).pipe(
596 Effect.map((kind) => [baseUrl, kind] as const),612 Effect.map((provider) => [baseUrl, provider] as const),
597 Effect.orElseSucceed(() => [baseUrl, "unknown"] as const),613 Effect.orElseSucceed(() => [baseUrl, null] as const),
598 ),614 ),
599 { concurrency: REPOSITORY_CONCURRENCY },615 { concurrency: REPOSITORY_CONCURRENCY },
600 ).pipe(Effect.map((resolved) => new Map(resolved)));616 ).pipe(Effect.map((resolved) => new Map(resolved)));
...@@ -617,10 +633,10 @@ export const make = Effect.gen(function* () {...@@ -617,10 +633,10 @@ export const make = Effect.gen(function* () {
617 ),633 ),
618 Effect.flatMap((projects) =>634 Effect.flatMap((projects) =>
619 refineUnknownProjectKinds(projects, filter).pipe(635 refineUnknownProjectKinds(projects, filter).pipe(
620 Effect.map((refinedKinds) => ({ refinedKinds, projects })),636 Effect.map((refinedProviders) => ({ refinedProviders, projects })),
621 ),637 ),
622 ),638 ),
623 Effect.map(({ refinedKinds, projects }) => {639 Effect.map(({ refinedProviders, projects }) => {
624 const supported: SupportedProject[] = [];640 const supported: SupportedProject[] = [];
625 const unimplemented = new Map<641 const unimplemented = new Map<
626 string,642 string,
...@@ -638,12 +654,22 @@ export const make = Effect.gen(function* () {...@@ -638,12 +654,22 @@ export const make = Effect.gen(function* () {
638 // Worktrees of one repository are separate projects; reading the remote once keeps654 // Worktrees of one repository are separate projects; reading the remote once keeps
639 // the page from repeating every change request per local checkout. The host is part655 // the page from repeating every change request per local checkout. The host is part
640 // of the key, so the same `owner/repo` on two hosts stays two repositories.656 // 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 ) {
642 const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl);662 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;
644 }672 }
645 const host = pullRequestHostOf(identity, kind);
646 if (filter.host !== undefined && host !== filter.host.toLowerCase()) continue;
647 const api = registry.get(kind);673 const api = registry.get(kind);
648 // Recorded before the de-duplication below, so the viewer lookup keeps the alternates674 // Recorded before the de-duplication below, so the viewer lookup keeps the alternates
649 // the listing is about to drop.675 // the listing is about to drop.
...@@ -816,7 +842,7 @@ export const make = Effect.gen(function* () {...@@ -816,7 +842,7 @@ export const make = Effect.gen(function* () {
816 return Effect.die(new Error(`Missing pull request provider: ${kind}`));842 return Effect.die(new Error(`Missing pull request provider: ${kind}`));
817 }843 }
818 const api = withRateLimitBackoff(registered, host, rateLimits);844 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(
820 Effect.map((viewer) => ({846 Effect.map((viewer) => ({
821 host,847 host,
822 kind,848 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(...@@ -17,6 +17,22 @@ it.effect(
17 VALUES ('project-1', 'Project', '/tmp/project', '[]', ${createdAt}, ${createdAt})17 VALUES ('project-1', 'Project', '/tmp/project', '[]', ${createdAt}, ${createdAt})
18 `;18 `;
19 const fixtures = [19 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 },
20 {36 {
21 id: "azure",37 id: "azure",
22 host: "dev.azure.com",38 host: "dev.azure.com",
...@@ -83,10 +99,24 @@ it.effect(...@@ -83,10 +99,24 @@ it.effect(
83 yield* sql`99 yield* sql`
84 INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at)100 INSERT INTO projection_thread_pull_requests (thread_id, host, repository, number, url, source, linked_at)
85 VALUES (${fixture.id}, ${fixture.host}, ${fixture.repository}, ${fixture.number},101 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})
87 `;103 `;
88 }104 }
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"]);
90 expect(120 expect(
91 (yield* listLinkedPullRequestThreads({121 (yield* listLinkedPullRequestThreads({
92 host: "org.visualstudio.com",122 host: "org.visualstudio.com",
apps/server/src/pullRequest/linkedThreads.ts+20-4
...@@ -1,4 +1,7 @@...@@ -1,4 +1,7 @@
1import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";1import {
2 normalizeThreadPullRequestKey,
3 threadPullRequestKeysEqual,
4} from "@t3tools/shared/threadPullRequests";
2import {5import {
3 PullRequestLinkedThreadsResult,6 PullRequestLinkedThreadsResult,
4 PullRequestOperationError,7 PullRequestOperationError,
...@@ -13,19 +16,32 @@ const decodeLinkedThreads = Schema.decodeUnknownEffect(PullRequestLinkedThreadsR...@@ -13,19 +16,32 @@ const decodeLinkedThreads = Schema.decodeUnknownEffect(PullRequestLinkedThreadsR
13export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThreads")(16export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThreads")(
14 function* (input: ThreadPullRequestKey) {17 function* (input: ThreadPullRequestKey) {
15 const key = normalizeThreadPullRequestKey(input);18 const key = normalizeThreadPullRequestKey(input);
19 const hostname = key.host.replace(/:\d+$/u, "");
16 const sql = yield* SqlClient.SqlClient;20 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 }>`
18 SELECT t.thread_id AS id, t.project_id AS "projectId", t.title,31 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
20 FROM projection_thread_pull_requests AS link33 FROM projection_thread_pull_requests AS link
21 JOIN projection_threads AS t ON t.thread_id = link.thread_id34 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})
23 AND link.repository = ${key.repository.toLowerCase()}36 AND link.repository = ${key.repository.toLowerCase()}
24 AND link.number = ${key.number}37 AND link.number = ${key.number}
25 AND link.source != 'stack-dismissed'38 AND link.source != 'stack-dismissed'
26 AND t.deleted_at IS NULL39 AND t.deleted_at IS NULL
27 ORDER BY t.updated_at DESC, t.thread_id ASC40 ORDER BY t.updated_at DESC, t.thread_id ASC
28 `;41 `;
42 const threads = rows
43 .filter((row) => threadPullRequestKeysEqual(row, key))
44 .map(({ id, projectId, title, archivedAt }) => ({ id, projectId, title, archivedAt }));
29 return yield* decodeLinkedThreads({ threads });45 return yield* decodeLinkedThreads({ threads });
30 },46 },
31 Effect.mapError(47 Effect.mapError(
apps/server/src/server.ts+50-3
...@@ -1,4 +1,8 @@...@@ -1,4 +1,8 @@
1import { EnvironmentHttpApi, ProviderDriverKind } from "@t3tools/contracts";1import {
2 EnvironmentHttpApi,
3 ProviderDriverKind,
4 type RepositoryIdentity,
5} from "@t3tools/contracts";
2import * as Cause from "effect/Cause";6import * as Cause from "effect/Cause";
3import * as Duration from "effect/Duration";7import * as Duration from "effect/Duration";
4import * as Deferred from "effect/Deferred";8import * as Deferred from "effect/Deferred";
...@@ -51,6 +55,7 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";...@@ -51,6 +55,7 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
51import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";55import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
52import * as GitHubCli from "./sourceControl/GitHubCli.ts";56import * as GitHubCli from "./sourceControl/GitHubCli.ts";
53import * as GitLabCli from "./sourceControl/GitLabCli.ts";57import * as GitLabCli from "./sourceControl/GitLabCli.ts";
58import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
54import * as TextGeneration from "./textGeneration/TextGeneration.ts";59import * as TextGeneration from "./textGeneration/TextGeneration.ts";
55import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";60import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
56import * as TerminalManager from "./terminal/Manager.ts";61import * as TerminalManager from "./terminal/Manager.ts";
...@@ -313,12 +318,54 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe(...@@ -313,12 +318,54 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe(
313318
314const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe(319const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe(
315 Layer.provide(320 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 ),
317 ),328 ),
318 Layer.provideMerge(GitVcsDriver.layer),329 Layer.provideMerge(GitVcsDriver.layer),
319 Layer.provideMerge(VcsDriverRegistryLayerLive),330 Layer.provideMerge(VcsDriverRegistryLayerLive),
320);331);
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
322const PullRequestServiceLive = PullRequestService.layer.pipe(369const PullRequestServiceLive = PullRequestService.layer.pipe(
323 Layer.provide(PullRequestProviderRegistry.layer),370 Layer.provide(PullRequestProviderRegistry.layer),
324 Layer.provide(PullRequestReadCache.layer),371 Layer.provide(PullRequestReadCache.layer),
...@@ -512,7 +559,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(...@@ -512,7 +559,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe(
512 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),559 Layer.provideMerge(OpenCodeRuntime.OpenCodeRuntimeLive),
513 Layer.provideMerge(WorkspaceLayerLive),560 Layer.provideMerge(WorkspaceLayerLive),
514 Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, ProjectFaviconResolverLayerLive)),561 Layer.provideMerge(Layer.mergeAll(NativeAppIconResolver.layer, ProjectFaviconResolverLayerLive)),
515 Layer.provideMerge(RepositoryIdentityResolver.layer),562 Layer.provideMerge(RepositoryIdentityResolverLayerLive),
516 Layer.provideMerge(ServerEnvironmentLayerLive),563 Layer.provideMerge(ServerEnvironmentLayerLive),
517 Layer.provideMerge(AuthLayerLive),564 Layer.provideMerge(AuthLayerLive),
518 Layer.provideMerge(ServerSecretStore.layer),565 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 @@...@@ -1,9 +1,14 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import type * as Context from "effect/Context";
5import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as Path from "effect/Path";
9import * as Schema from "effect/Schema";
6import { ChildProcessSpawner } from "effect/unstable/process";10import { ChildProcessSpawner } from "effect/unstable/process";
11import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http";
7import { VcsProcessSpawnError } from "@t3tools/contracts";12import { VcsProcessSpawnError } from "@t3tools/contracts";
813
9import * as ServerConfig from "../config.ts";14import * as ServerConfig from "../config.ts";
...@@ -13,8 +18,12 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";...@@ -13,8 +18,12 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
13import * as BitbucketApi from "./BitbucketApi.ts";18import * as BitbucketApi from "./BitbucketApi.ts";
14import * as GitHubCli from "./GitHubCli.ts";19import * as GitHubCli from "./GitHubCli.ts";
15import * as GitLabCli from "./GitLabCli.ts";20import * as GitLabCli from "./GitLabCli.ts";
21import * as ForgejoCli from "./ForgejoCli.ts";
22import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
23import * as ForgejoPullRequestProvider from "../pullRequest/ForgejoPullRequestProvider.ts";
16import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";24import * as SourceControlDiscovery from "./SourceControlDiscovery.ts";
17import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";25import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
26import { firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts";
1827
19const sourceControlProviderRegistryTestLayer = (input: {28const sourceControlProviderRegistryTestLayer = (input: {
20 readonly bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>;29 readonly bitbucket: Partial<BitbucketApi.BitbucketApi["Service"]>;
...@@ -30,6 +39,7 @@ const sourceControlProviderRegistryTestLayer = (input: {...@@ -30,6 +39,7 @@ const sourceControlProviderRegistryTestLayer = (input: {
30 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),39 Layer.mock(BitbucketApi.BitbucketApi)(input.bitbucket),
31 Layer.mock(GitHubCli.GitHubCli)({}),40 Layer.mock(GitHubCli.GitHubCli)({}),
32 Layer.mock(GitLabCli.GitLabCli)({}),41 Layer.mock(GitLabCli.GitLabCli)({}),
42 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
33 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),43 Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({}),
34 Layer.mock(VcsProcess.VcsProcess)(input.process),44 Layer.mock(VcsProcess.VcsProcess)(input.process),
35 ),45 ),
...@@ -50,6 +60,314 @@ const processOutput = (...@@ -50,6 +60,314 @@ const processOutput = (
50 stderrTruncated: false,60 stderrTruncated: false,
51});61});
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
53it.effect("reports implemented tools separately from locally available executables", () => {371it.effect("reports implemented tools separately from locally available executables", () => {
54 const processMock = {372 const processMock = {
55 run: (input: VcsProcess.VcsProcessInput) => {373 run: (input: VcsProcess.VcsProcessInput) => {
...@@ -62,7 +380,7 @@ it.effect("reports implemented tools separately from locally available executabl...@@ -62,7 +380,7 @@ it.effect("reports implemented tools separately from locally available executabl
62 if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {380 if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {
63 return Effect.succeed(381 return Effect.succeed(
64 processOutput(382 processOutput(
65 JSON.stringify({383 encodeJson({
66 hosts: {384 hosts: {
67 "github.com": [385 "github.com": [
68 {386 {
...@@ -161,6 +479,12 @@ it.effect("reports implemented tools separately from locally available executabl...@@ -161,6 +479,12 @@ it.effect("reports implemented tools separately from locally available executabl
161 auth: "unauthenticated",479 auth: "unauthenticated",
162 account: Option.none(),480 account: Option.none(),
163 },481 },
482 {
483 kind: "forgejo",
484 status: "missing",
485 auth: "unknown",
486 account: Option.none(),
487 },
164 ],488 ],
165 );489 );
166 const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket");490 const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket");
...@@ -178,7 +502,7 @@ it.effect("probes provider authentication without exposing token details", () =>...@@ -178,7 +502,7 @@ it.effect("probes provider authentication without exposing token details", () =>
178 if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {502 if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {
179 return Effect.succeed(503 return Effect.succeed(
180 processOutput(504 processOutput(
181 JSON.stringify({505 encodeJson({
182 hosts: {506 hosts: {
183 "github.com": [507 "github.com": [
184 {508 {
...@@ -202,6 +526,22 @@ Logged in to gitlab.com as gitlab-user...@@ -202,6 +526,22 @@ Logged in to gitlab.com as gitlab-user
202`),526`),
203 );527 );
204 }528 }
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 }
205 if (545 if (
206 input.command === "az" &&546 input.command === "az" &&
207 input.args.join(" ") === "account show --query user.name -o tsv"547 input.args.join(" ") === "account show --query user.name -o tsv"
...@@ -277,7 +617,1012 @@ Logged in to gitlab.com as gitlab-user...@@ -277,7 +617,1012 @@ Logged in to gitlab.com as gitlab-user
277 account: Option.some("bitbucket-user"),617 account: Option.some("bitbucket-user"),
278 detail: Option.none(),618 detail: Option.none(),
279 },619 },
620 {
621 kind: "forgejo",
622 auth: "authenticated",
623 account: Option.some("forgejo-user"),
624 detail: Option.none(),
625 },
280 ],626 ],
281 );627 );
282 }).pipe(Effect.provide(testLayer));628 }).pipe(Effect.provide(testLayer));
283});629});
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 {...@@ -14,6 +14,8 @@ export interface SourceControlProviderContext {
14 readonly provider: SourceControlProviderInfo;14 readonly provider: SourceControlProviderInfo;
15 readonly remoteName: string;15 readonly remoteName: string;
16 readonly remoteUrl: string;16 readonly remoteUrl: string;
17 /** An explicit web authority can disambiguate Forgejo logins sharing an SSH alias. */
18 readonly requestedHost?: string;
17}19}
1820
19export interface SourceControlRefSelector {21export interface SourceControlRefSelector {
apps/server/src/sourceControl/SourceControlProviderDiscovery.ts+23-6
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as NodeUtil from "node:util";
1import type {2import type {
2 SourceControlProviderAuth,3 SourceControlProviderAuth,
3 SourceControlProviderDiscoveryItem,4 SourceControlProviderDiscoveryItem,
...@@ -33,6 +34,7 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {...@@ -33,6 +34,7 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {
33 readonly executable: string;34 readonly executable: string;
34 readonly versionArgs: ReadonlyArray<string>;35 readonly versionArgs: ReadonlyArray<string>;
35 readonly authArgs: ReadonlyArray<string>;36 readonly authArgs: ReadonlyArray<string>;
37 readonly remoteRefinementArgs?: ReadonlyArray<string>;
36 readonly probeTimeoutMs?: number;38 readonly probeTimeoutMs?: number;
37 readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth;39 readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth;
38 readonly refineUnknownRemote?: (40 readonly refineUnknownRemote?: (
...@@ -45,8 +47,18 @@ export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & {...@@ -45,8 +47,18 @@ export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & {
45 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;47 readonly probeAuth: Effect.Effect<SourceControlProviderAuth, never>;
46};48};
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
48export type SourceControlProviderDiscoverySpec =59export type SourceControlProviderDiscoverySpec =
49 | SourceControlCliDiscoverySpec60 | SourceControlCliDiscoverySpec
61 | SourceControlManagedCliDiscoverySpec
50 | SourceControlApiDiscoverySpec;62 | SourceControlApiDiscoverySpec;
5163
52type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & {64type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & {
...@@ -72,7 +84,7 @@ interface DiscoveryProbeResult {...@@ -72,7 +84,7 @@ interface DiscoveryProbeResult {
72}84}
7385
74export function firstNonEmptyLine(text: string): Option.Option<string> {86export function firstNonEmptyLine(text: string): Option.Option<string> {
75 const line = text87 const line = NodeUtil.stripVTControlCharacters(text)
76 .split(/\r?\n/)88 .split(/\r?\n/)
77 .map((entry) => entry.trim())89 .map((entry) => entry.trim())
78 .find((entry) => entry.length > 0);90 .find((entry) => entry.length > 0);
...@@ -214,6 +226,7 @@ export function probeSourceControlProvider(input: {...@@ -214,6 +226,7 @@ export function probeSourceControlProvider(input: {
214 readonly process: VcsProcess.VcsProcess["Service"];226 readonly process: VcsProcess.VcsProcess["Service"];
215 readonly cwd: string;227 readonly cwd: string;
216}): Effect.Effect<SourceControlProviderDiscoveryItem> {228}): Effect.Effect<SourceControlProviderDiscoveryItem> {
229 if (input.spec.type === "managed-cli") return input.spec.probe(input.cwd);
217 if (input.spec.type === "api") {230 if (input.spec.type === "api") {
218 return input.spec.probeAuth.pipe(231 return input.spec.probeAuth.pipe(
219 Effect.map(232 Effect.map(
...@@ -288,12 +301,16 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide...@@ -288,12 +301,16 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide
288 }301 }
289 const context = input.context;302 const context = input.context;
290303
291 const providers = yield* Effect.forEach(input.specs.filter(isCliRemoteRefinementSpec), (spec) =>304 const providers = yield* Effect.forEach(input.specs, (spec) => {
292 input.process305 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
293 .run({310 .run({
294 operation: "source-control.discovery.refine-unknown-remote",311 operation: "source-control.discovery.refine-unknown-remote",
295 command: spec.executable,312 command: spec.executable,
296 args: spec.authArgs,313 args: spec.remoteRefinementArgs ?? spec.authArgs,
297 cwd: input.cwd,314 cwd: input.cwd,
298 allowNonZeroExit: true,315 allowNonZeroExit: true,
299 timeoutMs: probeTimeoutMs(spec),316 timeoutMs: probeTimeoutMs(spec),
...@@ -309,8 +326,8 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide...@@ -309,8 +326,8 @@ export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvide
309 }),326 }),
310 ),327 ),
311 Effect.orElseSucceed(() => null),328 Effect.orElseSucceed(() => null),
312 ),329 );
313 );330 });
314 const provider = providers.find((candidate) => candidate !== null);331 const provider = providers.find((candidate) => candidate !== null);
315332
316 return provider ? { ...context, provider } : context;333 return provider ? { ...context, provider } : context;
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+3
...@@ -15,6 +15,7 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";...@@ -15,6 +15,7 @@ import * as AzureDevOpsCli from "./AzureDevOpsCli.ts";
15import * as BitbucketApi from "./BitbucketApi.ts";15import * as BitbucketApi from "./BitbucketApi.ts";
16import * as GitHubCli from "./GitHubCli.ts";16import * as GitHubCli from "./GitHubCli.ts";
17import * as GitLabCli from "./GitLabCli.ts";17import * as GitLabCli from "./GitLabCli.ts";
18import * as ForgejoCli from "./ForgejoCli.ts";
18import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";19import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
1920
20const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");21const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
...@@ -86,12 +87,14 @@ function makeRegistry(input: {...@@ -86,12 +87,14 @@ function makeRegistry(input: {
86 return SourceControlProviderRegistry.make.pipe(87 return SourceControlProviderRegistry.make.pipe(
87 Effect.provide(88 Effect.provide(
88 Layer.mergeAll(89 Layer.mergeAll(
90 NodeServices.layer,
89 registryLayer,91 registryLayer,
90 processLayer,92 processLayer,
91 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),93 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
92 Layer.mock(BitbucketApi.BitbucketApi)({}),94 Layer.mock(BitbucketApi.BitbucketApi)({}),
93 Layer.mock(GitHubCli.GitHubCli)({}),95 Layer.mock(GitHubCli.GitHubCli)({}),
94 Layer.mock(GitLabCli.GitLabCli)({}),96 Layer.mock(GitLabCli.GitLabCli)({}),
97 Layer.mock(ForgejoCli.ForgejoCli)({ listLogins: () => Effect.succeed([]) }),
95 ServerConfig.layerTest(process.cwd(), {98 ServerConfig.layerTest(process.cwd(), {
96 prefix: "t3-source-control-registry-test-",99 prefix: "t3-source-control-registry-test-",
97 }).pipe(Layer.provide(NodeServices.layer)),100 }).pipe(Layer.provide(NodeServices.layer)),
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+4
...@@ -15,6 +15,7 @@ import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlPro...@@ -15,6 +15,7 @@ import * as AzureDevOpsSourceControlProvider from "./AzureDevOpsSourceControlPro
15import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";15import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts";
16import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";16import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
17import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";17import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
18import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts";
18import * as SourceControlProvider from "./SourceControlProvider.ts";19import * as SourceControlProvider from "./SourceControlProvider.ts";
19import {20import {
20 probeSourceControlProvider,21 probeSourceControlProvider,
...@@ -296,6 +297,8 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -296,6 +297,8 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
296export const make = Effect.gen(function* () {297export const make = Effect.gen(function* () {
297 const github = yield* GitHubSourceControlProvider.make;298 const github = yield* GitHubSourceControlProvider.make;
298 const gitlab = yield* GitLabSourceControlProvider.make;299 const gitlab = yield* GitLabSourceControlProvider.make;
300 const forgejo = yield* ForgejoSourceControlProvider.make;
301 const forgejoDiscovery = yield* ForgejoSourceControlProvider.makeDiscovery;
299 const bitbucket = yield* BitbucketSourceControlProvider.make;302 const bitbucket = yield* BitbucketSourceControlProvider.make;
300 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;303 const bitbucketDiscovery = yield* BitbucketSourceControlProvider.makeDiscovery;
301 const azureDevOps = yield* AzureDevOpsSourceControlProvider.make;304 const azureDevOps = yield* AzureDevOpsSourceControlProvider.make;
...@@ -320,6 +323,7 @@ export const make = Effect.gen(function* () {...@@ -320,6 +323,7 @@ export const make = Effect.gen(function* () {
320 provider: bitbucket,323 provider: bitbucket,
321 discovery: bitbucketDiscovery,324 discovery: bitbucketDiscovery,
322 },325 },
326 { kind: "forgejo", provider: forgejo, discovery: forgejoDiscovery },
323 ]);327 ]);
324});328});
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";...@@ -155,6 +155,7 @@ import * as AzureDevOpsCli from "./sourceControl/AzureDevOpsCli.ts";
155import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";155import * as BitbucketApi from "./sourceControl/BitbucketApi.ts";
156import * as GitHubCli from "./sourceControl/GitHubCli.ts";156import * as GitHubCli from "./sourceControl/GitHubCli.ts";
157import * as GitLabCli from "./sourceControl/GitLabCli.ts";157import * as GitLabCli from "./sourceControl/GitLabCli.ts";
158import * as ForgejoCli from "./sourceControl/ForgejoCli.ts";
158import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";159import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
159import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";160import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
160import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";161import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts";
...@@ -3111,6 +3112,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(...@@ -3111,6 +3112,7 @@ export const websocketRpcRouteLayer = Layer.unwrap(
3111 BitbucketApi.layer,3112 BitbucketApi.layer,
3112 GitHubCli.layer,3113 GitHubCli.layer,
3113 GitLabCli.layer,3114 GitLabCli.layer,
3115 ForgejoCli.layer,
3114 ),3116 ),
3115 ),3117 ),
3116 Layer.provideMerge(GitVcsDriver.layer),3118 Layer.provideMerge(GitVcsDriver.layer),
apps/web/src/components/CommandPalette.tsx+11-2
...@@ -150,7 +150,7 @@ import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sideb...@@ -150,7 +150,7 @@ import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sideb
150import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";150import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic";
151import { CommandPaletteContent } from "./CommandPaletteContent";151import { CommandPaletteContent } from "./CommandPaletteContent";
152import { CommandPaletteResults } from "./CommandPaletteResults";152import { CommandPaletteResults } from "./CommandPaletteResults";
153import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons";153import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons";
154import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";154import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon";
155import { ProjectFavicon } from "./ProjectFavicon";155import { ProjectFavicon } from "./ProjectFavicon";
156import { ProjectFilePicker } from "./files/ProjectFilePicker";156import { ProjectFilePicker } from "./files/ProjectFilePicker";
...@@ -244,7 +244,7 @@ interface AddProjectEnvironmentOption {...@@ -244,7 +244,7 @@ interface AddProjectEnvironmentOption {
244244
245type AddProjectRemoteProviderKind = Extract<245type AddProjectRemoteProviderKind = Extract<
246 SourceControlProviderKind,246 SourceControlProviderKind,
247 "github" | "gitlab" | "bitbucket" | "azure-devops"247 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"
248>;248>;
249type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";249type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
250250
...@@ -267,12 +267,14 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [...@@ -267,12 +267,14 @@ const REMOTE_PROJECT_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [
267 "url",267 "url",
268 "github",268 "github",
269 "gitlab",269 "gitlab",
270 "forgejo",
270 "bitbucket",271 "bitbucket",
271 "azure-devops",272 "azure-devops",
272];273];
273const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [274const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [
274 "github",275 "github",
275 "gitlab",276 "gitlab",
277 "forgejo",
276 "bitbucket",278 "bitbucket",
277 "azure-devops",279 "azure-devops",
278];280];
...@@ -281,6 +283,8 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {...@@ -281,6 +283,8 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {
281 switch (source) {283 switch (source) {
282 case "github":284 case "github":
283 return "GitHub";285 return "GitHub";
286 case "forgejo":
287 return "Forgejo / Gitea";
284 case "gitlab":288 case "gitlab":
285 return "GitLab";289 return "GitLab";
286 case "bitbucket":290 case "bitbucket":
...@@ -294,6 +298,7 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {...@@ -294,6 +298,7 @@ function remoteProjectSourceLabel(source: AddProjectRemoteSource): string {
294298
295function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string {299function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string {
296 switch (source) {300 switch (source) {
301 case "forgejo":
297 case "github":302 case "github":
298 return "owner/repo";303 return "owner/repo";
299 case "gitlab":304 case "gitlab":
...@@ -317,6 +322,8 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri...@@ -317,6 +322,8 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri
317 switch (source) {322 switch (source) {
318 case "github":323 case "github":
319 return <GitHubIcon className={className} />;324 return <GitHubIcon className={className} />;
325 case "forgejo":
326 return <ForgejoIcon className={className} />;
320 case "gitlab":327 case "gitlab":
321 return <GitLabIcon className={className} />;328 return <GitLabIcon className={className} />;
322 case "bitbucket":329 case "bitbucket":
...@@ -370,6 +377,7 @@ function buildAddProjectRemoteSourceReadiness(...@@ -370,6 +377,7 @@ function buildAddProjectRemoteSourceReadiness(
370 url: { ready: true, hint: null },377 url: { ready: true, hint: null },
371 github: unavailable,378 github: unavailable,
372 gitlab: unavailable,379 gitlab: unavailable,
380 forgejo: unavailable,
373 bitbucket: unavailable,381 bitbucket: unavailable,
374 "azure-devops": unavailable,382 "azure-devops": unavailable,
375 };383 };
...@@ -1759,6 +1767,7 @@ function OpenCommandPaletteDialog(props: {...@@ -1759,6 +1767,7 @@ function OpenCommandPaletteDialog(props: {
1759 "git",1767 "git",
1760 "github",1768 "github",
1761 "gitlab",1769 "gitlab",
1770 "forgejo",
1762 "bitbucket",1771 "bitbucket",
1763 "azure",1772 "azure",
1764 "devops",1773 "devops",
apps/web/src/components/GitActionsControl.tsx+25-3
...@@ -39,7 +39,13 @@ import {...@@ -39,7 +39,13 @@ import {
39 GlobeIcon,39 GlobeIcon,
40} from "lucide-react";40} from "lucide-react";
41import { Radio as RadioPrimitive } from "@base-ui/react/radio";41import { 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";
43import { RadioGroup } from "~/components/ui/radio-group";49import { RadioGroup } from "~/components/ui/radio-group";
44import { Spinner } from "~/components/ui/spinner";50import { Spinner } from "~/components/ui/spinner";
45import { toggleVariants } from "~/components/ui/toggle";51import { toggleVariants } from "~/components/ui/toggle";
...@@ -123,7 +129,7 @@ interface PendingDefaultBranchAction {...@@ -123,7 +129,7 @@ interface PendingDefaultBranchAction {
123129
124type PublishProviderKind = Extract<130type PublishProviderKind = Extract<
125 SourceControlProviderKind,131 SourceControlProviderKind,
126 "github" | "gitlab" | "bitbucket" | "azure-devops"132 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"
127>;133>;
128134
129type GitActionToastId = ReturnType<typeof toastManager.add>;135type GitActionToastId = ReturnType<typeof toastManager.add>;
...@@ -171,6 +177,14 @@ function requestVcsStatusRefresh(...@@ -171,6 +177,14 @@ function requestVcsStatusRefresh(
171const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;177const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const;
172178
173const PUBLISH_PROVIDER_OPTIONS = [179const 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 },
174 {188 {
175 value: "github",189 value: "github",
176 label: "GitHub",190 label: "GitHub",
...@@ -425,6 +439,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -425,6 +439,7 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
425 const accounts: Record<PublishProviderKind, string | null> = {439 const accounts: Record<PublishProviderKind, string | null> = {
426 github: null,440 github: null,
427 gitlab: null,441 gitlab: null,
442 forgejo: null,
428 bitbucket: null,443 bitbucket: null,
429 "azure-devops": null,444 "azure-devops": null,
430 };445 };
...@@ -476,7 +491,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {...@@ -476,7 +491,14 @@ function PublishRepositoryDialog(props: PublishRepositoryDialogProps) {
476 : "";491 : "";
477 const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill;492 const publishRepository = publishRepositoryOverride ?? publishRepositoryPrefill;
478 const currentPublishProvider = publishProviderOption(publishProvider);493 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;
480 const publishPathPlaceholder = currentPublishProvider.pathPlaceholder;502 const publishPathPlaceholder = currentPublishProvider.pathPlaceholder;
481 const publishProviderLabel = currentPublishProvider.label;503 const publishProviderLabel = currentPublishProvider.label;
482 const publishWizardSteps = ["Provider", "Repository", "Summary"] as const;504 const publishWizardSteps = ["Provider", "Repository", "Summary"] as const;
apps/web/src/components/Icons.tsx+13
...@@ -768,3 +768,16 @@ export const PiAgentIcon: Icon = ({ className, ...props }) => (...@@ -768,3 +768,16 @@ export const PiAgentIcon: Icon = ({ className, ...props }) => (
768 <path fill="#fff" d="M517.36 400H634.72V634.72H517.36Z" />768 <path fill="#fff" d="M517.36 400H634.72V634.72H517.36Z" />
769 </svg>769 </svg>
770);770);
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(...@@ -102,7 +102,9 @@ export function linkedPullRequestSnapshotStatus(
102 ? "azure-devops"102 ? "azure-devops"
103 : link.url.includes("/pull-requests/")103 : link.url.includes("/pull-requests/")
104 ? "bitbucket"104 ? "bitbucket"
105 : "github";105 : link.url.includes("/pulls/")
106 ? "forgejo"
107 : "github";
106 return {108 return {
107 pr: {109 pr: {
108 number: link.number,110 number: link.number,
apps/web/src/components/pullRequest/LinkPullRequestDialog.tsx+4-1
...@@ -144,7 +144,10 @@ function LinkPullRequestDialog({...@@ -144,7 +144,10 @@ function LinkPullRequestDialog({
144 return {144 return {
145 host,145 host,
146 repository,146 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),
148 };151 };
149 }, [environmentProjects, projectId]);152 }, [environmentProjects, projectId]);
150 const linking = usePullRequestLinking(threadRef.environmentId);153 const linking = usePullRequestLinking(threadRef.environmentId);
apps/web/src/components/pullRequest/PullRequestCodeTab.tsx+1
...@@ -976,6 +976,7 @@ function PullRequestCodeTab({...@@ -976,6 +976,7 @@ function PullRequestCodeTab({
976 environmentId={environmentId}976 environmentId={environmentId}
977 reference={reference}977 reference={reference}
978 verdicts={review.verdicts}978 verdicts={review.verdicts}
979 requestChangesSummaryRequired={detail.provider === "forgejo"}
979 onSubmitted={() => {980 onSubmitted={() => {
980 onRefresh();981 onRefresh();
981 setReviewOpen(false);982 setReviewOpen(false);
apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx+5-1
...@@ -727,6 +727,7 @@ export function PullRequestDetailPanel({...@@ -727,6 +727,7 @@ export function PullRequestDetailPanel({
727 detail.number,727 detail.number,
728 detail.headBranch,728 detail.headBranch,
729 detail.headRepositoryNameWithOwner,729 detail.headRepositoryNameWithOwner,
730 repositoryUrl,
730 )731 )
731 : null;732 : null;
732 const branchRefsQuery = useEnvironmentQuery(733 const branchRefsQuery = useEnvironmentQuery(
...@@ -784,11 +785,14 @@ export function PullRequestDetailPanel({...@@ -784,11 +785,14 @@ export function PullRequestDetailPanel({
784 if (!coreDetail) return;785 if (!coreDetail) return;
785 const next = { key: tabScopeKey, updatedAt: coreDetail.updatedAt };786 const next = { key: tabScopeKey, updatedAt: coreDetail.updatedAt };
786 if (shouldRefreshPullRequestActivity(activityRevision.current, next)) {787 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;
787 activityQuery.refresh();791 activityQuery.refresh();
788 setRefreshToken((token) => token + 1);792 setRefreshToken((token) => token + 1);
789 }793 }
790 activityRevision.current = next;794 activityRevision.current = next;
791 }, [activityQuery.refresh, coreDetail, tabScopeKey]);795 }, [activityQuery.isPending, activityQuery.refresh, coreDetail, tabScopeKey]);
792 // Reuse activity and diff until core detail reports a changed revision. Keyed by796 // Reuse activity and diff until core detail reports a changed revision. Keyed by
793 // the pull request rather than by the panel, because this one panel shows a different pull797 // the pull request rather than by the panel, because this one panel shows a different pull
794 // request every time it is opened.798 // request every time it is opened.
apps/web/src/components/pullRequest/PullRequestReviewBar.tsx+11-3
...@@ -50,11 +50,13 @@ export function PullRequestReviewBar({...@@ -50,11 +50,13 @@ export function PullRequestReviewBar({
50 environmentId,50 environmentId,
51 reference,51 reference,
52 verdicts,52 verdicts,
53 requestChangesSummaryRequired,
53 onSubmitted,54 onSubmitted,
54}: {55}: {
55 environmentId: EnvironmentId;56 environmentId: EnvironmentId;
56 reference: PullRequestRef;57 reference: PullRequestRef;
57 verdicts: ReadonlyArray<PullRequestReviewVerdict>;58 verdicts: ReadonlyArray<PullRequestReviewVerdict>;
59 requestChangesSummaryRequired: boolean;
58 onSubmitted: () => void;60 onSubmitted: () => void;
59}) {61}) {
60 const [pending, setPending] = useState(false);62 const [pending, setPending] = useState(false);
...@@ -106,9 +108,11 @@ export function PullRequestReviewBar({...@@ -106,9 +108,11 @@ export function PullRequestReviewBar({
106 onSubmitted();108 onSubmitted();
107 };109 };
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.
110 const canSubmit = (verdict: PullRequestReviewVerdict) =>112 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
113 return (117 return (
114 <div className="px-4 py-3">118 <div className="px-4 py-3">
...@@ -128,7 +132,11 @@ export function PullRequestReviewBar({...@@ -128,7 +132,11 @@ export function PullRequestReviewBar({
128 size="sm"132 size="sm"
129 className="mt-2"133 className="mt-2"
130 value={body}134 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 }
132 aria-label="Review summary"140 aria-label="Review summary"
133 onChange={(event) => setSummary(reviewKey, event.target.value)}141 onChange={(event) => setSummary(reviewKey, event.target.value)}
134 />142 />
apps/web/src/components/pullRequest/PullRequestThreadLinks.tsx+9-2
...@@ -7,6 +7,7 @@ import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-r...@@ -7,6 +7,7 @@ import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-r
7import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";7import { usePullRequestLinking } from "~/hooks/usePullRequestLinking";
88
9import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";9import { parseChangeRequestUrl } from "~/lib/openPullRequestLink";
10import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
10import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities";11import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities";
11import { pullRequestEnvironment } from "~/state/pullRequests";12import { pullRequestEnvironment } from "~/state/pullRequests";
12import { useEnvironmentQuery } from "~/state/query";13import { useEnvironmentQuery } from "~/state/query";
...@@ -56,7 +57,10 @@ function EnabledPullRequestThreadLinks({...@@ -56,7 +57,10 @@ function EnabledPullRequestThreadLinks({
56 linking.mode === "multiple" && display !== "menu-item"57 linking.mode === "multiple" && display !== "menu-item"
57 ? pullRequestEnvironment.linkedThreads({58 ? pullRequestEnvironment.linkedThreads({
58 environmentId,59 environmentId,
59 input: parsed === null ? reference : { ...reference, ...parsed },60 input:
61 parsed === null
62 ? reference
63 : { ...reference, ...normalizeThreadPullRequestKey(parsed) },
60 })64 })
61 : null,65 : null,
62 );66 );
...@@ -85,7 +89,10 @@ function EnabledPullRequestThreadLinks({...@@ -85,7 +89,10 @@ function EnabledPullRequestThreadLinks({
85 }89 }
86 if (linking.mode === "multiple") {90 if (linking.mode === "multiple") {
87 appAtomRegistry.refresh(91 appAtomRegistry.refresh(
88 pullRequestEnvironment.linkedThreads({ environmentId, input: { ...reference, ...parsed } }),92 pullRequestEnvironment.linkedThreads({
93 environmentId,
94 input: { ...reference, ...normalizeThreadPullRequestKey(parsed) },
95 }),
89 );96 );
90 }97 }
91 onPickerOpenChange?.(false);98 onPickerOpenChange?.(false);
apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts+27
...@@ -55,6 +55,7 @@ describe("pull request checkout commands", () => {...@@ -55,6 +55,7 @@ describe("pull request checkout commands", () => {
55 it.each([55 it.each([
56 ["github", "feature", null, "gh pr checkout 42"],56 ["github", "feature", null, "gh pr checkout 42"],
57 ["gitlab", "feature", null, "glab mr checkout 42"],57 ["gitlab", "feature", null, "glab mr checkout 42"],
58 ["forgejo", "feature", null, null],
58 ["azure-devops", "feature", null, "az repos pr checkout --id 42"],59 ["azure-devops", "feature", null, "az repos pr checkout --id 42"],
59 [60 [
60 "bitbucket",61 "bitbucket",
...@@ -66,6 +67,32 @@ describe("pull request checkout commands", () => {...@@ -66,6 +67,32 @@ describe("pull request checkout commands", () => {
66 ] as const)("builds the %s command", (provider, branch, repository, expected) => {67 ] as const)("builds the %s command", (provider, branch, repository, expected) => {
67 expect(pullRequestCheckoutCommand(provider, 42, branch, repository)).toBe(expected);68 expect(pullRequestCheckoutCommand(provider, 42, branch, repository)).toBe(expected);
68 });69 });
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 });
69});96});
7097
71const TIMELINE_SOURCE: Pick<98const TIMELINE_SOURCE: Pick<
apps/web/src/components/pullRequest/pullRequestDetail.logic.ts+5
...@@ -104,12 +104,17 @@ export function pullRequestCheckoutCommand(...@@ -104,12 +104,17 @@ export function pullRequestCheckoutCommand(
104 number: number,104 number: number,
105 headBranch: string,105 headBranch: string,
106 headRepositoryNameWithOwner?: string | null,106 headRepositoryNameWithOwner?: string | null,
107 repositoryUrl?: string | null,
107): string | null {108): string | null {
108 switch (provider) {109 switch (provider) {
109 case "github":110 case "github":
110 return `gh pr checkout ${number}`;111 return `gh pr checkout ${number}`;
111 case "gitlab":112 case "gitlab":
112 return `glab mr checkout ${number}`;113 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;
113 case "azure-devops":118 case "azure-devops":
114 return `az repos pr checkout --id ${number}`;119 return `az repos pr checkout --id ${number}`;
115 case "bitbucket": {120 case "bitbucket": {
apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts+1
...@@ -11,6 +11,7 @@ export type PullRequestLinkContextMenuAction = "copy-link" | "open-external";...@@ -11,6 +11,7 @@ export type PullRequestLinkContextMenuAction = "copy-link" | "open-external";
11const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = {11const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = {
12 github: "Open on GitHub",12 github: "Open on GitHub",
13 gitlab: "Open on GitLab",13 gitlab: "Open on GitLab",
14 forgejo: "Open on Forgejo",
14 bitbucket: "Open on Bitbucket",15 bitbucket: "Open on Bitbucket",
15 "azure-devops": "Open on Azure DevOps",16 "azure-devops": "Open on Azure DevOps",
16};17};
apps/web/src/components/settings/SourceControlSettings.tsx+2
...@@ -52,6 +52,7 @@ import {...@@ -52,6 +52,7 @@ import {
52 GitHubIcon,52 GitHubIcon,
53 GitIcon,53 GitIcon,
54 GitLabIcon,54 GitLabIcon,
55 ForgejoIcon,
55 JujutsuIcon,56 JujutsuIcon,
56 type Icon,57 type Icon,
57} from "../Icons";58} from "../Icons";
...@@ -75,6 +76,7 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {...@@ -75,6 +76,7 @@ const EMPTY_DISCOVERY_RESULT: SourceControlDiscoveryResult = {
75const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = {76const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = {
76 github: GitHubIcon,77 github: GitHubIcon,
77 gitlab: GitLabIcon,78 gitlab: GitLabIcon,
79 forgejo: ForgejoIcon,
78 "azure-devops": AzureDevOpsIcon,80 "azure-devops": AzureDevOpsIcon,
79 bitbucket: BitbucketIcon,81 bitbucket: BitbucketIcon,
80};82};
apps/web/src/components/settings/settingsSearch.ts+1-1
...@@ -567,7 +567,7 @@ export const SETTINGS_SEARCH_ITEMS = [...@@ -567,7 +567,7 @@ export const SETTINGS_SEARCH_ITEMS = [
567 to: "/settings/source-control",567 to: "/settings/source-control",
568 scope: "environment-defaults",568 scope: "environment-defaults",
569 searchTerms: [569 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",
571 ],571 ],
572 },572 },
573 {573 {
apps/web/src/lib/openPullRequestLink.test.ts+84
...@@ -170,6 +170,37 @@ describe("matchesLinkedPullRequestUrl", () => {...@@ -170,6 +170,37 @@ describe("matchesLinkedPullRequestUrl", () => {
170 ).toBe(true);170 ).toBe(true);
171 });171 });
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
173 it("rejects a different pull request or host", () => {204 it("rejects a different pull request or host", () => {
174 expect(205 expect(
175 matchesLinkedPullRequestUrl(linkedPullRequest, "https://github.com/pingdotgg/t3code/pull/43"),206 matchesLinkedPullRequestUrl(linkedPullRequest, "https://github.com/pingdotgg/t3code/pull/43"),
...@@ -350,6 +381,59 @@ describe("findProjectOnChangeRequestHost", () => {...@@ -350,6 +381,59 @@ describe("findProjectOnChangeRequestHost", () => {
350 ).toBe(backend);381 ).toBe(backend);
351 });382 });
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
353 it("lends any project on the host to a repository nobody has checked out", () => {437 it("lends any project on the host to a repository nobody has checked out", () => {
354 expect(438 expect(
355 findProjectOnChangeRequestHost([frontend], {439 findProjectOnChangeRequestHost([frontend], {
apps/web/src/lib/openPullRequestLink.ts+55-13
...@@ -26,13 +26,34 @@ export {...@@ -26,13 +26,34 @@ export {
26 changeRequestRepositoryUrl,26 changeRequestRepositoryUrl,
27} from "@t3tools/shared/changeRequestUrl";27} from "@t3tools/shared/changeRequestUrl";
2828
29/**29function resolvedForgejoRepository(project: EnvironmentProject): URL | null {
30 * Returns a click handler that opens a pull request URL in the system browser.30 const identity = project.repositoryIdentity;
31 *31 if (identity?.provider !== "forgejo" || !identity.webUrl) return null;
32 * Stops event propagation/default so activating the link does not also trigger32 try {
33 * an enclosing row or trigger (e.g. opening the branch dropdown), and surfaces a33 const url = new URL(identity.webUrl);
34 * toast when the local API is unavailable or the open fails.34 return url.protocol === "http:" || url.protocol === "https:" ? url : null;
35 */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
36/**57/**
37 * The project a link belongs to, or nothing. Matched the way the server matches: the repository58 * The project a link belongs to, or nothing. Matched the way the server matches: the repository
38 * identity is the full path below the host where one was recorded — which is what nested GitLab59 * identity is the full path below the host where one was recorded — which is what nested GitLab
...@@ -45,9 +66,15 @@ export function findProjectForChangeRequest(...@@ -45,9 +66,15 @@ export function findProjectForChangeRequest(
45): EnvironmentProject | undefined {66): EnvironmentProject | undefined {
46 return projects.find((project) => {67 return projects.find((project) => {
47 const identity = project.repositoryIdentity;68 const identity = project.repositoryIdentity;
48 if (!identity) return false;69 if (!identity || !matchesChangeRequestAuthority(project, link)) return false;
49 const kind = identity.provider as SourceControlProviderKind | undefined;70 const kind = identity.provider as SourceControlProviderKind | undefined;
50 if (kind === undefined) return false;71 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 );
51 if (kind === "azure-devops") {78 if (kind === "azure-devops") {
52 return (79 return (
53 canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) ===80 canonicalRepositoryKey(identity.canonicalKey.toLowerCase()) ===
...@@ -60,7 +87,8 @@ export function findProjectForChangeRequest(...@@ -60,7 +87,8 @@ export function findProjectForChangeRequest(
60 return (87 return (
61 repository !== null &&88 repository !== null &&
62 repository.toLowerCase() === link.repository.toLowerCase() &&89 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)
64 );92 );
65 });93 });
66}94}
...@@ -86,11 +114,25 @@ export function findProjectOnChangeRequestHost(...@@ -86,11 +114,25 @@ export function findProjectOnChangeRequestHost(
86 return projects.find((project) => {114 return projects.find((project) => {
87 const identity = project.repositoryIdentity;115 const identity = project.repositoryIdentity;
88 const kind = identity?.provider as SourceControlProviderKind | undefined;116 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 }
89 return (129 return (
90 identity != null &&130 identity != null &&
91 kind !== undefined &&131 kind !== undefined &&
92 kind !== "azure-devops" &&132 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)
94 );136 );
95 });137 });
96}138}
...@@ -188,7 +230,7 @@ export function useOpenChangeRequestLink(...@@ -188,7 +230,7 @@ export function useOpenChangeRequestLink(
188 projectId: project.id,230 projectId: project.id,
189 ...(serverConfigs.get(project.environmentId)?.environment.capabilities231 ...(serverConfigs.get(project.environmentId)?.environment.capabilities
190 .threadPullRequests === true232 .threadPullRequests === true
191 ? { host: parsed.host }233 ? { host: parsed.authority ?? parsed.host }
192 : {}),234 : {}),
193 repository,235 repository,
194 url: targetUrl,236 url: targetUrl,
...@@ -203,7 +245,7 @@ export function useOpenChangeRequestLink(...@@ -203,7 +245,7 @@ export function useOpenChangeRequestLink(
203 state: previous.state ?? "all",245 state: previous.state ?? "all",
204 repository,246 repository,
205 number: parsed.number,247 number: parsed.number,
206 selectedHost: parsed.host,248 selectedHost: parsed.authority ?? parsed.host,
207 selectedProjectId: project.id,249 selectedProjectId: project.id,
208 selectedEnvironmentId: project.environmentId,250 selectedEnvironmentId: project.environmentId,
209 }),251 }),
...@@ -221,7 +263,7 @@ export function useOpenChangeRequestLink(...@@ -221,7 +263,7 @@ export function useOpenChangeRequestLink(
221 state: "all",263 state: "all",
222 repository,264 repository,
223 number: parsed.number,265 number: parsed.number,
224 selectedHost: parsed.host,266 selectedHost: parsed.authority ?? parsed.host,
225 selectedProjectId: project.id,267 selectedProjectId: project.id,
226 // Named so the page opens the right one of two servers holding this project.268 // Named so the page opens the right one of two servers holding this project.
227 selectedEnvironmentId: project.environmentId,269 selectedEnvironmentId: project.environmentId,
apps/web/src/pullRequestReference.ts+5
...@@ -1,3 +1,6 @@...@@ -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;
1const GITHUB_PULL_REQUEST_URL_PATTERN =4const GITHUB_PULL_REQUEST_URL_PATTERN =
2 /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i;5 /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i;
3const GITLAB_MERGE_REQUEST_URL_PATTERN =6const GITLAB_MERGE_REQUEST_URL_PATTERN =
...@@ -32,6 +35,7 @@ export function parsePullRequestReference(input: string): string | null {...@@ -32,6 +35,7 @@ export function parsePullRequestReference(input: string): string | null {
32 const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed);35 const glabCliCheckoutMatch = GITLAB_CLI_MR_CHECKOUT_PATTERN.exec(trimmed);
33 const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);36 const azureDevOpsCliCheckoutMatch = AZURE_DEVOPS_CLI_PR_CHECKOUT_PATTERN.exec(trimmed);
34 const normalizedInput =37 const normalizedInput =
38 FORGEJO_CLI_PR_CHECKOUT_PATTERN.exec(trimmed)?.[1]?.trim() ??
35 ghCliCheckoutMatch?.[1]?.trim() ??39 ghCliCheckoutMatch?.[1]?.trim() ??
36 glabCliCheckoutMatch?.[1]?.trim() ??40 glabCliCheckoutMatch?.[1]?.trim() ??
37 (azureDevOpsCliCheckoutMatch?.[1]41 (azureDevOpsCliCheckoutMatch?.[1]
...@@ -43,6 +47,7 @@ export function parsePullRequestReference(input: string): string | null {...@@ -43,6 +47,7 @@ export function parsePullRequestReference(input: string): string | null {
43 }47 }
4448
45 const urlMatch =49 const urlMatch =
50 FORGEJO_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??
46 GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??51 GITHUB_PULL_REQUEST_URL_PATTERN.exec(normalizedInput) ??
47 GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ??52 GITLAB_MERGE_REQUEST_URL_PATTERN.exec(normalizedInput) ??
48 AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput);53 AZURE_DEVOPS_PULL_REQUEST_URL_PATTERN.exec(normalizedInput);
apps/web/src/sourceControlPresentation.ts+13-1
...@@ -13,7 +13,13 @@ import {...@@ -13,7 +13,13 @@ import {
13 resolveChangeRequestPresentation,13 resolveChangeRequestPresentation,
14 type ChangeRequestTerminology,14 type ChangeRequestTerminology,
15} from "@t3tools/shared/sourceControl";15} 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
18export interface SourceControlPresentation {24export interface SourceControlPresentation {
19 readonly providerName: string;25 readonly providerName: string;
...@@ -32,6 +38,12 @@ export function getSourceControlPresentation(...@@ -32,6 +38,12 @@ export function getSourceControlPresentation(
32 terminology: getChangeRequestTerminology(provider),38 terminology: getChangeRequestTerminology(provider),
33 Icon: GitHubIcon,39 Icon: GitHubIcon,
34 };40 };
41 case "forgejo":
42 return {
43 providerName: provider?.name || presentation.providerName,
44 terminology: getChangeRequestTerminology(provider),
45 Icon: ForgejoIcon,
46 };
35 case "gitlab":47 case "gitlab":
36 return {48 return {
37 providerName: provider?.name || presentation.providerName,49 providerName: provider?.name || presentation.providerName,
apps/web/src/state/sourceControlActions.ts+1-1
...@@ -269,7 +269,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi...@@ -269,7 +269,7 @@ export function useSourceControlPublishRepositoryAction(scope: SourceControlActi
269 );269 );
270 const action = useCallback(270 const action = useCallback(
271 async (input: {271 async (input: {
272 provider: "github" | "gitlab" | "bitbucket" | "azure-devops";272 provider: "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops";
273 repository: string;273 repository: string;
274 visibility: SourceControlRepositoryVisibility;274 visibility: SourceControlRepositoryVisibility;
275 remoteName: string;275 remoteName: string;
docs/user/source-control.md+21-1
...@@ -1,6 +1,6 @@...@@ -1,6 +1,6 @@
1# Source control1# Source control
22
3T3 Code integrates with GitHub, GitLab, Bitbucket, and Azure DevOps to clone and publish3T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish
4repositories, create pull requests, and review changes.4repositories, create pull requests, and review changes.
55
6## Connect an account6## Connect an account
...@@ -17,6 +17,26 @@ Install [GitHub CLI](https://cli.github.com/) 2.81.0 or newer, then sign in:...@@ -17,6 +17,26 @@ Install [GitHub CLI](https://cli.github.com/) 2.81.0 or newer, then sign in:
17gh auth login17gh auth login
18```18```
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
20### GitLab40### GitLab
2141
22Install [GitLab CLI](https://gitlab.com/gitlab-org/cli), then sign in:42Install [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", () => {...@@ -76,6 +76,13 @@ describe("add project shared logic", () => {
76 sshUrl: "git@github.com:imputnet/helium.git",76 sshUrl: "git@github.com:imputnet/helium.git",
77 }),77 }),
78 ).toBe("https://github.com/imputnet/helium");78 ).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");
79 });86 });
8087
81 it("preserves existing clone transport behavior for other providers", () => {88 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";...@@ -25,7 +25,7 @@ import type { EnvironmentProject } from "../state/models.ts";
2525
26export type AddProjectRemoteProviderKind = Extract<26export type AddProjectRemoteProviderKind = Extract<
27 SourceControlProviderKind,27 SourceControlProviderKind,
28 "github" | "gitlab" | "bitbucket" | "azure-devops"28 "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"
29>;29>;
30export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";30export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url";
3131
...@@ -59,6 +59,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [...@@ -59,6 +59,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [
59 "url",59 "url",
60 "github",60 "github",
61 "gitlab",61 "gitlab",
62 "forgejo",
62 "bitbucket",63 "bitbucket",
63 "azure-devops",64 "azure-devops",
64];65];
...@@ -66,6 +67,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [...@@ -66,6 +67,7 @@ const ADD_PROJECT_REMOTE_SOURCES: ReadonlyArray<AddProjectRemoteSource> = [
66const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [67const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [
67 "github",68 "github",
68 "gitlab",69 "gitlab",
70 "forgejo",
69 "bitbucket",71 "bitbucket",
70 "azure-devops",72 "azure-devops",
71];73];
...@@ -74,6 +76,8 @@ export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): str...@@ -74,6 +76,8 @@ export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): str
74 switch (source) {76 switch (source) {
75 case "github":77 case "github":
76 return "GitHub";78 return "GitHub";
79 case "forgejo":
80 return "Forgejo / Gitea";
77 case "gitlab":81 case "gitlab":
78 return "GitLab";82 return "GitLab";
79 case "bitbucket":83 case "bitbucket":
...@@ -87,6 +91,7 @@ export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): str...@@ -87,6 +91,7 @@ export function addProjectRemoteSourceLabel(source: AddProjectRemoteSource): str
8791
88export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string {92export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string {
89 switch (source) {93 switch (source) {
94 case "forgejo":
90 case "github":95 case "github":
91 return "owner/repo";96 return "owner/repo";
92 case "gitlab":97 case "gitlab":
...@@ -117,11 +122,13 @@ export function normalizePastedCloneUrl(input: string): string {...@@ -117,11 +122,13 @@ export function normalizePastedCloneUrl(input: string): string {
117 return `https://github.com/${repository}`;122 return `https://github.com/${repository}`;
118}123}
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. */
121export function getDefaultCloneUrl(126export function getDefaultCloneUrl(
122 repository: Pick<SourceControlRepositoryInfo, "provider" | "url" | "sshUrl">,127 repository: Pick<SourceControlRepositoryInfo, "provider" | "url" | "sshUrl">,
123): string {128): string {
124 return repository.provider === "github" ? repository.url : repository.sshUrl;129 return repository.provider === "github" || repository.provider === "forgejo"
130 ? repository.url
131 : repository.sshUrl;
125}132}
126133
127export function sortAddProjectProviderSources(134export function sortAddProjectProviderSources(
...@@ -153,6 +160,7 @@ export function buildAddProjectRemoteSourceReadiness(...@@ -153,6 +160,7 @@ export function buildAddProjectRemoteSourceReadiness(
153 url: { ready: true, hint: null },160 url: { ready: true, hint: null },
154 github: unavailable,161 github: unavailable,
155 gitlab: unavailable,162 gitlab: unavailable,
163 forgejo: unavailable,
156 bitbucket: unavailable,164 bitbucket: unavailable,
157 "azure-devops": unavailable,165 "azure-devops": unavailable,
158 };166 };
packages/client-runtime/src/threadPullRequestCompatibility.test.ts+18
...@@ -19,6 +19,24 @@ const input = {...@@ -19,6 +19,24 @@ const input = {
19};19};
2020
21describe("thread pull request capability negotiation", () => {21describe("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
22 it.each([undefined, {}, { threadPullRequests: false, threadPullRequestLinking: false }])(40 it.each([undefined, {}, { threadPullRequests: false, threadPullRequestLinking: false }])(
23 "does not dispatch when linking is unadvertised: %j",41 "does not dispatch when linking is unadvertised: %j",
24 (capabilities) => {42 (capabilities) => {
packages/client-runtime/src/threadPullRequestCompatibility.ts+4-4
...@@ -4,6 +4,7 @@ import type {...@@ -4,6 +4,7 @@ import type {
4 ThreadId,4 ThreadId,
5 ThreadPullRequestKey,5 ThreadPullRequestKey,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests";
78
8type LinkingCapabilities = Pick<9type LinkingCapabilities = Pick<
9 ExecutionEnvironmentCapabilities,10 ExecutionEnvironmentCapabilities,
...@@ -36,20 +37,19 @@ export function planThreadPullRequestMutation({...@@ -36,20 +37,19 @@ export function planThreadPullRequestMutation({
36 legacyRepository?: string | undefined;37 legacyRepository?: string | undefined;
37 linked: boolean;38 linked: boolean;
38}) {39}) {
40 const key = normalizeThreadPullRequestKey(reference);
39 switch (threadPullRequestLinkMode(capabilities)) {41 switch (threadPullRequestLinkMode(capabilities)) {
40 case "multiple":42 case "multiple":
41 return linked43 return linked
42 ? {44 ? {
43 type: "thread.pull-request.link" as const,45 type: "thread.pull-request.link" as const,
44 input: { threadId, ...reference, source: "manual" as const },46 input: { threadId, ...reference, ...key, source: "manual" as const },
45 }47 }
46 : {48 : {
47 type: "thread.pull-request.unlink" as const,49 type: "thread.pull-request.unlink" as const,
48 input: {50 input: {
49 threadId,51 threadId,
50 host: reference.host,52 ...key,
51 repository: reference.repository,
52 number: reference.number,
53 },53 },
54 };54 };
55 case "single":55 case "single":
packages/contracts/src/environment.ts+2
...@@ -192,6 +192,8 @@ export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type;...@@ -192,6 +192,8 @@ export type RepositoryIdentityLocator = typeof RepositoryIdentityLocator.Type;
192export const RepositoryIdentity = Schema.Struct({192export const RepositoryIdentity = Schema.Struct({
193 canonicalKey: TrimmedNonEmptyString,193 canonicalKey: TrimmedNonEmptyString,
194 locator: RepositoryIdentityLocator,194 locator: RepositoryIdentityLocator,
195 /** Repository browser URL resolved from the server's configured hosting account. */
196 webUrl: Schema.optionalKey(TrimmedNonEmptyString),
195 rootPath: Schema.optionalKey(TrimmedNonEmptyString),197 rootPath: Schema.optionalKey(TrimmedNonEmptyString),
196 displayName: Schema.optionalKey(TrimmedNonEmptyString),198 displayName: Schema.optionalKey(TrimmedNonEmptyString),
197 provider: Schema.optionalKey(TrimmedNonEmptyString),199 provider: Schema.optionalKey(TrimmedNonEmptyString),
packages/contracts/src/pullRequest.test.ts+15
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 PullRequestListInput,7 PullRequestListInput,
8 PullRequestListResult,8 PullRequestListResult,
9 PullRequestReviewerRequestInput,9 PullRequestReviewerRequestInput,
10 pullRequestHostOf,
10 resolvePullRequestAuthorFilter,11 resolvePullRequestAuthorFilter,
11} from "./pullRequest.ts";12} from "./pullRequest.ts";
1213
...@@ -65,6 +66,20 @@ const LIST_RESULT: PullRequestListResult = {...@@ -65,6 +66,20 @@ const LIST_RESULT: PullRequestListResult = {
65};66};
6667
67describe("PullRequestListResult", () => {68describe("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 });
68 /**83 /**
69 * The RPC builds this codec at call time, so a shape it cannot lower — an open-keyed record84 * The RPC builds this codec at call time, so a shape it cannot lower — an open-keyed record
70 * with an optional value, for one — fails as an interrupted request rather than as a schema85 * 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<...@@ -1134,6 +1134,12 @@ const PROVIDER_REQUIREMENT: Partial<
1134 "GitHub CLI (`gh`) is required to browse change requests on this host. Install it from https://cli.github.com/ and reload.",1134 "GitHub CLI (`gh`) is required to browse change requests on this host. Install it from https://cli.github.com/ and reload.",
1135 unauthenticated: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",1135 unauthenticated: "GitHub CLI is not authenticated. Run `gh auth login` and retry.",
1136 },1136 },
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 },
1137 gitlab: {1143 gitlab: {
1138 missing:1144 missing:
1139 "GitLab CLI (`glab`) is required to browse change requests on this host. Install it from https://gitlab.com/gitlab-org/cli and reload.",1145 "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<...@@ -1161,9 +1167,24 @@ const PROVIDER_REQUIREMENT: Partial<
1161 * knows its hosts before the listing answers, and the two must agree on what they are called.1167 * knows its hosts before the listing answers, and the two must agree on what they are called.
1162 */1168 */
1163export function pullRequestHostOf(1169export 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,
1165 kind: SourceControlProviderKind,1177 kind: SourceControlProviderKind,
1166): string {1178): 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 }
1167 const host = identity?.canonicalKey?.split("/")[0]?.trim();1188 const host = identity?.canonicalKey?.split("/")[0]?.trim();
1168 return host === undefined || host.length === 0 ? kind : host.toLowerCase();1189 return host === undefined || host.length === 0 ? kind : host.toLowerCase();
1169}1190}
packages/contracts/src/sourceControl.ts+1
...@@ -5,6 +5,7 @@ import { VcsDriverKind } from "./vcs.ts";...@@ -5,6 +5,7 @@ import { VcsDriverKind } from "./vcs.ts";
5export const SourceControlProviderKind = Schema.Literals([5export const SourceControlProviderKind = Schema.Literals([
6 "github",6 "github",
7 "gitlab",7 "gitlab",
8 "forgejo",
8 "azure-devops",9 "azure-devops",
9 "bitbucket",10 "bitbucket",
10 "unknown",11 "unknown",
packages/shared/src/changeRequestUrl.test.ts+48
...@@ -24,6 +24,15 @@ describe("parseChangeRequestUrl", () => {...@@ -24,6 +24,15 @@ describe("parseChangeRequestUrl", () => {
24 });24 });
25 });25 });
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
27 it("reads a supported GitHub host with a middle DNS label", () => {36 it("reads a supported GitHub host with a middle DNS label", () => {
28 expect(parseChangeRequestUrl("https://code.github.example.com/acme/web/pull/42")).toEqual({37 expect(parseChangeRequestUrl("https://code.github.example.com/acme/web/pull/42")).toEqual({
29 host: "code.github.example.com",38 host: "code.github.example.com",
...@@ -90,7 +99,23 @@ describe("parseChangeRequestUrl", () => {...@@ -90,7 +99,23 @@ describe("parseChangeRequestUrl", () => {
90});99});
91100
92describe("siblingPullRequestUrl", () => {101describe("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 });
93 it.each([110 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 ],
94 ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"],119 ["https://github.com/pull/1/pull/42/files", "https://github.com/pull/1/pull/43"],
95 [120 [
96 "https://git.acme.test/team/merge_requests/1/repo/-/merge_requests/42/diffs",121 "https://git.acme.test/team/merge_requests/1/repo/-/merge_requests/42/diffs",
...@@ -125,6 +150,29 @@ describe("siblingPullRequestUrl", () => {...@@ -125,6 +150,29 @@ describe("siblingPullRequestUrl", () => {
125});150});
126151
127describe("changeRequestUrlFor", () => {152describe("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
128 it.each([176 it.each([
129 ["ssh.dev.azure.com", "v3/org/project/web"],177 ["ssh.dev.azure.com", "v3/org/project/web"],
130 ["vs-ssh.visualstudio.com", "v3/org/project/web"],178 ["vs-ssh.visualstudio.com", "v3/org/project/web"],
packages/shared/src/changeRequestUrl.ts+29-4
...@@ -13,6 +13,8 @@ export interface ChangeRequestLink {...@@ -13,6 +13,8 @@ export interface ChangeRequestLink {
13 readonly host: string;13 readonly host: string;
14 readonly repository: string;14 readonly repository: string;
15 readonly number: number;15 readonly number: number;
16 /** Forgejo's HTTP host and port, separate from the portless repository identity. */
17 readonly authority?: string;
16}18}
1719
18/** The host itself, one of its subdomains, or an install named after the provider. */20/** 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...@@ -49,7 +51,13 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu
49 // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n}51 // GitHub, and any Enterprise install: /{owner}/{repo}/pull/{n}
50 if (isHostOf(host, "github.com", "github")) {52 if (isHostOf(host, "github.com", "github")) {
51 const match = /^\/([^/]+\/[^/]+)\/pull\/(\d+)(?:\/|$)/u.exec(url.pathname);53 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() };
53 }61 }
54 // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/`62 // GitLab, self-hosted included: /{group}/[{subgroup}/...]{repo}/-/merge_requests/{n}. The `/-/`
55 // separator is GitLab's own, so the hostname is not asked about.63 // separator is GitLab's own, so the hostname is not asked about.
...@@ -83,10 +91,26 @@ export function changeRequestUrlFor(...@@ -83,10 +91,26 @@ export function changeRequestUrlFor(
83 host: string,91 host: string,
84 repository: string,92 repository: string,
85 number: number,93 number: number,
94 remoteUrl?: string,
86): string | null {95): string | null {
87 switch (kind) {96 switch (kind) {
88 case "github":97 case "github":
89 return `https://${host}/${repository}/pull/${number}`;98 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 }
90 case "gitlab":114 case "gitlab":
91 return `https://${host}/${repository}/-/merge_requests/${number}`;115 return `https://${host}/${repository}/-/merge_requests/${number}`;
92 case "bitbucket":116 case "bitbucket":
...@@ -174,7 +198,8 @@ export function matchesLinkedPullRequestUrl(...@@ -174,7 +198,8 @@ export function matchesLinkedPullRequestUrl(
174 target !== null &&198 target !== null &&
175 linked.host === target.host &&199 linked.host === target.host &&
176 linked.repository === target.repository &&200 linked.repository === target.repository &&
177 linked.number === target.number201 linked.number === target.number &&
202 linked.authority === target.authority
178 );203 );
179}204}
180205
...@@ -185,7 +210,7 @@ export function changeRequestRepositoryUrl(targetUrl: string): string | null {...@@ -185,7 +210,7 @@ export function changeRequestRepositoryUrl(targetUrl: string): string | null {
185 const url = new URL(targetUrl);210 const url = new URL(targetUrl);
186 const repositoryPath =211 const repositoryPath =
187 /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ??212 /^(.*?)\/-\/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(
189 url.pathname,214 url.pathname,
190 )?.[1];215 )?.[1];
191 if (!repositoryPath) return null;216 if (!repositoryPath) return null;
...@@ -199,7 +224,7 @@ export function siblingPullRequestUrl(url: string, number: number): string | nul...@@ -199,7 +224,7 @@ export function siblingPullRequestUrl(url: string, number: number): string | nul
199 const reference = parseChangeRequestUrl(url);224 const reference = parseChangeRequestUrl(url);
200 if (reference === null || !Number.isSafeInteger(number) || number < 1) return null;225 if (reference === null || !Number.isSafeInteger(number) || number < 1) return null;
201 const sibling = new URL(url);226 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(
203 sibling.pathname.slice(reference.repository.length + 1),228 sibling.pathname.slice(reference.repository.length + 1),
204 )?.[1];229 )?.[1];
205 if (route === undefined) return null;230 if (route === undefined) return null;
packages/shared/src/sourceControl.test.ts+16
...@@ -59,6 +59,22 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {...@@ -59,6 +59,22 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {
59 ).toBe("bitbucket");59 ).toBe("bitbucket");
60 });60 });
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
62 it("detects Azure DevOps SSH remotes", () => {78 it("detects Azure DevOps SSH remotes", () => {
63 // The default Azure DevOps SSH clone URL uses the ssh.dev.azure.com host.79 // The default Azure DevOps SSH clone URL uses the ssh.dev.azure.com host.
64 expect(80 expect(
packages/shared/src/sourceControl.ts+28-1
...@@ -5,7 +5,7 @@ import type {...@@ -5,7 +5,7 @@ import type {
5} from "@t3tools/contracts";5} from "@t3tools/contracts";
66
7export interface ChangeRequestPresentation {7export interface ChangeRequestPresentation {
8 readonly icon: "github" | "gitlab" | "azure-devops" | "bitbucket" | "change-request";8 readonly icon: "github" | "gitlab" | "forgejo" | "azure-devops" | "bitbucket" | "change-request";
9 readonly providerName: string;9 readonly providerName: string;
10 readonly shortName: string;10 readonly shortName: string;
11 readonly longName: string;11 readonly longName: string;
...@@ -47,6 +47,17 @@ const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {...@@ -47,6 +47,17 @@ const GITLAB_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
47 urlExample: "https://gitlab.com/group/project/-/merge_requests/42",47 urlExample: "https://gitlab.com/group/project/-/merge_requests/42",
48};48};
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
50const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {61const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = {
51 icon: "azure-devops",62 icon: "azure-devops",
52 providerName: "Azure DevOps",63 providerName: "Azure DevOps",
...@@ -87,6 +98,8 @@ export function resolveChangeRequestPresentation(...@@ -87,6 +98,8 @@ export function resolveChangeRequestPresentation(
87 return GITHUB_CHANGE_REQUEST_PRESENTATION;98 return GITHUB_CHANGE_REQUEST_PRESENTATION;
88 case "gitlab":99 case "gitlab":
89 return GITLAB_CHANGE_REQUEST_PRESENTATION;100 return GITLAB_CHANGE_REQUEST_PRESENTATION;
101 case "forgejo":
102 return FORGEJO_CHANGE_REQUEST_PRESENTATION;
90 case "azure-devops":103 case "azure-devops":
91 return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION;104 return AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION;
92 case "bitbucket":105 case "bitbucket":
...@@ -200,6 +213,20 @@ export function detectSourceControlProviderFromRemoteUrl(...@@ -200,6 +213,20 @@ export function detectSourceControlProviderFromRemoteUrl(
200 }213 }
201 const hostname = parseHostName(host);214 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
203 if (isGitHubHost(hostname)) {230 if (isGitHubHost(hostname)) {
204 return {231 return {
205 kind: "github",232 kind: "github",
packages/shared/src/threadPullRequests.test.ts+22
...@@ -62,6 +62,28 @@ function link(...@@ -62,6 +62,28 @@ function link(
62}62}
6363
64describe("threadPullRequestKeysEqual", () => {64describe("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
65 it("ignores host and repository case", () => {87 it("ignores host and repository case", () => {
66 expect(88 expect(
67 threadPullRequestKeysEqual(89 threadPullRequestKeysEqual(
packages/shared/src/threadPullRequests.ts+40-10
...@@ -10,10 +10,23 @@ import { pullRequestHostOf } from "@t3tools/contracts";...@@ -10,10 +10,23 @@ import { pullRequestHostOf } from "@t3tools/contracts";
10import { parseChangeRequestUrl } from "./changeRequestUrl.ts";10import { parseChangeRequestUrl } from "./changeRequestUrl.ts";
11import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts";11import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts";
1212
13/** Normalize stored link identity, including Azure's SSH and browser host aliases. */13type ThreadPullRequestKeySource = ThreadPullRequestKey & {
14export function normalizeThreadPullRequestKey(key: ThreadPullRequestKey): 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);
15 const canonical = canonicalRepositoryKey(28 const canonical = canonicalRepositoryKey(
16 `${key.host.trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`,29 `${(authority ?? key.host).trim().toLowerCase()}/${key.repository.trim().toLowerCase()}`,
17 );30 );
18 const separator = canonical.indexOf("/");31 const separator = canonical.indexOf("/");
19 return {32 return {
...@@ -31,7 +44,7 @@ export function legacyThreadPullRequestKey(...@@ -31,7 +44,7 @@ export function legacyThreadPullRequestKey(
31 const parsed = parseChangeRequestUrl(linked.url);44 const parsed = parseChangeRequestUrl(linked.url);
32 if (parsed !== null && parsed.number === linked.number) {45 if (parsed !== null && parsed.number === linked.number) {
33 const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`);46 const canonical = canonicalRepositoryKey(`${parsed.host}/${parsed.repository}`);
34 if (canonical.startsWith("dev.azure.com/")) {47 if (parsed.authority !== undefined || canonical.startsWith("dev.azure.com/")) {
35 return normalizeThreadPullRequestKey(parsed);48 return normalizeThreadPullRequestKey(parsed);
36 }49 }
37 }50 }
...@@ -52,13 +65,13 @@ export function legacyThreadPullRequestKey(...@@ -52,13 +65,13 @@ export function legacyThreadPullRequestKey(
5265
53/** Identity comparison for links: host-level, case-insensitive on host and repository. */66/** Identity comparison for links: host-level, case-insensitive on host and repository. */
54export function threadPullRequestKeysEqual(67export function threadPullRequestKeysEqual(
55 left: ThreadPullRequestKey,68 left: ThreadPullRequestKeySource,
56 right: ThreadPullRequestKey,69 right: ThreadPullRequestKeySource,
57): boolean {70): boolean {
58 return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right);71 return threadPullRequestKeyOf(left) === threadPullRequestKeyOf(right);
59}72}
6073
61export function threadPullRequestKeyOf(key: ThreadPullRequestKey): string {74export function threadPullRequestKeyOf(key: ThreadPullRequestKeySource): string {
62 const normalized = normalizeThreadPullRequestKey(key);75 const normalized = normalizeThreadPullRequestKey(key);
63 return `${normalized.host}/${normalized.repository}#${normalized.number}`;76 return `${normalized.host}/${normalized.repository}#${normalized.number}`;
64}77}
...@@ -157,6 +170,20 @@ export function legacyLinkedPullRequestOf(...@@ -157,6 +170,20 @@ export function legacyLinkedPullRequestOf(
157 const key = legacyThreadPullRequestKey(link, link.host);170 const key = legacyThreadPullRequestKey(link, link.host);
158 return canonicalRepositoryKey(`${key.host}/${key.repository}`) === azureKey;171 return canonicalRepositoryKey(`${key.host}/${key.repository}`) === azureKey;
159 }172 }
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 }
160 return (187 return (
161 link.host.toLowerCase() === host.toLowerCase() &&188 link.host.toLowerCase() === host.toLowerCase() &&
162 link.repository.toLowerCase() === repository.toLowerCase()189 link.repository.toLowerCase() === repository.toLowerCase()
...@@ -193,7 +220,8 @@ export function resolveThreadPullRequestChains(...@@ -193,7 +220,8 @@ export function resolveThreadPullRequestChains(
193 const nativeStacks = new Map<string, Array<ThreadPullRequestLink>>();220 const nativeStacks = new Map<string, Array<ThreadPullRequestLink>>();
194 for (const link of visible) {221 for (const link of visible) {
195 if (link.stack === null) continue;222 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}`;
197 const members = nativeStacks.get(stackKey) ?? [];225 const members = nativeStacks.get(stackKey) ?? [];
198 members.push(link);226 members.push(link);
199 nativeStacks.set(stackKey, members);227 nativeStacks.set(stackKey, members);
...@@ -206,8 +234,10 @@ export function resolveThreadPullRequestChains(...@@ -206,8 +234,10 @@ export function resolveThreadPullRequestChains(
206 }234 }
207235
208 const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link)));236 const remaining = visible.filter((link) => !placed.has(threadPullRequestKeyOf(link)));
209 const branchKey = (link: ThreadPullRequestLink, branch: string) =>237 const branchKey = (link: ThreadPullRequestLink, branch: string) => {
210 `${link.host.toLowerCase()}/${link.repository.toLowerCase()}:${branch}`;238 const key = normalizeThreadPullRequestKey(link);
239 return `${key.host}/${key.repository}:${branch}`;
240 };
211 // Reused head names cannot identify a parent unambiguously.241 // Reused head names cannot identify a parent unambiguously.
212 const byHead = new Map<string, ThreadPullRequestLink | null>();242 const byHead = new Map<string, ThreadPullRequestLink | null>();
213 for (const link of remaining) {243 for (const link of remaining) {