| 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,6 +64,7 @@ describe("AzureDevOpsCli.layer", () => { |
| 64 | 64 | ||
| 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 | ); |
| 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 | 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"; |
| 7 | 7 | ||
| 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | 8 | import * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 9 | import * as GitHubCli from "./GitHubCli.ts"; | 9 | import * as GitHubCli from "./GitHubCli.ts"; |
| 10 | import { parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; | ||
| 10 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; | 11 | import * as GitHubSourceControlProvider from "./GitHubSourceControlProvider.ts"; |
| 11 | 12 | ||
| 12 | const processResult = (stdout: string): VcsProcess.VcsProcessOutput => ({ | 13 | const 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 | |||
| 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,6 +10,7 @@ import { |
| 10 | } from "@t3tools/contracts"; | 10 | } from "@t3tools/contracts"; |
| 11 | 11 | ||
| 12 | import * as GitHubCli from "./GitHubCli.ts"; | 12 | import * as GitHubCli from "./GitHubCli.ts"; |
| 13 | import { findAuthenticatedGitHubAccount, parseGitHubAuthStatus } from "./gitHubAuthStatus.ts"; | ||
| 13 | import * as GitHubPullRequests from "./gitHubPullRequests.ts"; | 14 | import * as GitHubPullRequests from "./gitHubPullRequests.ts"; |
| 14 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | 15 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 15 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | 16 | import * 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 |
| 51 | 52 | ||
| 52 | function parseGitHubAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { | 53 | function 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 | } | ||
| 59 | 77 | ||
| 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 | } |
| 69 | 87 | ||
| 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"; |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; | 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 5 | 6 | ||
| 6 | import * as GitLabCli from "./GitLabCli.ts"; | 7 | import * as GitLabCli from "./GitLabCli.ts"; |
| 8 | import { parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; | ||
| 7 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; | 9 | import * as GitLabSourceControlProvider from "./GitLabSourceControlProvider.ts"; |
| 8 | 10 | ||
| 9 | function makeProvider(gitlab: Partial<GitLabCli.GitLabCliShape>) { | 11 | function 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 | |||
| 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 +6,7 @@ import { SourceControlProviderError, type ChangeRequest } from "@t3tools/contrac |
| 6 | import * as GitLabCli from "./GitLabCli.ts"; | 6 | import * as GitLabCli from "./GitLabCli.ts"; |
| 7 | import * as SourceControlProvider from "./SourceControlProvider.ts"; | 7 | import * as SourceControlProvider from "./SourceControlProvider.ts"; |
| 8 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; | 8 | import * as SourceControlProviderDiscovery from "./SourceControlProviderDiscovery.ts"; |
| 9 | import { findAuthenticatedGitLabHost, parseGitLabAuthStatusHosts } from "./gitLabAuthStatus.ts"; | ||
| 9 | 10 | ||
| 10 | function providerError( | 11 | function 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 |
| 43 | 44 | ||
| 44 | function parseGitLabAuth(input: SourceControlProviderDiscovery.SourceControlAuthProbeInput) { | 45 | function 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 | } | ||
| 52 | 60 | ||
| 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 | } |
| 62 | 70 | ||
| 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 | } |
| 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 | export const discovery = { | 99 | export 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.com | 64 | processOutput( |
| 65 | Logged in to github.com account juliusmarminge (keyring) | 65 | JSON.stringify({ |
| 66 | - Active account: true | 66 | hosts: { |
| 67 | - Git operations protocol: ssh | 67 | "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.com | 178 | processOutput( |
| 170 | Logged 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 @@ |
| 1 | import type { | 1 | import type { |
| 2 | SourceControlProviderAuth, | 2 | SourceControlProviderAuth, |
| 3 | SourceControlProviderDiscoveryItem, | 3 | SourceControlProviderDiscoveryItem, |
| 4 | SourceControlProviderInfo, | ||
| 4 | SourceControlProviderKind, | 5 | SourceControlProviderKind, |
| 5 | } from "@t3tools/contracts"; | 6 | } from "@t3tools/contracts"; |
| 6 | import * as Effect from "effect/Effect"; | 7 | import * as Effect from "effect/Effect"; |
| 7 | import * as Option from "effect/Option"; | 8 | import * as Option from "effect/Option"; |
| 8 | 9 | ||
| 10 | import type * as SourceControlProvider from "./SourceControlProvider.ts"; | ||
| 9 | import type * as VcsProcess from "../vcs/VcsProcess.ts"; | 11 | import type * as VcsProcess from "../vcs/VcsProcess.ts"; |
| 10 | 12 | ||
| 11 | export interface SourceControlAuthProbeInput { | 13 | export 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 | } |
| 16 | 18 | ||
| 19 | export interface SourceControlUnknownRemoteRefinementInput { | ||
| 20 | readonly cwd: string; | ||
| 21 | readonly context: SourceControlProvider.SourceControlProviderContext; | ||
| 22 | readonly auth: SourceControlAuthProbeInput; | ||
| 23 | } | ||
| 24 | |||
| 17 | interface SourceControlDiscoverySpecBase { | 25 | interface 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 | }; |
| 30 | 41 | ||
| 31 | export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { | 42 | export type SourceControlApiDiscoverySpec = SourceControlDiscoverySpecBase & { |
| ... | @@ -37,6 +48,10 @@ export type SourceControlProviderDiscoverySpec = | ... | @@ -37,6 +48,10 @@ export type SourceControlProviderDiscoverySpec = |
| 37 | | SourceControlCliDiscoverySpec | 48 | | SourceControlCliDiscoverySpec |
| 38 | | SourceControlApiDiscoverySpec; | 49 | | SourceControlApiDiscoverySpec; |
| 39 | 50 | ||
| 51 | type SourceControlCliRemoteRefinementSpec = SourceControlCliDiscoverySpec & { | ||
| 52 | readonly refineUnknownRemote: NonNullable<SourceControlCliDiscoverySpec["refineUnknownRemote"]>; | ||
| 53 | }; | ||
| 54 | |||
| 40 | interface DiscoveryProbeResult { | 55 | interface 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 | } |
| 137 | 152 | ||
| 153 | function isCliRemoteRefinementSpec( | ||
| 154 | spec: SourceControlProviderDiscoverySpec, | ||
| 155 | ): spec is SourceControlCliRemoteRefinementSpec { | ||
| 156 | return spec.type === "cli" && spec.refineUnknownRemote !== undefined; | ||
| 157 | } | ||
| 158 | |||
| 138 | function probeCli(input: { | 159 | function 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 | |||
| 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,6 +4,7 @@ import * as DateTime from "effect/DateTime"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; | 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | 6 | import * as Option from "effect/Option"; |
| 7 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 7 | 8 | ||
| 8 | import { ServerConfig } from "../config.ts"; | 9 | import { ServerConfig } from "../config.ts"; |
| 9 | import type * as VcsDriver from "../vcs/VcsDriver.ts"; | 10 | import 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. |
| 17 | 18 | ||
| 18 | const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); | 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 | function makeRegistry(input: { | 35 | function 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 | }); |
| 60 | 76 | ||
| 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 | ); |
| 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 | it.effect("routes Bitbucket remotes to the Bitbucket provider", () => | 185 | it.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.driver | 178 | 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); | ||
| 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,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 | } |
| 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 | function normalizeAzureDevOpsPullRequestRecord( | 132 | function 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 @@ | ||
| 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,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 | } |
| 150 | 150 | ||
| 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 | } |
| 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 | function toBaseUrl(host: string): string { | 166 | function 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); | ||
| 185 | 194 | ||
| 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 | } |
| 193 | 202 | ||
| 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 | } |
| 201 | 210 | ||
| 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 | } |
| 209 | 218 | ||
| 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 | } |