Skip to content

Commit 75d71e5

Browse files
committed
fix(source-control): preserve mapped endpoints for pull requests
1 parent a2df32f commit 75d71e5

6 files changed

Lines changed: 304 additions & 54 deletions

File tree

‎apps/server/src/pullRequest/PullRequestService.test.ts‎

Lines changed: 156 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as Clock from "effect/Clock";
1010
import * as Deferred from "effect/Deferred";
1111
import * as Effect from "effect/Effect";
1212
import * as Exit from "effect/Exit";
13+
import * as FileSystem from "effect/FileSystem";
1314
import * as Fiber from "effect/Fiber";
1415
import * as Layer from "effect/Layer";
1516
import * as Option from "effect/Option";
@@ -43,6 +44,7 @@ import * as GitHubPullRequestProvider from "@t3tools/source-control-github/serve
4344
import * as GitHubQuota from "@t3tools/source-control-github/server/GitHubQuota";
4445
import * as TestSourceControlHost from "@t3tools/source-control-testing/TestSourceControlHost";
4546
import {
47+
PullRequestBaseUrl,
4648
PullRequestProviderError,
4749
type ProviderChangeRequest,
4850
type PullRequestProviderApi,
@@ -531,16 +533,20 @@ it.effect.each(["forgejo", "azure-devops"] as const)(
531533
const service = yield* makeService({
532534
settings: {
533535
...DEFAULT_SERVER_SETTINGS,
534-
sourceControlHostOverrides: { "code.example.test": { kind } },
536+
sourceControlHostOverrides: {
537+
"code.example.test": { kind, baseUrl: "https://code.example.test/forge" },
538+
},
535539
},
536540
projects: [project({ id: "inbox", title: "Inbox", workspaceRoot: "/inbox" })],
537541
providers: [
538542
fakeProvider(SourceControlProviderKind.make(kind), {
539-
getChangeRequestSummary: (input) => {
540-
reads++;
541-
assert.strictEqual(input.host, "code.example.test");
542-
return Effect.succeed(changeRequest(42, "2026-07-02T00:00:00Z"));
543-
},
543+
getChangeRequestSummary: (input) =>
544+
Effect.gen(function* () {
545+
reads++;
546+
assert.strictEqual(input.host, "code.example.test");
547+
assert.strictEqual(yield* PullRequestBaseUrl, "https://code.example.test/forge");
548+
return changeRequest(42, "2026-07-02T00:00:00Z");
549+
}),
544550
}),
545551
],
546552
});
@@ -715,41 +721,158 @@ it.effect.each(["unknown", "github", "omitted"])(
715721
}),
716722
);
717723

718-
it.effect(
719-
"routes a mapped SSH host through its configured web authority without automatic refinement",
720-
() =>
724+
it.effect.each([
725+
{
726+
remoteUrl: "ssh://[email protected]:2222/team/repo.git",
727+
repository: "team/repo",
728+
mount: "/Forgejo",
729+
loginPresent: true,
730+
},
731+
{
732+
remoteUrl: "https://ssh.example.test:2222/Forgejo/team/repo.git",
733+
repository: "Forgejo/team/repo",
734+
mount: "/Forgejo",
735+
loginPresent: true,
736+
},
737+
{
738+
remoteUrl: "ssh://[email protected]:2222/team/repo.git",
739+
repository: "team/repo",
740+
mount: "",
741+
loginPresent: true,
742+
},
743+
{
744+
remoteUrl: "[email protected]:team/repo.git",
745+
repository: "team/repo",
746+
mount: "/Forgejo",
747+
loginPresent: false,
748+
},
749+
])(
750+
"routes mapped $remoteUrl with mount '$mount' through the Forgejo endpoint and login",
751+
({ remoteUrl, repository, mount, loginPresent }) =>
721752
Effect.gen(function* () {
753+
const baseUrl = `http://web.example.test:3000${mount}`;
754+
const urls: string[] = [];
755+
const rawPull = {
756+
number: 42,
757+
title: "Mounted Forgejo PR",
758+
body: null,
759+
html_url: `${baseUrl}/team/repo/pulls/42`,
760+
user: { login: "contributor" },
761+
state: "open",
762+
merged: false,
763+
head: { ref: "feature", sha: "abc", repo: { full_name: "team/repo" } },
764+
base: { ref: "main", sha: "def", repo: { full_name: "team/repo" } },
765+
created_at: "2026-07-01T00:00:00Z",
766+
updated_at: "2026-07-02T00:00:00Z",
767+
closed_at: null,
768+
merged_at: null,
769+
labels: [],
770+
};
771+
const cli = yield* ForgejoCli.make.pipe(
772+
Effect.provideService(
773+
FileSystem.FileSystem,
774+
FileSystem.makeNoop({ exists: () => Effect.succeed(false) }),
775+
),
776+
Effect.provideService(
777+
HttpClient.HttpClient,
778+
HttpClient.make(() => Effect.die("tea handles HTTP")),
779+
),
780+
Effect.provide(
781+
TestSourceControlHost.layer({
782+
process: {
783+
run: (input) => {
784+
assert.strictEqual(input.command, "tea");
785+
if (input.args[0] === "login")
786+
return Effect.succeed(
787+
TestSourceControlHost.processOutput(
788+
JSON.stringify([
789+
{
790+
name: "unrelated-default",
791+
url: "https://web.example.test:3000",
792+
user: "wrong",
793+
default: "true",
794+
},
795+
{
796+
name: "other-mount",
797+
url: "http://web.example.test:3000/other",
798+
user: "wrong",
799+
default: "false",
800+
},
801+
...(loginPresent
802+
? [
803+
{
804+
name: "mapped",
805+
url: `${baseUrl}/`,
806+
user: "viewer",
807+
default: "false",
808+
},
809+
]
810+
: []),
811+
]),
812+
),
813+
);
814+
assert.strictEqual(input.args[input.args.indexOf("--login") + 1], "mapped");
815+
const url = input.args.at(-1)!;
816+
assert.ok(url.startsWith(`${baseUrl}/api/v1/`));
817+
urls.push(url);
818+
return Effect.succeed(
819+
TestSourceControlHost.processOutput(
820+
JSON.stringify(
821+
url.endsWith("/user")
822+
? { login: "viewer" }
823+
: url.includes("?state=")
824+
? []
825+
: rawPull,
826+
),
827+
{ stderr: "HTTP/1.1 200 OK\n" },
828+
),
829+
);
830+
},
831+
},
832+
}),
833+
),
834+
);
835+
const provider = yield* ForgejoPullRequestProvider.make.pipe(
836+
Effect.provideService(ForgejoCli.ForgejoCli, cli),
837+
);
722838
const service = yield* makeService({
723839
settings: {
724840
...DEFAULT_SERVER_SETTINGS,
725841
sourceControlHostOverrides: {
726-
"ssh.example.test:2222": {
727-
kind: "forgejo",
728-
baseUrl: "http://web.example.test:3000/forgejo",
729-
},
842+
"ssh.example.test:2222": { kind: "forgejo", baseUrl },
843+
"ssh.example.test": { kind: "forgejo", baseUrl },
730844
},
731845
},
732846
projects: [
733847
project({
734848
id: "forge",
735-
title: "SSH forge",
849+
title: "mapped forge",
736850
workspaceRoot: "/forge",
737-
repository: "team/repo",
851+
repository,
738852
host: "ssh.example.test",
739853
provider: "forgejo",
740-
remoteUrl: "ssh://[email protected]:2222/team/repo.git",
741-
}),
742-
],
743-
providers: [
744-
fakeProvider(SourceControlProviderKind.make("forgejo"), {
745-
getChangeRequestSummary: (input) => {
746-
assert.strictEqual(input.host, "web.example.test:3000");
747-
return Effect.succeed(changeRequest(42, "2026-07-02T00:00:00Z"));
748-
},
854+
remoteUrl,
749855
}),
750856
],
857+
providers: [provider],
751858
});
752-
yield* service.summary(
859+
if (!loginPresent) {
860+
const result = yield* service
861+
.summary(
862+
{
863+
projectId: "forge" as ProjectId,
864+
host: "web.example.test:3000",
865+
repository: "team/repo",
866+
number: 42,
867+
},
868+
{ recoverTransientFailure: false },
869+
)
870+
.pipe(Effect.result);
871+
assert.strictEqual(result._tag, "Failure");
872+
assert.deepStrictEqual(urls, []);
873+
return;
874+
}
875+
const summary = yield* service.summary(
753876
{
754877
projectId: "forge" as ProjectId,
755878
host: "web.example.test:3000",
@@ -758,6 +881,14 @@ it.effect(
758881
},
759882
{ recoverTransientFailure: false },
760883
);
884+
assert.strictEqual(summary.title, "Mounted Forgejo PR");
885+
const listed = yield* service.list({ state: "open", involvement: "all", limit: 10 });
886+
assert.deepStrictEqual(listed.errors, []);
887+
assert.deepStrictEqual(urls, [
888+
`${baseUrl}/api/v1/repos/team/repo/pulls/42`,
889+
`${baseUrl}/api/v1/user`,
890+
`${baseUrl}/api/v1/repos/team/repo/pulls?state=open&sort=recentupdate&limit=50&page=1`,
891+
]);
761892
}),
762893
);
763894

‎apps/server/src/pullRequest/PullRequestService.ts‎

Lines changed: 50 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import {
9494
type ProviderListCursor,
9595
type ProviderChangeRequestWatchFingerprint,
9696
type PullRequestProviderApi,
97+
PullRequestBaseUrl,
9798
PullRequestProviderError,
9899
} from "@t3tools/source-control-core/server/PullRequestProvider";
99100
import * as PullRequestReadCache from "./PullRequestReadCache.ts";
@@ -306,6 +307,7 @@ export interface SupportedProject {
306307
readonly cursorKey: string;
307308
readonly project: OrchestrationProjectShell;
308309
readonly api: PullRequestProviderApi;
310+
readonly baseUrl?: string;
309311
readonly repository: string;
310312
readonly host: string;
311313
/**
@@ -427,7 +429,7 @@ function withRateLimitBackoff(
427429
api: PullRequestProviderApi,
428430
host: string,
429431
limits: SourceControlRateLimit.SourceControlRateLimit["Service"],
430-
options?: { readonly viewerAllowsPause: boolean },
432+
options?: { readonly viewerAllowsPause?: boolean; readonly baseUrl?: string },
431433
): PullRequestProviderApi {
432434
const key = { provider: api.kind, host };
433435
const protect = <A>(
@@ -450,6 +452,7 @@ function withRateLimitBackoff(
450452
Effect.flatMap((lease) =>
451453
effect.pipe(
452454
Effect.provideService(SourceControlRateLimit.Interactive, allowPaused),
455+
Effect.provideService(PullRequestBaseUrl, options?.baseUrl),
453456
Effect.tap(() => limits.recordSuccess({ ...key, lease })),
454457
Effect.tapError((error) =>
455458
error.reason === "rate-limited"
@@ -739,9 +742,11 @@ export const make = Effect.gen(function* () {
739742
let kind = identity?.provider as SourceControlProviderKind | undefined;
740743
if (!identity) continue;
741744
let refinedProvider: SourceControlProviderInfo | null | undefined;
742-
const overridden =
743-
sourceControlHostOverrideForRemoteUrl(identity.locator.remoteUrl, overrides) !==
744-
undefined;
745+
const override = sourceControlHostOverrideForRemoteUrl(
746+
identity.locator.remoteUrl,
747+
overrides,
748+
);
749+
const overridden = override !== undefined;
745750
if (overridden) {
746751
refinedProvider = detectSourceControlProviderFromRemoteUrl(
747752
identity.locator.remoteUrl,
@@ -757,8 +762,15 @@ export const make = Effect.gen(function* () {
757762
kind = refinedProvider?.kind ?? kind;
758763
}
759764
if (kind === undefined) continue;
760-
const repository = sourceControlRepositorySelector({ ...identity, provider: kind });
765+
let repository = sourceControlRepositorySelector({ ...identity, provider: kind });
761766
if (repository === null) continue;
767+
const baseUrl = override?.baseUrl === undefined ? undefined : refinedProvider?.baseUrl;
768+
if (baseUrl && /^https?:/i.test(identity.locator.remoteUrl)) {
769+
const mount = new URL(baseUrl).pathname.replace(/^\/+|\/+$/g, "");
770+
if (mount && repository.startsWith(`${mount}/`)) {
771+
repository = repository.slice(mount.length + 1);
772+
}
773+
}
762774
const host =
763775
refinedProvider && (overridden || refinedProvider.kind === "forgejo")
764776
? new URL(refinedProvider.baseUrl).host.toLowerCase()
@@ -786,7 +798,13 @@ export const make = Effect.gen(function* () {
786798
supported.push({
787799
cursorKey: key,
788800
project,
789-
api: withRateLimitBackoff(api, host, rateLimits),
801+
api: withRateLimitBackoff(
802+
api,
803+
host,
804+
rateLimits,
805+
baseUrl === undefined ? undefined : { baseUrl },
806+
),
807+
...(baseUrl === undefined ? {} : { baseUrl }),
790808
repository,
791809
host,
792810
remote: repositoryKey ?? normalizeGitRemoteUrl(`https://${host}/${repository}`),
@@ -865,6 +883,13 @@ export const make = Effect.gen(function* () {
865883
settings.sourceControlHostOverrides,
866884
)
867885
: null;
886+
const baseUrl =
887+
sourceControlHostOverrideForRemoteUrl(
888+
remoteUrl,
889+
settings.sourceControlHostOverrides,
890+
)?.baseUrl === undefined
891+
? undefined
892+
: provider?.baseUrl;
868893
const api = provider === null ? null : registry.get(provider.kind);
869894
if (api !== null && provider?.kind !== "azure-devops") {
870895
const selected = yield* projects.getShell(ref.projectId).pipe(
@@ -881,7 +906,13 @@ export const make = Effect.gen(function* () {
881906
return {
882907
cursorKey: listCursorKey(host, repository),
883908
project: selected.value,
884-
api: withRateLimitBackoff(api, host, rateLimits),
909+
api: withRateLimitBackoff(
910+
api,
911+
host,
912+
rateLimits,
913+
baseUrl === undefined ? undefined : { baseUrl },
914+
),
915+
...(baseUrl === undefined ? {} : { baseUrl }),
885916
repository,
886917
host,
887918
remote: normalizeGitRemoteUrl(`https://${host}/${repository}`),
@@ -970,16 +1001,20 @@ export const make = Effect.gen(function* () {
9701001
const viewersByHost = new Map<string, { readonly at: number; readonly result: ResolvedViewer }>();
9711002
const viewerFlights = yield* Cache.makeWith(
9721003
(key: string): Effect.Effect<ResolvedViewer> => {
973-
const [host, kind, roots] = JSON.parse(key) as [
1004+
const [host, kind, roots, baseUrl] = JSON.parse(key) as [
9741005
string,
9751006
SourceControlProviderKind,
9761007
ReadonlyArray<string>,
1008+
string | undefined,
9771009
];
9781010
const registered = registry.get(kind);
9791011
if (registered === null) {
9801012
return Effect.die(new Error(`Missing pull request provider: ${kind}`));
9811013
}
982-
const api = withRateLimitBackoff(registered, host, rateLimits, { viewerAllowsPause: true });
1014+
const api = withRateLimitBackoff(registered, host, rateLimits, {
1015+
viewerAllowsPause: true,
1016+
...(baseUrl ? { baseUrl } : {}),
1017+
});
9831018
return Effect.firstSuccessOf(roots.map((cwd) => api.getViewer({ cwd, host }))).pipe(
9841019
Effect.map((viewer) => ({
9851020
host,
@@ -1028,7 +1063,12 @@ export const make = Effect.gen(function* () {
10281063
// unreadable worktree would otherwise report the whole host as signed out.
10291064
const roots =
10301065
viewerRoots.get(host) ?? forHost.map(({ project }) => project.workspaceRoot);
1031-
const key = JSON.stringify([host, api.kind, [...new Set(roots)].sort()]);
1066+
const key = JSON.stringify([
1067+
host,
1068+
api.kind,
1069+
[...new Set(roots)].sort(),
1070+
forHost[0]!.baseUrl,
1071+
]);
10321072
if (options?.allowPaused === true) return Cache.get(viewerFlights, key);
10331073
// The pause is checked here rather than inside the lookup, so that it holds back the
10341074
// callers nobody is waiting on without splitting the flight they share with a press.

‎packages/source-control-core/src/server/PullRequestProvider.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as Context from "effect/Context";
12
import * as Effect from "effect/Effect";
23
import * as Schema from "effect/Schema";
34
import type {
@@ -39,6 +40,12 @@ import type {
3940
} from "@t3tools/contracts";
4041
import { SourceControlProviderKind as SourceControlProviderKindSchema } from "@t3tools/contracts";
4142

43+
/** Request-scoped endpoint: a host alone cannot distinguish installations mounted at different paths. */
44+
export const PullRequestBaseUrl = Context.Reference<string | undefined>(
45+
"@t3tools/source-control-core/server/PullRequestBaseUrl",
46+
{ defaultValue: () => undefined },
47+
);
48+
4249
export class PullRequestProviderError extends Schema.TaggedError<PullRequestProviderError>()(
4350
"PullRequestProviderError",
4451
{

0 commit comments

Comments
 (0)