authorgravatar for 46866023+GuilhermeVieiraDev@users.noreply.github.comGuilherme Vieira <46866023+GuilhermeVieiraDev@users.noreply.github.com> 2026-08-11 12:11:19+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-11 13:11:19+02:00
logf5fce74169a5629f701aeb8c4535cab6f7bd3c92
tree50d952eb5180b85771fb1e6d996d92436df64677
parent9c7622dac3d1a385351e6c74354a9e6b9c2037d5
signature Signed by PGP key B5690EEEBB952194

fix(pull-requests): route self-hosted GitLab remotes (#6061)


5 files changed, 229 insertions(+), 7 deletions(-)

apps/server/src/pullRequest/PullRequestService.test.ts+110
......@@ -10,6 +10,7 @@ import type {
1010} from "@t3tools/contracts";
1111
1212import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
13import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
1314import {
1415 PullRequestProviderError,
1516 type ProviderChangeRequest,
......@@ -144,11 +145,16 @@ function fakeProvider(
144145function makeService(input: {
145146 readonly projects: ReadonlyArray<OrchestrationProjectShell>;
146147 readonly providers: ReadonlyArray<PullRequestProviderApi>;
148 readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"];
147149}) {
148150 return PullRequestService.make.pipe(
149151 Effect.provide(
150152 Layer.mergeAll(
151153 Layer.succeed(PullRequestProviderRegistry, fromProviders(input.providers)),
154 Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({
155 resolveHandle:
156 input.resolveHandle ?? (() => Effect.die("Unexpected provider refinement")),
157 }),
152158 Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({
153159 getShellSnapshot: () =>
154160 Effect.succeed({
......@@ -163,6 +169,110 @@ function makeService(input: {
163169 );
164170}
165171
172it.effect("refines unknown self-hosted GitLab projects before listing merge requests", () =>
173 Effect.gen(function* () {
174 let refinementCalls = 0;
175 const selfHosted = project({
176 id: "p1",
177 title: "self-hosted",
178 workspaceRoot: "/gitlab",
179 repository: "group/project",
180 provider: "unknown",
181 host: "code.example.test",
182 });
183 const service = yield* makeService({
184 projects: [
185 selfHosted,
186 { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/gitlab-worktree" },
187 ],
188 providers: [fakeProvider("gitlab")],
189 resolveHandle: ({ context }) => {
190 refinementCalls += 1;
191 assert.strictEqual(context?.remoteUrl, "https://code.example.test/group/project.git");
192 return Effect.succeed({
193 context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } },
194 provider: undefined as never,
195 });
196 },
197 });
198
199 const result = yield* service.list({ state: "open" });
200
201 assert.strictEqual(refinementCalls, 1);
202 assert.strictEqual(result.providers[0]?.host, "code.example.test");
203 assert.strictEqual(result.providers[0]?.kind, "gitlab");
204 }),
205);
206
207it.effect("derives a legacy repository host after refining its provider", () =>
208 Effect.gen(function* () {
209 const current = project({
210 id: "p1",
211 title: "legacy self-hosted",
212 workspaceRoot: "/gitlab",
213 repository: "group/project",
214 provider: "unknown",
215 host: "code.example.test",
216 });
217 const identity = current.repositoryIdentity!;
218 // Persisted identities from before canonicalKey existed are still accepted at runtime.
219 const legacy = {
220 ...current,
221 repositoryIdentity: {
222 locator: identity.locator,
223 provider: identity.provider,
224 displayName: identity.displayName,
225 },
226 } as unknown as OrchestrationProjectShell;
227 const service = yield* makeService({
228 projects: [legacy],
229 providers: [fakeProvider("gitlab")],
230 resolveHandle: ({ context }) =>
231 Effect.succeed({
232 context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } },
233 provider: undefined as never,
234 }),
235 });
236
237 const result = yield* service.list({ state: "open", host: "gitlab" });
238
239 assert.strictEqual(result.providers[0]?.host, "gitlab");
240 assert.strictEqual(result.providers[0]?.kind, "gitlab");
241 }),
242);
243
244it.effect("tries another checkout when provider refinement remains unknown", () =>
245 Effect.gen(function* () {
246 const asked: string[] = [];
247 const selfHosted = project({
248 id: "p1",
249 title: "self-hosted",
250 workspaceRoot: "/gone",
251 repository: "group/project",
252 provider: "unknown",
253 host: "code.example.test",
254 });
255 const service = yield* makeService({
256 projects: [selfHosted, { ...selfHosted, id: "p2" as ProjectId, workspaceRoot: "/healthy" }],
257 providers: [fakeProvider("gitlab")],
258 resolveHandle: ({ cwd, context }) => {
259 asked.push(cwd);
260 return cwd === "/gone"
261 ? Effect.succeed({ context: context!, provider: undefined as never })
262 : Effect.succeed({
263 context: { ...context!, provider: { ...context!.provider, kind: "gitlab" } },
264 provider: undefined as never,
265 });
266 },
267 });
268
269 const result = yield* service.list({ state: "open" });
270
271 assert.deepStrictEqual(asked, ["/gone", "/healthy"]);
272 assert.strictEqual(result.providers[0]?.kind, "gitlab");
273 }),
274);
275
166276/** A row as a host that reads several repositories at once hands it over. */
167277function batchedChangeRequest(number: number, repository: string, updatedAt: string) {
168278 return { ...changeRequest(number, updatedAt), repository };
apps/server/src/pullRequest/PullRequestService.ts+76-6
......@@ -36,10 +36,13 @@ import {
3636 type PullRequestSubmitReviewInput,
3737 type PullRequestThreadReplyInput,
3838 type PullRequestThreadResolutionInput,
39 type SourceControlProviderInfo,
3940 type SourceControlProviderKind,
4041} from "@t3tools/contracts";
42import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl";
4143
4244import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts";
45import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
4346import {
4447 type ProviderChangeRequest,
4548 type ProviderListCursor,
......@@ -360,6 +363,65 @@ function repositoryIdentityOf(project: OrchestrationProjectShell): string | null
360363export const make = Effect.gen(function* () {
361364 const registry = yield* PullRequestProviderRegistry;
362365 const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
366 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
367
368 const refineUnknownProjectKinds = (
369 projects: ReadonlyArray<OrchestrationProjectShell>,
370 filter: Pick<PullRequestListInput, "projectId" | "host">,
371 ) => {
372 type RefinementCandidate = {
373 readonly project: OrchestrationProjectShell;
374 readonly provider: SourceControlProviderInfo;
375 readonly remoteName: string;
376 readonly remoteUrl: string;
377 };
378 const refinements = new Map<string, RefinementCandidate[]>();
379 for (const project of projects) {
380 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;
381 const identity = project.repositoryIdentity;
382 if (identity?.provider !== "unknown" || repositoryIdentityOf(project) === null) continue;
383 const host = pullRequestHostOf(identity, "unknown");
384 // A legacy identity has no canonical host until its provider is refined, so it must reach
385 // the refinement before a host filter can decide whether it belongs in the result.
386 if (filter.host !== undefined && host !== "unknown" && host !== filter.host.toLowerCase()) {
387 continue;
388 }
389 const { remoteName, remoteUrl } = identity.locator;
390 const provider = detectSourceControlProviderFromRemoteUrl(remoteUrl);
391 if (provider !== null) {
392 const candidates = refinements.get(provider.baseUrl);
393 const candidate = { project, provider, remoteName, remoteUrl };
394 if (candidates === undefined) refinements.set(provider.baseUrl, [candidate]);
395 else candidates.push(candidate);
396 }
397 }
398
399 return Effect.forEach(
400 refinements,
401 ([baseUrl, candidates]) =>
402 Effect.firstSuccessOf(
403 candidates.map(({ project, provider, remoteName, remoteUrl }) =>
404 Effect.suspend(() =>
405 sourceControlProviders.resolveHandle({
406 cwd: project.workspaceRoot,
407 context: { provider, remoteName, remoteUrl },
408 }),
409 ).pipe(
410 Effect.flatMap((handle) => {
411 const kind = handle.context?.provider.kind;
412 return kind === undefined || kind === "unknown"
413 ? Effect.fail(undefined)
414 : Effect.succeed(kind);
415 }),
416 ),
417 ),
418 ).pipe(
419 Effect.map((kind) => [baseUrl, kind] as const),
420 Effect.orElseSucceed(() => [baseUrl, "unknown"] as const),
421 ),
422 { concurrency: REPOSITORY_CONCURRENCY },
423 ).pipe(Effect.map((resolved) => new Map(resolved)));
424 };
363425
364426 const listWorkspaceProjects = (
365427 filter: Pick<PullRequestListInput, "projectId" | "host">,
......@@ -373,7 +435,12 @@ export const make = Effect.gen(function* () {
373435 cause: error,
374436 }),
375437 ),
376 Effect.map((snapshot) => {
438 Effect.flatMap((snapshot) =>
439 refineUnknownProjectKinds(snapshot.projects, filter).pipe(
440 Effect.map((refinedKinds) => ({ refinedKinds, snapshot })),
441 ),
442 ),
443 Effect.map(({ refinedKinds, snapshot }) => {
377444 const supported: SupportedProject[] = [];
378445 const unimplemented = new Map<
379446 string,
......@@ -383,15 +450,18 @@ export const make = Effect.gen(function* () {
383450 const seen = new Set<string>();
384451 for (const project of snapshot.projects) {
385452 if (filter.projectId !== undefined && project.id !== filter.projectId) continue;
386 const kind = project.repositoryIdentity?.provider as
387 | SourceControlProviderKind
388 | undefined;
453 const identity = project.repositoryIdentity;
454 let kind = identity?.provider as SourceControlProviderKind | undefined;
389455 const repository = repositoryIdentityOf(project);
390 if (kind === undefined || repository === null) continue;
456 if (!identity || kind === undefined || repository === null) continue;
391457 // Worktrees of one repository are separate projects; reading the remote once keeps
392458 // the page from repeating every change request per local checkout. The host is part
393459 // of the key, so the same `owner/repo` on two hosts stays two repositories.
394 const host = pullRequestHostOf(project.repositoryIdentity, kind);
460 if (kind === "unknown") {
461 const provider = detectSourceControlProviderFromRemoteUrl(identity.locator.remoteUrl);
462 kind = provider === null ? kind : (refinedKinds.get(provider.baseUrl) ?? kind);
463 }
464 const host = pullRequestHostOf(identity, kind);
395465 if (filter.host !== undefined && host !== filter.host.toLowerCase()) continue;
396466 const api = registry.get(kind);
397467 // Recorded before the de-duplication below, so the viewer lookup keeps the alternates
apps/server/src/server.ts+1
......@@ -435,6 +435,7 @@ const commandReadinessLayer = HttpRouter.middleware(
435435const PullRequestServiceLive = PullRequestService.layer.pipe(
436436 // One registry entry per supported host; the service only knows the registry.
437437 Layer.provide(PullRequestProviderRegistry.layer),
438 Layer.provide(SourceControlProviderRegistryLayerLive),
438439 Layer.provide(VcsProcess.layer),
439440);
440441
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+32
......@@ -203,6 +203,38 @@ self-hosted.example.test
203203 }),
204204);
205205
206it.effect("refines the caller-selected remote instead of choosing another configured remote", () =>
207 Effect.gen(function* () {
208 const registry = yield* makeRegistry({
209 remotes: [{ name: "origin", url: "git@github.com:fork/project.git" }],
210 process: {
211 run: () =>
212 Effect.succeed(
213 processOutput(`self-hosted.example.test
214 ✓ Logged in to self-hosted.example.test as gitlab-user
215`),
216 ),
217 },
218 });
219
220 const handle = yield* registry.resolveHandle({
221 cwd: "/repo",
222 context: {
223 provider: {
224 kind: "unknown",
225 name: "self-hosted.example.test",
226 baseUrl: "https://self-hosted.example.test",
227 },
228 remoteName: "upstream",
229 remoteUrl: "https://self-hosted.example.test/group/project.git",
230 },
231 });
232
233 assert.strictEqual(handle.context?.provider.kind, "gitlab");
234 assert.strictEqual(handle.context?.remoteName, "upstream");
235 }),
236);
237
206238it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports", () =>
207239 Effect.gen(function* () {
208240 const registry = yield* makeRegistry({
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+10-1
......@@ -50,6 +50,7 @@ export class SourceControlProviderRegistry extends Context.Service<
5050 >;
5151 readonly resolveHandle: (input: {
5252 readonly cwd: string;
53 readonly context?: SourceControlProvider.SourceControlProviderContext;
5354 }) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>;
5455 readonly resolve: (input: {
5556 readonly cwd: string;
......@@ -254,7 +255,15 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
254255 });
255256
256257 const resolveHandle: SourceControlProviderRegistry["Service"]["resolveHandle"] = (input) =>
257 Cache.get(providerContextCache, input.cwd).pipe(
258 (input.context === undefined
259 ? Cache.get(providerContextCache, input.cwd)
260 : refineUnknownRemoteProvider({
261 specs: discoverySpecs,
262 process,
263 cwd: input.cwd,
264 context: input.context,
265 })
266 ).pipe(
258267 Effect.map((context) => {
259268 const kind = context?.provider.kind ?? "unknown";
260269 const provider = providers.get(kind) ?? unsupportedProvider(kind);