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", () => {...@@ -64,6 +64,7 @@ describe("AzureDevOpsCli.layer", () => {
6464
65 assert.strictEqual(result.number, 42);65 assert.strictEqual(result.number, 42);
66 assert.strictEqual(result.title, "Add Azure provider");66 assert.strictEqual(result.title, "Add Azure provider");
67 assert.strictEqual(result.url, "https://dev.azure.com/acme/project/_git/repo/pullrequest/42");
67 assert.strictEqual(result.baseRefName, "main");68 assert.strictEqual(result.baseRefName, "main");
68 assert.strictEqual(result.headRefName, "feature/source-control");69 assert.strictEqual(result.headRefName, "feature/source-control");
69 assert.strictEqual(result.state, "open");70 assert.strictEqual(result.state, "open");
...@@ -89,6 +90,43 @@ describe("AzureDevOpsCli.layer", () => {...@@ -89,6 +90,43 @@ describe("AzureDevOpsCli.layer", () => {
89 }).pipe(Effect.provide(layer)),90 }).pipe(Effect.provide(layer)),
90 );91 );
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
92 it.effect("lists pull requests with Azure status and source branch arguments", () =>130 it.effect("lists pull requests with Azure status and source branch arguments", () =>
93 Effect.gen(function* () {131 Effect.gen(function* () {
94 mockRun.mockReturnValueOnce(132 mockRun.mockReturnValueOnce(
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+185-3
...@@ -7,12 +7,19 @@ import { ChildProcessSpawner } from "effect/unstable/process";...@@ -7,12 +7,19 @@ import { ChildProcessSpawner } from "effect/unstable/process";
77
8import * as VcsProcess from "../vcs/VcsProcess.ts";8import * as VcsProcess from "../vcs/VcsProcess.ts";
9import * as GitHubCli from "./GitHubCli.ts";9import * as GitHubCli from "./GitHubCli.ts";
10import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
10import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";11import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts";
1112
12const processResult = (stdout: string): VcsProcess.VcsProcessOutput => ({13const processResult = (
13 exitCode: ChildProcessSpawner.ExitCode(0),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),
14 stdout,21 stdout,
15 stderr: "",22 stderr: options?.stderr ?? "",
16 stdoutTruncated: false,23 stdoutTruncated: false,
17 stderrTruncated: false,24 stderrTruncated: false,
18});25});
...@@ -157,3 +164,178 @@ it.effect("creates GitHub PRs through provider-neutral input names", () =>...@@ -157,3 +164,178 @@ it.effect("creates GitHub PRs through provider-neutral input names", () =>
157 });164 });
158 }),165 }),
159);166);
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 {...@@ -10,6 +10,7 @@ import {
10} from "@t3tools/contracts";10} from "@t3tools/contracts";
1111
12import * as GitHubCli from "./GitHubCli.ts";12import * as GitHubCli from "./GitHubCli.ts";
13import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts";
13import * as GitHubPullRequests from "./gitHubPullRequests.ts";14import * as GitHubPullRequests from "./gitHubPullRequests.ts";
14import * as SourceControlProvider from "./SourceControlProvider.ts";15import * as SourceControlProvider from "./SourceControlProvider.ts";
15import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";16import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
...@@ -51,11 +52,28 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq...@@ -51,11 +52,28 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq
5152
52function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {53function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {
53 const output = SourceControlProviderDiscovery.combinedAuthOutput(input);54 const output = SourceControlProviderDiscovery.combinedAuthOutput(input);
54 const account = SourceControlProviderDiscovery.matchFirst(output, [55 const authStatus = parseGitHubAuthStatus(input.stdout);
55 /Logged in to .* account\s+([^\s(]+)/iu,56 const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts);
56 /Logged in to .* as\s+([^\s(]+)/iu,57 const host = authenticatedAccount?.host;
57 ]);58
58 const host = SourceControlProviderDiscovery.parseCliHost(output);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
60 if (input.exitCode !== 0) {78 if (input.exitCode !== 0) {
61 return SourceControlProviderDiscovery.providerAuth({79 return SourceControlProviderDiscovery.providerAuth({
...@@ -67,10 +85,6 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth...@@ -67,10 +85,6 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth
67 });85 });
68 }86 }
6987
70 if (account) {
71 return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host });
72 }
73
74 return SourceControlProviderDiscovery.providerAuth({88 return SourceControlProviderDiscovery.providerAuth({
75 status: "unknown",89 status: "unknown",
76 host,90 host,
...@@ -86,7 +100,7 @@ export const discovery = {...@@ -86,7 +100,7 @@ export const discovery = {
86 label: "GitHub",100 label: "GitHub",
87 executable: "gh",101 executable: "gh",
88 versionArgs: ["--version"],102 versionArgs: ["--version"],
89 authArgs: ["auth", "status"],103 authArgs: ["auth", "status", "--json", "hosts"],
90 parseAuth: parseGitHubAuth,104 parseAuth: parseGitHubAuth,
91 installHint:105 installHint:
92 "Install the GitHub command-line tool (`gh`) via https://cli.github.com/ or your package manager (for example `brew install gh`).",106 "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";...@@ -2,8 +2,10 @@ import { assert, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";3import * as Layer from "effect/Layer";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import { ChildProcessSpawner } from "effect/unstable/process";
56
6import * as GitLabCli from "./GitLabCli.ts";7import * as GitLabCli from "./GitLabCli.ts";
8import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
7import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";9import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts";
810
9function makeProvider(gitlab: Partial<GitLabCli.GitLabCliShape>) {11function makeProvider(gitlab: Partial<GitLabCli.GitLabCliShape>) {
...@@ -107,3 +109,73 @@ it.effect("creates GitLab MRs through provider-neutral input names", () =>...@@ -107,3 +109,73 @@ it.effect("creates GitLab MRs through provider-neutral input names", () =>
107 });109 });
108 }),110 }),
109);111);
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...@@ -6,6 +6,7 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac
6import * as GitLabCli from "./GitLabCli.ts";6import * as GitLabCli from "./GitLabCli.ts";
7import * as SourceControlProvider from "./SourceControlProvider.ts";7import * as SourceControlProvider from "./SourceControlProvider.ts";
8import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";8import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts";
9import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts";
910
10function providerError(11function providerError(
11 operation: string,12 operation: string,
...@@ -43,12 +44,19 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe...@@ -43,12 +44,19 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe
4344
44function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {45function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) {
45 const output = SourceControlProviderDiscovery.combinedAuthOutput(input);46 const output = SourceControlProviderDiscovery.combinedAuthOutput(input);
46 const account = SourceControlProviderDiscovery.matchFirst(output, [47 const authenticatedHost = findAuthenticatedGitLabHost(parseGitLabAuthStatusHosts(output));
47 /Logged in to .* as\s+([^\s(]+)/iu,48 const account =
48 /Logged in to .* account\s+([^\s(]+)/iu,49 authenticatedHost?.account ??
49 /account:\s*([^\s(]+)/iu,50 SourceControlProviderDiscovery.matchFirst(output, [
50 ]);51 /Logged in to .* as\s+([^\s(]+)/iu,
51 const host = SourceControlProviderDiscovery.parseCliHost(output);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
53 if (input.exitCode !== 0) {61 if (input.exitCode !== 0) {
54 return SourceControlProviderDiscovery.providerAuth({62 return SourceControlProviderDiscovery.providerAuth({
...@@ -60,10 +68,6 @@ function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuth...@@ -60,10 +68,6 @@ function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuth
60 });68 });
61 }69 }
6270
63 if (account) {
64 return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host });
65 }
66
67 return SourceControlProviderDiscovery.providerAuth({71 return SourceControlProviderDiscovery.providerAuth({
68 status: "unknown",72 status: "unknown",
69 host,73 host,
...@@ -73,6 +77,25 @@ function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuth...@@ -73,6 +77,25 @@ function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuth
73 });77 });
74}78}
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
76export const discovery = {99export const discovery = {
77 type: "cli",100 type: "cli",
78 kind: "gitlab",101 kind: "gitlab",
...@@ -81,6 +104,7 @@ export const discovery = {...@@ -81,6 +104,7 @@ export const discovery = {
81 versionArgs: ["--version"],104 versionArgs: ["--version"],
82 authArgs: ["auth", "status"],105 authArgs: ["auth", "status"],
83 parseAuth: parseGitLabAuth,106 parseAuth: parseGitLabAuth,
107 refineUnknownRemote: refineUnknownGitLabRemote,
84 installHint:108 installHint:
85 "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).",109 "Install the GitLab command-line tool (`glab`) from https://gitlab.com/gitlab-org/cli or your package manager (for example `brew install glab`).",
86} satisfies SourceControlProviderDiscovery.SourceControlCliDiscoverySpec;110} 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...@@ -59,15 +59,24 @@ it.effect("reports implemented tools separately from locally available executabl
59 if (input.command === "gh" && input.args[0] === "--version") {59 if (input.command === "gh" && input.args[0] === "--version") {
60 return Effect.succeed(processOutput("gh version 2.83.0\n"));60 return Effect.succeed(processOutput("gh version 2.83.0\n"));
61 }61 }
62 if (input.command === "gh" && input.args.join(" ") === "auth status") {62 if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {
63 return Effect.succeed(63 return Effect.succeed(
64 processOutput(`github.com64 processOutput(
65Logged in to github.com account juliusmarminge (keyring)65 JSON.stringify({
66- Active account: true66 hosts: {
67- Git operations protocol: ssh67 "github.com": [
68- Token: gho_************************************68 {
69- Token scopes: 'admin:public_key', 'gist', 'read:org', 'repo'69 state: "success",
70`),70 active: true,
71 host: "github.com",
72 login: "juliusmarminge",
73 tokenSource: "keyring",
74 gitProtocol: "ssh",
75 },
76 ],
77 },
78 }),
79 ),
71 );80 );
72 }81 }
73 return Effect.fail(82 return Effect.fail(
...@@ -164,13 +173,24 @@ it.effect("probes provider authentication without exposing token details", () =>...@@ -164,13 +173,24 @@ it.effect("probes provider authentication without exposing token details", () =>
164 if (input.args[0] === "--version") {173 if (input.args[0] === "--version") {
165 return Effect.succeed(processOutput(`${input.command} version test\n`));174 return Effect.succeed(processOutput(`${input.command} version test\n`));
166 }175 }
167 if (input.command === "gh" && input.args.join(" ") === "auth status") {176 if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") {
168 return Effect.succeed(177 return Effect.succeed(
169 processOutput(`github.com178 processOutput(
170Logged in to github.com account octocat (keyring)179 JSON.stringify({
171- Token: gho_************************************180 hosts: {
172- Token scopes: 'repo'181 "github.com": [
173`),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 ),
174 );194 );
175 }195 }
176 if (input.command === "glab" && input.args.join(" ") === "auth status") {196 if (input.command === "glab" && input.args.join(" ") === "auth status") {
apps/server/src/sourceControl/SourceControlProviderDiscovery.ts+62
...@@ -1,11 +1,13 @@...@@ -1,11 +1,13 @@
1import type {1import type {
2 SourceControlProviderAuth,2 SourceControlProviderAuth,
3 SourceControlProviderDiscoveryItem,3 SourceControlProviderDiscoveryItem,
4 SourceControlProviderInfo,
4 SourceControlProviderKind,5 SourceControlProviderKind,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";8import * as Option from "effect/Option";
89
10import type * as SourceControlProvider from "./SourceControlProvider.ts";
9import type * as VcsProcess from "../vcs/VcsProcess.ts";11import type * as VcsProcess from "../vcs/VcsProcess.ts";
1012
11export interface SourceControlAuthProbeInput {13export interface SourceControlAuthProbeInput {
...@@ -14,6 +16,12 @@ export interface SourceControlAuthProbeInput {...@@ -14,6 +16,12 @@ export interface SourceControlAuthProbeInput {
14 readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];16 readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"];
15}17}
1618
19export interface SourceControlUnknownRemoteRefinementInput {
20 readonly cwd: string;
21 readonly context: SourceControlProvider.SourceControlProviderContext;
22 readonly auth: SourceControlAuthProbeInput;
23}
24
17interface SourceControlDiscoverySpecBase {25interface SourceControlDiscoverySpecBase {
18 readonly kind: SourceControlProviderKind;26 readonly kind: SourceControlProviderKind;
19 readonly label: string;27 readonly label: string;
...@@ -26,6 +34,9 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {...@@ -26,6 +34,9 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & {
26 readonly versionArgs: ReadonlyArray<string>;34 readonly versionArgs: ReadonlyArray<string>;
27 readonly authArgs: ReadonlyArray<string>;35 readonly authArgs: ReadonlyArray<string>;
28 readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth;36 readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth;
37 readonly refineUnknownRemote?: (
38 input: SourceControlUnknownRemoteRefinementInput,
39 ) => SourceControlProviderInfo | null;
29};40};
3041
31export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & {42export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & {
...@@ -37,6 +48,10 @@ export type SourceControlProviderDiscoverySpec =...@@ -37,6 +48,10 @@ export type SourceControlProviderDiscoverySpec =
37 | SourceControlCliDiscoverySpec48 | SourceControlCliDiscoverySpec
38 | SourceControlApiDiscoverySpec;49 | SourceControlApiDiscoverySpec;
3950
51type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & {
52 readonly refineUnknownRemote: NonNullable<SourceControlCliDiscoverySpec["refineUnknownRemote"]>;
53};
54
40interface DiscoveryProbeResult {55interface DiscoveryProbeResult {
41 readonly kind: SourceControlProviderKind;56 readonly kind: SourceControlProviderKind;
42 readonly label: string;57 readonly label: string;
...@@ -135,6 +150,12 @@ export function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): strin...@@ -135,6 +150,12 @@ export function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): strin
135 return undefined;150 return undefined;
136}151}
137152
153function isCliRemoteRefinementSpec(
154 spec: SourceControlProviderDiscoverySpec,
155): spec is SourceControlCliRemoteRefinementSpec {
156 return spec.type === "cli" && spec.refineUnknownRemote !== undefined;
157}
158
138function probeCli(input: {159function probeCli(input: {
139 readonly spec: SourceControlCliDiscoverySpec;160 readonly spec: SourceControlCliDiscoverySpec;
140 readonly process: VcsProcess.VcsProcessShape;161 readonly process: VcsProcess.VcsProcessShape;
...@@ -245,3 +266,44 @@ export function probeSourceControlProvider(input: {...@@ -245,3 +266,44 @@ export function probeSourceControlProvider(input: {
245 }),266 }),
246 );267 );
247}268}
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";...@@ -4,6 +4,7 @@ import * as DateTime from "effect/DateTime";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";5import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";6import * as Option from "effect/Option";
7import { ChildProcessSpawner } from "effect/unstable/process";
78
8import { ServerConfig } from "../config.ts";9import { ServerConfig } from "../config.ts";
9import type * as VcsDriver from "../vcs/VcsDriver.ts";10import type * as VcsDriver from "../vcs/VcsDriver.ts";
...@@ -17,11 +18,26 @@ import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry....@@ -17,11 +18,26 @@ import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.
1718
18const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z");19const 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
20function makeRegistry(input: {35function makeRegistry(input: {
21 readonly remotes: ReadonlyArray<{36 readonly remotes: ReadonlyArray<{
22 readonly name: string;37 readonly name: string;
23 readonly url: string;38 readonly url: string;
24 }>;39 }>;
40 readonly process?: Partial<VcsProcess.VcsProcessShape>;
25}) {41}) {
26 const driver = {42 const driver = {
27 listRemotes: () =>43 listRemotes: () =>
...@@ -58,15 +74,20 @@ function makeRegistry(input: {...@@ -58,15 +74,20 @@ function makeRegistry(input: {
58 }),74 }),
59 });75 });
6076
77 const processLayer = Layer.mock(VcsProcess.VcsProcess)({
78 run: () => Effect.succeed(processOutput("")),
79 ...input.process,
80 });
81
61 return SourceControlProviderRegistry.make().pipe(82 return SourceControlProviderRegistry.make().pipe(
62 Effect.provide(83 Effect.provide(
63 Layer.mergeAll(84 Layer.mergeAll(
64 registryLayer,85 registryLayer,
86 processLayer,
65 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),87 Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}),
66 Layer.mock(BitbucketApi.BitbucketApi)({}),88 Layer.mock(BitbucketApi.BitbucketApi)({}),
67 Layer.mock(GitHubCli.GitHubCli)({}),89 Layer.mock(GitHubCli.GitHubCli)({}),
68 Layer.mock(GitLabCli.GitLabCli)({}),90 Layer.mock(GitLabCli.GitLabCli)({}),
69 Layer.mock(VcsProcess.VcsProcess)({}),
70 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe(91 ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe(
71 Layer.provide(NodeServices.layer),92 Layer.provide(NodeServices.layer),
72 ),93 ),
...@@ -111,6 +132,56 @@ it.effect("routes GitLab remotes to the GitLab provider", () =>...@@ -111,6 +132,56 @@ it.effect("routes GitLab remotes to the GitLab provider", () =>
111 }),132 }),
112);133);
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
114it.effect("routes Bitbucket remotes to the Bitbucket provider", () =>185it.effect("routes Bitbucket remotes to the Bitbucket provider", () =>
115 Effect.gen(function* () {186 Effect.gen(function* () {
116 const registry = yield* makeRegistry({187 const registry = yield* makeRegistry({
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+7-1
...@@ -178,8 +178,14 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit...@@ -178,8 +178,14 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit
178 const remotes = yield* handle.driver178 const remotes = yield* handle.driver
179 .listRemotes(cwd)179 .listRemotes(cwd)
180 .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error)));180 .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 });
183 },189 },
184 );190 );
185191
apps/server/src/sourceControl/azureDevOpsPullRequests.ts+73-1
...@@ -21,6 +21,17 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({...@@ -21,6 +21,17 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({
21 pullRequestId: PositiveInt,21 pullRequestId: PositiveInt,
22 title: TrimmedNonEmptyString,22 title: TrimmedNonEmptyString,
23 url: Schema.optional(Schema.String),23 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 ),
24 sourceRefName: TrimmedNonEmptyString,35 sourceRefName: TrimmedNonEmptyString,
25 targetRefName: TrimmedNonEmptyString,36 targetRefName: TrimmedNonEmptyString,
26 status: Schema.String,37 status: Schema.String,
...@@ -57,13 +68,74 @@ function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed"...@@ -57,13 +68,74 @@ function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed"
57 }68 }
58}69}
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
60function normalizeAzureDevOpsPullRequestRecord(132function normalizeAzureDevOpsPullRequestRecord(
61 raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,133 raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>,
62): NormalizedAzureDevOpsPullRequestRecord {134): NormalizedAzureDevOpsPullRequestRecord {
63 return {135 return {
64 number: raw.pullRequestId,136 number: raw.pullRequestId,
65 title: raw.title,137 title: raw.title,
66 url: trimOptionalString(raw._links?.web?.href) ?? trimOptionalString(raw.url) ?? "",138 url: normalizeAzureDevOpsPullRequestUrl(raw),
67 baseRefName: normalizeRefName(raw.targetRefName),139 baseRefName: normalizeRefName(raw.targetRefName),
68 headRefName: normalizeRefName(raw.sourceRefName),140 headRefName: normalizeRefName(raw.sourceRefName),
69 state: normalizeAzureDevOpsPullRequestState(raw.status),141 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", () => {...@@ -56,4 +56,23 @@ describe("detectSourceControlProviderFromRemoteUrl", () => {
56 detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind,56 detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind,
57 ).toBe("bitbucket");57 ).toBe("bitbucket");
58 });58 });
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 });
59});78});
packages/shared/src/sourceControl.ts+17-8
...@@ -149,12 +149,20 @@ function parseRemoteHost(remoteUrl: string): string | null {...@@ -149,12 +149,20 @@ function parseRemoteHost(remoteUrl: string): string | null {
149 }149 }
150150
151 try {151 try {
152 return new URL(trimmed).hostname.toLowerCase();152 return new URL(trimmed).host.toLowerCase();
153 } catch {153 } catch {
154 return null;154 return null;
155 }155 }
156}156}
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
158function toBaseUrl(host: string): string {166function toBaseUrl(host: string): string {
159 return `https://${host}`;167 return `https://${host}`;
160}168}
...@@ -182,24 +190,25 @@ export function detectSourceControlProviderFromRemoteUrl(...@@ -182,24 +190,25 @@ export function detectSourceControlProviderFromRemoteUrl(
182 if (!host) {190 if (!host) {
183 return null;191 return null;
184 }192 }
193 const hostname = parseHostName(host);
185194
186 if (isGitHubHost(host)) {195 if (isGitHubHost(hostname)) {
187 return {196 return {
188 kind: "github",197 kind: "github",
189 name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted",198 name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted",
190 baseUrl: toBaseUrl(host),199 baseUrl: toBaseUrl(host),
191 };200 };
192 }201 }
193202
194 if (isGitLabHost(host)) {203 if (isGitLabHost(hostname)) {
195 return {204 return {
196 kind: "gitlab",205 kind: "gitlab",
197 name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted",206 name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted",
198 baseUrl: toBaseUrl(host),207 baseUrl: toBaseUrl(host),
199 };208 };
200 }209 }
201210
202 if (isAzureDevOpsHost(host)) {211 if (isAzureDevOpsHost(hostname)) {
203 return {212 return {
204 kind: "azure-devops",213 kind: "azure-devops",
205 name: "Azure DevOps",214 name: "Azure DevOps",
...@@ -207,10 +216,10 @@ export function detectSourceControlProviderFromRemoteUrl(...@@ -207,10 +216,10 @@ export function detectSourceControlProviderFromRemoteUrl(
207 };216 };
208 }217 }
209218
210 if (isBitbucketHost(host)) {219 if (isBitbucketHost(hostname)) {
211 return {220 return {
212 kind: "bitbucket",221 kind: "bitbucket",
213 name: host === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted",222 name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted",
214 baseUrl: toBaseUrl(host),223 baseUrl: toBaseUrl(host),
215 };224 };
216 }225 }