| author | |
| committer | |
| log | f5fce74169a5629f701aeb8c4535cab6f7bd3c92 |
| tree | 50d952eb5180b85771fb1e6d996d92436df64677 |
| parent | 9c7622dac3d1a385351e6c74354a9e6b9c2037d5 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 229 insertions(+), 7 deletions(-)
apps/server/src/pullRequest/PullRequestService.test.ts+110| ... | ... | @@ -10,6 +10,7 @@ import type { |
| 10 | 10 | } from "@t3tools/contracts"; |
| 11 | 11 | |
| 12 | 12 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 13 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; | |
| 13 | 14 | import { |
| 14 | 15 | PullRequestProviderError, |
| 15 | 16 | type ProviderChangeRequest, |
| ... | ... | @@ -144,11 +145,16 @@ function fakeProvider( |
| 144 | 145 | function makeService(input: { |
| 145 | 146 | readonly projects: ReadonlyArray<OrchestrationProjectShell>; |
| 146 | 147 | readonly providers: ReadonlyArray<PullRequestProviderApi>; |
| 148 | readonly resolveHandle?: SourceControlProviderRegistry.SourceControlProviderRegistry["Service"]["resolveHandle"]; | |
| 147 | 149 | }) { |
| 148 | 150 | return PullRequestService.make.pipe( |
| 149 | 151 | Effect.provide( |
| 150 | 152 | Layer.mergeAll( |
| 151 | 153 | Layer.succeed(PullRequestProviderRegistry, fromProviders(input.providers)), |
| 154 | Layer.mock(SourceControlProviderRegistry.SourceControlProviderRegistry)({ | |
| 155 | resolveHandle: | |
| 156 | input.resolveHandle ?? (() => Effect.die("Unexpected provider refinement")), | |
| 157 | }), | |
| 152 | 158 | Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ |
| 153 | 159 | getShellSnapshot: () => |
| 154 | 160 | Effect.succeed({ |
| ... | ... | @@ -163,6 +169,110 @@ function makeService(input: { |
| 163 | 169 | ); |
| 164 | 170 | } |
| 165 | 171 | |
| 172 | it.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 | ||
| 207 | it.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 | ||
| 244 | it.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 | ||
| 166 | 276 | /** A row as a host that reads several repositories at once hands it over. */ |
| 167 | 277 | function batchedChangeRequest(number: number, repository: string, updatedAt: string) { |
| 168 | 278 | return { ...changeRequest(number, updatedAt), repository }; |
apps/server/src/pullRequest/PullRequestService.ts+76-6| ... | ... | @@ -36,10 +36,13 @@ import { |
| 36 | 36 | type PullRequestSubmitReviewInput, |
| 37 | 37 | type PullRequestThreadReplyInput, |
| 38 | 38 | type PullRequestThreadResolutionInput, |
| 39 | type SourceControlProviderInfo, | |
| 39 | 40 | type SourceControlProviderKind, |
| 40 | 41 | } from "@t3tools/contracts"; |
| 42 | import { detectSourceControlProviderFromRemoteUrl } from "@t3tools/shared/sourceControl"; | |
| 41 | 43 | |
| 42 | 44 | import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; |
| 45 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; | |
| 43 | 46 | import { |
| 44 | 47 | type ProviderChangeRequest, |
| 45 | 48 | type ProviderListCursor, |
| ... | ... | @@ -360,6 +363,65 @@ function repositoryIdentityOf(project: OrchestrationProjectShell): string | null |
| 360 | 363 | export const make = Effect.gen(function* () { |
| 361 | 364 | const registry = yield* PullRequestProviderRegistry; |
| 362 | 365 | 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 | }; | |
| 363 | 425 | |
| 364 | 426 | const listWorkspaceProjects = ( |
| 365 | 427 | filter: Pick<PullRequestListInput, "projectId" | "host">, |
| ... | ... | @@ -373,7 +435,12 @@ export const make = Effect.gen(function* () { |
| 373 | 435 | cause: error, |
| 374 | 436 | }), |
| 375 | 437 | ), |
| 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 }) => { | |
| 377 | 444 | const supported: SupportedProject[] = []; |
| 378 | 445 | const unimplemented = new Map< |
| 379 | 446 | string, |
| ... | ... | @@ -383,15 +450,18 @@ export const make = Effect.gen(function* () { |
| 383 | 450 | const seen = new Set<string>(); |
| 384 | 451 | for (const project of snapshot.projects) { |
| 385 | 452 | 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; | |
| 389 | 455 | const repository = repositoryIdentityOf(project); |
| 390 | if (kind === undefined || repository === null) continue; | |
| 456 | if (!identity || kind === undefined || repository === null) continue; | |
| 391 | 457 | // Worktrees of one repository are separate projects; reading the remote once keeps |
| 392 | 458 | // the page from repeating every change request per local checkout. The host is part |
| 393 | 459 | // 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); | |
| 395 | 465 | if (filter.host !== undefined && host !== filter.host.toLowerCase()) continue; |
| 396 | 466 | const api = registry.get(kind); |
| 397 | 467 | // 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( |
| 435 | 435 | const PullRequestServiceLive = PullRequestService.layer.pipe( |
| 436 | 436 | // One registry entry per supported host; the service only knows the registry. |
| 437 | 437 | Layer.provide(PullRequestProviderRegistry.layer), |
| 438 | Layer.provide(SourceControlProviderRegistryLayerLive), | |
| 438 | 439 | Layer.provide(VcsProcess.layer), |
| 439 | 440 | ); |
| 440 | 441 |
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+32| ... | ... | @@ -203,6 +203,38 @@ self-hosted.example.test |
| 203 | 203 | }), |
| 204 | 204 | ); |
| 205 | 205 | |
| 206 | it.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 | ||
| 206 | 238 | it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports", () => |
| 207 | 239 | Effect.gen(function* () { |
| 208 | 240 | const registry = yield* makeRegistry({ |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+10-1| ... | ... | @@ -50,6 +50,7 @@ export class SourceControlProviderRegistry extends Context.Service< |
| 50 | 50 | >; |
| 51 | 51 | readonly resolveHandle: (input: { |
| 52 | 52 | readonly cwd: string; |
| 53 | readonly context?: SourceControlProvider.SourceControlProviderContext; | |
| 53 | 54 | }) => Effect.Effect<SourceControlProviderHandle, SourceControlProviderError>; |
| 54 | 55 | readonly resolve: (input: { |
| 55 | 56 | readonly cwd: string; |
| ... | ... | @@ -254,7 +255,15 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit |
| 254 | 255 | }); |
| 255 | 256 | |
| 256 | 257 | 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( | |
| 258 | 267 | Effect.map((context) => { |
| 259 | 268 | const kind = context?.provider.kind ?? "unknown"; |
| 260 | 269 | const provider = providers.get(kind) ?? unsupportedProvider(kind); |