authorgravatar for 46866023+GuilhermeVieiraDev@users.noreply.github.comGuilherme Vieira <46866023+GuilhermeVieiraDev@users.noreply.github.com> 2026-06-04 23:13:04+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-04 15:13:04-07:00
log49c1b64687f8acdbe145f760988314e397170da6
treeab35813e64be0b15f1f1f72abcd14f55da376a1c
parentdfdb5a46db30fb46db5c58d5fe047b32483e2ad5
signature Signed by PGP key B5690EEEBB952194

fix(source-control): handle self-hosted GitLab, multi-account GitHub auth & azure devops web url (#2480)

Co-authored-by: Julius Marminge <julius@mac.lan>

14 files changed, 757 insertions(+), 48 deletions(-)

apps/server/src/sourceControl/AzureDevOpsCli.test.ts+38
......@@ -64,6 +64,7 @@ describe("AzureDevOpsCli.layer", () => {
6464
6565 assert.strictEqual(result.number, 42);
6666 assert.strictEqual(result.title, "Add Azure provider");
67 assert.strictEqual(result.url, "https://dev.azure.com/acme/project/_git/repo/pullrequest/42");
6768 assert.strictEqual(result.baseRefName, "main");
6869 assert.strictEqual(result.headRefName, "feature/source-control");
6970 assert.strictEqual(result.state, "open");
......@@ -89,6 +90,43 @@ describe("AzureDevOpsCli.layer", () => {
8990 }).pipe(Effect.provide(layer)),
9091 );
9192
93 it.effect("builds a web URL when Azure returns only the pull request REST URL", () =>
94 Effect.gen(function* () {
95 mockRun.mockReturnValueOnce(
96 Effect.succeed(
97 processOutput(
98 // @effect-diagnostics-next-line preferSchemaOverJson:off
99 JSON.stringify({
100 pullRequestId: 863,
101 title: "Fix Azure link",
102 url: "https://dev.azure.com/saplyai/a8fe4088-ad76-4eda-aaaa-e3329713a097/_apis/git/repositories/16108a25-93a3-4eff-b77d-85e894ee0fe4/pullRequests/863",
103 repository: {
104 name: "CV-engine",
105 project: {
106 name: "CV-engine",
107 },
108 },
109 sourceRefName: "refs/heads/feature/azure-pr-link",
110 targetRefName: "refs/heads/main",
111 status: "active",
112 }),
113 ),
114 ),
115 );
116
117 const az = yield* AzureDevOpsCli.AzureDevOpsCli;
118 const result = yield* az.getPullRequest({
119 cwd: "/repo",
120 reference: "863",
121 });
122
123 assert.strictEqual(
124 result.url,
125 "https://dev.azure.com/saplyai/CV-engine/_git/CV-engine/pullrequest/863",
126 );
127 }).pipe(Effect.provide(layer)),
128 );
129
92130 it.effect("lists pull requests with Azure status and source branch arguments", () =>
93131 Effect.gen(function* () {
94132 mockRun.mockReturnValueOnce(
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+185-3
......@@ -7,12 +7,19 @@ import { ChildProcessSpawner } from "effect/unstable/process";
77
88import * as VcsProcess from "../vcs/VcsProcess.ts";
99import * as GitHubCli from "./GitHubCli.ts";
10import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
1011import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1112
12const processResult = (stdout: string): VcsProcess.VcsProcessOutput => ({
13 exitCode: ChildProcessSpawner.ExitCode(0),
13const processResult = (
14 stdout: string,
15 options?: {
16 readonly stderr?: string;
17 readonly exitCode?: ChildProcessSpawner.ExitCode;
18 },
19): VcsProcess.VcsProcessOutput => ({
20 exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0),
1421 stdout,
15 stderr: "",
22 stderr: options?.stderr ?? "",
1623 stdoutTruncated: false,
1724 stderrTruncated: false,
1825});
......@@ -157,3 +164,178 @@ it.effect("creates GitHub PRs through provider-neutral input names", () =>
157164 });
158165 }),
159166);
167
168it("accepts active authenticated GitHub accounts when another account fails", () => {
169 const auth = GitHubSourceControlProvider.discovery.parseAuth(
170 processResult(
171 JSON.stringify({
172 hosts: {
173 "github.com": [
174 {
175 state: "success",
176 active: true,
177 host: "github.com",
178 login: "active-user",
179 tokenSource: "keyring",
180 gitProtocol: "ssh",
181 },
182 {
183 state: "error",
184 active: false,
185 host: "github.com",
186 login: "stale-user",
187 tokenSource: "keyring",
188 gitProtocol: "ssh",
189 error: "The token in keyring is invalid.",
190 },
191 ],
192 },
193 }),
194 ),
195 );
196
197 assert.deepStrictEqual(
198 {
199 status: auth.status,
200 account: auth.account,
201 host: auth.host,
202 },
203 {
204 status: "authenticated",
205 account: Option.some("active-user"),
206 host: Option.some("github.com"),
207 },
208 );
209});
210
211it("parses GitHub auth JSON from stdout when stderr has warnings", () => {
212 const auth = GitHubSourceControlProvider.discovery.parseAuth(
213 processResult(
214 JSON.stringify({
215 hosts: {
216 "github.com": [
217 {
218 state: "success",
219 active: true,
220 host: "github.com",
221 login: "active-user",
222 tokenSource: "keyring",
223 gitProtocol: "ssh",
224 },
225 ],
226 },
227 }),
228 { stderr: "warning: ignored diagnostic from gh\n" },
229 ),
230 );
231
232 assert.deepStrictEqual(
233 {
234 status: auth.status,
235 account: auth.account,
236 host: auth.host,
237 },
238 {
239 status: "authenticated",
240 account: Option.some("active-user"),
241 host: Option.some("github.com"),
242 },
243 );
244});
245
246it("parses GitHub auth status accounts by host and active state", () => {
247 assert.deepStrictEqual(
248 parseGitHubAuthStatus(
249 JSON.stringify({
250 hosts: {
251 "github.com": [
252 {
253 state: "success",
254 active: true,
255 host: "github.com",
256 login: "active-user",
257 tokenSource: "keyring",
258 gitProtocol: "ssh",
259 },
260 {
261 state: "error",
262 active: false,
263 host: "github.com",
264 login: "stale-user",
265 tokenSource: "keyring",
266 gitProtocol: "ssh",
267 },
268 ],
269 "github.example.test": [
270 {
271 state: "success",
272 active: false,
273 host: "github.example.test",
274 login: "enterprise-user",
275 tokenSource: "keyring",
276 gitProtocol: "ssh",
277 },
278 ],
279 },
280 }),
281 ).accounts,
282 [
283 {
284 host: "github.com",
285 account: "active-user",
286 authenticated: true,
287 active: true,
288 error: null,
289 },
290 {
291 host: "github.com",
292 account: "stale-user",
293 authenticated: false,
294 active: false,
295 error: null,
296 },
297 {
298 host: "github.example.test",
299 account: "enterprise-user",
300 authenticated: true,
301 active: false,
302 error: null,
303 },
304 ],
305 );
306});
307
308it("reports unauthenticated when GitHub JSON has accounts but none are valid", () => {
309 const auth = GitHubSourceControlProvider.discovery.parseAuth(
310 processResult(
311 JSON.stringify({
312 hosts: {
313 "github.com": [
314 {
315 state: "error",
316 active: true,
317 host: "github.com",
318 login: "stale-user",
319 tokenSource: "keyring",
320 gitProtocol: "ssh",
321 error: "The token in keyring is invalid.",
322 },
323 ],
324 },
325 }),
326 ),
327 );
328
329 assert.deepStrictEqual(
330 {
331 status: auth.status,
332 host: auth.host,
333 detail: auth.detail,
334 },
335 {
336 status: "unauthenticated",
337 host: Option.some("github.com"),
338 detail: Option.some("The token in keyring is invalid."),
339 },
340 );
341});
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+24-10
......@@ -10,6 +10,7 @@ import {
1010} from "@t3tools/contracts";
1111
1212import * as GitHubCli from "./GitHubCli.ts";
13import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
1314import * as GitHubPullRequests from "./gitHubPullRequests.ts";
1415import * as SourceControlProvider from "./SourceControlProvider.ts";
1516import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
......@@ -51,11 +52,28 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
5152
5253function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {
5354 const output = SourceControlProviderDiscovery.combinedAuthOutput(input);
54 const account = SourceControlProviderDiscovery.matchFirst(output, [
55 /Logged in to .* account\s+([^\s(]+)/iu,
56 /Logged in to .* as\s+([^\s(]+)/iu,
57 ]);
58 const host = SourceControlProviderDiscovery.parseCliHost(output);
55 const authStatus = parseGitHubAuthStatus(input.stdout);
56 const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts);
57 const host = authenticatedAccount?.host;
58
59 if (authenticatedAccount) {
60 return SourceControlProviderDiscovery.providerAuth({
61 status: "authenticated",
62 account: authenticatedAccount.account,
63 host,
64 });
65 }
66
67 const failedAccount = authStatus.accounts.find((entry) => entry.active) ?? authStatus.accounts[0];
68 if (authStatus.parsed) {
69 return SourceControlProviderDiscovery.providerAuth({
70 status: "unauthenticated",
71 host: failedAccount?.host,
72 detail:
73 failedAccount?.error ??
74 "Run `gh auth login` to authenticate GitHub CLI with an active account.",
75 });
76 }
5977
6078 if (input.exitCode !== 0) {
6179 return SourceControlProviderDiscovery.providerAuth({
......@@ -67,10 +85,6 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth
6785 });
6886 }
6987
70 if (account) {
71 return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host });
72 }
73
7488 return SourceControlProviderDiscovery.providerAuth({
7589 status: "unknown",
7690 host,
......@@ -86,7 +100,7 @@ export const discovery = {
86100 label: "GitHub",
87101 executable: "gh",
88102 versionArgs: ["--version"],
89 authArgs: ["auth", "status"],
103 authArgs: ["auth", "status", "--json", "hosts"],
90104 parseAuth: parseGitHubAuth,
91105 installHint:
92106 "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).",
apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts+72
......@@ -2,8 +2,10 @@ import { assert, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
33import * as Layer from "effect/Layer";
44import * as Option from "effect/Option";
5import { ChildProcessSpawner } from "effect/unstable/process";
56
67import * as GitLabCli from "./GitLabCli.ts";
8import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
79import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
810
911function makeProvider(gitlab: Partial<GitLabCli.GitLabCliShape>) {
......@@ -107,3 +109,73 @@ it.effect("creates GitLab MRs through provider-neutral input names", () =>
107109 });
108110 }),
109111);
112
113it("accepts authenticated GitLab hosts when another configured host fails", () => {
114 const auth = GitLabSourceControlProvider.discovery.parseAuth({
115 exitCode: ChildProcessSpawner.ExitCode(1),
116 stdout: `gitlab.com
117 x gitlab.com: API call failed: 401 Unauthorized
118 ! No token found
119self-hosted.example.test
120 ✓ Logged in to self-hosted.example.test as gitlab-user
121 ✓ Token found: ******
122`,
123 stderr: "",
124 });
125
126 assert.deepStrictEqual(
127 {
128 status: auth.status,
129 account: auth.account,
130 host: auth.host,
131 },
132 {
133 status: "authenticated",
134 account: Option.some("gitlab-user"),
135 host: Option.some("self-hosted.example.test"),
136 },
137 );
138});
139
140it("refines unknown GitLab remotes with mixed-case provider hosts", () => {
141 const provider = GitLabSourceControlProvider.discovery.refineUnknownRemote?.({
142 cwd: "/repo",
143 context: {
144 provider: {
145 kind: "unknown",
146 name: "Self-Hosted.Example.Test",
147 baseUrl: "https://Self-Hosted.Example.Test",
148 },
149 remoteName: "origin",
150 remoteUrl: "https://Self-Hosted.Example.Test/group/project.git",
151 },
152 auth: {
153 exitCode: ChildProcessSpawner.ExitCode(0),
154 stdout: `self-hosted.example.test
155 ✓ Logged in to self-hosted.example.test as gitlab-user
156 ✓ Token found: ******
157`,
158 stderr: "",
159 },
160 });
161
162 assert.deepStrictEqual(provider, {
163 kind: "gitlab",
164 name: "GitLab Self-Hosted",
165 baseUrl: "https://Self-Hosted.Example.Test",
166 });
167});
168
169it("parses authenticated GitLab auth status hosts with ports and single-label names", () => {
170 assert.deepStrictEqual(
171 parseGitLabAuthStatusHosts(`localhost:8080
172 ✓ Logged in to localhost:8080 as local-user
173selfhosted
174 ✓ Logged in to selfhosted as single-label-user
175`),
176 [
177 { host: "localhost:8080", account: "local-user" },
178 { host: "selfhosted", account: "single-label-user" },
179 ],
180 );
181});
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+34-10
......@@ -6,6 +6,7 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac
66import * as GitLabCli from "./GitLabCli.ts";
77import * as SourceControlProvider from "./SourceControlProvider.ts";
88import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
9import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
910
1011function providerError(
1112 operation: string,
......@@ -43,12 +44,19 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe
4344
4445function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {
4546 const output = SourceControlProviderDiscovery.combinedAuthOutput(input);
46 const account = SourceControlProviderDiscovery.matchFirst(output, [
47 /Logged in to .* as\s+([^\s(]+)/iu,
48 /Logged in to .* account\s+([^\s(]+)/iu,
49 /account:\s*([^\s(]+)/iu,
50 ]);
51 const host = SourceControlProviderDiscovery.parseCliHost(output);
47 const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output));
48 const account =
49 authenticatedHost?.account ??
50 SourceControlProviderDiscovery.matchFirst(output, [
51 /Logged in to .* as\s+([^\s(]+)/iu,
52 /Logged in to .* account\s+([^\s(]+)/iu,
53 /account:\s*([^\s(]+)/iu,
54 ]);
55 const host = authenticatedHost?.host ?? SourceControlProviderDiscovery.parseCliHost(output);
56
57 if (account) {
58 return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host });
59 }
5260
5361 if (input.exitCode !== 0) {
5462 return SourceControlProviderDiscovery.providerAuth({
......@@ -60,10 +68,6 @@ function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuth
6068 });
6169 }
6270
63 if (account) {
64 return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host });
65 }
66
6771 return SourceControlProviderDiscovery.providerAuth({
6872 status: "unknown",
6973 host,
......@@ -73,6 +77,25 @@ function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuth
7377 });
7478}
7579
80function refineUnknownGitLabRemote(
81 input: SourceControlProviderDiscovery.SourceControlUnknownRemoteRefinementInput,
82) {
83 const host = input.context.provider.name.toLowerCase();
84 const authenticated = parseGitLabAuthStatusHosts(
85 SourceControlProviderDiscovery.combinedAuthOutput(input.auth),
86 ).some((entry) => entry.account !== null && entry.host === host);
87
88 if (!authenticated) {
89 return null;
90 }
91
92 return {
93 kind: "gitlab",
94 name: "GitLab Self-Hosted",
95 baseUrl: input.context.provider.baseUrl,
96 } as const;
97}
98
7699export const discovery = {
77100 type: "cli",
78101 kind: "gitlab",
......@@ -81,6 +104,7 @@ export const discovery = {
81104 versionArgs: ["--version"],
82105 authArgs: ["auth", "status"],
83106 parseAuth: parseGitLabAuth,
107 refineUnknownRemote: refineUnknownGitLabRemote,
84108 installHint:
85109 "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).",
86110} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec;
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+34-14
......@@ -59,15 +59,24 @@ it.effect("reports implemented tools separately from locally available executabl
5959 if (input.command === "gh" && input.args[0] === "--version") {
6060 return Effect.succeed(processOutput("gh version 2.83.0\n"));
6161 }
62 if (input.command === "gh" && input.args.join(" ") === "auth status") {
62 if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {
6363 return Effect.succeed(
64 processOutput(`github.com
65Logged in to github.com account juliusmarminge (keyring)
66- Active account: true
67- Git operations protocol: ssh
68- Token: gho_************************************
69- Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo'
70`),
64 processOutput(
65 JSON.stringify({
66 hosts: {
67 "github.com": [
68 {
69 state: "success",
70 active: true,
71 host: "github.com",
72 login: "juliusmarminge",
73 tokenSource: "keyring",
74 gitProtocol: "ssh",
75 },
76 ],
77 },
78 }),
79 ),
7180 );
7281 }
7382 return Effect.fail(
......@@ -164,13 +173,24 @@ it.effect("probes provider authentication without exposing token details", () =>
164173 if (input.args[0] === "--version") {
165174 return Effect.succeed(processOutput(`${input.command} version test\n`));
166175 }
167 if (input.command === "gh" && input.args.join(" ") === "auth status") {
176 if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {
168177 return Effect.succeed(
169 processOutput(`github.com
170Logged in to github.com account octocat (keyring)
171- Token: gho_************************************
172- Token scopes: 'repo'
173`),
178 processOutput(
179 JSON.stringify({
180 hosts: {
181 "github.com": [
182 {
183 state: "success",
184 active: true,
185 host: "github.com",
186 login: "octocat",
187 tokenSource: "keyring",
188 gitProtocol: "ssh",
189 },
190 ],
191 },
192 }),
193 ),
174194 );
175195 }
176196 if (input.command === "glab" && input.args.join(" ") === "auth status") {
apps/server/src/sourceControl/SourceControlProviderDiscovery.ts+62
......@@ -1,11 +1,13 @@
11import type {
22 SourceControlProviderAuth,
33 SourceControlProviderDiscoveryItem,
4 SourceControlProviderInfo,
45 SourceControlProviderKind,
56} from "@t3tools/contracts";
67import * as Effect from "effect/Effect";
78import * as Option from "effect/Option";
89
10import type * as SourceControlProvider from "./SourceControlProvider.ts";
911import type * as VcsProcess from "../vcs/VcsProcess.ts";
1012
1113export interface SourceControlAuthProbeInput {
......@@ -14,6 +16,12 @@ export interface SourceControlAuthProbeInput {
1416 readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];
1517}
1618
19export interface SourceControlUnknownRemoteRefinementInput {
20 readonly cwd: string;
21 readonly context: SourceControlProvider.SourceControlProviderContext;
22 readonly auth: SourceControlAuthProbeInput;
23}
24
1725interface SourceControlDiscoverySpecBase {
1826 readonly kind: SourceControlProviderKind;
1927 readonly label: string;
......@@ -26,6 +34,9 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {
2634 readonly versionArgs: ReadonlyArray<string>;
2735 readonly authArgs: ReadonlyArray<string>;
2836 readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth;
37 readonly refineUnknownRemote?: (
38 input: SourceControlUnknownRemoteRefinementInput,
39 ) => SourceControlProviderInfo | null;
2940};
3041
3142export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & {
......@@ -37,6 +48,10 @@ export type SourceControlProviderDiscoverySpec =
3748 | SourceControlCliDiscoverySpec
3849 | SourceControlApiDiscoverySpec;
3950
51type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & {
52 readonly refineUnknownRemote: NonNullable<SourceControlCliDiscoverySpec["refineUnknownRemote"]>;
53};
54
4055interface DiscoveryProbeResult {
4156 readonly kind: SourceControlProviderKind;
4257 readonly label: string;
......@@ -135,6 +150,12 @@ export function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): strin
135150 return undefined;
136151}
137152
153function isCliRemoteRefinementSpec(
154 spec: SourceControlProviderDiscoverySpec,
155): spec is SourceControlCliRemoteRefinementSpec {
156 return spec.type === "cli" && spec.refineUnknownRemote !== undefined;
157}
158
138159function probeCli(input: {
139160 readonly spec: SourceControlCliDiscoverySpec;
140161 readonly process: VcsProcess.VcsProcessShape;
......@@ -245,3 +266,44 @@ export function probeSourceControlProvider(input: {
245266 }),
246267 );
247268}
269
270export const refineUnknownRemoteProvider = Effect.fn("refineUnknownRemoteProvider")(
271 function* (input: {
272 readonly specs: ReadonlyArray<SourceControlProviderDiscoverySpec>;
273 readonly process: VcsProcess.VcsProcessShape;
274 readonly cwd: string;
275 readonly context: SourceControlProvider.SourceControlProviderContext | null;
276 }): Effect.fn.Return<SourceControlProvider.SourceControlProviderContext | null> {
277 if (input.context === null || input.context.provider.kind !== "unknown") {
278 return input.context;
279 }
280 const context = input.context;
281
282 const providers = yield* Effect.forEach(input.specs.filter(isCliRemoteRefinementSpec), (spec) =>
283 input.process
284 .run({
285 operation: "source-control.discovery.refine-unknown-remote",
286 command: spec.executable,
287 args: spec.authArgs,
288 cwd: input.cwd,
289 allowNonZeroExit: true,
290 timeoutMs: 5_000,
291 maxOutputBytes: 8_000,
292 appendTruncationMarker: true,
293 })
294 .pipe(
295 Effect.map((auth) =>
296 spec.refineUnknownRemote({
297 cwd: input.cwd,
298 context,
299 auth,
300 }),
301 ),
302 Effect.catch(() => Effect.succeed(null)),
303 ),
304 );
305 const provider = providers.find((candidate) => candidate !== null);
306
307 return provider ? { ...context, provider } : context;
308 },
309);
apps/server/src/sourceControl/SourceControlProviderRegistry.test.ts+72-1
......@@ -4,6 +4,7 @@ import * as DateTime from "effect/DateTime";
44import * as Effect from "effect/Effect";
55import * as Layer from "effect/Layer";
66import * as Option from "effect/Option";
7import { ChildProcessSpawner } from "effect/unstable/process";
78
89import { ServerConfig } from "../config.ts";
910import type * as VcsDriver from "../vcs/VcsDriver.ts";
......@@ -17,11 +18,26 @@ import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.
1718
1819const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");
1920
21const processOutput = (
22 stdout: string,
23 options?: {
24 readonly stderr?: string;
25 readonly exitCode?: ChildProcessSpawner.ExitCode;
26 },
27): VcsProcess.VcsProcessOutput => ({
28 exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0),
29 stdout,
30 stderr: options?.stderr ?? "",
31 stdoutTruncated: false,
32 stderrTruncated: false,
33});
34
2035function makeRegistry(input: {
2136 readonly remotes: ReadonlyArray<{
2237 readonly name: string;
2338 readonly url: string;
2439 }>;
40 readonly process?: Partial<VcsProcess.VcsProcessShape>;
2541}) {
2642 const driver = {
2743 listRemotes: () =>
......@@ -58,15 +74,20 @@ function makeRegistry(input: {
5874 }),
5975 });
6076
77 const processLayer = Layer.mock(VcsProcess.VcsProcess)({
78 run: () => Effect.succeed(processOutput("")),
79 ...input.process,
80 });
81
6182 return SourceControlProviderRegistry.make().pipe(
6283 Effect.provide(
6384 Layer.mergeAll(
6485 registryLayer,
86 processLayer,
6587 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
6688 Layer.mock(BitbucketApi.BitbucketApi)({}),
6789 Layer.mock(GitHubCli.GitHubCli)({}),
6890 Layer.mock(GitLabCli.GitLabCli)({}),
69 Layer.mock(VcsProcess.VcsProcess)({}),
7091 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe(
7192 Layer.provide(NodeServices.layer),
7293 ),
......@@ -111,6 +132,56 @@ it.effect("routes GitLab remotes to the GitLab provider", () =>
111132 }),
112133);
113134
135it.effect("routes authenticated self-hosted GitLab remotes without relying on host naming", () =>
136 Effect.gen(function* () {
137 const registry = yield* makeRegistry({
138 remotes: [{ name: "origin", url: "https://self-hosted.example.test/group/project.git" }],
139 process: {
140 run: () =>
141 Effect.succeed(
142 processOutput(
143 `gitlab.com
144 x gitlab.com: API call failed: 401 Unauthorized
145 ! No token found
146self-hosted.example.test
147 ✓ Logged in to self-hosted.example.test as gitlab-user
148 ✓ Token found: ******
149`,
150 { exitCode: ChildProcessSpawner.ExitCode(1) },
151 ),
152 ),
153 },
154 });
155
156 const provider = yield* registry.resolve({ cwd: "/repo" });
157
158 assert.strictEqual(provider.kind, "gitlab");
159 }),
160);
161
162it.effect("routes authenticated self-hosted GitLab remotes on non-standard ports", () =>
163 Effect.gen(function* () {
164 const registry = yield* makeRegistry({
165 remotes: [{ name: "origin", url: "https://self-hosted.example.test:8443/group/project.git" }],
166 process: {
167 run: () =>
168 Effect.succeed(
169 processOutput(
170 `self-hosted.example.test:8443
171 ✓ Logged in to self-hosted.example.test:8443 as gitlab-user
172 ✓ Token found: ******
173`,
174 ),
175 ),
176 },
177 });
178
179 const provider = yield* registry.resolve({ cwd: "/repo" });
180
181 assert.strictEqual(provider.kind, "gitlab");
182 }),
183);
184
114185it.effect("routes Bitbucket remotes to the Bitbucket provider", () =>
115186 Effect.gen(function* () {
116187 const registry = yield* makeRegistry({
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+7-1
......@@ -178,8 +178,14 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
178178 const remotes = yield* handle.driver
179179 .listRemotes(cwd)
180180 .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)));
181 const context = selectProviderContext(remotes.remotes);
181182
182 return selectProviderContext(remotes.remotes);
183 return yield* SourceControlProviderDiscovery.refineUnknownRemoteProvider({
184 specs: discoverySpecs,
185 process,
186 cwd,
187 context,
188 });
183189 },
184190 );
185191
apps/server/src/sourceControl/azureDevOpsPullRequests.ts+73-1
......@@ -21,6 +21,17 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({
2121 pullRequestId: PositiveInt,
2222 title: TrimmedNonEmptyString,
2323 url: Schema.optional(Schema.String),
24 repository: Schema.optional(
25 Schema.Struct({
26 name: Schema.optional(Schema.String),
27 webUrl: Schema.optional(Schema.String),
28 project: Schema.optional(
29 Schema.Struct({
30 name: Schema.optional(Schema.String),
31 }),
32 ),
33 }),
34 ),
2435 sourceRefName: TrimmedNonEmptyString,
2536 targetRefName: TrimmedNonEmptyString,
2637 status: Schema.String,
......@@ -57,13 +68,74 @@ function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed"
5768 }
5869}
5970
71function encodeAzureDevOpsPathSegment(segment: string): string {
72 return encodeURIComponent(segment);
73}
74
75function azureDevOpsOrganizationBaseFromRestApiUrl(
76 value: string | null | undefined,
77): string | null {
78 const rawUrl = trimOptionalString(value);
79 if (!rawUrl) {
80 return null;
81 }
82
83 try {
84 const url = new URL(rawUrl);
85 const hostname = url.hostname.toLowerCase();
86 const pathSegments = url.pathname.split("/").filter((segment) => segment.length > 0);
87 const isAzureRestUrl = pathSegments.some((segment) => segment.toLowerCase() === "_apis");
88 if (!isAzureRestUrl) {
89 return null;
90 }
91
92 if (hostname === "dev.azure.com") {
93 const organization = pathSegments[0];
94 return organization ? `${url.origin}/${organization}` : null;
95 }
96
97 if (hostname.endsWith(".visualstudio.com")) {
98 return url.origin;
99 }
100
101 return null;
102 } catch {
103 return null;
104 }
105}
106
107function normalizeAzureDevOpsPullRequestUrl(
108 raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,
109): string {
110 const webLink = trimOptionalString(raw._links?.web?.href);
111 if (webLink) {
112 return webLink;
113 }
114
115 const repositoryWebUrl = trimOptionalString(raw.repository?.webUrl);
116 if (repositoryWebUrl) {
117 return `${repositoryWebUrl.replace(/\/+$/, "")}/pullrequest/${raw.pullRequestId}`;
118 }
119
120 const organizationBase = azureDevOpsOrganizationBaseFromRestApiUrl(raw.url);
121 const projectName = trimOptionalString(raw.repository?.project?.name);
122 const repositoryName = trimOptionalString(raw.repository?.name);
123 if (organizationBase && projectName && repositoryName) {
124 const encodedProjectName = encodeAzureDevOpsPathSegment(projectName);
125 const encodedRepositoryName = encodeAzureDevOpsPathSegment(repositoryName);
126 return `${organizationBase}/${encodedProjectName}/_git/${encodedRepositoryName}/pullrequest/${raw.pullRequestId}`;
127 }
128
129 return trimOptionalString(raw.url) ?? "";
130}
131
60132function normalizeAzureDevOpsPullRequestRecord(
61133 raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,
62134): NormalizedAzureDevOpsPullRequestRecord {
63135 return {
64136 number: raw.pullRequestId,
65137 title: raw.title,
66 url: trimOptionalString(raw._links?.web?.href) ?? trimOptionalString(raw.url) ?? "",
138 url: normalizeAzureDevOpsPullRequestUrl(raw),
67139 baseRefName: normalizeRefName(raw.targetRefName),
68140 headRefName: normalizeRefName(raw.sourceRefName),
69141 state: normalizeAzureDevOpsPullRequestState(raw.status),
apps/server/src/sourceControl/gitHubAuthStatus.ts created+72
......@@ -0,0 +1,72 @@
1import * as Option from "effect/Option";
2import * as Schema from "effect/Schema";
3
4const GitHubAuthStatusAccountSchema = Schema.Struct({
5 state: Schema.String,
6 error: Schema.optional(Schema.String),
7 active: Schema.Boolean,
8 host: Schema.String,
9 login: Schema.String,
10});
11
12const GitHubAuthStatusSchema = Schema.Struct({
13 hosts: Schema.Record(Schema.String, Schema.Array(GitHubAuthStatusAccountSchema)),
14});
15
16const decodeGitHubAuthStatusJson = Schema.decodeUnknownOption(
17 Schema.fromJsonString(GitHubAuthStatusSchema),
18);
19
20export interface GitHubAuthStatusAccount {
21 readonly host: string;
22 readonly account: string;
23 readonly authenticated: boolean;
24 readonly active: boolean;
25 readonly error: string | null;
26}
27
28export interface GitHubAuthStatus {
29 readonly parsed: boolean;
30 readonly accounts: ReadonlyArray<GitHubAuthStatusAccount>;
31}
32
33function nonEmptyString(value: string): string | null {
34 const trimmed = value.trim();
35 return trimmed.length > 0 ? trimmed : null;
36}
37
38export function parseGitHubAuthStatus(text: string): GitHubAuthStatus {
39 return Option.match(decodeGitHubAuthStatusJson(text), {
40 onNone: () => ({ parsed: false, accounts: [] }),
41 onSome: (status) =>
42 ({
43 parsed: true,
44 accounts: Object.values(status.hosts).flatMap((accounts) =>
45 accounts.flatMap((account) => {
46 const host = nonEmptyString(account.host);
47 const login = nonEmptyString(account.login);
48 if (host === null || login === null) return [];
49
50 return [
51 {
52 host: host.toLowerCase(),
53 account: login,
54 authenticated: account.state === "success",
55 active: account.active,
56 error: account.error?.trim() || null,
57 },
58 ];
59 }),
60 ),
61 }) satisfies GitHubAuthStatus,
62 });
63}
64
65export function findAuthenticatedGitHubAccount(
66 accounts: ReadonlyArray<GitHubAuthStatusAccount>,
67): GitHubAuthStatusAccount | undefined {
68 return (
69 accounts.find((account) => account.authenticated && account.active) ??
70 accounts.find((account) => account.authenticated)
71 );
72}
apps/server/src/sourceControl/gitLabAuthStatus.ts created+48
......@@ -0,0 +1,48 @@
1const HOST_LINE_PATTERN = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[a-f0-9:.]+\])(?::\d+)?$/iu;
2const LOGGED_IN_PATTERN = /Logged in to .+? as\s+([^\s(]+)/iu;
3
4export interface GitLabAuthStatusHost {
5 readonly host: string;
6 readonly account: string | null;
7}
8
9export function parseGitLabAuthStatusHosts(text: string): ReadonlyArray<GitLabAuthStatusHost> {
10 const hosts: GitLabAuthStatusHost[] = [];
11 let currentHost: string | null = null;
12 let currentLines: string[] = [];
13
14 const flush = () => {
15 if (currentHost === null) return;
16
17 const account = LOGGED_IN_PATTERN.exec(currentLines.join("\n"))?.[1]?.trim() || null;
18 hosts.push({ host: currentHost, account });
19 currentHost = null;
20 currentLines = [];
21 };
22
23 for (const rawLine of text.split(/\r?\n/)) {
24 const line = rawLine.trim();
25 if (line.length === 0) continue;
26
27 const isHostLine =
28 rawLine.length === rawLine.trimStart().length && HOST_LINE_PATTERN.test(line);
29 if (isHostLine) {
30 flush();
31 currentHost = line.toLowerCase();
32 continue;
33 }
34
35 if (currentHost !== null) {
36 currentLines.push(line);
37 }
38 }
39
40 flush();
41 return hosts;
42}
43
44export function findAuthenticatedGitLabHost(
45 hosts: ReadonlyArray<GitLabAuthStatusHost>,
46): GitLabAuthStatusHost | undefined {
47 return hosts.find((host) => host.account !== null);
48}
packages/shared/src/sourceControl.test.ts+19
......@@ -56,4 +56,23 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {
5656 detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind,
5757 ).toBe("bitbucket");
5858 });
59
60 it("preserves ports while classifying by hostname", () => {
61 expect(
62 detectSourceControlProviderFromRemoteUrl("https://gitlab.com:8443/group/repo.git"),
63 ).toEqual({
64 kind: "gitlab",
65 name: "GitLab",
66 baseUrl: "https://gitlab.com:8443",
67 });
68 expect(
69 detectSourceControlProviderFromRemoteUrl(
70 "https://self-hosted.example.test:8443/group/repo.git",
71 ),
72 ).toEqual({
73 kind: "unknown",
74 name: "self-hosted.example.test:8443",
75 baseUrl: "https://self-hosted.example.test:8443",
76 });
77 });
5978});
packages/shared/src/sourceControl.ts+17-8
......@@ -149,12 +149,20 @@ function parseRemoteHost(remoteUrl: string): string | null {
149149 }
150150
151151 try {
152 return new URL(trimmed).hostname.toLowerCase();
152 return new URL(trimmed).host.toLowerCase();
153153 } catch {
154154 return null;
155155 }
156156}
157157
158function parseHostName(host: string): string {
159 try {
160 return new URL(`https://${host}`).hostname.toLowerCase();
161 } catch {
162 return host.replace(/:\d+$/u, "").toLowerCase();
163 }
164}
165
158166function toBaseUrl(host: string): string {
159167 return `https://${host}`;
160168}
......@@ -182,24 +190,25 @@ export function detectSourceControlProviderFromRemoteUrl(
182190 if (!host) {
183191 return null;
184192 }
193 const hostname = parseHostName(host);
185194
186 if (isGitHubHost(host)) {
195 if (isGitHubHost(hostname)) {
187196 return {
188197 kind: "github",
189 name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted",
198 name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted",
190199 baseUrl: toBaseUrl(host),
191200 };
192201 }
193202
194 if (isGitLabHost(host)) {
203 if (isGitLabHost(hostname)) {
195204 return {
196205 kind: "gitlab",
197 name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted",
206 name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted",
198207 baseUrl: toBaseUrl(host),
199208 };
200209 }
201210
202 if (isAzureDevOpsHost(host)) {
211 if (isAzureDevOpsHost(hostname)) {
203212 return {
204213 kind: "azure-devops",
205214 name: "Azure DevOps",
......@@ -207,10 +216,10 @@ export function detectSourceControlProviderFromRemoteUrl(
207216 };
208217 }
209218
210 if (isBitbucketHost(host)) {
219 if (isBitbucketHost(hostname)) {
211220 return {
212221 kind: "bitbucket",
213 name: host === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted",
222 name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted",
214223 baseUrl: toBaseUrl(host),
215224 };
216225 }