| author | |
| committer | |
| log | 6fd68f5c3e7c81a9650ba36df32f2241912e00ba |
| tree | 882f1bc0bffb35875b5fc1a674794f92d6f84400 |
| parent | c29976458a7dbbb61c83b3d06ea894869b718607 |
| signature | Signed by PGP key B5690EEEBB952194 |
66 files changed, 4545 insertions(+), 102 deletions(-)
apps/mobile/src/components/SourceControlIcon.tsx+15-2| ... | @@ -1,9 +1,9 @@ | ... | @@ -1,9 +1,9 @@ |
| 1 | import Svg, { Defs, LinearGradient, Path, Stop } from "react-native-svg"; | 1 | import Svg, { Circle, Defs, G, LinearGradient, Path, Stop } from "react-native-svg"; |
| 2 | import { withUniwind } from "uniwind"; | 2 | import { withUniwind } from "uniwind"; |
| 3 | 3 | ||
| 4 | const ThemedSvg = withUniwind(Svg); | 4 | const ThemedSvg = withUniwind(Svg); |
| 5 | 5 | ||
| 6 | export type SourceControlIconKind = "github" | "gitlab" | "bitbucket" | "azure-devops"; | 6 | export type SourceControlIconKind = "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops"; |
| 7 | 7 | ||
| 8 | export function SourceControlIcon(props: { | 8 | export 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; |
| 15 | 15 | ||
| 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 | <ThemedSvg | 32 | <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"; |
| 37 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 37 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 38 | import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; | 38 | import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; |
| 39 | import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; | 39 | import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; |
| 40 | import { | ||
| 41 | ForgejoPullRequestSchema, | ||
| 42 | toForgejoChangeRequest, | ||
| 43 | } from "../sourceControl/forgejoPullRequests.ts"; | ||
| 40 | import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; | 44 | import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; |
| 41 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; | 45 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 42 | import * as ServerConfig from "../config.ts"; | 46 | import * 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"; |
| 46 | import * as GitManager from "./GitManager.ts"; | 50 | import * as GitManager from "./GitManager.ts"; |
| 47 | 51 | ||
| 48 | const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | 52 | const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); |
| 53 | const decodeForgejoPullRequest = Schema.decodeEffect(ForgejoPullRequestSchema); | ||
| 49 | 54 | ||
| 50 | interface FakeGhScenario { | 55 | interface 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 | ); |
| 3853 | 3858 | ||
| 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 | } |
| 279 | 279 | ||
| 280 | function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { | 280 | export 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 | } |
| 293 | 302 | ||
| ... | @@ -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")); |
| 1293 | 1302 | ||
| 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 | }); |
| 1296 | 1313 | ||
| 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 | } |
| 1308 | 1325 | ||
| 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 | ); |
| 267 | 267 | ||
| 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 | ); |
| 336 | 370 | ||
| 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"; |
| 10 | import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; | 10 | import { changeRequestUrlFor, parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; |
| 11 | import { | 11 | import { |
| 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 | }); |
| 95 | 100 | ||
| 96 | function entryOf( | 101 | function 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" }]); |
| 891 | 891 | ||
| 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 | ); |
| 422 | 422 | ||
| 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 { |
| 11 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | 11 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 12 | import { | 12 | import { |
| 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 | }; |
| 116 | 116 | ||
| 117 | it.layer(NodeServices.layer)("pull request link decider", (it) => { | 117 | it.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. |
| 2 | import * as NodeFS from "node:fs"; | 2 | import * as NodeFS from "node:fs"; |
| 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 4 | import { SourceControlProviderError } from "@t3tools/contracts"; | ||
| 4 | import { expect, it } from "@effect/vitest"; | 5 | import { expect, it } from "@effect/vitest"; |
| 5 | import * as Duration from "effect/Duration"; | 6 | import * as Duration from "effect/Duration"; |
| 6 | import * as Effect from "effect/Effect"; | 7 | import * 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)); |
| 66 | 92 | ||
| 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) => { |
| 72 | 98 | ||
| 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 | }); |
| 89 | 128 |
apps/server/src/project/RepositoryIdentityResolver.ts+9-1| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import type { RepositoryIdentity } from "@t3tools/contracts"; | 1 | import type { RepositoryIdentity, SourceControlProviderError } from "@t3tools/contracts"; |
| 2 | import { | 2 | import { |
| 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 | } |
| 24 | 27 | ||
| 25 | export class RepositoryIdentityResolver extends Context.Service< | 28 | export 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 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | import * as Result from "effect/Result"; | ||
| 4 | import type { PullRequestCapabilities, PullRequestViewerPermissions } from "@t3tools/contracts"; | ||
| 5 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 6 | import { ForgejoCli, type ForgejoApiInput } from "../sourceControl/ForgejoCli.ts"; | ||
| 7 | import { | ||
| 8 | PullRequestProviderError, | ||
| 9 | type ProviderChangeRequestDetail, | ||
| 10 | type ProviderRepositoryRef, | ||
| 11 | type PullRequestProviderApi, | ||
| 12 | } from "./PullRequestProvider.ts"; | ||
| 13 | import { | ||
| 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 | |||
| 35 | const 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 | }; | ||
| 54 | const repoPath = (input: ProviderRepositoryRef) => | ||
| 55 | `repos/${input.repository.split("/").map(encodeURIComponent).join("/")}`; | ||
| 56 | const pullPath = (input: ProviderRepositoryRef & { readonly number: number }) => | ||
| 57 | `${repoPath(input)}/pulls/${input.number}`; | ||
| 58 | const 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. | ||
| 61 | const reviewCommentId = (review: typeof ForgejoReview.Type) => | ||
| 62 | /#issuecomment-([1-9]\d*)$/.exec(review.html_url ?? "")?.[1]; | ||
| 63 | |||
| 64 | export 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>; |
| 286 | 287 | ||
| 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"; |
| 8 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; | 8 | import * as GitHubCli from "../sourceControl/GitHubCli.ts"; |
| 9 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; | 9 | import * as GitHubGraphQlBudget from "../sourceControl/githubGraphQlBudget.ts"; |
| 10 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; | 10 | import * as GitLabCli from "../sourceControl/GitLabCli.ts"; |
| 11 | import * as ForgejoCli from "../sourceControl/ForgejoCli.ts"; | ||
| 12 | import * as ForgejoPullRequestProvider from "./ForgejoPullRequestProvider.ts"; | ||
| 11 | import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; | 13 | import * as AzureDevOpsPullRequestCli from "./AzureDevOpsPullRequestCli.ts"; |
| 12 | import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; | 14 | import * as AzureDevOpsPullRequestProvider from "./AzureDevOpsPullRequestProvider.ts"; |
| 13 | import * as BitbucketPullRequestApi from "./BitbucketPullRequestApi.ts"; | 15 | import * 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 | ); |
| 1661 | 1661 | ||
| 1662 | it.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 | |||
| 1743 | it.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 | |||
| 1662 | it.effect("routes a hosted reference to another repository through a project on that host", () => | 1774 | it.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 @@ |
| 1 | import { | 1 | import { |
| 2 | canonicalRepositoryKey, | 2 | canonicalRepositoryKey, |
| 3 | isSshRemoteUrl, | ||
| 3 | sourceControlRepositorySelector, | 4 | sourceControlRepositorySelector, |
| 4 | } from "@t3tools/shared/sourceControl"; | 5 | } from "@t3tools/shared/sourceControl"; |
| 5 | import * as Cache from "effect/Cache"; | 6 | import * 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) === null | 559 | 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 reach | 563 | // 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 keeps | 654 | // 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 part | 655 | // 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 alternates | 674 | // 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 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import * as DateTime from "effect/DateTime"; | ||
| 3 | import * as Option from "effect/Option"; | ||
| 4 | import type { | ||
| 5 | PullRequestActor, | ||
| 6 | PullRequestCheck, | ||
| 7 | PullRequestComment, | ||
| 8 | PullRequestCommit, | ||
| 9 | PullRequestReaction, | ||
| 10 | PullRequestReactionContent, | ||
| 11 | PullRequestReviewThread, | ||
| 12 | } from "@t3tools/contracts"; | ||
| 13 | import type { ProviderChangeRequest } from "./PullRequestProvider.ts"; | ||
| 14 | import { dedupeChecks } from "./pullRequestChecks.ts"; | ||
| 15 | |||
| 16 | export 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 | }); | ||
| 21 | export 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 | }); | ||
| 27 | export 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 | }); | ||
| 36 | const Branch = Schema.Struct({ | ||
| 37 | ref: Schema.String, | ||
| 38 | sha: Schema.String, | ||
| 39 | repo: Schema.NullOr(ForgejoRepository), | ||
| 40 | }); | ||
| 41 | export 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 | }); | ||
| 66 | export 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 | }); | ||
| 73 | export 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 | }); | ||
| 82 | export 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 | }); | ||
| 91 | export 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 | }); | ||
| 103 | export 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 | }); | ||
| 110 | export const ForgejoReaction = Schema.Struct({ | ||
| 111 | content: Schema.String, | ||
| 112 | user: Schema.NullOr(ForgejoUser), | ||
| 113 | }); | ||
| 114 | |||
| 115 | export 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 | |||
| 123 | function toIsoUtc(value: string): string { | ||
| 124 | return Option.match(DateTime.make(value), { onNone: () => value, onSome: DateTime.formatIso }); | ||
| 125 | } | ||
| 126 | |||
| 127 | export 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 | |||
| 151 | export 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 | |||
| 164 | export 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 | |||
| 182 | export 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 | |||
| 198 | export 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 | |||
| 211 | export 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 | |||
| 233 | export 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 | }; | ||
| 243 | export 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 | } |
| 89 | 105 | ||
| 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 @@ |
| 1 | import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; | 1 | import { |
| 2 | normalizeThreadPullRequestKey, | ||
| 3 | threadPullRequestKeysEqual, | ||
| 4 | } from "@t3tools/shared/threadPullRequests"; | ||
| 2 | import { | 5 | import { |
| 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 |
| 13 | export const listLinkedPullRequestThreads = Effect.fn("listLinkedPullRequestThreads")( | 16 | export 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 link | 33 | FROM projection_thread_pull_requests AS link |
| 21 | JOIN projection_threads AS t ON t.thread_id = link.thread_id | 34 | 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 NULL | 39 | AND t.deleted_at IS NULL |
| 27 | ORDER BY t.updated_at DESC, t.thread_id ASC | 40 | 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 @@ |
| 1 | import { EnvironmentHttpApi, ProviderDriverKind } from "@t3tools/contracts"; | 1 | import { |
| 2 | EnvironmentHttpApi, | ||
| 3 | ProviderDriverKind, | ||
| 4 | type RepositoryIdentity, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import * as Cause from "effect/Cause"; | 6 | import * as Cause from "effect/Cause"; |
| 3 | import * as Duration from "effect/Duration"; | 7 | import * as Duration from "effect/Duration"; |
| 4 | import * as Deferred from "effect/Deferred"; | 8 | import * 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"; |
| 51 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; | 55 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 52 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; | 56 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; |
| 53 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; | 57 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 58 | import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; | ||
| 54 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; | 59 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| 55 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; | 60 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; |
| 56 | import * as TerminalManager from "./terminal/Manager.ts"; | 61 | import * as TerminalManager from "./terminal/Manager.ts"; |
| ... | @@ -313,12 +318,54 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( | ... | @@ -313,12 +318,54 @@ const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( |
| 313 | 318 | ||
| 314 | const SourceControlProviderRegistryLayerLive = SourceControlProviderRegistry.layer.pipe( | 319 | const 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 | ); |
| 321 | 332 | ||
| 333 | const 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 | |||
| 322 | const PullRequestServiceLive = PullRequestService.layer.pipe( | 369 | const 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 @@ | ||
| 1 | import * as Context from "effect/Context"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | import * as Result from "effect/Result"; | ||
| 6 | import * as Clock from "effect/Clock"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Semaphore from "effect/Semaphore"; | ||
| 9 | import * 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. | ||
| 11 | import * as NodePath from "node:path"; | ||
| 12 | import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"; | ||
| 13 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 14 | import { decodeJsonResult } from "@t3tools/shared/schemaJson"; | ||
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | ||
| 16 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 17 | import { collectUint8StreamText } from "../stream/collectUint8StreamText.ts"; | ||
| 18 | import type { SourceControlProviderContext } from "./SourceControlProvider.ts"; | ||
| 19 | |||
| 20 | const encodeApiBody = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 21 | |||
| 22 | export 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 | |||
| 40 | export 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 | |||
| 49 | export function parseForgejoLogins(raw: string) { | ||
| 50 | const decoded = decodeJsonResult(Schema.Array(ForgejoLoginSchema))(raw); | ||
| 51 | return Result.isSuccess(decoded) ? decoded.success : []; | ||
| 52 | } | ||
| 53 | |||
| 54 | export 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 | |||
| 65 | const parseForgejoKeys = decodeJsonResult(ForgejoKeysSchema); | ||
| 66 | |||
| 67 | /** Matches fj's directories::ProjectDirs, including its pre-0.6 organization name. */ | ||
| 68 | function 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 | |||
| 105 | export interface ForgejoRepositoryInput { | ||
| 106 | readonly cwd: string; | ||
| 107 | readonly context?: SourceControlProviderContext; | ||
| 108 | readonly repository?: string; | ||
| 109 | readonly reference?: string; | ||
| 110 | readonly host?: string; | ||
| 111 | } | ||
| 112 | |||
| 113 | export interface ForgejoRepository { | ||
| 114 | readonly command?: "fj" | "tea"; | ||
| 115 | readonly login: string; | ||
| 116 | readonly repository: string; | ||
| 117 | readonly baseUrl: string; | ||
| 118 | } | ||
| 119 | |||
| 120 | export interface ForgejoApiInput extends ForgejoRepositoryInput { | ||
| 121 | readonly path: string; | ||
| 122 | readonly method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; | ||
| 123 | readonly body?: unknown; | ||
| 124 | } | ||
| 125 | |||
| 126 | export 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 | |||
| 155 | export 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 | |||
| 181 | export 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 | |||
| 214 | export 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 | |||
| 720 | export const layer = Layer.effect(ForgejoCli, make); | ||
apps/server/src/sourceControl/ForgejoSourceControlProvider.ts created+387| ... | @@ -0,0 +1,387 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as FileSystem from "effect/FileSystem"; | ||
| 3 | import * as Schema from "effect/Schema"; | ||
| 4 | import * as Result from "effect/Result"; | ||
| 5 | import { SourceControlProviderError } from "@t3tools/contracts"; | ||
| 6 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 7 | import * as ForgejoCli from "./ForgejoCli.ts"; | ||
| 8 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | ||
| 9 | import { | ||
| 10 | providerAuth, | ||
| 11 | probeSourceControlProvider, | ||
| 12 | type SourceControlCliDiscoverySpec, | ||
| 13 | type SourceControlManagedCliDiscoverySpec, | ||
| 14 | } from "./SourceControlProviderDiscovery.ts"; | ||
| 15 | import { ForgejoPullRequestSchema, toForgejoChangeRequest } from "./forgejoPullRequests.ts"; | ||
| 16 | |||
| 17 | const isForgejoCliError = Schema.is(ForgejoCli.ForgejoCliError); | ||
| 18 | |||
| 19 | export 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 | |||
| 56 | export 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 | |||
| 161 | const 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 | }); | ||
| 167 | const cloneUrls = (raw: typeof RepositorySchema.Type) => ({ | ||
| 168 | nameWithOwner: raw.full_name, | ||
| 169 | url: raw.clone_url, | ||
| 170 | sshUrl: raw.ssh_url, | ||
| 171 | }); | ||
| 172 | const repositoryPath = (repository: string) => | ||
| 173 | `repos/${repository.split("/").map(encodeURIComponent).join("/")}`; | ||
| 174 | |||
| 175 | export 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 @@ |
| 1 | import { assert, it } from "@effect/vitest"; | 1 | import { assert, it } from "@effect/vitest"; |
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | import type * as Context from "effect/Context"; | ||
| 5 | import * as FileSystem from "effect/FileSystem"; | ||
| 4 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 5 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import * as Path from "effect/Path"; | ||
| 9 | import * as Schema from "effect/Schema"; | ||
| 6 | import { ChildProcessSpawner } from "effect/unstable/process"; | 10 | import { ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 7 | import { VcsProcessSpawnError } from "@t3tools/contracts"; | 12 | import { VcsProcessSpawnError } from "@t3tools/contracts"; |
| 8 | 13 | ||
| 9 | import * as ServerConfig from "../config.ts"; | 14 | import * 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"; |
| 13 | import * as BitbucketApi from "./BitbucketApi.ts"; | 18 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 14 | import * as GitHubCli from "./GitHubCli.ts"; | 19 | import * as GitHubCli from "./GitHubCli.ts"; |
| 15 | import * as GitLabCli from "./GitLabCli.ts"; | 20 | import * as GitLabCli from "./GitLabCli.ts"; |
| 21 | import * as ForgejoCli from "./ForgejoCli.ts"; | ||
| 22 | import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; | ||
| 23 | import * as ForgejoPullRequestProvider from "../pullRequest/ForgejoPullRequestProvider.ts"; | ||
| 16 | import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; | 24 | import * as SourceControlDiscovery from "./SourceControlDiscovery.ts"; |
| 17 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | 25 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 26 | import { firstNonEmptyLine } from "./SourceControlProviderDiscovery.ts"; | ||
| 18 | 27 | ||
| 19 | const sourceControlProviderRegistryTestLayer = (input: { | 28 | const 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 | }); |
| 52 | 62 | ||
| 63 | const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); | ||
| 64 | const encodeJsonEffect = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); | ||
| 65 | |||
| 66 | it.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 | |||
| 160 | it.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 | |||
| 212 | it.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 | |||
| 53 | it.effect("reports implemented tools separately from locally available executables", () => { | 371 | it.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 | |||
| 631 | it.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 | |||
| 699 | it.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 | |||
| 761 | it.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 | |||
| 824 | it.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 | |||
| 921 | it.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 | |||
| 1047 | it.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 | |||
| 1154 | it.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 | |||
| 1244 | it.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 | |||
| 1317 | it.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 | |||
| 1445 | it.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 | } |
| 18 | 20 | ||
| 19 | export interface SourceControlRefSelector { | 21 | export interface SourceControlRefSelector { |
apps/server/src/sourceControl/SourceControlProviderDiscovery.ts+23-6| ... | @@ -1,3 +1,4 @@ | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as NodeUtil from "node:util"; | ||
| 1 | import type { | 2 | import 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 | }; |
| 47 | 49 | ||
| 50 | export 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 | |||
| 48 | export type SourceControlProviderDiscoverySpec = | 59 | export type SourceControlProviderDiscoverySpec = |
| 49 | | SourceControlCliDiscoverySpec | 60 | | SourceControlCliDiscoverySpec |
| 61 | | SourceControlManagedCliDiscoverySpec | ||
| 50 | | SourceControlApiDiscoverySpec; | 62 | | SourceControlApiDiscoverySpec; |
| 51 | 63 | ||
| 52 | type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { | 64 | type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { |
| ... | @@ -72,7 +84,7 @@ interface DiscoveryProbeResult { | ... | @@ -72,7 +84,7 @@ interface DiscoveryProbeResult { |
| 72 | } | 84 | } |
| 73 | 85 | ||
| 74 | export function firstNonEmptyLine(text: string): Option.Option<string> { | 86 | export function firstNonEmptyLine(text: string): Option.Option<string> { |
| 75 | const line = text | 87 | 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; |
| 290 | 303 | ||
| 291 | const providers = yield* Effect.forEach(input.specs.filter(isCliRemoteRefinementSpec), (spec) => | 304 | const providers = yield* Effect.forEach(input.specs, (spec) => { |
| 292 | input.process | 305 | if (spec.type === "managed-cli") { |
| 306 | return spec.refineUnknownRemote({ cwd: input.cwd, context }); | ||
| 307 | } | ||
| 308 | if (!isCliRemoteRefinementSpec(spec)) return Effect.succeed(null); | ||
| 309 | return input.process | ||
| 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); |
| 315 | 332 | ||
| 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"; |
| 15 | import * as BitbucketApi from "./BitbucketApi.ts"; | 15 | import * as BitbucketApi from "./BitbucketApi.ts"; |
| 16 | import * as GitHubCli from "./GitHubCli.ts"; | 16 | import * as GitHubCli from "./GitHubCli.ts"; |
| 17 | import * as GitLabCli from "./GitLabCli.ts"; | 17 | import * as GitLabCli from "./GitLabCli.ts"; |
| 18 | import * as ForgejoCli from "./ForgejoCli.ts"; | ||
| 18 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; | 19 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 19 | 20 | ||
| 20 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | 21 | const 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 |
| 15 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; | 15 | import * as BitbucketSourceControlProvider from "./BitbucketSourceControlProvider.ts"; |
| 16 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | 16 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 17 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; | 17 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; |
| 18 | import * as ForgejoSourceControlProvider from "./ForgejoSourceControlProvider.ts"; | ||
| 18 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | 19 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 19 | import { | 20 | import { |
| 20 | probeSourceControlProvider, | 21 | probeSourceControlProvider, |
| ... | @@ -296,6 +297,8 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit | ... | @@ -296,6 +297,8 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit |
| 296 | export const make = Effect.gen(function* () { | 297 | export 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 | }); |
| 325 | 329 |
apps/server/src/sourceControl/forgejoPullRequests.ts created+47| ... | @@ -0,0 +1,47 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import * as Option from "effect/Option"; | ||
| 3 | import type { ChangeRequest } from "@t3tools/contracts"; | ||
| 4 | |||
| 5 | const Repository = Schema.Struct({ | ||
| 6 | full_name: Schema.String, | ||
| 7 | owner: Schema.Struct({ login: Schema.String }), | ||
| 8 | }); | ||
| 9 | const Branch = Schema.Struct({ | ||
| 10 | ref: Schema.String, | ||
| 11 | sha: Schema.String, | ||
| 12 | repo: Schema.NullOr(Repository), | ||
| 13 | }); | ||
| 14 | export 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 | }); | ||
| 27 | export 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"; |
| 155 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; | 155 | import * as BitbucketApi from "./sourceControl/BitbucketApi.ts"; |
| 156 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; | 156 | import * as GitHubCli from "./sourceControl/GitHubCli.ts"; |
| 157 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; | 157 | import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 158 | import * as ForgejoCli from "./sourceControl/ForgejoCli.ts"; | ||
| 158 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; | 159 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| 159 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | 160 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; |
| 160 | import * as VcsDriverRegistry from "./vcs/VcsDriverRegistry.ts"; | 161 | import * 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 |
| 150 | import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; | 150 | import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; |
| 151 | import { CommandPaletteContent } from "./CommandPaletteContent"; | 151 | import { CommandPaletteContent } from "./CommandPaletteContent"; |
| 152 | import { CommandPaletteResults } from "./CommandPaletteResults"; | 152 | import { CommandPaletteResults } from "./CommandPaletteResults"; |
| 153 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; | 153 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon, ForgejoIcon } from "./Icons"; |
| 154 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; | 154 | import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; |
| 155 | import { ProjectFavicon } from "./ProjectFavicon"; | 155 | import { ProjectFavicon } from "./ProjectFavicon"; |
| 156 | import { ProjectFilePicker } from "./files/ProjectFilePicker"; | 156 | import { ProjectFilePicker } from "./files/ProjectFilePicker"; |
| ... | @@ -244,7 +244,7 @@ interface AddProjectEnvironmentOption { | ... | @@ -244,7 +244,7 @@ interface AddProjectEnvironmentOption { |
| 244 | 244 | ||
| 245 | type AddProjectRemoteProviderKind = Extract< | 245 | type AddProjectRemoteProviderKind = Extract< |
| 246 | SourceControlProviderKind, | 246 | SourceControlProviderKind, |
| 247 | "github" | "gitlab" | "bitbucket" | "azure-devops" | 247 | "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" |
| 248 | >; | 248 | >; |
| 249 | type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; | 249 | type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; |
| 250 | 250 | ||
| ... | @@ -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 | ]; |
| 273 | const REMOTE_PROJECT_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [ | 274 | const 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 { |
| 294 | 298 | ||
| 295 | function remoteProjectSourcePathHint(source: AddProjectRemoteSource): string { | 299 | function 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"; |
| 41 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; | 41 | import { Radio as RadioPrimitive } from "@base-ui/react/radio"; |
| 42 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "~/components/Icons"; | 42 | import { |
| 43 | AzureDevOpsIcon, | ||
| 44 | BitbucketIcon, | ||
| 45 | GitHubIcon, | ||
| 46 | GitLabIcon, | ||
| 47 | ForgejoIcon, | ||
| 48 | } from "~/components/Icons"; | ||
| 43 | import { RadioGroup } from "~/components/ui/radio-group"; | 49 | import { RadioGroup } from "~/components/ui/radio-group"; |
| 44 | import { Spinner } from "~/components/ui/spinner"; | 50 | import { Spinner } from "~/components/ui/spinner"; |
| 45 | import { toggleVariants } from "~/components/ui/toggle"; | 51 | import { toggleVariants } from "~/components/ui/toggle"; |
| ... | @@ -123,7 +129,7 @@ interface PendingDefaultBranchAction { | ... | @@ -123,7 +129,7 @@ interface PendingDefaultBranchAction { |
| 123 | 129 | ||
| 124 | type PublishProviderKind = Extract< | 130 | type PublishProviderKind = Extract< |
| 125 | SourceControlProviderKind, | 131 | SourceControlProviderKind, |
| 126 | "github" | "gitlab" | "bitbucket" | "azure-devops" | 132 | "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" |
| 127 | >; | 133 | >; |
| 128 | 134 | ||
| 129 | type GitActionToastId = ReturnType<typeof toastManager.add>; | 135 | type GitActionToastId = ReturnType<typeof toastManager.add>; |
| ... | @@ -171,6 +177,14 @@ function requestVcsStatusRefresh( | ... | @@ -171,6 +177,14 @@ function requestVcsStatusRefresh( |
| 171 | const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; | 177 | const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; |
| 172 | 178 | ||
| 173 | const PUBLISH_PROVIDER_OPTIONS = [ | 179 | const 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. | ||
| 773 | export 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 by | 796 | // 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 pull | 797 | // 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 | }; |
| 108 | 110 | ||
| 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; | ||
| 112 | 116 | ||
| 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 |
| 7 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; | 7 | import { usePullRequestLinking } from "~/hooks/usePullRequestLinking"; |
| 8 | 8 | ||
| 9 | import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; | 9 | import { parseChangeRequestUrl } from "~/lib/openPullRequestLink"; |
| 10 | import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; | ||
| 10 | import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities"; | 11 | import { useProjects, useServerConfigs, useThreadShell, useThreadShells } from "~/state/entities"; |
| 11 | import { pullRequestEnvironment } from "~/state/pullRequests"; | 12 | import { pullRequestEnvironment } from "~/state/pullRequests"; |
| 12 | import { useEnvironmentQuery } from "~/state/query"; | 13 | import { 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 | }); |
| 70 | 97 | ||
| 71 | const TIMELINE_SOURCE: Pick< | 98 | const 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"; |
| 11 | const OPEN_ON_HOST_LABELS: Partial<Record<string, string>> = { | 11 | const 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 = { |
| 75 | const SOURCE_CONTROL_PROVIDER_ICONS: Partial<Record<SourceControlProviderKind, Icon>> = { | 76 | const 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 | }); |
| 172 | 172 | ||
| 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 | }); |
| 352 | 383 | ||
| 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"; |
| 28 | 28 | ||
| 29 | /** | 29 | function 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 trigger | 32 | try { |
| 33 | * an enclosing row or trigger (e.g. opening the branch dropdown), and surfaces a | 33 | 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. */ | ||
| 41 | function 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 repository | 58 | * 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 GitLab | 59 | * 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.capabilities | 231 | ...(serverConfigs.get(project.environmentId)?.environment.capabilities |
| 190 | .threadPullRequests === true | 232 | .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 @@ |
| 1 | const FORGEJO_PULL_REQUEST_URL_PATTERN = | ||
| 2 | /^https?:\/\/[^/\s]+\/(?:[^/\s]+\/)+[^/\s]+\/pulls\/(\d+)(?:[/?#].*)?$/i; | ||
| 3 | const FORGEJO_CLI_PR_CHECKOUT_PATTERN = /^tea\s+(?:pr|pulls)\s+checkout\s+(.+)$/i; | ||
| 1 | const GITHUB_PULL_REQUEST_URL_PATTERN = | 4 | const GITHUB_PULL_REQUEST_URL_PATTERN = |
| 2 | /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; | 5 | /^https:\/\/github\.com\/[^/\s]+\/[^/\s]+\/pull\/(\d+)(?:[/?#].*)?$/i; |
| 3 | const GITLAB_MERGE_REQUEST_URL_PATTERN = | 6 | const 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 | } |
| 44 | 48 | ||
| 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"; |
| 16 | import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./components/Icons"; | 16 | import { |
| 17 | AzureDevOpsIcon, | ||
| 18 | BitbucketIcon, | ||
| 19 | GitHubIcon, | ||
| 20 | GitLabIcon, | ||
| 21 | ForgejoIcon, | ||
| 22 | } from "./components/Icons"; | ||
| 17 | 23 | ||
| 18 | export interface SourceControlPresentation { | 24 | export 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 control | 1 | # Source control |
| 2 | 2 | ||
| 3 | T3 Code integrates with GitHub, GitLab, Bitbucket, and Azure DevOps to clone and publish | 3 | T3 Code integrates with GitHub, GitLab, Forgejo, Gitea, Bitbucket, and Azure DevOps to clone and publish |
| 4 | repositories, create pull requests, and review changes. | 4 | repositories, create pull requests, and review changes. |
| 5 | 5 | ||
| 6 | ## Connect an account | 6 | ## 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: |
| 17 | gh auth login | 17 | gh auth login |
| 18 | ``` | 18 | ``` |
| 19 | 19 | ||
| 20 | ### Forgejo and Gitea | ||
| 21 | |||
| 22 | Install [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. | ||
| 24 | Sign in with `fj --host https://your-server auth add-token` or `tea login add`. | ||
| 25 | Repeat for each server you use, including Codeberg. | ||
| 26 | |||
| 27 | T3 Code prefers a matching `fj` login and falls back to `tea` when `fj` is unavailable | ||
| 28 | or has no login for that server. Once an account is selected, failed actions stay on that | ||
| 29 | account. Settings shows the detected CLI. Forgejo and Gitea share one integration entry. | ||
| 30 | Servers hosted under a URL subpath, such as `https://example.com/forgejo`, use `tea` because | ||
| 31 | fj 0.6 does not preserve the subpath when checking its account. | ||
| 32 | |||
| 33 | When cloning or publishing, use a full repository URL to select a specific server. | ||
| 34 | You can use `owner/repo` when only one fj server is configured, or with your default `tea` | ||
| 35 | login when fj is unavailable or unconfigured. With multiple fj servers, use the full URL. | ||
| 36 | If 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 | ||
| 38 | for that server. | ||
| 39 | |||
| 20 | ### GitLab | 40 | ### GitLab |
| 21 | 41 | ||
| 22 | Install [GitLab CLI](https://gitlab.com/gitlab-org/cli), then sign in: | 42 | Install [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 | }); |
| 80 | 87 | ||
| 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"; |
| 25 | 25 | ||
| 26 | export type AddProjectRemoteProviderKind = Extract< | 26 | export type AddProjectRemoteProviderKind = Extract< |
| 27 | SourceControlProviderKind, | 27 | SourceControlProviderKind, |
| 28 | "github" | "gitlab" | "bitbucket" | "azure-devops" | 28 | "github" | "gitlab" | "forgejo" | "bitbucket" | "azure-devops" |
| 29 | >; | 29 | >; |
| 30 | export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; | 30 | export type AddProjectRemoteSource = AddProjectRemoteProviderKind | "url"; |
| 31 | 31 | ||
| ... | @@ -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> = [ |
| 66 | const ADD_PROJECT_REMOTE_PROVIDER_SOURCES: ReadonlyArray<AddProjectRemoteProviderKind> = [ | 67 | const 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 |
| 87 | 91 | ||
| 88 | export function addProjectRemoteSourcePathHint(source: AddProjectRemoteSource): string { | 92 | export 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 | } |
| 119 | 124 | ||
| 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. */ |
| 121 | export function getDefaultCloneUrl( | 126 | export 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 | } |
| 126 | 133 | ||
| 127 | export function sortAddProjectProviderSources( | 134 | export 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 | }; |
| 20 | 20 | ||
| 21 | describe("thread pull request capability negotiation", () => { | 21 | describe("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"; |
| 7 | import { normalizeThreadPullRequestKey } from "@t3tools/shared/threadPullRequests"; | ||
| 7 | 8 | ||
| 8 | type LinkingCapabilities = Pick< | 9 | type 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 linked | 43 | 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; |
| 192 | export const RepositoryIdentity = Schema.Struct({ | 192 | export 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"; |
| 12 | 13 | ||
| ... | @@ -65,6 +66,20 @@ const LIST_RESULT: PullRequestListResult = { | ... | @@ -65,6 +66,20 @@ const LIST_RESULT: PullRequestListResult = { |
| 65 | }; | 66 | }; |
| 66 | 67 | ||
| 67 | describe("PullRequestListResult", () => { | 68 | describe("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 record | 84 | * 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 schema | 85 | * 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 | */ |
| 1163 | export function pullRequestHostOf( | 1169 | export 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"; |
| 5 | export const SourceControlProviderKind = Schema.Literals([ | 5 | export 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 | }); |
| 26 | 26 | ||
| 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 | }); |
| 91 | 100 | ||
| 92 | describe("siblingPullRequestUrl", () => { | 101 | describe("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 | }); |
| 126 | 151 | ||
| 127 | describe("changeRequestUrlFor", () => { | 152 | describe("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 | } |
| 17 | 19 | ||
| 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.number | 201 | linked.number === target.number && |
| 202 | linked.authority === target.authority | ||
| 178 | ); | 203 | ); |
| 179 | } | 204 | } |
| 180 | 205 | ||
| ... | @@ -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 | }); |
| 61 | 61 | ||
| 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"; |
| 6 | 6 | ||
| 7 | export interface ChangeRequestPresentation { | 7 | export 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 | }; |
| 49 | 49 | ||
| 50 | const 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 | |||
| 50 | const AZURE_DEVOPS_CHANGE_REQUEST_PRESENTATION: ChangeRequestPresentation = { | 61 | const 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); |
| 202 | 215 | ||
| 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 | } |
| 63 | 63 | ||
| 64 | describe("threadPullRequestKeysEqual", () => { | 64 | describe("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"; |
| 10 | import { parseChangeRequestUrl } from "./changeRequestUrl.ts"; | 10 | import { parseChangeRequestUrl } from "./changeRequestUrl.ts"; |
| 11 | import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts"; | 11 | import { canonicalRepositoryKey, sourceControlRepositorySelector } from "./sourceControl.ts"; |
| 12 | 12 | ||
| 13 | /** Normalize stored link identity, including Azure's SSH and browser host aliases. */ | 13 | type ThreadPullRequestKeySource = ThreadPullRequestKey & { |
| 14 | export 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. */ | ||
| 19 | export 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( |
| 52 | 65 | ||
| 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. */ |
| 54 | export function threadPullRequestKeysEqual( | 67 | export 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 | } |
| 60 | 73 | ||
| 61 | export function threadPullRequestKeyOf(key: ThreadPullRequestKey): string { | 74 | export 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 | } |
| 207 | 235 | ||
| 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) { |