| author | |
| committer | |
| log | 49c1b64687f8acdbe145f760988314e397170da6 |
| tree | ab35813e64be0b15f1f1f72abcd14f55da376a1c |
| parent | dfdb5a46db30fb46db5c58d5fe047b32483e2ad5 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 64 | |
| 65 | 65 | assert.strictEqual(result.number, 42); |
| 66 | 66 | assert.strictEqual(result.title, "Add Azure provider"); |
| 67 | assert.strictEqual(result.url, "https://dev.azure.com/acme/project/_git/repo/pullrequest/42"); | |
| 67 | 68 | assert.strictEqual(result.baseRefName, "main"); |
| 68 | 69 | assert.strictEqual(result.headRefName, "feature/source-control"); |
| 69 | 70 | assert.strictEqual(result.state, "open"); |
| ... | ... | @@ -89,6 +90,43 @@ describe("AzureDevOpsCli.layer", () => { |
| 89 | 90 | }).pipe(Effect.provide(layer)), |
| 90 | 91 | ); |
| 91 | 92 | |
| 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 | 130 | it.effect("lists pull requests with Azure status and source branch arguments", () => |
| 93 | 131 | Effect.gen(function* () { |
| 94 | 132 | mockRun.mockReturnValueOnce( |
apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts+185-3| ... | ... | @@ -7,12 +7,19 @@ import { ChildProcessSpawner } from "effect/unstable/process"; |
| 7 | 7 | |
| 8 | 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 9 | 9 | import * as GitHubCli from "./GitHubCli.ts"; |
| 10 | import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; | |
| 10 | 11 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 11 | 12 | |
| 12 | const processResult = (stdout: string): VcsProcess.VcsProcessOutput => ({ | |
| 13 | exitCode: ChildProcessSpawner.ExitCode(0), | |
| 13 | const 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), | |
| 14 | 21 | stdout, |
| 15 | stderr: "", | |
| 22 | stderr: options?.stderr ?? "", | |
| 16 | 23 | stdoutTruncated: false, |
| 17 | 24 | stderrTruncated: false, |
| 18 | 25 | }); |
| ... | ... | @@ -157,3 +164,178 @@ it.effect("creates GitHub PRs through provider-neutral input names", () => |
| 157 | 164 | }); |
| 158 | 165 | }), |
| 159 | 166 | ); |
| 167 | ||
| 168 | it("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 | ||
| 211 | it("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 | ||
| 246 | it("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 | ||
| 308 | it("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 | 10 | } from "@t3tools/contracts"; |
| 11 | 11 | |
| 12 | 12 | import * as GitHubCli from "./GitHubCli.ts"; |
| 13 | import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; | |
| 13 | 14 | import * as GitHubPullRequests from "./gitHubPullRequests.ts"; |
| 14 | 15 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 15 | 16 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; |
| ... | ... | @@ -51,11 +52,28 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq |
| 51 | 52 | |
| 52 | 53 | function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { |
| 53 | 54 | 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 | } | |
| 59 | 77 | |
| 60 | 78 | if (input.exitCode !== 0) { |
| 61 | 79 | return SourceControlProviderDiscovery.providerAuth({ |
| ... | ... | @@ -67,10 +85,6 @@ function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuth |
| 67 | 85 | }); |
| 68 | 86 | } |
| 69 | 87 | |
| 70 | if (account) { | |
| 71 | return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); | |
| 72 | } | |
| 73 | ||
| 74 | 88 | return SourceControlProviderDiscovery.providerAuth({ |
| 75 | 89 | status: "unknown", |
| 76 | 90 | host, |
| ... | ... | @@ -86,7 +100,7 @@ export const discovery = { |
| 86 | 100 | label: "GitHub", |
| 87 | 101 | executable: "gh", |
| 88 | 102 | versionArgs: ["--version"], |
| 89 | authArgs: ["auth", "status"], | |
| 103 | authArgs: ["auth", "status", "--json", "hosts"], | |
| 90 | 104 | parseAuth: parseGitHubAuth, |
| 91 | 105 | installHint: |
| 92 | 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 | 2 | import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as Layer from "effect/Layer"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 5 | 6 | |
| 6 | 7 | import * as GitLabCli from "./GitLabCli.ts"; |
| 8 | import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; | |
| 7 | 9 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; |
| 8 | 10 | |
| 9 | 11 | function makeProvider(gitlab: Partial<GitLabCli.GitLabCliShape>) { |
| ... | ... | @@ -107,3 +109,73 @@ it.effect("creates GitLab MRs through provider-neutral input names", () => |
| 107 | 109 | }); |
| 108 | 110 | }), |
| 109 | 111 | ); |
| 112 | ||
| 113 | it("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 | |
| 119 | self-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 | ||
| 140 | it("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 | ||
| 169 | it("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 | |
| 173 | selfhosted | |
| 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 | import * as GitLabCli from "./GitLabCli.ts"; |
| 7 | 7 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 8 | 8 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; |
| 9 | import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; | |
| 9 | 10 | |
| 10 | 11 | function providerError( |
| 11 | 12 | operation: string, |
| ... | ... | @@ -43,12 +44,19 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe |
| 43 | 44 | |
| 44 | 45 | function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { |
| 45 | 46 | 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 | } | |
| 52 | 60 | |
| 53 | 61 | if (input.exitCode !== 0) { |
| 54 | 62 | return SourceControlProviderDiscovery.providerAuth({ |
| ... | ... | @@ -60,10 +68,6 @@ function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuth |
| 60 | 68 | }); |
| 61 | 69 | } |
| 62 | 70 | |
| 63 | if (account) { | |
| 64 | return SourceControlProviderDiscovery.providerAuth({ status: "authenticated", account, host }); | |
| 65 | } | |
| 66 | ||
| 67 | 71 | return SourceControlProviderDiscovery.providerAuth({ |
| 68 | 72 | status: "unknown", |
| 69 | 73 | host, |
| ... | ... | @@ -73,6 +77,25 @@ function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuth |
| 73 | 77 | }); |
| 74 | 78 | } |
| 75 | 79 | |
| 80 | function 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 | ||
| 76 | 99 | export const discovery = { |
| 77 | 100 | type: "cli", |
| 78 | 101 | kind: "gitlab", |
| ... | ... | @@ -81,6 +104,7 @@ export const discovery = { |
| 81 | 104 | versionArgs: ["--version"], |
| 82 | 105 | authArgs: ["auth", "status"], |
| 83 | 106 | parseAuth: parseGitLabAuth, |
| 107 | refineUnknownRemote: refineUnknownGitLabRemote, | |
| 84 | 108 | installHint: |
| 85 | 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 | 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 | 59 | if (input.command === "gh" && input.args[0] === "--version") { |
| 60 | 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 | 63 | return Effect.succeed( |
| 64 | processOutput(`github.com | |
| 65 | Logged 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 | ), | |
| 71 | 80 | ); |
| 72 | 81 | } |
| 73 | 82 | return Effect.fail( |
| ... | ... | @@ -164,13 +173,24 @@ it.effect("probes provider authentication without exposing token details", () => |
| 164 | 173 | if (input.args[0] === "--version") { |
| 165 | 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 | 177 | return Effect.succeed( |
| 169 | processOutput(`github.com | |
| 170 | Logged 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 | ), | |
| 174 | 194 | ); |
| 175 | 195 | } |
| 176 | 196 | if (input.command === "glab" && input.args.join(" ") === "auth status") { |
apps/server/src/sourceControl/SourceControlProviderDiscovery.ts+62| ... | ... | @@ -1,11 +1,13 @@ |
| 1 | 1 | import type { |
| 2 | 2 | SourceControlProviderAuth, |
| 3 | 3 | SourceControlProviderDiscoveryItem, |
| 4 | SourceControlProviderInfo, | |
| 4 | 5 | SourceControlProviderKind, |
| 5 | 6 | } from "@t3tools/contracts"; |
| 6 | 7 | import * as Effect from "effect/Effect"; |
| 7 | 8 | import * as Option from "effect/Option"; |
| 8 | 9 | |
| 10 | import type * as SourceControlProvider from "./SourceControlProvider.ts"; | |
| 9 | 11 | import type * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 10 | 12 | |
| 11 | 13 | export interface SourceControlAuthProbeInput { |
| ... | ... | @@ -14,6 +16,12 @@ export interface SourceControlAuthProbeInput { |
| 14 | 16 | readonly exitCode: VcsProcess.VcsProcessOutput["exitCode"]; |
| 15 | 17 | } |
| 16 | 18 | |
| 19 | export interface SourceControlUnknownRemoteRefinementInput { | |
| 20 | readonly cwd: string; | |
| 21 | readonly context: SourceControlProvider.SourceControlProviderContext; | |
| 22 | readonly auth: SourceControlAuthProbeInput; | |
| 23 | } | |
| 24 | ||
| 17 | 25 | interface SourceControlDiscoverySpecBase { |
| 18 | 26 | readonly kind: SourceControlProviderKind; |
| 19 | 27 | readonly label: string; |
| ... | ... | @@ -26,6 +34,9 @@ export type SourceControlCliDiscoverySpec = SourceControlDiscoverySpecBase & { |
| 26 | 34 | readonly versionArgs: ReadonlyArray<string>; |
| 27 | 35 | readonly authArgs: ReadonlyArray<string>; |
| 28 | 36 | readonly parseAuth: (input: SourceControlAuthProbeInput) => SourceControlProviderAuth; |
| 37 | readonly refineUnknownRemote?: ( | |
| 38 | input: SourceControlUnknownRemoteRefinementInput, | |
| 39 | ) => SourceControlProviderInfo | null; | |
| 29 | 40 | }; |
| 30 | 41 | |
| 31 | 42 | export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { |
| ... | ... | @@ -37,6 +48,10 @@ export type SourceControlProviderDiscoverySpec = |
| 37 | 48 | | SourceControlCliDiscoverySpec |
| 38 | 49 | | SourceControlApiDiscoverySpec; |
| 39 | 50 | |
| 51 | type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { | |
| 52 | readonly refineUnknownRemote: NonNullable<SourceControlCliDiscoverySpec["refineUnknownRemote"]>; | |
| 53 | }; | |
| 54 | ||
| 40 | 55 | interface DiscoveryProbeResult { |
| 41 | 56 | readonly kind: SourceControlProviderKind; |
| 42 | 57 | readonly label: string; |
| ... | ... | @@ -135,6 +150,12 @@ export function matchFirst(text: string, patterns: ReadonlyArray<RegExp>): strin |
| 135 | 150 | return undefined; |
| 136 | 151 | } |
| 137 | 152 | |
| 153 | function isCliRemoteRefinementSpec( | |
| 154 | spec: SourceControlProviderDiscoverySpec, | |
| 155 | ): spec is SourceControlCliRemoteRefinementSpec { | |
| 156 | return spec.type === "cli" && spec.refineUnknownRemote !== undefined; | |
| 157 | } | |
| 158 | ||
| 138 | 159 | function probeCli(input: { |
| 139 | 160 | readonly spec: SourceControlCliDiscoverySpec; |
| 140 | 161 | readonly process: VcsProcess.VcsProcessShape; |
| ... | ... | @@ -245,3 +266,44 @@ export function probeSourceControlProvider(input: { |
| 245 | 266 | }), |
| 246 | 267 | ); |
| 247 | 268 | } |
| 269 | ||
| 270 | export 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 | 4 | import * as Effect from "effect/Effect"; |
| 5 | 5 | import * as Layer from "effect/Layer"; |
| 6 | 6 | import * as Option from "effect/Option"; |
| 7 | import { ChildProcessSpawner } from "effect/unstable/process"; | |
| 7 | 8 | |
| 8 | 9 | import { ServerConfig } from "../config.ts"; |
| 9 | 10 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; |
| ... | ... | @@ -17,11 +18,26 @@ import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry. |
| 17 | 18 | |
| 18 | 19 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); |
| 19 | 20 | |
| 21 | const 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 | ||
| 20 | 35 | function makeRegistry(input: { |
| 21 | 36 | readonly remotes: ReadonlyArray<{ |
| 22 | 37 | readonly name: string; |
| 23 | 38 | readonly url: string; |
| 24 | 39 | }>; |
| 40 | readonly process?: Partial<VcsProcess.VcsProcessShape>; | |
| 25 | 41 | }) { |
| 26 | 42 | const driver = { |
| 27 | 43 | listRemotes: () => |
| ... | ... | @@ -58,15 +74,20 @@ function makeRegistry(input: { |
| 58 | 74 | }), |
| 59 | 75 | }); |
| 60 | 76 | |
| 77 | const processLayer = Layer.mock(VcsProcess.VcsProcess)({ | |
| 78 | run: () => Effect.succeed(processOutput("")), | |
| 79 | ...input.process, | |
| 80 | }); | |
| 81 | ||
| 61 | 82 | return SourceControlProviderRegistry.make().pipe( |
| 62 | 83 | Effect.provide( |
| 63 | 84 | Layer.mergeAll( |
| 64 | 85 | registryLayer, |
| 86 | processLayer, | |
| 65 | 87 | Layer.mock(AzureDevOpsCli.AzureDevOpsCli)({}), |
| 66 | 88 | Layer.mock(BitbucketApi.BitbucketApi)({}), |
| 67 | 89 | Layer.mock(GitHubCli.GitHubCli)({}), |
| 68 | 90 | Layer.mock(GitLabCli.GitLabCli)({}), |
| 69 | Layer.mock(VcsProcess.VcsProcess)({}), | |
| 70 | 91 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-registry-test-" }).pipe( |
| 71 | 92 | Layer.provide(NodeServices.layer), |
| 72 | 93 | ), |
| ... | ... | @@ -111,6 +132,56 @@ it.effect("routes GitLab remotes to the GitLab provider", () => |
| 111 | 132 | }), |
| 112 | 133 | ); |
| 113 | 134 | |
| 135 | it.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 | |
| 146 | self-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 | ||
| 162 | it.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 | ||
| 114 | 185 | it.effect("routes Bitbucket remotes to the Bitbucket provider", () => |
| 115 | 186 | Effect.gen(function* () { |
| 116 | 187 | const registry = yield* makeRegistry({ |
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+7-1| ... | ... | @@ -178,8 +178,14 @@ export const makeWithProviders = Effect.fn("makeSourceControlProviderRegistryWit |
| 178 | 178 | const remotes = yield* handle.driver |
| 179 | 179 | .listRemotes(cwd) |
| 180 | 180 | .pipe(Effect.mapError((error) => providerDetectionError("detectProvider", cwd, error))); |
| 181 | const context = selectProviderContext(remotes.remotes); | |
| 181 | 182 | |
| 182 | return selectProviderContext(remotes.remotes); | |
| 183 | return yield* SourceControlProviderDiscovery.refineUnknownRemoteProvider({ | |
| 184 | specs: discoverySpecs, | |
| 185 | process, | |
| 186 | cwd, | |
| 187 | context, | |
| 188 | }); | |
| 183 | 189 | }, |
| 184 | 190 | ); |
| 185 | 191 |
apps/server/src/sourceControl/azureDevOpsPullRequests.ts+73-1| ... | ... | @@ -21,6 +21,17 @@ const AzureDevOpsPullRequestSchema = Schema.Struct({ |
| 21 | 21 | pullRequestId: PositiveInt, |
| 22 | 22 | title: TrimmedNonEmptyString, |
| 23 | 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 | 35 | sourceRefName: TrimmedNonEmptyString, |
| 25 | 36 | targetRefName: TrimmedNonEmptyString, |
| 26 | 37 | status: Schema.String, |
| ... | ... | @@ -57,13 +68,74 @@ function normalizeAzureDevOpsPullRequestState(status: string): "open" | "closed" |
| 57 | 68 | } |
| 58 | 69 | } |
| 59 | 70 | |
| 71 | function encodeAzureDevOpsPathSegment(segment: string): string { | |
| 72 | return encodeURIComponent(segment); | |
| 73 | } | |
| 74 | ||
| 75 | function 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 | ||
| 107 | function 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 | ||
| 60 | 132 | function normalizeAzureDevOpsPullRequestRecord( |
| 61 | 133 | raw: Schema.Schema.Type<typeof AzureDevOpsPullRequestSchema>, |
| 62 | 134 | ): NormalizedAzureDevOpsPullRequestRecord { |
| 63 | 135 | return { |
| 64 | 136 | number: raw.pullRequestId, |
| 65 | 137 | title: raw.title, |
| 66 | url: trimOptionalString(raw._links?.web?.href) ?? trimOptionalString(raw.url) ?? "", | |
| 138 | url: normalizeAzureDevOpsPullRequestUrl(raw), | |
| 67 | 139 | baseRefName: normalizeRefName(raw.targetRefName), |
| 68 | 140 | headRefName: normalizeRefName(raw.sourceRefName), |
| 69 | 141 | state: normalizeAzureDevOpsPullRequestState(raw.status), |
apps/server/src/sourceControl/gitHubAuthStatus.ts created+72| ... | ... | @@ -0,0 +1,72 @@ |
| 1 | import * as Option from "effect/Option"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | ||
| 4 | const 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 | ||
| 12 | const GitHubAuthStatusSchema = Schema.Struct({ | |
| 13 | hosts: Schema.Record(Schema.String, Schema.Array(GitHubAuthStatusAccountSchema)), | |
| 14 | }); | |
| 15 | ||
| 16 | const decodeGitHubAuthStatusJson = Schema.decodeUnknownOption( | |
| 17 | Schema.fromJsonString(GitHubAuthStatusSchema), | |
| 18 | ); | |
| 19 | ||
| 20 | export 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 | ||
| 28 | export interface GitHubAuthStatus { | |
| 29 | readonly parsed: boolean; | |
| 30 | readonly accounts: ReadonlyArray<GitHubAuthStatusAccount>; | |
| 31 | } | |
| 32 | ||
| 33 | function nonEmptyString(value: string): string | null { | |
| 34 | const trimmed = value.trim(); | |
| 35 | return trimmed.length > 0 ? trimmed : null; | |
| 36 | } | |
| 37 | ||
| 38 | export 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 | ||
| 65 | export 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 @@ |
| 1 | const HOST_LINE_PATTERN = /^(?:[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?|\[[a-f0-9:.]+\])(?::\d+)?$/iu; | |
| 2 | const LOGGED_IN_PATTERN = /Logged in to .+? as\s+([^\s(]+)/iu; | |
| 3 | ||
| 4 | export interface GitLabAuthStatusHost { | |
| 5 | readonly host: string; | |
| 6 | readonly account: string | null; | |
| 7 | } | |
| 8 | ||
| 9 | export 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 | ||
| 44 | export 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 | 56 | detectSourceControlProviderFromRemoteUrl("git@bitbucket.org:workspace/repo.git")?.kind, |
| 57 | 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 | 149 | } |
| 150 | 150 | |
| 151 | 151 | try { |
| 152 | return new URL(trimmed).hostname.toLowerCase(); | |
| 152 | return new URL(trimmed).host.toLowerCase(); | |
| 153 | 153 | } catch { |
| 154 | 154 | return null; |
| 155 | 155 | } |
| 156 | 156 | } |
| 157 | 157 | |
| 158 | function 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 | ||
| 158 | 166 | function toBaseUrl(host: string): string { |
| 159 | 167 | return `https://${host}`; |
| 160 | 168 | } |
| ... | ... | @@ -182,24 +190,25 @@ export function detectSourceControlProviderFromRemoteUrl( |
| 182 | 190 | if (!host) { |
| 183 | 191 | return null; |
| 184 | 192 | } |
| 193 | const hostname = parseHostName(host); | |
| 185 | 194 | |
| 186 | if (isGitHubHost(host)) { | |
| 195 | if (isGitHubHost(hostname)) { | |
| 187 | 196 | return { |
| 188 | 197 | kind: "github", |
| 189 | name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted", | |
| 198 | name: hostname === "github.com" ? "GitHub" : "GitHub Self-Hosted", | |
| 190 | 199 | baseUrl: toBaseUrl(host), |
| 191 | 200 | }; |
| 192 | 201 | } |
| 193 | 202 | |
| 194 | if (isGitLabHost(host)) { | |
| 203 | if (isGitLabHost(hostname)) { | |
| 195 | 204 | return { |
| 196 | 205 | kind: "gitlab", |
| 197 | name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", | |
| 206 | name: hostname === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", | |
| 198 | 207 | baseUrl: toBaseUrl(host), |
| 199 | 208 | }; |
| 200 | 209 | } |
| 201 | 210 | |
| 202 | if (isAzureDevOpsHost(host)) { | |
| 211 | if (isAzureDevOpsHost(hostname)) { | |
| 203 | 212 | return { |
| 204 | 213 | kind: "azure-devops", |
| 205 | 214 | name: "Azure DevOps", |
| ... | ... | @@ -207,10 +216,10 @@ export function detectSourceControlProviderFromRemoteUrl( |
| 207 | 216 | }; |
| 208 | 217 | } |
| 209 | 218 | |
| 210 | if (isBitbucketHost(host)) { | |
| 219 | if (isBitbucketHost(hostname)) { | |
| 211 | 220 | return { |
| 212 | 221 | kind: "bitbucket", |
| 213 | name: host === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", | |
| 222 | name: hostname === "bitbucket.org" ? "Bitbucket" : "Bitbucket Self-Hosted", | |
| 214 | 223 | baseUrl: toBaseUrl(host), |
| 215 | 224 | }; |
| 216 | 225 | } |