From 2f30c13a94a02b6c2bce3ec93743605115bf93d4 Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Mon, 27 Jul 2026 09:17:00 +0200 Subject: [PATCH 1/6] Detect repository provider for self-hosted VCS hosts --- README.md | 36 ++- examples/circleci-continuous/config.yml | 2 + examples/circleci-scheduled/config.yml | 4 + src/ci-env.test.ts | 295 ++++++++++++++++++++++- src/ci-env.ts | 233 ++++++++++++++++++ src/git.test.ts | 305 +++++++++++++++++++++--- src/git.ts | 180 +++++++++++--- src/index.test.ts | 278 +++++++++++++++++++++ src/index.ts | 41 +++- src/types.ts | 16 +- 10 files changed, 1314 insertions(+), 76 deletions(-) create mode 100644 src/index.test.ts diff --git a/README.md b/README.md index 235ddc0..f7b798d 100644 --- a/README.md +++ b/README.md @@ -143,9 +143,39 @@ linear-release update --stage="in review" --name="Release 1.2.0" ### Environment Variables -| Variable | Required | Description | -| ------------------- | -------- | ------------------------------- | -| `LINEAR_ACCESS_KEY` | Yes | Pipeline access key from Linear | +| Variable | Required | Description | +| ------------------------------------ | -------- | ------------------------------------------------------------------------------------------- | +| `LINEAR_ACCESS_KEY` | Yes | Pipeline access key from Linear | +| `LINEAR_RELEASE_REPOSITORY_PROVIDER` | No | Force repository provider detection: `github`, `gitlab`, or `bitbucket` (case-insensitive). | + +### Provider detection + +When `sync` finds an `origin` remote, it determines the repository provider in this order: + +1. `LINEAR_RELEASE_REPOSITORY_PROVIDER`, if set. +2. The remote hostname, including the existing GitHub, GitLab, and Bitbucket hostname matching. +3. CI platform signals, when they can be bound to the checked-out remote by matching its host or repository path. + +CI inference supports GitLab CI, GitHub Actions, Bitbucket Pipelines, Buildkite, Azure Pipelines repositories hosted on GitHub or Bitbucket, AppVeyor, and Semaphore. Hostname detection retains the existing substring matching and GitLab → GitHub → Bitbucket precedence. + +Use the override for self-hosted providers on custom domains or CI platforms without a trustworthy provider signal: + +```bash +LINEAR_RELEASE_REPOSITORY_PROVIDER=gitlab linear-release sync +``` + +For CircleCI, wire its project type into the job environment: + +```yaml +environment: + LINEAR_RELEASE_REPOSITORY_PROVIDER: << pipeline.project.type >> # "github"|"bitbucket"|"gitlab" +``` + +If the provider cannot be determined for a remote, `sync` stops before making a mutation and explains how to set the override. These deterministic configuration errors exit with code `2`; other errors continue to exit with code `1`. With `--json`, the error emitted on stderr includes a machine-readable code such as `{"error":"unknown-provider","host":"git.example.com"}`. + +The CLI does not probe unknown VCS hosts. Azure Repos, Gitea, Forgejo, and other systems without a supported provider value cannot be synced with repository metadata. A repository with no `origin` remote retains the existing behavior of omitting repository metadata. + +For `ssh://` remotes, the SSH port is omitted from the generated web URL. Outside GitLab CI or GitHub Actions, a dedicated SSH hostname may therefore point at a host that does not serve the web UI; those CI environments substitute their canonical project URL when the remote is bound by project path. Bitbucket Server `/scm/PROJECT/repository` URLs are corrected, but relative installs such as `/bitbucket/scm/...` are not. ### CLI Options diff --git a/examples/circleci-continuous/config.yml b/examples/circleci-continuous/config.yml index 36179e3..a2061e6 100644 --- a/examples/circleci-continuous/config.yml +++ b/examples/circleci-continuous/config.yml @@ -11,6 +11,8 @@ jobs: linear-release-sync: docker: - image: cimg/base:current + environment: + LINEAR_RELEASE_REPOSITORY_PROVIDER: << pipeline.project.type >> steps: - checkout - run: diff --git a/examples/circleci-scheduled/config.yml b/examples/circleci-scheduled/config.yml index 3cf408a..1768e34 100644 --- a/examples/circleci-scheduled/config.yml +++ b/examples/circleci-scheduled/config.yml @@ -33,6 +33,8 @@ jobs: linear-release-sync-main: docker: - image: cimg/base:current + environment: + LINEAR_RELEASE_REPOSITORY_PROVIDER: << pipeline.project.type >> steps: - checkout - run: @@ -47,6 +49,8 @@ jobs: linear-release-sync-release: docker: - image: cimg/base:current + environment: + LINEAR_RELEASE_REPOSITORY_PROVIDER: << pipeline.project.type >> steps: - checkout - run: diff --git a/src/ci-env.test.ts b/src/ci-env.test.ts index 7365e5d..5a45b07 100644 --- a/src/ci-env.test.ts +++ b/src/ci-env.test.ts @@ -1,5 +1,14 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { detectCIEnvironment } from "./ci-env"; +import { ConfigurationError, detectCIEnvironment, inferProviderFromCI } from "./ci-env"; +import { parseRepoUrl } from "./git"; + +function remote(url: string) { + const parsed = parseRepoUrl(url); + if (!parsed) { + throw new Error(`Could not parse test remote ${url}`); + } + return parsed; +} describe("detectCIEnvironment", () => { const originalEnv = process.env; @@ -82,3 +91,287 @@ describe("detectCIEnvironment", () => { expect(detectCIEnvironment()).toEqual({ name: "github-actions" }); }); }); + +describe("inferProviderFromCI", () => { + it("infers when the CI host matches even if the project path differs", () => { + expect( + inferProviderFromCI( + { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com", CI_PROJECT_PATH: "group/other-repo" }, + remote("https://git.example.com/group/checked-out-repo.git"), + ), + ).toEqual({ provider: "gitlab" }); + }); + + it("infers when clone_url rewrites the host but the project path matches", () => { + expect( + inferProviderFromCI( + { + GITLAB_CI: "true", + CI_SERVER_HOST: "gitlab.example.com", + CI_PROJECT_PATH: "group/project", + CI_PROJECT_URL: "https://gitlab.example.com/group/project", + }, + remote("https://clone.internal/group/project.git"), + ), + ).toEqual({ + provider: "gitlab", + owner: "group", + name: "project", + url: "https://gitlab.example.com/group/project", + }); + }); + + it("does not infer for a foreign clone when both host and path mismatch", () => { + expect( + inferProviderFromCI( + { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com", CI_PROJECT_PATH: "group/project" }, + remote("https://foreign.example.com/other/repository.git"), + ), + ).toBeNull(); + }); + + describe("GitLab", () => { + it("preserves nested-group repository identity", () => { + expect( + inferProviderFromCI( + { + GITLAB_CI: "true", + CI_PROJECT_PATH: "org/group/subgroup/repo", + CI_PROJECT_URL: "https://git.example.com/org/group/subgroup/repo", + }, + remote("https://clone.internal/org/group/subgroup/repo.git"), + ), + ).toMatchInlineSnapshot(` + { + "name": "group/subgroup/repo", + "owner": "org", + "provider": "gitlab", + "url": "https://git.example.com/org/group/subgroup/repo", + } + `); + }); + + it("corrects a relative-URL install with the canonical project URL", () => { + expect( + inferProviderFromCI( + { + GITLAB_CI: "true", + CI_PROJECT_PATH: "group/repo", + CI_PROJECT_URL: "https://example.com/gitlab/group/repo", + }, + remote("https://example.com/gitlab/group/repo.git"), + ), + ).toEqual({ + provider: "gitlab", + owner: "group", + name: "repo", + url: "https://example.com/gitlab/group/repo", + }); + }); + + it("binds token-bearing runner URLs by path", () => { + expect( + inferProviderFromCI( + { + GITLAB_CI: "true", + CI_PROJECT_PATH: "group/repo", + CI_PROJECT_URL: "https://git.example.com/group/repo", + }, + remote("https://gitlab-ci-token:secret@runner.internal/group/repo.git"), + ), + ).toEqual({ + provider: "gitlab", + owner: "group", + name: "repo", + url: "https://git.example.com/group/repo", + }); + }); + + it("uses CI_SERVER_SHELL_SSH_HOST as a binding host", () => { + expect( + inferProviderFromCI( + { GITLAB_CI: "true", CI_SERVER_SHELL_SSH_HOST: "ssh.git.example.com" }, + remote("ssh://git@ssh.git.example.com:2222/group/repo.git"), + ), + ).toEqual({ provider: "gitlab" }); + }); + + it("matches CI_PROJECT_PATH literally when it contains regex metacharacters", () => { + expect( + inferProviderFromCI( + { + GITLAB_CI: "true", + CI_PROJECT_PATH: "group[one]/repo.+", + CI_PROJECT_URL: "https://git.example.com/group[one]/repo.+", + }, + remote("https://clone.internal/group[one]/repo.+.git"), + ), + ).toEqual({ + provider: "gitlab", + owner: "group[one]", + name: "repo.+", + url: "https://git.example.com/group[one]/repo.+", + }); + }); + + it("infers without enrichment when pre-15.11 variables are absent", () => { + expect( + inferProviderFromCI( + { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com" }, + remote("https://git.example.com/group/repo.git"), + ), + ).toEqual({ provider: "gitlab" }); + }); + }); + + it("enriches GitHub Actions repositories from the bound repository path", () => { + expect( + inferProviderFromCI( + { + GITHUB_ACTIONS: "true", + GITHUB_SERVER_URL: "https://github.enterprise.example", + GITHUB_REPOSITORY: "octo/project", + }, + remote("https://clone.internal/octo/project.git"), + ), + ).toEqual({ + provider: "github", + owner: "octo", + name: "project", + url: "https://github.enterprise.example/octo/project", + }); + }); + + it("infers Bitbucket from a bound Pipelines origin", () => { + expect( + inferProviderFromCI( + { BITBUCKET_GIT_HTTP_ORIGIN: "https://bitbucket.internal/workspace/repo.git" }, + remote("https://clone.internal/workspace/repo.git"), + ), + ).toEqual({ provider: "bitbucket" }); + }); + + describe("Buildkite", () => { + it("accepts enterprise provider variants at the documented boundary", () => { + expect( + inferProviderFromCI( + { BUILDKITE_PIPELINE_PROVIDER: " github_enterprise ", BUILDKITE_REPO: "git@code.example.com:team/repo.git" }, + remote("https://clone.internal/team/repo.git"), + ), + ).toEqual({ provider: "github" }); + }); + + it("does not match provider prefixes outside the boundary", () => { + expect( + inferProviderFromCI( + { BUILDKITE_PIPELINE_PROVIDER: "githubish", BUILDKITE_REPO: "https://code.example.com/team/repo.git" }, + remote("https://code.example.com/team/repo.git"), + ), + ).toBeNull(); + }); + }); + + describe("Azure Pipelines", () => { + it("raises the Azure Repos configuration error for a bound TfsGit repository", () => { + expect(() => + inferProviderFromCI( + { BUILD_REPOSITORY_PROVIDER: "TfsGit", BUILD_REPOSITORY_URI: "https://dev.azure.com/org/project/_git/repo" }, + remote("https://dev.azure.com/org/project/_git/repo.git"), + ), + ).toThrow(ConfigurationError); + try { + inferProviderFromCI( + { BUILD_REPOSITORY_PROVIDER: "TfsGit", BUILD_REPOSITORY_URI: "https://dev.azure.com/org/project/_git/repo" }, + remote("https://dev.azure.com/org/project/_git/repo.git"), + ); + } catch (error) { + expect(error).toMatchObject({ code: "unsupported-azure-repos" }); + } + }); + + it("only infers observed Bitbucket support with URI corroboration", () => { + expect( + inferProviderFromCI( + { BUILD_REPOSITORY_PROVIDER: "Bitbucket", BUILD_REPOSITORY_URI: "https://bitbucket.example/team/repo.git" }, + remote("https://foreign.example/other/repo.git"), + ), + ).toBeNull(); + expect( + inferProviderFromCI( + { BUILD_REPOSITORY_PROVIDER: "Bitbucket", BUILD_REPOSITORY_URI: "https://bitbucket.example/team/repo.git" }, + remote("https://clone.internal/team/repo.git"), + ), + ).toEqual({ provider: "bitbucket" }); + }); + }); + + it("maps AppVeyor provider prefixes when the repository path is bound", () => { + expect( + inferProviderFromCI( + { APPVEYOR_REPO_PROVIDER: "gitLabEnterprise", APPVEYOR_REPO_NAME: "group/repo" }, + remote("https://clone.internal/group/repo.git"), + ), + ).toEqual({ provider: "gitlab" }); + expect( + inferProviderFromCI( + { APPVEYOR_REPO_PROVIDER: "stash", APPVEYOR_REPO_NAME: "team/repo" }, + remote("https://clone.internal/team/repo.git"), + ), + ).toEqual({ provider: "bitbucket" }); + }); + + it("infers Semaphore's documented providers only for a bound repository", () => { + expect( + inferProviderFromCI( + { SEMAPHORE_GIT_PROVIDER: "github", SEMAPHORE_GIT_URL: "https://github.example/team/repo.git" }, + remote("https://clone.internal/team/repo.git"), + ), + ).toEqual({ provider: "github" }); + expect( + inferProviderFromCI( + { SEMAPHORE_GIT_PROVIDER: "gitlab", SEMAPHORE_GIT_URL: "https://gitlab.example/team/repo.git" }, + remote("https://clone.internal/team/repo.git"), + ), + ).toBeNull(); + }); + + it("falls through unknown values and signal-less CI platforms", () => { + expect( + inferProviderFromCI( + { + CIRCLECI: "true", + BUILDKITE_PIPELINE_PROVIDER: "forgejo", + BUILD_REPOSITORY_PROVIDER: "Git", + APPVEYOR_REPO_PROVIDER: "unknown", + }, + remote("https://code.example/team/repo.git"), + ), + ).toBeNull(); + }); + + it("falls through when simultaneous CI signals infer different providers", () => { + expect( + inferProviderFromCI( + { + GITLAB_CI: "true", + CI_SERVER_HOST: "gitlab.example", + GITHUB_ACTIONS: "true", + GITHUB_SERVER_URL: "https://github.example", + }, + remote("https://gitlab.example/team/repo.git"), + ), + ).toEqual({ provider: "gitlab" }); + + expect( + inferProviderFromCI( + { + GITLAB_CI: "true", + CI_PROJECT_PATH: "team/repo", + GITHUB_ACTIONS: "true", + GITHUB_REPOSITORY: "team/repo", + }, + remote("https://clone.example/team/repo.git"), + ), + ).toBeNull(); + }); +}); diff --git a/src/ci-env.ts b/src/ci-env.ts index ca6ecea..68d4d51 100644 --- a/src/ci-env.ts +++ b/src/ci-env.ts @@ -1,7 +1,240 @@ +import type { RepoInfo, RepositoryProvider } from "./types"; + export interface CIEnvironment { name: string; } +export type ConfigurationErrorCode = "invalid-provider-override" | "unknown-provider" | "unsupported-azure-repos"; + +export class ConfigurationError extends Error { + constructor( + message: string, + readonly code: ConfigurationErrorCode, + readonly details: Record = {}, + ) { + super(message); + this.name = "ConfigurationError"; + } +} + +export type CIProviderInference = { + provider: RepositoryProvider; + owner?: string; + name?: string; + url?: string; +}; + +type Environment = Record; + +type Binding = { + hosts: string[]; + paths: string[]; +}; + +function normalizeHost(value: string): string { + let authority = value.trim(); + const userinfoIndex = authority.lastIndexOf("@"); + if (userinfoIndex !== -1) { + authority = authority.slice(userinfoIndex + 1); + } + if (authority.startsWith("[")) { + const end = authority.indexOf("]"); + if (end !== -1) { + return authority.slice(1, end).toLowerCase().replace(/\.$/, ""); + } + } + const lastColon = authority.lastIndexOf(":"); + if (lastColon !== -1 && authority.indexOf(":") === lastColon && /^\d+$/.test(authority.slice(lastColon + 1))) { + authority = authority.slice(0, lastColon); + } + return authority.toLowerCase().replace(/\.$/, ""); +} + +function normalizeDeclaredHost(value: string | undefined): string | null { + if (!value?.trim()) { + return null; + } + try { + return normalizeHost(new URL(value).host); + } catch { + return normalizeHost(value); + } +} + +function normalizePath(value: string): string { + return value + .trim() + .replace(/^\/+|\/+$/g, "") + .replace(/\.git$/i, ""); +} + +function parseBindingUrl(value: string | undefined): { host: string; path: string } | null { + if (!value?.trim()) { + return null; + } + try { + const parsed = new URL(value); + return { + host: normalizeHost(parsed.host), + path: normalizePath(parsed.pathname), + }; + } catch { + const scpMatch = value.trim().match(/^(?:[^@]+@)?([^:]+):(.+)$/); + if (!scpMatch?.[1] || !scpMatch[2]) { + return null; + } + return { + host: normalizeHost(scpMatch[1]), + path: normalizePath(scpMatch[2]), + }; + } +} + +function pathMatches(remotePath: string, declaredPath: string): boolean { + const normalizedRemote = normalizePath(remotePath); + const normalizedDeclared = normalizePath(declaredPath); + return ( + normalizedDeclared.length > 0 && + (normalizedRemote === normalizedDeclared || normalizedRemote.endsWith(`/${normalizedDeclared}`)) + ); +} + +function getBinding(remote: RepoInfo, binding: Binding): { bound: boolean; pathMatched: boolean } { + const hostMatched = binding.hosts.some((host) => host === remote.host); + const pathMatched = binding.paths.some((path) => pathMatches(remote.path, path)); + // Host OR path suffices: GitLab runner clone_url rewrites the origin host + // while preserving the project path, so a host mismatch alone must not + // block inference. Both mismatching means a foreign checkout. + return { bound: hostMatched || pathMatched, pathMatched }; +} + +// Splits CI_PROJECT_PATH-style values on the first slash, mirroring +// createRepoInfo — NOT CI_PROJECT_NAMESPACE, which contains the whole +// subgroup chain and would change repository identity for nested groups. +function splitProjectPath(path: string): { owner: string; name: string } | null { + const normalized = normalizePath(path); + const slash = normalized.indexOf("/"); + if (slash <= 0 || slash === normalized.length - 1) { + return null; + } + return { + owner: normalized.slice(0, slash), + name: normalized.slice(slash + 1), + }; +} + +function compact(values: Array): T[] { + return values.filter((value): value is T => value !== null); +} + +export function inferProviderFromCI(env: Environment, remote: RepoInfo): CIProviderInference | null { + const candidates: CIProviderInference[] = []; + let azureReposBound = false; + + if (env.GITLAB_CI === "true") { + const projectPath = env.CI_PROJECT_PATH; + const binding = getBinding(remote, { + hosts: compact([normalizeDeclaredHost(env.CI_SERVER_HOST), normalizeDeclaredHost(env.CI_SERVER_SHELL_SSH_HOST)]), + paths: projectPath ? [projectPath] : [], + }); + if (binding.bound) { + const project = binding.pathMatched && projectPath ? splitProjectPath(projectPath) : null; + candidates.push({ + provider: "gitlab", + ...(project ?? {}), + ...(project && env.CI_PROJECT_URL ? { url: env.CI_PROJECT_URL.trim().replace(/\/+$/, "") } : {}), + }); + } + } + + if (env.GITHUB_ACTIONS === "true") { + const repository = env.GITHUB_REPOSITORY; + const binding = getBinding(remote, { + hosts: compact([normalizeDeclaredHost(env.GITHUB_SERVER_URL)]), + paths: repository ? [repository] : [], + }); + if (binding.bound) { + const project = binding.pathMatched && repository ? splitProjectPath(repository) : null; + const serverUrl = env.GITHUB_SERVER_URL?.trim().replace(/\/+$/, ""); + candidates.push({ + provider: "github", + ...(project ?? {}), + ...(project && serverUrl ? { url: `${serverUrl}/${normalizePath(repository!)}` } : {}), + }); + } + } + + if (env.BITBUCKET_GIT_HTTP_ORIGIN || env.BITBUCKET_REPO_FULL_NAME) { + const origin = parseBindingUrl(env.BITBUCKET_GIT_HTTP_ORIGIN); + const binding = getBinding(remote, { + hosts: origin ? [origin.host] : [], + paths: compact([origin?.path ?? null, env.BITBUCKET_REPO_FULL_NAME ?? null]), + }); + if (binding.bound) { + candidates.push({ provider: "bitbucket" }); + } + } + + const buildkiteMatch = env.BUILDKITE_PIPELINE_PROVIDER?.trim() + .toLowerCase() + .match(/^(github|gitlab|bitbucket)(?:_|$)/); + if (buildkiteMatch?.[1]) { + const repository = parseBindingUrl(env.BUILDKITE_REPO); + if (repository && getBinding(remote, { hosts: [repository.host], paths: [repository.path] }).bound) { + candidates.push({ provider: buildkiteMatch[1] as RepositoryProvider }); + } + } + + const azureProvider = env.BUILD_REPOSITORY_PROVIDER?.trim().toLowerCase(); + if (azureProvider) { + const repository = parseBindingUrl(env.BUILD_REPOSITORY_URI); + const bound = + repository !== null && getBinding(remote, { hosts: [repository.host], paths: [repository.path] }).bound; + if (bound && azureProvider === "github") { + candidates.push({ provider: "github" }); + } else if (bound && azureProvider === "bitbucket") { + candidates.push({ provider: "bitbucket" }); + } else if (bound && azureProvider === "tfsgit") { + azureReposBound = true; + } + } + + const appVeyorProvider = env.APPVEYOR_REPO_PROVIDER?.trim().toLowerCase(); + const appVeyorPath = env.APPVEYOR_REPO_NAME; + if (appVeyorProvider && appVeyorPath && pathMatches(remote.path, appVeyorPath)) { + if (appVeyorProvider.startsWith("github")) { + candidates.push({ provider: "github" }); + } else if (appVeyorProvider.startsWith("gitlab")) { + candidates.push({ provider: "gitlab" }); + } else if (appVeyorProvider.startsWith("bitbucket") || appVeyorProvider.startsWith("stash")) { + candidates.push({ provider: "bitbucket" }); + } + } + + const semaphoreProvider = env.SEMAPHORE_GIT_PROVIDER?.trim().toLowerCase(); + if (semaphoreProvider === "github" || semaphoreProvider === "bitbucket") { + const repository = parseBindingUrl(env.SEMAPHORE_GIT_URL); + if (repository && getBinding(remote, { hosts: [repository.host], paths: [repository.path] }).bound) { + candidates.push({ provider: semaphoreProvider }); + } + } + + const providers = new Set(candidates.map((candidate) => candidate.provider)); + if (providers.size > 1 || (azureReposBound && candidates.length > 0)) { + return null; + } + if (azureReposBound) { + throw new ConfigurationError( + "Azure Repos repositories are not supported because the Linear API has no Azure Repos provider value.", + "unsupported-azure-repos", + ); + } + if (candidates.length === 0) { + return null; + } + return candidates.find((candidate) => candidate.owner || candidate.name || candidate.url) ?? candidates[0]!; +} + /** * Detects the CI environment based on environment variables. * Returns null if not running in a recognized CI environment. diff --git a/src/git.test.ts b/src/git.test.ts index dd3cb1c..775587b 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -12,12 +12,14 @@ import { getCommitContext, getCommitContextsBetweenShas, getCommitParents, - getRepoInfo, + getRemoteUrl, isAncestor, normalizePathspec, parseRepoUrl, + resolveRepoInfo, resolveFirstSyncBoundary, } from "./git"; +import { ConfigurationError } from "./ci-env"; describe("normalizePathspec", () => { it("should strip leading ./", () => { @@ -128,14 +130,18 @@ describe("extractBranchName", () => { }); }); -describe("getRepoInfo", () => { +describe("repository remote resolution", () => { it("should return the repo info", () => { - const result = getRepoInfo(); - expect(result).toBeDefined(); - expect(result?.owner).toBe("linear"); - expect(result?.name).toBe("linear-release"); - expect(result?.provider).toBe("github"); - expect(result?.url).toBe("https://github.com/linear/linear-release"); + const remoteUrl = getRemoteUrl(); + expect(remoteUrl).toBeDefined(); + const parsed = parseRepoUrl(remoteUrl!); + expect(parsed).toBeDefined(); + expect(resolveRepoInfo(parsed!)).toEqual({ + owner: "linear", + name: "linear-release", + provider: "github", + url: "https://github.com/linear/linear-release", + }); }); }); @@ -143,7 +149,7 @@ describe("parseRepoUrl", () => { describe("HTTPS URLs", () => { it("should parse github.com HTTPS URL", () => { const result = parseRepoUrl("https://github.com/linear/linear-app.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "linear", name: "linear-app", provider: "github", @@ -153,7 +159,7 @@ describe("parseRepoUrl", () => { it("should parse github.com HTTPS URL without .git suffix", () => { const result = parseRepoUrl("https://github.com/linear/linear-app"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "linear", name: "linear-app", provider: "github", @@ -163,7 +169,7 @@ describe("parseRepoUrl", () => { it("should parse gitlab.com HTTPS URL", () => { const result = parseRepoUrl("https://gitlab.com/myorg/myrepo.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "myorg", name: "myrepo", provider: "gitlab", @@ -173,7 +179,7 @@ describe("parseRepoUrl", () => { it("should parse GitHub Enterprise HTTPS URL", () => { const result = parseRepoUrl("https://github.mycompany.com/engineering/platform.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "engineering", name: "platform", provider: "github", @@ -183,7 +189,7 @@ describe("parseRepoUrl", () => { it("should parse self-hosted GitLab HTTPS URL", () => { const result = parseRepoUrl("https://gitlab.internal.io/team/service.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "team", name: "service", provider: "gitlab", @@ -193,7 +199,7 @@ describe("parseRepoUrl", () => { it("should parse gitlab.com HTTPS URL with nested groups", () => { const result = parseRepoUrl("https://gitlab.com/my-org/my-group/my-repo.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "my-org", name: "my-group/my-repo", provider: "gitlab", @@ -203,7 +209,7 @@ describe("parseRepoUrl", () => { it("should parse gitlab.com HTTPS URL with deeply nested groups", () => { const result = parseRepoUrl("https://gitlab.com/org/group/subgroup/repo.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "org", name: "group/subgroup/repo", provider: "gitlab", @@ -213,7 +219,7 @@ describe("parseRepoUrl", () => { it("should parse self-hosted GitLab HTTPS URL with nested groups and no .git suffix", () => { const result = parseRepoUrl("https://gitlab.internal.io/team/platform/service"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "team", name: "platform/service", provider: "gitlab", @@ -223,7 +229,7 @@ describe("parseRepoUrl", () => { it("should parse bitbucket.org HTTPS URL", () => { const result = parseRepoUrl("https://bitbucket.org/myorg/myrepo.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "myorg", name: "myrepo", provider: "bitbucket", @@ -233,7 +239,7 @@ describe("parseRepoUrl", () => { it("should parse self-hosted Bitbucket HTTPS URL", () => { const result = parseRepoUrl("https://bitbucket.mycompany.com/team/service.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "team", name: "service", provider: "bitbucket", @@ -243,7 +249,7 @@ describe("parseRepoUrl", () => { it("should parse HTTPS URL with credentials", () => { const result = parseRepoUrl("https://token@github.com/linear/linear-app.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "linear", name: "linear-app", provider: "github", @@ -255,7 +261,7 @@ describe("parseRepoUrl", () => { describe("SSH URLs", () => { it("should parse github.com SSH URL", () => { const result = parseRepoUrl("git@github.com:linear/linear-app.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "linear", name: "linear-app", provider: "github", @@ -265,7 +271,7 @@ describe("parseRepoUrl", () => { it("should parse github.com SSH URL without .git suffix", () => { const result = parseRepoUrl("git@github.com:linear/linear-app"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "linear", name: "linear-app", provider: "github", @@ -275,7 +281,7 @@ describe("parseRepoUrl", () => { it("should parse gitlab.com SSH URL", () => { const result = parseRepoUrl("git@gitlab.com:myorg/myrepo.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "myorg", name: "myrepo", provider: "gitlab", @@ -285,7 +291,7 @@ describe("parseRepoUrl", () => { it("should parse GitHub Enterprise SSH URL", () => { const result = parseRepoUrl("git@github.mycompany.com:engineering/platform.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "engineering", name: "platform", provider: "github", @@ -295,7 +301,7 @@ describe("parseRepoUrl", () => { it("should parse self-hosted GitLab SSH URL", () => { const result = parseRepoUrl("git@gitlab.internal.io:team/service.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "team", name: "service", provider: "gitlab", @@ -305,7 +311,7 @@ describe("parseRepoUrl", () => { it("should parse gitlab.com SSH URL with nested groups", () => { const result = parseRepoUrl("git@gitlab.com:my-org/my-group/my-repo.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "my-org", name: "my-group/my-repo", provider: "gitlab", @@ -315,7 +321,7 @@ describe("parseRepoUrl", () => { it("should parse gitlab.com SSH URL with deeply nested groups", () => { const result = parseRepoUrl("git@gitlab.com:org/group/subgroup/repo.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "org", name: "group/subgroup/repo", provider: "gitlab", @@ -325,7 +331,7 @@ describe("parseRepoUrl", () => { it("should parse bitbucket.org SSH URL", () => { const result = parseRepoUrl("git@bitbucket.org:myorg/myrepo.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "myorg", name: "myrepo", provider: "bitbucket", @@ -335,7 +341,7 @@ describe("parseRepoUrl", () => { it("should parse self-hosted Bitbucket SSH URL", () => { const result = parseRepoUrl("git@bitbucket.mycompany.com:team/service.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "team", name: "service", provider: "bitbucket", @@ -347,7 +353,7 @@ describe("parseRepoUrl", () => { describe("GitHub Enterprise Cloud (*.ghe.com)", () => { it("should detect github provider for a *.ghe.com host", () => { const result = parseRepoUrl("https://acme.ghe.com/engineering/platform.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "engineering", name: "platform", provider: "github", @@ -376,7 +382,7 @@ describe("parseRepoUrl", () => { describe("unknown providers", () => { it("should return null provider for unknown hosts", () => { const result = parseRepoUrl("https://example.com/myorg/myrepo.git"); - expect(result).toEqual({ + expect(result).toMatchObject({ owner: "myorg", name: "myrepo", provider: null, @@ -391,6 +397,245 @@ describe("parseRepoUrl", () => { }); }); +describe("repository provider resolution", () => { + function parsed(url: string) { + const result = parseRepoUrl(url); + expect(result).not.toBeNull(); + return result!; + } + + it("applies override before hostname and CI detection", () => { + const remote = parsed("https://github.com/linear/linear-release.git"); + expect( + resolveRepoInfo(remote, { + LINEAR_RELEASE_REPOSITORY_PROVIDER: " GitLab ", + GITHUB_ACTIONS: "true", + GITHUB_SERVER_URL: "https://github.com", + GITHUB_REPOSITORY: "linear/linear-release", + }), + ).toEqual({ + owner: "linear", + name: "linear-release", + provider: "gitlab", + url: "https://github.com/linear/linear-release", + }); + }); + + it("applies hostname detection before CI detection", () => { + const remote = parsed("https://github.com/linear/linear-release.git"); + expect( + resolveRepoInfo(remote, { + GITLAB_CI: "true", + CI_SERVER_HOST: "github.com", + CI_PROJECT_PATH: "linear/linear-release", + })?.provider, + ).toBe("github"); + }); + + it("uses CI detection when the hostname is unknown", () => { + const remote = parsed("https://git.example.com/linear/linear-release.git"); + expect( + resolveRepoInfo(remote, { + GITHUB_ACTIONS: "true", + GITHUB_SERVER_URL: "https://git.example.com", + GITHUB_REPOSITORY: "linear/linear-release", + }), + ).toEqual({ + owner: "linear", + name: "linear-release", + provider: "github", + url: "https://git.example.com/linear/linear-release", + }); + }); + + it("rejects an invalid override", () => { + const remote = parsed("https://github.com/linear/linear-release.git"); + expect(() => resolveRepoInfo(remote, { LINEAR_RELEASE_REPOSITORY_PROVIDER: "gitea" })).toThrow(ConfigurationError); + try { + resolveRepoInfo(remote, { LINEAR_RELEASE_REPOSITORY_PROVIDER: "gitea" }); + } catch (error) { + expect(error).toMatchObject({ code: "invalid-provider-override" }); + expect((error as Error).message).toContain("github, gitlab, or bitbucket"); + } + }); + + it("preserves legacy ambiguous-host precedence", () => { + const remote = parsed("https://gitlab-github.example/owner/repo.git"); + expect(resolveRepoInfo(remote, {})?.provider).toBe("gitlab"); + }); + + it("returns null when every detection tier misses", () => { + expect(resolveRepoInfo(parsed("https://git.example.com/owner/repo.git"), {})).toBeNull(); + }); + + it("keeps nested GitLab repository identity byte-identical under GitLab CI", () => { + const remote = parsed("https://gitlab.com/org/group/subgroup/repo.git"); + expect( + resolveRepoInfo(remote, { + GITLAB_CI: "true", + CI_SERVER_HOST: "gitlab.com", + CI_PROJECT_PATH: "org/group/subgroup/repo", + CI_PROJECT_NAMESPACE: "org/group/subgroup", + CI_PROJECT_URL: "https://gitlab.com/org/group/subgroup/repo", + }), + ).toMatchInlineSnapshot(` + { + "name": "group/subgroup/repo", + "owner": "org", + "provider": "gitlab", + "url": "https://gitlab.com/org/group/subgroup/repo", + } + `); + }); +}); + +describe("repository host normalization", () => { + it("normalizes uppercase and trailing-dot hosts for matching", () => { + const result = parseRepoUrl("https://GITHUB.COM./linear/linear-release.git"); + expect(result).toMatchObject({ + host: "github.com", + provider: "github", + authority: "GITHUB.COM.", + url: "https://GITHUB.COM./linear/linear-release", + }); + }); + + it("matches a GHE host with a port and retains the web authority", () => { + const result = parseRepoUrl("https://tenant.ghe.com:8443/owner/repo.git"); + expect(result).toMatchObject({ + host: "tenant.ghe.com", + port: "8443", + provider: "github", + url: "https://tenant.ghe.com:8443/owner/repo", + }); + }); + + it("normalizes bracketed IPv6 hosts and separates the port", () => { + const result = parseRepoUrl("https://user@[2001:DB8::1]:8443/owner/repo.git"); + expect(result).toMatchObject({ + host: "2001:db8::1", + port: "8443", + authority: "[2001:DB8::1]:8443", + provider: null, + url: "https://[2001:DB8::1]:8443/owner/repo", + }); + }); + + it("strips userinfo from matching and web URLs", () => { + const result = parseRepoUrl("https://user:token@gitlab.example.com/owner/repo.git"); + expect(result).toMatchObject({ + host: "gitlab.example.com", + provider: "gitlab", + url: "https://gitlab.example.com/owner/repo", + }); + }); +}); + +describe("ssh:// repository URLs", () => { + it("parses ssh:// without a port", () => { + expect(parseRepoUrl("ssh://git@gitlab.example.com/org/repo.git")).toMatchObject({ + owner: "org", + name: "repo", + provider: "gitlab", + host: "gitlab.example.com", + port: null, + scheme: "ssh", + url: "https://gitlab.example.com/org/repo", + }); + }); + + it("drops the SSH port from the web URL", () => { + expect(parseRepoUrl("ssh://git@gitlab.example.com:2222/org/group/repo.git")).toMatchObject({ + owner: "org", + name: "group/repo", + provider: "gitlab", + host: "gitlab.example.com", + port: "2222", + scheme: "ssh", + url: "https://gitlab.example.com/org/group/repo", + }); + }); +}); + +describe("Bitbucket Server /scm/ paths", () => { + function resolved(url: string, env: Record = {}) { + const parsed = parseRepoUrl(url); + expect(parsed).not.toBeNull(); + return resolveRepoInfo(parsed!, env); + } + + it("shifts a three-segment HTTPS path", () => { + expect(resolved("https://bitbucket.example.com/scm/PROJ/repo.git")).toEqual({ + owner: "PROJ", + name: "repo", + provider: "bitbucket", + url: "https://bitbucket.example.com/PROJ/repo", + }); + }); + + it("does not shift a two-segment path", () => { + expect(resolved("https://bitbucket.example.com/scm/repo.git")).toEqual({ + owner: "scm", + name: "repo", + provider: "bitbucket", + url: "https://bitbucket.example.com/scm/repo", + }); + }); + + it("does not shift bitbucket.org paths", () => { + expect(resolved("https://bitbucket.org/scm/PROJ/repo.git")).toEqual({ + owner: "scm", + name: "PROJ/repo", + provider: "bitbucket", + url: "https://bitbucket.org/scm/PROJ/repo", + }); + }); + + it("does not shift scp-style SSH paths", () => { + expect(resolved("git@bitbucket.example.com:scm/PROJ/repo.git")).toEqual({ + owner: "scm", + name: "PROJ/repo", + provider: "bitbucket", + url: "https://bitbucket.example.com/scm/PROJ/repo", + }); + }); + + it("does not shift ssh:// paths", () => { + expect( + resolved("ssh://git@vcs.example.com/scm/PROJ/repo.git", { + LINEAR_RELEASE_REPOSITORY_PROVIDER: "bitbucket", + }), + ).toEqual({ + owner: "scm", + name: "PROJ/repo", + provider: "bitbucket", + url: "https://vcs.example.com/scm/PROJ/repo", + }); + }); + + it("does not shift paths resolved to a non-Bitbucket provider", () => { + expect(resolved("https://github.example.com/scm/PROJ/repo.git")).toEqual({ + owner: "scm", + name: "PROJ/repo", + provider: "github", + url: "https://github.example.com/scm/PROJ/repo", + }); + }); + + it("shifts after an override resolves a custom host to Bitbucket", () => { + expect( + resolved("https://vcs.example.com/scm/PROJ/repo.git", { + LINEAR_RELEASE_REPOSITORY_PROVIDER: "bitbucket", + }), + ).toEqual({ + owner: "PROJ", + name: "repo", + provider: "bitbucket", + url: "https://vcs.example.com/PROJ/repo", + }); + }); +}); + describe("extractBranchNameFromMergeMessage", () => { describe("GitHub format", () => { it("should extract branch name from standard GitHub merge message", () => { diff --git a/src/git.ts b/src/git.ts index c8eb6fe..d3a5300 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,5 +1,6 @@ import { execFileSync, execSync } from "node:child_process"; -import type { CommitContext, GitInfo, RepoInfo } from "./types"; +import { ConfigurationError, inferProviderFromCI } from "./ci-env"; +import type { CommitContext, GitInfo, RepoInfo, RepositoryProvider, ResolvedRepoInfo } from "./types"; import { error as logError, verbose, warn } from "./log"; /** Strips leading "./" or "/" so paths are clean for git pathspec. */ @@ -516,7 +517,7 @@ export function getCommitContextsBetweenShas( return commits; } -function hostToProvider(host: string): string | null { +function hostToProvider(host: string): RepositoryProvider | null { if (host === "gitlab.com" || host.includes("gitlab")) { return "gitlab"; } @@ -529,55 +530,160 @@ function hostToProvider(host: string): string | null { return null; } -/** - * Parses a git remote URL (HTTPS or SSH) into repo information. - * - * @param remoteUrl The raw git remote URL string. - * @returns Parsed repo info, or null if the URL could not be parsed. - */ -export function parseRepoUrl(remoteUrl: string): RepoInfo | null { - // GitLab nested groups: split on the first slash so subgroup paths fold - // into the name segment (e.g. owner=group, name=subgroup/repo). - const httpsMatch = remoteUrl.match(/^https?:\/\/(?:[^@]+@)?([^/]+)\/([^/]+)\/(.+?)(?:\.git)?$/); - if (httpsMatch) { - const host = httpsMatch[1]; - const owner = httpsMatch[2] || null; - const name = httpsMatch[3]?.replace(/\.git$/, "") || null; - return { - owner, - name, - provider: hostToProvider(host), - url: owner && name ? `https://${host}/${owner}/${name}` : null, - }; +type ParsedAuthority = { + authority: string; + host: string; + port: string | null; + hostAuthority: string; +}; + +function parseAuthority(value: string): ParsedAuthority | null { + const userinfoIndex = value.lastIndexOf("@"); + const authority = value.slice(userinfoIndex + 1); + if (!authority) { + return null; } - // Handle SSH URLs: git@github.com:owner/repo.git (GitLab nested groups - // follow the same first-slash split as the HTTPS case above). - const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/); - if (sshMatch) { - const host = sshMatch[1]; - const owner = sshMatch[2] || null; - const name = sshMatch[3]?.replace(/\.git$/, "") || null; + if (authority.startsWith("[")) { + const bracket = authority.indexOf("]"); + if (bracket === -1) { + return null; + } + const rawHost = authority.slice(1, bracket); + const suffix = authority.slice(bracket + 1); + const port = suffix.startsWith(":") && /^\d+$/.test(suffix.slice(1)) ? suffix.slice(1) : null; + if (suffix && port === null) { + return null; + } return { - owner, - name, - provider: hostToProvider(host), - url: owner && name ? `https://${host}/${owner}/${name}` : null, + authority, + host: rawHost.toLowerCase().replace(/\.$/, ""), + port, + hostAuthority: authority.slice(0, bracket + 1), }; } + const lastColon = authority.lastIndexOf(":"); + const hasPort = + lastColon !== -1 && authority.indexOf(":") === lastColon && /^\d+$/.test(authority.slice(lastColon + 1)); + const rawHost = hasPort ? authority.slice(0, lastColon) : authority; + if (!rawHost) { + return null; + } + return { + authority, + host: rawHost.toLowerCase().replace(/\.$/, ""), + port: hasPort ? authority.slice(lastColon + 1) : null, + hostAuthority: rawHost, + }; +} + +function createRepoInfo(authority: ParsedAuthority, rawPath: string, scheme: RepoInfo["scheme"]): RepoInfo | null { + const path = rawPath.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, ""); + // GitLab nested groups: split on the first slash so subgroup paths fold + // into the name segment (e.g. owner=group, name=subgroup/repo). + const slash = path.indexOf("/"); + if (slash <= 0 || slash === path.length - 1) { + return null; + } + const owner = path.slice(0, slash); + const name = path.slice(slash + 1); + const webAuthority = scheme === "ssh" ? authority.hostAuthority : authority.authority; + return { + owner, + name, + provider: hostToProvider(authority.host), + url: `https://${webAuthority}/${owner}/${name}`, + host: authority.host, + port: authority.port, + authority: authority.authority, + path, + scheme, + }; +} + +export function parseRepoUrl(remoteUrl: string): RepoInfo | null { + const urlMatch = remoteUrl.trim().match(/^(https?|ssh):\/\/([^/]+)\/(.+)$/i); + if (urlMatch?.[1] && urlMatch[2]) { + const scheme = urlMatch[1].toLowerCase() as RepoInfo["scheme"]; + const authority = parseAuthority(urlMatch[2]); + if (!authority) { + return null; + } + try { + const parsed = new URL(remoteUrl.trim()); + return createRepoInfo(authority, parsed.pathname, scheme); + } catch { + return null; + } + } + + const sshMatch = remoteUrl.trim().match(/^git@([^:]+):(.+)$/); + if (sshMatch?.[1] && sshMatch[2]) { + const authority = parseAuthority(sshMatch[1]); + return authority ? createRepoInfo(authority, sshMatch[2], "ssh") : null; + } + return null; } -export function getRepoInfo(remote: string = "origin", cwd: string = process.cwd()): RepoInfo | null { +function toResolvedRepoInfo( + parsed: RepoInfo, + provider: RepositoryProvider, + enrichment: { owner?: string; name?: string; url?: string } = {}, +): ResolvedRepoInfo { + let owner = enrichment.owner ?? parsed.owner; + let name = enrichment.name ?? parsed.name; + let url = enrichment.url ?? parsed.url; + + const segments = parsed.path.split("/"); + if ( + provider === "bitbucket" && + parsed.scheme !== "ssh" && + parsed.host !== "bitbucket.org" && + segments[0] === "scm" && + segments.length >= 3 + ) { + owner = segments[1] ?? null; + name = segments.slice(2).join("/") || null; + url = owner && name ? `https://${parsed.authority}/${owner}/${name}` : null; + } + + return { owner, name, provider, url }; +} + +export function resolveRepoInfo( + parsed: RepoInfo, + env: Record = process.env, +): ResolvedRepoInfo | null { + const rawOverride = env.LINEAR_RELEASE_REPOSITORY_PROVIDER; + if (rawOverride !== undefined) { + const provider = rawOverride.trim().toLowerCase(); + if (provider !== "github" && provider !== "gitlab" && provider !== "bitbucket") { + throw new ConfigurationError( + `Invalid LINEAR_RELEASE_REPOSITORY_PROVIDER value "${rawOverride}". Expected github, gitlab, or bitbucket.`, + "invalid-provider-override", + { value: rawOverride }, + ); + } + return toResolvedRepoInfo(parsed, provider); + } + + if (parsed.provider) { + return toResolvedRepoInfo(parsed, parsed.provider); + } + + const inferred = inferProviderFromCI(env, parsed); + return inferred ? toResolvedRepoInfo(parsed, inferred.provider, inferred) : null; +} + +export function getRemoteUrl(remote: string = "origin", cwd: string = process.cwd()): string | null { try { - const url = execSync(`git remote get-url ${remote}`, { + return execFileSync("git", ["remote", "get-url", remote], { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", }).trim(); - - return parseRepoUrl(url); } catch (error) { logError(`Failed to read repo info: ${error instanceof Error ? error.message : String(error)}`); return null; diff --git a/src/index.test.ts b/src/index.test.ts new file mode 100644 index 0000000..e7a4ddb --- /dev/null +++ b/src/index.test.ts @@ -0,0 +1,278 @@ +import { execFileSync, spawn } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; + +const repositoryRoot = process.cwd(); +const tsxLoader = join(repositoryRoot, "node_modules", "tsx", "dist", "loader.mjs"); + +type GraphQLRequest = { + query: string; + variables?: { + input?: Record; + }; +}; + +type CliResult = { + code: number | null; + stdout: string; + stderr: string; +}; + +let requests: GraphQLRequest[] = []; +const repositories: string[] = []; +let mockDirectory: string; +let registerMock: string; +let requestLogSequence = 0; + +function runGit(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); +} + +function createRepository(options: { remote?: string; message?: string } = {}): string { + const cwd = mkdtempSync(join(tmpdir(), "linear-release-index-")); + repositories.push(cwd); + runGit(cwd, "init"); + runGit(cwd, "config", "user.email", "test@example.com"); + runGit(cwd, "config", "user.name", "Test User"); + writeFileSync(join(cwd, "file.txt"), "content"); + runGit(cwd, "add", "."); + runGit(cwd, "commit", "-m", options.message ?? "Initial commit"); + if (options.remote) { + runGit(cwd, "remote", "add", "origin", options.remote); + } + return cwd; +} + +function runCli(cwd: string, args: string[], env: Record = {}): Promise { + return new Promise((resolve, reject) => { + const requestLog = join(mockDirectory, `requests-${requestLogSequence++}.jsonl`); + const child = spawn( + process.execPath, + ["--import", registerMock, "--import", tsxLoader, join(repositoryRoot, "src", "index.ts"), ...args], + { + cwd, + env: { + PATH: process.env.PATH, + NODE_ENV: "development", + NODE_NO_WARNINGS: "1", + LINEAR_ACCESS_KEY: "test-access-key", + LINEAR_RELEASE_TEST_REQUESTS: requestLog, + ...env, + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + requests = existsSync(requestLog) + ? readFileSync(requestLog, "utf8") + .trim() + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as GraphQLRequest) + : []; + resolve({ code, stdout, stderr }); + }); + }); +} + +beforeAll(() => { + mockDirectory = mkdtempSync(join(tmpdir(), "linear-release-sdk-mock-")); + registerMock = join(mockDirectory, "register.mjs"); + writeFileSync( + registerMock, + `import { registerHooks } from "node:module"; +const stub = new URL("./sdk.cjs", import.meta.url).href; +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === "@linear/sdk") return { url: stub, shortCircuit: true }; + return nextResolve(specifier, context); + }, +}); +`, + ); + writeFileSync( + join(mockDirectory, "sdk.cjs"), + `const { appendFileSync } = require("node:fs"); +class LinearError extends Error {} +class RatelimitedLinearError extends LinearError {} +const LinearErrorType = { + AuthenticationError: "AuthenticationError", + Forbidden: "Forbidden", + FeatureNotAccessible: "FeatureNotAccessible", + GraphqlError: "GraphqlError", + InvalidInput: "InvalidInput", + UserError: "UserError", + UsageLimitExceeded: "UsageLimitExceeded", +}; +class LinearClient { + constructor() { + this.client = { + setHeader() {}, + rawRequest: async (query, variables) => { + appendFileSync(process.env.LINEAR_RELEASE_TEST_REQUESTS, JSON.stringify({ query, variables }) + "\\n"); + if (query.includes("pipelineSettingsByAccessKey")) { + return { data: { releasePipelineByAccessKey: { includePathPatterns: [] } } }; + } + if (query.includes("recentReleasesByAccessKey")) { + return { data: { recentReleasesByAccessKey: [] } }; + } + return { + data: { + releaseSyncByAccessKey: { + success: true, + release: { + id: "release-id", + name: "test-release", + version: "1.0.0", + url: "https://linear.app/release", + commitSha: variables?.input?.commitSha, + createdAt: "2026-07-27T00:00:00.000Z", + }, + }, + }, + }; + }, + }; + } +} +module.exports = { LinearClient, LinearError, LinearErrorType, RatelimitedLinearError }; +`, + ); +}); + +beforeEach(() => { + requests = []; +}); + +afterAll(() => { + for (const repository of repositories) { + rmSync(repository, { recursive: true, force: true }); + } + rmSync(mockDirectory, { recursive: true, force: true }); +}); + +describe("provider configuration errors", () => { + it("exits 2 with actionable copy before the mutation for an unknown provider", async () => { + const cwd = createRepository({ remote: "https://git.example.com/acme/repo.git" }); + const result = await runCli(cwd, ["sync"]); + + expect(result.code).toBe(2); + expect(result.stderr).toContain( + 'Error: Could not determine the VCS provider for remote host "git.example.com".\n' + + "Set LINEAR_RELEASE_REPOSITORY_PROVIDER=github|gitlab|bitbucket in your CI environment.\n", + ); + expect(requests.some((request) => request.query.includes("mutation syncReleaseByAccessKey"))).toBe(false); + }); + + it("exits 2 for an invalid override", async () => { + const cwd = createRepository({ remote: "https://github.com/acme/repo.git" }); + const result = await runCli(cwd, ["sync"], { + LINEAR_RELEASE_REPOSITORY_PROVIDER: "gitea", + }); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("Invalid LINEAR_RELEASE_REPOSITORY_PROVIDER"); + expect(requests.some((request) => request.query.includes("mutation syncReleaseByAccessKey"))).toBe(false); + }); + + it("exits 2 with the Azure Repos-specific error", async () => { + const remote = "https://dev.azure.com/acme/project/_git/repo.git"; + const cwd = createRepository({ remote }); + const result = await runCli(cwd, ["sync"], { + BUILD_REPOSITORY_PROVIDER: "TfsGit", + BUILD_REPOSITORY_URI: remote, + }); + + expect(result.code).toBe(2); + expect(result.stderr).toContain("Azure Repos repositories are not supported"); + expect(requests.some((request) => request.query.includes("mutation syncReleaseByAccessKey"))).toBe(false); + }); + + it("emits the machine-readable error code on stderr with --json", async () => { + const cwd = createRepository({ remote: "https://git.example.com/acme/repo.git" }); + const result = await runCli(cwd, ["sync", "--json"]); + const errorLine = result.stderr + .trim() + .split("\n") + .map((line) => JSON.parse(line) as Record) + .find((line) => line.error === "unknown-provider"); + + expect(result.code).toBe(2); + expect(errorLine).toEqual({ error: "unknown-provider", host: "git.example.com" }); + expect(result.stdout).toBe(""); + }); + + it("still validates provider detection during a dry run", async () => { + const cwd = createRepository({ remote: "https://git.example.com/acme/repo.git" }); + const result = await runCli(cwd, ["sync", "--dry-run"]); + + expect(result.code).toBe(2); + expect(requests.some((request) => request.query.includes("mutation syncReleaseByAccessKey"))).toBe(false); + }); +}); + +describe("existing exit behavior and no-origin compatibility", () => { + it("lists the provider override in help", async () => { + const cwd = createRepository(); + const result = await runCli(cwd, ["--help"]); + + expect(result.code).toBe(0); + expect(result.stdout).toContain("Environment:"); + expect(result.stdout).toContain("LINEAR_RELEASE_REPOSITORY_PROVIDER"); + }); + + it("keeps existing errors on exit code 1", async () => { + const cwd = createRepository(); + const result = await runCli(cwd, ["not-a-command"]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain('Unknown command "not-a-command"'); + }); + + it("omits repository data and syncs when origin is absent", async () => { + const cwd = createRepository(); + const result = await runCli(cwd, ["sync"]); + const mutation = requests.find((request) => request.query.includes("mutation syncReleaseByAccessKey")); + + expect(result.code).toBe(0); + expect(mutation).toBeDefined(); + expect(mutation?.variables?.input).not.toHaveProperty("repository"); + }); + + it("omits repository data and syncs when the remote URL is unparseable", async () => { + const cwd = createRepository({ remote: "/srv/git/repo.git" }); + const result = await runCli(cwd, ["sync"]); + const mutation = requests.find((request) => request.query.includes("mutation syncReleaseByAccessKey")); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('warning: Could not parse remote URL "/srv/git/repo.git"'); + expect(mutation).toBeDefined(); + expect(mutation?.variables?.input).not.toHaveProperty("repository"); + }); + + it("keeps the pull-request reference error when origin is absent", async () => { + const cwd = createRepository({ message: "Fix regression (#42)" }); + const result = await runCli(cwd, ["sync"]); + + expect(result.code).toBe(1); + expect(result.stderr).toContain("Repository info is required to sync a release with pull request references"); + expect(requests.some((request) => request.query.includes("mutation syncReleaseByAccessKey"))).toBe(false); + }); +}); diff --git a/src/index.ts b/src/index.ts index ae6a256..4afc7c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,9 @@ import { ensureCommitAvailable, getCommitContextsBetweenShas, getCurrentGitInfo, - getRepoInfo, + getRemoteUrl, + parseRepoUrl, + resolveRepoInfo, resolveCommitRef, verifyAncestorReachable, } from "./git"; @@ -26,7 +28,7 @@ import { AccessKeyUpdateByPipelineResponse, DebugSink, IssueReference, - RepoInfo, + ResolvedRepoInfo, } from "./types"; import { getCLIWarnings, @@ -41,6 +43,7 @@ import { pluralize } from "./util"; import { buildUserAgent } from "./user-agent"; import { withRetry } from "./retry"; import { getCliVersion } from "./version"; +import { ConfigurationError } from "./ci-env"; if (process.argv.includes("--version") || process.argv.includes("-v")) { console.log(getCliVersion()); @@ -81,6 +84,7 @@ Options: Environment: LINEAR_ACCESS_KEY Pipeline access key (required) + LINEAR_RELEASE_REPOSITORY_PROVIDER Force repository provider: github|gitlab|bitbucket Examples: linear-release sync @@ -250,6 +254,27 @@ async function apiRequest(query: string, variables?: Record) return withRetry(() => linearClient.client.rawRequest(query, variables)) as Promise; } +function getResolvedRepoInfo(): ResolvedRepoInfo | null { + const remoteUrl = getRemoteUrl(); + if (!remoteUrl) { + return null; + } + const parsed = parseRepoUrl(remoteUrl); + if (!parsed) { + warn(`Could not parse remote URL "${remoteUrl}"; syncing without repository information.`); + return null; + } + const resolved = resolveRepoInfo(parsed, process.env); + if (!resolved) { + throw new ConfigurationError( + `Could not determine the VCS provider for remote host "${parsed.host}".\nSet LINEAR_RELEASE_REPOSITORY_PROVIDER=github|gitlab|bitbucket in your CI environment.`, + "unknown-provider", + { host: parsed.host }, + ); + } + return resolved; +} + async function syncCommand(): Promise<{ release: { id: string; name: string; version?: string; url?: string }; } | null> { @@ -368,7 +393,7 @@ async function syncCommand(): Promise<{ info(`Reverted issue keys: ${revertedIssueReferences.map((f) => f.identifier).join(", ")}`); } - const repoInfo = getRepoInfo(); + const repoInfo = getResolvedRepoInfo(); const issueIds = issueReferences.map((f) => f.identifier); const parts: string[] = []; @@ -584,7 +609,7 @@ async function syncRelease( issueReferences: IssueReference[], revertedIssueReferences: IssueReference[], prNumbers: number[], - repoInfo: RepoInfo | null, + repoInfo: ResolvedRepoInfo | null, debugSink: DebugSink, releaseLinks: ReleaseLink[], releaseDocuments: ReleaseDocument[], @@ -801,6 +826,14 @@ timeout.unref(); main() .catch((e) => { + if (e instanceof ConfigurationError) { + if (jsonOutput) { + process.stderr.write(`${JSON.stringify({ error: e.code, ...e.details })}\n`); + } else { + error(`Error: ${e.message}`); + } + process.exit(2); + } error(`Error: ${e.message}`); process.exit(1); }) diff --git a/src/types.ts b/src/types.ts index 6f5d5f5..92e8acc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,10 +77,24 @@ export type GitInfo = { message: string | null; }; +export type RepositoryProvider = "github" | "gitlab" | "bitbucket"; + export type RepoInfo = { owner: string | null; name: string | null; - provider: string | null; + provider: RepositoryProvider | null; + url: string | null; + host: string; + port: string | null; + authority: string; + path: string; + scheme: "http" | "https" | "ssh"; +}; + +export type ResolvedRepoInfo = { + owner: string | null; + name: string | null; + provider: RepositoryProvider; url: string | null; }; From 1731ac72f9b65ebe4a630d634480786fbdba386c Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Mon, 27 Jul 2026 09:31:10 +0200 Subject: [PATCH 2/6] Trim provider detection to GitLab CI inference and explicit override --- README.md | 23 +- examples/circleci-continuous/config.yml | 2 - examples/circleci-scheduled/config.yml | 4 - src/ci-env.test.ts | 311 +++--------------------- src/ci-env.ts | 227 ++--------------- src/git.test.ts | 305 +++-------------------- src/git.ts | 180 +++----------- src/index.test.ts | 45 ++-- src/index.ts | 36 +-- src/types.ts | 7 +- 10 files changed, 177 insertions(+), 963 deletions(-) diff --git a/README.md b/README.md index f7b798d..bbc45e4 100644 --- a/README.md +++ b/README.md @@ -150,33 +150,12 @@ linear-release update --stage="in review" --name="Release 1.2.0" ### Provider detection -When `sync` finds an `origin` remote, it determines the repository provider in this order: - -1. `LINEAR_RELEASE_REPOSITORY_PROVIDER`, if set. -2. The remote hostname, including the existing GitHub, GitLab, and Bitbucket hostname matching. -3. CI platform signals, when they can be bound to the checked-out remote by matching its host or repository path. - -CI inference supports GitLab CI, GitHub Actions, Bitbucket Pipelines, Buildkite, Azure Pipelines repositories hosted on GitHub or Bitbucket, AppVeyor, and Semaphore. Hostname detection retains the existing substring matching and GitLab → GitHub → Bitbucket precedence. - -Use the override for self-hosted providers on custom domains or CI platforms without a trustworthy provider signal: +`sync` determines the repository provider from `LINEAR_RELEASE_REPOSITORY_PROVIDER` if set, then the remote hostname, then the GitLab CI environment (`GITLAB_CI` with a matching `CI_SERVER_HOST` or `CI_PROJECT_PATH`) — so self-hosted GitLab on a custom domain works without configuration. If none of these resolve, `sync` stops before making a mutation and exits with code `2` (other errors keep code `1`); with `--json` the error on stderr includes a machine-readable code such as `{"error":"unknown-provider","host":"git.example.com"}`. Set the override for other self-hosted providers on custom domains: ```bash LINEAR_RELEASE_REPOSITORY_PROVIDER=gitlab linear-release sync ``` -For CircleCI, wire its project type into the job environment: - -```yaml -environment: - LINEAR_RELEASE_REPOSITORY_PROVIDER: << pipeline.project.type >> # "github"|"bitbucket"|"gitlab" -``` - -If the provider cannot be determined for a remote, `sync` stops before making a mutation and explains how to set the override. These deterministic configuration errors exit with code `2`; other errors continue to exit with code `1`. With `--json`, the error emitted on stderr includes a machine-readable code such as `{"error":"unknown-provider","host":"git.example.com"}`. - -The CLI does not probe unknown VCS hosts. Azure Repos, Gitea, Forgejo, and other systems without a supported provider value cannot be synced with repository metadata. A repository with no `origin` remote retains the existing behavior of omitting repository metadata. - -For `ssh://` remotes, the SSH port is omitted from the generated web URL. Outside GitLab CI or GitHub Actions, a dedicated SSH hostname may therefore point at a host that does not serve the web UI; those CI environments substitute their canonical project URL when the remote is bound by project path. Bitbucket Server `/scm/PROJECT/repository` URLs are corrected, but relative installs such as `/bitbucket/scm/...` are not. - ### CLI Options | Option | Commands | Description | diff --git a/examples/circleci-continuous/config.yml b/examples/circleci-continuous/config.yml index a2061e6..36179e3 100644 --- a/examples/circleci-continuous/config.yml +++ b/examples/circleci-continuous/config.yml @@ -11,8 +11,6 @@ jobs: linear-release-sync: docker: - image: cimg/base:current - environment: - LINEAR_RELEASE_REPOSITORY_PROVIDER: << pipeline.project.type >> steps: - checkout - run: diff --git a/examples/circleci-scheduled/config.yml b/examples/circleci-scheduled/config.yml index 1768e34..3cf408a 100644 --- a/examples/circleci-scheduled/config.yml +++ b/examples/circleci-scheduled/config.yml @@ -33,8 +33,6 @@ jobs: linear-release-sync-main: docker: - image: cimg/base:current - environment: - LINEAR_RELEASE_REPOSITORY_PROVIDER: << pipeline.project.type >> steps: - checkout - run: @@ -49,8 +47,6 @@ jobs: linear-release-sync-release: docker: - image: cimg/base:current - environment: - LINEAR_RELEASE_REPOSITORY_PROVIDER: << pipeline.project.type >> steps: - checkout - run: diff --git a/src/ci-env.test.ts b/src/ci-env.test.ts index 5a45b07..f42766b 100644 --- a/src/ci-env.test.ts +++ b/src/ci-env.test.ts @@ -1,14 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { ConfigurationError, detectCIEnvironment, inferProviderFromCI } from "./ci-env"; -import { parseRepoUrl } from "./git"; - -function remote(url: string) { - const parsed = parseRepoUrl(url); - if (!parsed) { - throw new Error(`Could not parse test remote ${url}`); - } - return parsed; -} +import { detectCIEnvironment, inferProviderFromCI, parseProvider } from "./ci-env"; describe("detectCIEnvironment", () => { const originalEnv = process.env; @@ -93,285 +84,51 @@ describe("detectCIEnvironment", () => { }); describe("inferProviderFromCI", () => { - it("infers when the CI host matches even if the project path differs", () => { - expect( - inferProviderFromCI( - { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com", CI_PROJECT_PATH: "group/other-repo" }, - remote("https://git.example.com/group/checked-out-repo.git"), - ), - ).toEqual({ provider: "gitlab" }); - }); + const repoInfo = { + owner: "group", + name: "subgroup/repo", + provider: null, + url: "https://git.example.com/group/subgroup/repo", + }; - it("infers when clone_url rewrites the host but the project path matches", () => { - expect( - inferProviderFromCI( - { - GITLAB_CI: "true", - CI_SERVER_HOST: "gitlab.example.com", - CI_PROJECT_PATH: "group/project", - CI_PROJECT_URL: "https://gitlab.example.com/group/project", - }, - remote("https://clone.internal/group/project.git"), - ), - ).toEqual({ - provider: "gitlab", - owner: "group", - name: "project", - url: "https://gitlab.example.com/group/project", - }); + it("infers gitlab when the remote host matches CI_SERVER_HOST", () => { + const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com" }; + expect(inferProviderFromCI(env, repoInfo)).toBe("gitlab"); }); - it("does not infer for a foreign clone when both host and path mismatch", () => { - expect( - inferProviderFromCI( - { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com", CI_PROJECT_PATH: "group/project" }, - remote("https://foreign.example.com/other/repository.git"), - ), - ).toBeNull(); + it("infers gitlab when clone_url rewrites the host but the project path matches", () => { + const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com", CI_PROJECT_PATH: "group/subgroup/repo" }; + const rewritten = { ...repoInfo, url: "https://192.168.1.23/group/subgroup/repo" }; + expect(inferProviderFromCI(env, rewritten)).toBe("gitlab"); }); - describe("GitLab", () => { - it("preserves nested-group repository identity", () => { - expect( - inferProviderFromCI( - { - GITLAB_CI: "true", - CI_PROJECT_PATH: "org/group/subgroup/repo", - CI_PROJECT_URL: "https://git.example.com/org/group/subgroup/repo", - }, - remote("https://clone.internal/org/group/subgroup/repo.git"), - ), - ).toMatchInlineSnapshot(` - { - "name": "group/subgroup/repo", - "owner": "org", - "provider": "gitlab", - "url": "https://git.example.com/org/group/subgroup/repo", - } - `); - }); - - it("corrects a relative-URL install with the canonical project URL", () => { - expect( - inferProviderFromCI( - { - GITLAB_CI: "true", - CI_PROJECT_PATH: "group/repo", - CI_PROJECT_URL: "https://example.com/gitlab/group/repo", - }, - remote("https://example.com/gitlab/group/repo.git"), - ), - ).toEqual({ - provider: "gitlab", - owner: "group", - name: "repo", - url: "https://example.com/gitlab/group/repo", - }); - }); - - it("binds token-bearing runner URLs by path", () => { - expect( - inferProviderFromCI( - { - GITLAB_CI: "true", - CI_PROJECT_PATH: "group/repo", - CI_PROJECT_URL: "https://git.example.com/group/repo", - }, - remote("https://gitlab-ci-token:secret@runner.internal/group/repo.git"), - ), - ).toEqual({ - provider: "gitlab", - owner: "group", - name: "repo", - url: "https://git.example.com/group/repo", - }); - }); - - it("uses CI_SERVER_SHELL_SSH_HOST as a binding host", () => { - expect( - inferProviderFromCI( - { GITLAB_CI: "true", CI_SERVER_SHELL_SSH_HOST: "ssh.git.example.com" }, - remote("ssh://git@ssh.git.example.com:2222/group/repo.git"), - ), - ).toEqual({ provider: "gitlab" }); - }); - - it("matches CI_PROJECT_PATH literally when it contains regex metacharacters", () => { - expect( - inferProviderFromCI( - { - GITLAB_CI: "true", - CI_PROJECT_PATH: "group[one]/repo.+", - CI_PROJECT_URL: "https://git.example.com/group[one]/repo.+", - }, - remote("https://clone.internal/group[one]/repo.+.git"), - ), - ).toEqual({ - provider: "gitlab", - owner: "group[one]", - name: "repo.+", - url: "https://git.example.com/group[one]/repo.+", - }); - }); - - it("infers without enrichment when pre-15.11 variables are absent", () => { - expect( - inferProviderFromCI( - { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com" }, - remote("https://git.example.com/group/repo.git"), - ), - ).toEqual({ provider: "gitlab" }); - }); - }); - - it("enriches GitHub Actions repositories from the bound repository path", () => { - expect( - inferProviderFromCI( - { - GITHUB_ACTIONS: "true", - GITHUB_SERVER_URL: "https://github.enterprise.example", - GITHUB_REPOSITORY: "octo/project", - }, - remote("https://clone.internal/octo/project.git"), - ), - ).toEqual({ - provider: "github", - owner: "octo", - name: "project", - url: "https://github.enterprise.example/octo/project", - }); - }); - - it("infers Bitbucket from a bound Pipelines origin", () => { - expect( - inferProviderFromCI( - { BITBUCKET_GIT_HTTP_ORIGIN: "https://bitbucket.internal/workspace/repo.git" }, - remote("https://clone.internal/workspace/repo.git"), - ), - ).toEqual({ provider: "bitbucket" }); - }); - - describe("Buildkite", () => { - it("accepts enterprise provider variants at the documented boundary", () => { - expect( - inferProviderFromCI( - { BUILDKITE_PIPELINE_PROVIDER: " github_enterprise ", BUILDKITE_REPO: "git@code.example.com:team/repo.git" }, - remote("https://clone.internal/team/repo.git"), - ), - ).toEqual({ provider: "github" }); - }); - - it("does not match provider prefixes outside the boundary", () => { - expect( - inferProviderFromCI( - { BUILDKITE_PIPELINE_PROVIDER: "githubish", BUILDKITE_REPO: "https://code.example.com/team/repo.git" }, - remote("https://code.example.com/team/repo.git"), - ), - ).toBeNull(); - }); - }); - - describe("Azure Pipelines", () => { - it("raises the Azure Repos configuration error for a bound TfsGit repository", () => { - expect(() => - inferProviderFromCI( - { BUILD_REPOSITORY_PROVIDER: "TfsGit", BUILD_REPOSITORY_URI: "https://dev.azure.com/org/project/_git/repo" }, - remote("https://dev.azure.com/org/project/_git/repo.git"), - ), - ).toThrow(ConfigurationError); - try { - inferProviderFromCI( - { BUILD_REPOSITORY_PROVIDER: "TfsGit", BUILD_REPOSITORY_URI: "https://dev.azure.com/org/project/_git/repo" }, - remote("https://dev.azure.com/org/project/_git/repo.git"), - ); - } catch (error) { - expect(error).toMatchObject({ code: "unsupported-azure-repos" }); - } - }); - - it("only infers observed Bitbucket support with URI corroboration", () => { - expect( - inferProviderFromCI( - { BUILD_REPOSITORY_PROVIDER: "Bitbucket", BUILD_REPOSITORY_URI: "https://bitbucket.example/team/repo.git" }, - remote("https://foreign.example/other/repo.git"), - ), - ).toBeNull(); - expect( - inferProviderFromCI( - { BUILD_REPOSITORY_PROVIDER: "Bitbucket", BUILD_REPOSITORY_URI: "https://bitbucket.example/team/repo.git" }, - remote("https://clone.internal/team/repo.git"), - ), - ).toEqual({ provider: "bitbucket" }); - }); + it("does not infer for a foreign clone when both host and path mismatch", () => { + const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com", CI_PROJECT_PATH: "group/subgroup/repo" }; + const foreign = { owner: "acme", name: "other", provider: null, url: "https://git.other.example/acme/other" }; + expect(inferProviderFromCI(env, foreign)).toBeNull(); }); - it("maps AppVeyor provider prefixes when the repository path is bound", () => { - expect( - inferProviderFromCI( - { APPVEYOR_REPO_PROVIDER: "gitLabEnterprise", APPVEYOR_REPO_NAME: "group/repo" }, - remote("https://clone.internal/group/repo.git"), - ), - ).toEqual({ provider: "gitlab" }); - expect( - inferProviderFromCI( - { APPVEYOR_REPO_PROVIDER: "stash", APPVEYOR_REPO_NAME: "team/repo" }, - remote("https://clone.internal/team/repo.git"), - ), - ).toEqual({ provider: "bitbucket" }); + it("matches hosts case-insensitively and ignores the port", () => { + const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com" }; + const withPort = { ...repoInfo, url: "https://Git.Example.com:8443/group/subgroup/repo" }; + expect(inferProviderFromCI(env, withPort)).toBe("gitlab"); }); - it("infers Semaphore's documented providers only for a bound repository", () => { - expect( - inferProviderFromCI( - { SEMAPHORE_GIT_PROVIDER: "github", SEMAPHORE_GIT_URL: "https://github.example/team/repo.git" }, - remote("https://clone.internal/team/repo.git"), - ), - ).toEqual({ provider: "github" }); - expect( - inferProviderFromCI( - { SEMAPHORE_GIT_PROVIDER: "gitlab", SEMAPHORE_GIT_URL: "https://gitlab.example/team/repo.git" }, - remote("https://clone.internal/team/repo.git"), - ), - ).toBeNull(); + it("does not infer outside GitLab CI", () => { + expect(inferProviderFromCI({ CI_SERVER_HOST: "git.example.com" }, repoInfo)).toBeNull(); }); +}); - it("falls through unknown values and signal-less CI platforms", () => { - expect( - inferProviderFromCI( - { - CIRCLECI: "true", - BUILDKITE_PIPELINE_PROVIDER: "forgejo", - BUILD_REPOSITORY_PROVIDER: "Git", - APPVEYOR_REPO_PROVIDER: "unknown", - }, - remote("https://code.example/team/repo.git"), - ), - ).toBeNull(); +describe("parseProvider", () => { + it("accepts the three providers case-insensitively", () => { + expect(parseProvider("GitLab")).toBe("gitlab"); + expect(parseProvider(" github ")).toBe("github"); + expect(parseProvider("bitbucket")).toBe("bitbucket"); }); - it("falls through when simultaneous CI signals infer different providers", () => { - expect( - inferProviderFromCI( - { - GITLAB_CI: "true", - CI_SERVER_HOST: "gitlab.example", - GITHUB_ACTIONS: "true", - GITHUB_SERVER_URL: "https://github.example", - }, - remote("https://gitlab.example/team/repo.git"), - ), - ).toEqual({ provider: "gitlab" }); - - expect( - inferProviderFromCI( - { - GITLAB_CI: "true", - CI_PROJECT_PATH: "team/repo", - GITHUB_ACTIONS: "true", - GITHUB_REPOSITORY: "team/repo", - }, - remote("https://clone.example/team/repo.git"), - ), - ).toBeNull(); + it("rejects anything else", () => { + expect(parseProvider("gitea")).toBeNull(); + expect(parseProvider(null)).toBeNull(); + expect(parseProvider(undefined)).toBeNull(); }); }); diff --git a/src/ci-env.ts b/src/ci-env.ts index 68d4d51..2f8a796 100644 --- a/src/ci-env.ts +++ b/src/ci-env.ts @@ -4,7 +4,7 @@ export interface CIEnvironment { name: string; } -export type ConfigurationErrorCode = "invalid-provider-override" | "unknown-provider" | "unsupported-azure-repos"; +export type ConfigurationErrorCode = "invalid-provider-override" | "unknown-provider"; export class ConfigurationError extends Error { constructor( @@ -17,222 +17,39 @@ export class ConfigurationError extends Error { } } -export type CIProviderInference = { - provider: RepositoryProvider; - owner?: string; - name?: string; - url?: string; -}; - -type Environment = Record; - -type Binding = { - hosts: string[]; - paths: string[]; -}; - -function normalizeHost(value: string): string { - let authority = value.trim(); - const userinfoIndex = authority.lastIndexOf("@"); - if (userinfoIndex !== -1) { - authority = authority.slice(userinfoIndex + 1); - } - if (authority.startsWith("[")) { - const end = authority.indexOf("]"); - if (end !== -1) { - return authority.slice(1, end).toLowerCase().replace(/\.$/, ""); - } - } - const lastColon = authority.lastIndexOf(":"); - if (lastColon !== -1 && authority.indexOf(":") === lastColon && /^\d+$/.test(authority.slice(lastColon + 1))) { - authority = authority.slice(0, lastColon); - } - return authority.toLowerCase().replace(/\.$/, ""); -} - -function normalizeDeclaredHost(value: string | undefined): string | null { - if (!value?.trim()) { - return null; - } - try { - return normalizeHost(new URL(value).host); - } catch { - return normalizeHost(value); - } -} - -function normalizePath(value: string): string { - return value - .trim() - .replace(/^\/+|\/+$/g, "") - .replace(/\.git$/i, ""); +export function parseProvider(value: string | null | undefined): RepositoryProvider | null { + const provider = value?.trim().toLowerCase(); + return provider === "github" || provider === "gitlab" || provider === "bitbucket" ? provider : null; } -function parseBindingUrl(value: string | undefined): { host: string; path: string } | null { - if (!value?.trim()) { +export function remoteHost(repoInfo: RepoInfo): string | null { + if (!repoInfo.url) { return null; } try { - const parsed = new URL(value); - return { - host: normalizeHost(parsed.host), - path: normalizePath(parsed.pathname), - }; + return new URL(repoInfo.url).hostname.toLowerCase(); } catch { - const scpMatch = value.trim().match(/^(?:[^@]+@)?([^:]+):(.+)$/); - if (!scpMatch?.[1] || !scpMatch[2]) { - return null; - } - return { - host: normalizeHost(scpMatch[1]), - path: normalizePath(scpMatch[2]), - }; - } -} - -function pathMatches(remotePath: string, declaredPath: string): boolean { - const normalizedRemote = normalizePath(remotePath); - const normalizedDeclared = normalizePath(declaredPath); - return ( - normalizedDeclared.length > 0 && - (normalizedRemote === normalizedDeclared || normalizedRemote.endsWith(`/${normalizedDeclared}`)) - ); -} - -function getBinding(remote: RepoInfo, binding: Binding): { bound: boolean; pathMatched: boolean } { - const hostMatched = binding.hosts.some((host) => host === remote.host); - const pathMatched = binding.paths.some((path) => pathMatches(remote.path, path)); - // Host OR path suffices: GitLab runner clone_url rewrites the origin host - // while preserving the project path, so a host mismatch alone must not - // block inference. Both mismatching means a foreign checkout. - return { bound: hostMatched || pathMatched, pathMatched }; -} - -// Splits CI_PROJECT_PATH-style values on the first slash, mirroring -// createRepoInfo — NOT CI_PROJECT_NAMESPACE, which contains the whole -// subgroup chain and would change repository identity for nested groups. -function splitProjectPath(path: string): { owner: string; name: string } | null { - const normalized = normalizePath(path); - const slash = normalized.indexOf("/"); - if (slash <= 0 || slash === normalized.length - 1) { return null; } - return { - owner: normalized.slice(0, slash), - name: normalized.slice(slash + 1), - }; -} - -function compact(values: Array): T[] { - return values.filter((value): value is T => value !== null); } -export function inferProviderFromCI(env: Environment, remote: RepoInfo): CIProviderInference | null { - const candidates: CIProviderInference[] = []; - let azureReposBound = false; - - if (env.GITLAB_CI === "true") { - const projectPath = env.CI_PROJECT_PATH; - const binding = getBinding(remote, { - hosts: compact([normalizeDeclaredHost(env.CI_SERVER_HOST), normalizeDeclaredHost(env.CI_SERVER_SHELL_SSH_HOST)]), - paths: projectPath ? [projectPath] : [], - }); - if (binding.bound) { - const project = binding.pathMatched && projectPath ? splitProjectPath(projectPath) : null; - candidates.push({ - provider: "gitlab", - ...(project ?? {}), - ...(project && env.CI_PROJECT_URL ? { url: env.CI_PROJECT_URL.trim().replace(/\/+$/, "") } : {}), - }); - } - } - - if (env.GITHUB_ACTIONS === "true") { - const repository = env.GITHUB_REPOSITORY; - const binding = getBinding(remote, { - hosts: compact([normalizeDeclaredHost(env.GITHUB_SERVER_URL)]), - paths: repository ? [repository] : [], - }); - if (binding.bound) { - const project = binding.pathMatched && repository ? splitProjectPath(repository) : null; - const serverUrl = env.GITHUB_SERVER_URL?.trim().replace(/\/+$/, ""); - candidates.push({ - provider: "github", - ...(project ?? {}), - ...(project && serverUrl ? { url: `${serverUrl}/${normalizePath(repository!)}` } : {}), - }); - } - } - - if (env.BITBUCKET_GIT_HTTP_ORIGIN || env.BITBUCKET_REPO_FULL_NAME) { - const origin = parseBindingUrl(env.BITBUCKET_GIT_HTTP_ORIGIN); - const binding = getBinding(remote, { - hosts: origin ? [origin.host] : [], - paths: compact([origin?.path ?? null, env.BITBUCKET_REPO_FULL_NAME ?? null]), - }); - if (binding.bound) { - candidates.push({ provider: "bitbucket" }); - } - } - - const buildkiteMatch = env.BUILDKITE_PIPELINE_PROVIDER?.trim() - .toLowerCase() - .match(/^(github|gitlab|bitbucket)(?:_|$)/); - if (buildkiteMatch?.[1]) { - const repository = parseBindingUrl(env.BUILDKITE_REPO); - if (repository && getBinding(remote, { hosts: [repository.host], paths: [repository.path] }).bound) { - candidates.push({ provider: buildkiteMatch[1] as RepositoryProvider }); - } - } - - const azureProvider = env.BUILD_REPOSITORY_PROVIDER?.trim().toLowerCase(); - if (azureProvider) { - const repository = parseBindingUrl(env.BUILD_REPOSITORY_URI); - const bound = - repository !== null && getBinding(remote, { hosts: [repository.host], paths: [repository.path] }).bound; - if (bound && azureProvider === "github") { - candidates.push({ provider: "github" }); - } else if (bound && azureProvider === "bitbucket") { - candidates.push({ provider: "bitbucket" }); - } else if (bound && azureProvider === "tfsgit") { - azureReposBound = true; - } - } - - const appVeyorProvider = env.APPVEYOR_REPO_PROVIDER?.trim().toLowerCase(); - const appVeyorPath = env.APPVEYOR_REPO_NAME; - if (appVeyorProvider && appVeyorPath && pathMatches(remote.path, appVeyorPath)) { - if (appVeyorProvider.startsWith("github")) { - candidates.push({ provider: "github" }); - } else if (appVeyorProvider.startsWith("gitlab")) { - candidates.push({ provider: "gitlab" }); - } else if (appVeyorProvider.startsWith("bitbucket") || appVeyorProvider.startsWith("stash")) { - candidates.push({ provider: "bitbucket" }); - } - } - - const semaphoreProvider = env.SEMAPHORE_GIT_PROVIDER?.trim().toLowerCase(); - if (semaphoreProvider === "github" || semaphoreProvider === "bitbucket") { - const repository = parseBindingUrl(env.SEMAPHORE_GIT_URL); - if (repository && getBinding(remote, { hosts: [repository.host], paths: [repository.path] }).bound) { - candidates.push({ provider: semaphoreProvider }); - } - } - - const providers = new Set(candidates.map((candidate) => candidate.provider)); - if (providers.size > 1 || (azureReposBound && candidates.length > 0)) { +export function inferProviderFromCI( + env: Record, + repoInfo: RepoInfo, +): RepositoryProvider | null { + if (env.GITLAB_CI !== "true") { return null; } - if (azureReposBound) { - throw new ConfigurationError( - "Azure Repos repositories are not supported because the Linear API has no Azure Repos provider value.", - "unsupported-azure-repos", - ); - } - if (candidates.length === 0) { - return null; - } - return candidates.find((candidate) => candidate.owner || candidate.name || candidate.url) ?? candidates[0]!; + const host = remoteHost(repoInfo); + const serverHost = env.CI_SERVER_HOST?.trim().toLowerCase(); + const hostMatched = host !== null && !!serverHost && host === serverHost; + const projectPath = env.CI_PROJECT_PATH?.trim().replace(/^\/+|\/+$/g, ""); + const remotePath = repoInfo.owner && repoInfo.name ? `${repoInfo.owner}/${repoInfo.name}` : null; + const pathMatched = + !!projectPath && remotePath !== null && (remotePath === projectPath || remotePath.endsWith(`/${projectPath}`)); + // Host OR path suffices: GitLab runner clone_url rewrites the origin host + // while preserving the project path. Both mismatching means a foreign checkout. + return hostMatched || pathMatched ? "gitlab" : null; } /** diff --git a/src/git.test.ts b/src/git.test.ts index 775587b..dd3cb1c 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -12,14 +12,12 @@ import { getCommitContext, getCommitContextsBetweenShas, getCommitParents, - getRemoteUrl, + getRepoInfo, isAncestor, normalizePathspec, parseRepoUrl, - resolveRepoInfo, resolveFirstSyncBoundary, } from "./git"; -import { ConfigurationError } from "./ci-env"; describe("normalizePathspec", () => { it("should strip leading ./", () => { @@ -130,18 +128,14 @@ describe("extractBranchName", () => { }); }); -describe("repository remote resolution", () => { +describe("getRepoInfo", () => { it("should return the repo info", () => { - const remoteUrl = getRemoteUrl(); - expect(remoteUrl).toBeDefined(); - const parsed = parseRepoUrl(remoteUrl!); - expect(parsed).toBeDefined(); - expect(resolveRepoInfo(parsed!)).toEqual({ - owner: "linear", - name: "linear-release", - provider: "github", - url: "https://github.com/linear/linear-release", - }); + const result = getRepoInfo(); + expect(result).toBeDefined(); + expect(result?.owner).toBe("linear"); + expect(result?.name).toBe("linear-release"); + expect(result?.provider).toBe("github"); + expect(result?.url).toBe("https://github.com/linear/linear-release"); }); }); @@ -149,7 +143,7 @@ describe("parseRepoUrl", () => { describe("HTTPS URLs", () => { it("should parse github.com HTTPS URL", () => { const result = parseRepoUrl("https://github.com/linear/linear-app.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "linear", name: "linear-app", provider: "github", @@ -159,7 +153,7 @@ describe("parseRepoUrl", () => { it("should parse github.com HTTPS URL without .git suffix", () => { const result = parseRepoUrl("https://github.com/linear/linear-app"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "linear", name: "linear-app", provider: "github", @@ -169,7 +163,7 @@ describe("parseRepoUrl", () => { it("should parse gitlab.com HTTPS URL", () => { const result = parseRepoUrl("https://gitlab.com/myorg/myrepo.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "myorg", name: "myrepo", provider: "gitlab", @@ -179,7 +173,7 @@ describe("parseRepoUrl", () => { it("should parse GitHub Enterprise HTTPS URL", () => { const result = parseRepoUrl("https://github.mycompany.com/engineering/platform.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "engineering", name: "platform", provider: "github", @@ -189,7 +183,7 @@ describe("parseRepoUrl", () => { it("should parse self-hosted GitLab HTTPS URL", () => { const result = parseRepoUrl("https://gitlab.internal.io/team/service.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "team", name: "service", provider: "gitlab", @@ -199,7 +193,7 @@ describe("parseRepoUrl", () => { it("should parse gitlab.com HTTPS URL with nested groups", () => { const result = parseRepoUrl("https://gitlab.com/my-org/my-group/my-repo.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "my-org", name: "my-group/my-repo", provider: "gitlab", @@ -209,7 +203,7 @@ describe("parseRepoUrl", () => { it("should parse gitlab.com HTTPS URL with deeply nested groups", () => { const result = parseRepoUrl("https://gitlab.com/org/group/subgroup/repo.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "org", name: "group/subgroup/repo", provider: "gitlab", @@ -219,7 +213,7 @@ describe("parseRepoUrl", () => { it("should parse self-hosted GitLab HTTPS URL with nested groups and no .git suffix", () => { const result = parseRepoUrl("https://gitlab.internal.io/team/platform/service"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "team", name: "platform/service", provider: "gitlab", @@ -229,7 +223,7 @@ describe("parseRepoUrl", () => { it("should parse bitbucket.org HTTPS URL", () => { const result = parseRepoUrl("https://bitbucket.org/myorg/myrepo.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "myorg", name: "myrepo", provider: "bitbucket", @@ -239,7 +233,7 @@ describe("parseRepoUrl", () => { it("should parse self-hosted Bitbucket HTTPS URL", () => { const result = parseRepoUrl("https://bitbucket.mycompany.com/team/service.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "team", name: "service", provider: "bitbucket", @@ -249,7 +243,7 @@ describe("parseRepoUrl", () => { it("should parse HTTPS URL with credentials", () => { const result = parseRepoUrl("https://token@github.com/linear/linear-app.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "linear", name: "linear-app", provider: "github", @@ -261,7 +255,7 @@ describe("parseRepoUrl", () => { describe("SSH URLs", () => { it("should parse github.com SSH URL", () => { const result = parseRepoUrl("git@github.com:linear/linear-app.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "linear", name: "linear-app", provider: "github", @@ -271,7 +265,7 @@ describe("parseRepoUrl", () => { it("should parse github.com SSH URL without .git suffix", () => { const result = parseRepoUrl("git@github.com:linear/linear-app"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "linear", name: "linear-app", provider: "github", @@ -281,7 +275,7 @@ describe("parseRepoUrl", () => { it("should parse gitlab.com SSH URL", () => { const result = parseRepoUrl("git@gitlab.com:myorg/myrepo.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "myorg", name: "myrepo", provider: "gitlab", @@ -291,7 +285,7 @@ describe("parseRepoUrl", () => { it("should parse GitHub Enterprise SSH URL", () => { const result = parseRepoUrl("git@github.mycompany.com:engineering/platform.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "engineering", name: "platform", provider: "github", @@ -301,7 +295,7 @@ describe("parseRepoUrl", () => { it("should parse self-hosted GitLab SSH URL", () => { const result = parseRepoUrl("git@gitlab.internal.io:team/service.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "team", name: "service", provider: "gitlab", @@ -311,7 +305,7 @@ describe("parseRepoUrl", () => { it("should parse gitlab.com SSH URL with nested groups", () => { const result = parseRepoUrl("git@gitlab.com:my-org/my-group/my-repo.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "my-org", name: "my-group/my-repo", provider: "gitlab", @@ -321,7 +315,7 @@ describe("parseRepoUrl", () => { it("should parse gitlab.com SSH URL with deeply nested groups", () => { const result = parseRepoUrl("git@gitlab.com:org/group/subgroup/repo.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "org", name: "group/subgroup/repo", provider: "gitlab", @@ -331,7 +325,7 @@ describe("parseRepoUrl", () => { it("should parse bitbucket.org SSH URL", () => { const result = parseRepoUrl("git@bitbucket.org:myorg/myrepo.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "myorg", name: "myrepo", provider: "bitbucket", @@ -341,7 +335,7 @@ describe("parseRepoUrl", () => { it("should parse self-hosted Bitbucket SSH URL", () => { const result = parseRepoUrl("git@bitbucket.mycompany.com:team/service.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "team", name: "service", provider: "bitbucket", @@ -353,7 +347,7 @@ describe("parseRepoUrl", () => { describe("GitHub Enterprise Cloud (*.ghe.com)", () => { it("should detect github provider for a *.ghe.com host", () => { const result = parseRepoUrl("https://acme.ghe.com/engineering/platform.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "engineering", name: "platform", provider: "github", @@ -382,7 +376,7 @@ describe("parseRepoUrl", () => { describe("unknown providers", () => { it("should return null provider for unknown hosts", () => { const result = parseRepoUrl("https://example.com/myorg/myrepo.git"); - expect(result).toMatchObject({ + expect(result).toEqual({ owner: "myorg", name: "myrepo", provider: null, @@ -397,245 +391,6 @@ describe("parseRepoUrl", () => { }); }); -describe("repository provider resolution", () => { - function parsed(url: string) { - const result = parseRepoUrl(url); - expect(result).not.toBeNull(); - return result!; - } - - it("applies override before hostname and CI detection", () => { - const remote = parsed("https://github.com/linear/linear-release.git"); - expect( - resolveRepoInfo(remote, { - LINEAR_RELEASE_REPOSITORY_PROVIDER: " GitLab ", - GITHUB_ACTIONS: "true", - GITHUB_SERVER_URL: "https://github.com", - GITHUB_REPOSITORY: "linear/linear-release", - }), - ).toEqual({ - owner: "linear", - name: "linear-release", - provider: "gitlab", - url: "https://github.com/linear/linear-release", - }); - }); - - it("applies hostname detection before CI detection", () => { - const remote = parsed("https://github.com/linear/linear-release.git"); - expect( - resolveRepoInfo(remote, { - GITLAB_CI: "true", - CI_SERVER_HOST: "github.com", - CI_PROJECT_PATH: "linear/linear-release", - })?.provider, - ).toBe("github"); - }); - - it("uses CI detection when the hostname is unknown", () => { - const remote = parsed("https://git.example.com/linear/linear-release.git"); - expect( - resolveRepoInfo(remote, { - GITHUB_ACTIONS: "true", - GITHUB_SERVER_URL: "https://git.example.com", - GITHUB_REPOSITORY: "linear/linear-release", - }), - ).toEqual({ - owner: "linear", - name: "linear-release", - provider: "github", - url: "https://git.example.com/linear/linear-release", - }); - }); - - it("rejects an invalid override", () => { - const remote = parsed("https://github.com/linear/linear-release.git"); - expect(() => resolveRepoInfo(remote, { LINEAR_RELEASE_REPOSITORY_PROVIDER: "gitea" })).toThrow(ConfigurationError); - try { - resolveRepoInfo(remote, { LINEAR_RELEASE_REPOSITORY_PROVIDER: "gitea" }); - } catch (error) { - expect(error).toMatchObject({ code: "invalid-provider-override" }); - expect((error as Error).message).toContain("github, gitlab, or bitbucket"); - } - }); - - it("preserves legacy ambiguous-host precedence", () => { - const remote = parsed("https://gitlab-github.example/owner/repo.git"); - expect(resolveRepoInfo(remote, {})?.provider).toBe("gitlab"); - }); - - it("returns null when every detection tier misses", () => { - expect(resolveRepoInfo(parsed("https://git.example.com/owner/repo.git"), {})).toBeNull(); - }); - - it("keeps nested GitLab repository identity byte-identical under GitLab CI", () => { - const remote = parsed("https://gitlab.com/org/group/subgroup/repo.git"); - expect( - resolveRepoInfo(remote, { - GITLAB_CI: "true", - CI_SERVER_HOST: "gitlab.com", - CI_PROJECT_PATH: "org/group/subgroup/repo", - CI_PROJECT_NAMESPACE: "org/group/subgroup", - CI_PROJECT_URL: "https://gitlab.com/org/group/subgroup/repo", - }), - ).toMatchInlineSnapshot(` - { - "name": "group/subgroup/repo", - "owner": "org", - "provider": "gitlab", - "url": "https://gitlab.com/org/group/subgroup/repo", - } - `); - }); -}); - -describe("repository host normalization", () => { - it("normalizes uppercase and trailing-dot hosts for matching", () => { - const result = parseRepoUrl("https://GITHUB.COM./linear/linear-release.git"); - expect(result).toMatchObject({ - host: "github.com", - provider: "github", - authority: "GITHUB.COM.", - url: "https://GITHUB.COM./linear/linear-release", - }); - }); - - it("matches a GHE host with a port and retains the web authority", () => { - const result = parseRepoUrl("https://tenant.ghe.com:8443/owner/repo.git"); - expect(result).toMatchObject({ - host: "tenant.ghe.com", - port: "8443", - provider: "github", - url: "https://tenant.ghe.com:8443/owner/repo", - }); - }); - - it("normalizes bracketed IPv6 hosts and separates the port", () => { - const result = parseRepoUrl("https://user@[2001:DB8::1]:8443/owner/repo.git"); - expect(result).toMatchObject({ - host: "2001:db8::1", - port: "8443", - authority: "[2001:DB8::1]:8443", - provider: null, - url: "https://[2001:DB8::1]:8443/owner/repo", - }); - }); - - it("strips userinfo from matching and web URLs", () => { - const result = parseRepoUrl("https://user:token@gitlab.example.com/owner/repo.git"); - expect(result).toMatchObject({ - host: "gitlab.example.com", - provider: "gitlab", - url: "https://gitlab.example.com/owner/repo", - }); - }); -}); - -describe("ssh:// repository URLs", () => { - it("parses ssh:// without a port", () => { - expect(parseRepoUrl("ssh://git@gitlab.example.com/org/repo.git")).toMatchObject({ - owner: "org", - name: "repo", - provider: "gitlab", - host: "gitlab.example.com", - port: null, - scheme: "ssh", - url: "https://gitlab.example.com/org/repo", - }); - }); - - it("drops the SSH port from the web URL", () => { - expect(parseRepoUrl("ssh://git@gitlab.example.com:2222/org/group/repo.git")).toMatchObject({ - owner: "org", - name: "group/repo", - provider: "gitlab", - host: "gitlab.example.com", - port: "2222", - scheme: "ssh", - url: "https://gitlab.example.com/org/group/repo", - }); - }); -}); - -describe("Bitbucket Server /scm/ paths", () => { - function resolved(url: string, env: Record = {}) { - const parsed = parseRepoUrl(url); - expect(parsed).not.toBeNull(); - return resolveRepoInfo(parsed!, env); - } - - it("shifts a three-segment HTTPS path", () => { - expect(resolved("https://bitbucket.example.com/scm/PROJ/repo.git")).toEqual({ - owner: "PROJ", - name: "repo", - provider: "bitbucket", - url: "https://bitbucket.example.com/PROJ/repo", - }); - }); - - it("does not shift a two-segment path", () => { - expect(resolved("https://bitbucket.example.com/scm/repo.git")).toEqual({ - owner: "scm", - name: "repo", - provider: "bitbucket", - url: "https://bitbucket.example.com/scm/repo", - }); - }); - - it("does not shift bitbucket.org paths", () => { - expect(resolved("https://bitbucket.org/scm/PROJ/repo.git")).toEqual({ - owner: "scm", - name: "PROJ/repo", - provider: "bitbucket", - url: "https://bitbucket.org/scm/PROJ/repo", - }); - }); - - it("does not shift scp-style SSH paths", () => { - expect(resolved("git@bitbucket.example.com:scm/PROJ/repo.git")).toEqual({ - owner: "scm", - name: "PROJ/repo", - provider: "bitbucket", - url: "https://bitbucket.example.com/scm/PROJ/repo", - }); - }); - - it("does not shift ssh:// paths", () => { - expect( - resolved("ssh://git@vcs.example.com/scm/PROJ/repo.git", { - LINEAR_RELEASE_REPOSITORY_PROVIDER: "bitbucket", - }), - ).toEqual({ - owner: "scm", - name: "PROJ/repo", - provider: "bitbucket", - url: "https://vcs.example.com/scm/PROJ/repo", - }); - }); - - it("does not shift paths resolved to a non-Bitbucket provider", () => { - expect(resolved("https://github.example.com/scm/PROJ/repo.git")).toEqual({ - owner: "scm", - name: "PROJ/repo", - provider: "github", - url: "https://github.example.com/scm/PROJ/repo", - }); - }); - - it("shifts after an override resolves a custom host to Bitbucket", () => { - expect( - resolved("https://vcs.example.com/scm/PROJ/repo.git", { - LINEAR_RELEASE_REPOSITORY_PROVIDER: "bitbucket", - }), - ).toEqual({ - owner: "PROJ", - name: "repo", - provider: "bitbucket", - url: "https://vcs.example.com/PROJ/repo", - }); - }); -}); - describe("extractBranchNameFromMergeMessage", () => { describe("GitHub format", () => { it("should extract branch name from standard GitHub merge message", () => { diff --git a/src/git.ts b/src/git.ts index d3a5300..c8eb6fe 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,6 +1,5 @@ import { execFileSync, execSync } from "node:child_process"; -import { ConfigurationError, inferProviderFromCI } from "./ci-env"; -import type { CommitContext, GitInfo, RepoInfo, RepositoryProvider, ResolvedRepoInfo } from "./types"; +import type { CommitContext, GitInfo, RepoInfo } from "./types"; import { error as logError, verbose, warn } from "./log"; /** Strips leading "./" or "/" so paths are clean for git pathspec. */ @@ -517,7 +516,7 @@ export function getCommitContextsBetweenShas( return commits; } -function hostToProvider(host: string): RepositoryProvider | null { +function hostToProvider(host: string): string | null { if (host === "gitlab.com" || host.includes("gitlab")) { return "gitlab"; } @@ -530,160 +529,55 @@ function hostToProvider(host: string): RepositoryProvider | null { return null; } -type ParsedAuthority = { - authority: string; - host: string; - port: string | null; - hostAuthority: string; -}; - -function parseAuthority(value: string): ParsedAuthority | null { - const userinfoIndex = value.lastIndexOf("@"); - const authority = value.slice(userinfoIndex + 1); - if (!authority) { - return null; - } - - if (authority.startsWith("[")) { - const bracket = authority.indexOf("]"); - if (bracket === -1) { - return null; - } - const rawHost = authority.slice(1, bracket); - const suffix = authority.slice(bracket + 1); - const port = suffix.startsWith(":") && /^\d+$/.test(suffix.slice(1)) ? suffix.slice(1) : null; - if (suffix && port === null) { - return null; - } - return { - authority, - host: rawHost.toLowerCase().replace(/\.$/, ""), - port, - hostAuthority: authority.slice(0, bracket + 1), - }; - } - - const lastColon = authority.lastIndexOf(":"); - const hasPort = - lastColon !== -1 && authority.indexOf(":") === lastColon && /^\d+$/.test(authority.slice(lastColon + 1)); - const rawHost = hasPort ? authority.slice(0, lastColon) : authority; - if (!rawHost) { - return null; - } - return { - authority, - host: rawHost.toLowerCase().replace(/\.$/, ""), - port: hasPort ? authority.slice(lastColon + 1) : null, - hostAuthority: rawHost, - }; -} - -function createRepoInfo(authority: ParsedAuthority, rawPath: string, scheme: RepoInfo["scheme"]): RepoInfo | null { - const path = rawPath.replace(/^\/+|\/+$/g, "").replace(/\.git$/i, ""); +/** + * Parses a git remote URL (HTTPS or SSH) into repo information. + * + * @param remoteUrl The raw git remote URL string. + * @returns Parsed repo info, or null if the URL could not be parsed. + */ +export function parseRepoUrl(remoteUrl: string): RepoInfo | null { // GitLab nested groups: split on the first slash so subgroup paths fold // into the name segment (e.g. owner=group, name=subgroup/repo). - const slash = path.indexOf("/"); - if (slash <= 0 || slash === path.length - 1) { - return null; - } - const owner = path.slice(0, slash); - const name = path.slice(slash + 1); - const webAuthority = scheme === "ssh" ? authority.hostAuthority : authority.authority; - return { - owner, - name, - provider: hostToProvider(authority.host), - url: `https://${webAuthority}/${owner}/${name}`, - host: authority.host, - port: authority.port, - authority: authority.authority, - path, - scheme, - }; -} - -export function parseRepoUrl(remoteUrl: string): RepoInfo | null { - const urlMatch = remoteUrl.trim().match(/^(https?|ssh):\/\/([^/]+)\/(.+)$/i); - if (urlMatch?.[1] && urlMatch[2]) { - const scheme = urlMatch[1].toLowerCase() as RepoInfo["scheme"]; - const authority = parseAuthority(urlMatch[2]); - if (!authority) { - return null; - } - try { - const parsed = new URL(remoteUrl.trim()); - return createRepoInfo(authority, parsed.pathname, scheme); - } catch { - return null; - } + const httpsMatch = remoteUrl.match(/^https?:\/\/(?:[^@]+@)?([^/]+)\/([^/]+)\/(.+?)(?:\.git)?$/); + if (httpsMatch) { + const host = httpsMatch[1]; + const owner = httpsMatch[2] || null; + const name = httpsMatch[3]?.replace(/\.git$/, "") || null; + return { + owner, + name, + provider: hostToProvider(host), + url: owner && name ? `https://${host}/${owner}/${name}` : null, + }; } - const sshMatch = remoteUrl.trim().match(/^git@([^:]+):(.+)$/); - if (sshMatch?.[1] && sshMatch[2]) { - const authority = parseAuthority(sshMatch[1]); - return authority ? createRepoInfo(authority, sshMatch[2], "ssh") : null; + // Handle SSH URLs: git@github.com:owner/repo.git (GitLab nested groups + // follow the same first-slash split as the HTTPS case above). + const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/); + if (sshMatch) { + const host = sshMatch[1]; + const owner = sshMatch[2] || null; + const name = sshMatch[3]?.replace(/\.git$/, "") || null; + return { + owner, + name, + provider: hostToProvider(host), + url: owner && name ? `https://${host}/${owner}/${name}` : null, + }; } return null; } -function toResolvedRepoInfo( - parsed: RepoInfo, - provider: RepositoryProvider, - enrichment: { owner?: string; name?: string; url?: string } = {}, -): ResolvedRepoInfo { - let owner = enrichment.owner ?? parsed.owner; - let name = enrichment.name ?? parsed.name; - let url = enrichment.url ?? parsed.url; - - const segments = parsed.path.split("/"); - if ( - provider === "bitbucket" && - parsed.scheme !== "ssh" && - parsed.host !== "bitbucket.org" && - segments[0] === "scm" && - segments.length >= 3 - ) { - owner = segments[1] ?? null; - name = segments.slice(2).join("/") || null; - url = owner && name ? `https://${parsed.authority}/${owner}/${name}` : null; - } - - return { owner, name, provider, url }; -} - -export function resolveRepoInfo( - parsed: RepoInfo, - env: Record = process.env, -): ResolvedRepoInfo | null { - const rawOverride = env.LINEAR_RELEASE_REPOSITORY_PROVIDER; - if (rawOverride !== undefined) { - const provider = rawOverride.trim().toLowerCase(); - if (provider !== "github" && provider !== "gitlab" && provider !== "bitbucket") { - throw new ConfigurationError( - `Invalid LINEAR_RELEASE_REPOSITORY_PROVIDER value "${rawOverride}". Expected github, gitlab, or bitbucket.`, - "invalid-provider-override", - { value: rawOverride }, - ); - } - return toResolvedRepoInfo(parsed, provider); - } - - if (parsed.provider) { - return toResolvedRepoInfo(parsed, parsed.provider); - } - - const inferred = inferProviderFromCI(env, parsed); - return inferred ? toResolvedRepoInfo(parsed, inferred.provider, inferred) : null; -} - -export function getRemoteUrl(remote: string = "origin", cwd: string = process.cwd()): string | null { +export function getRepoInfo(remote: string = "origin", cwd: string = process.cwd()): RepoInfo | null { try { - return execFileSync("git", ["remote", "get-url", remote], { + const url = execSync(`git remote get-url ${remote}`, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", }).trim(); + + return parseRepoUrl(url); } catch (error) { logError(`Failed to read repo info: ${error instanceof Error ? error.message : String(error)}`); return null; diff --git a/src/index.test.ts b/src/index.test.ts index e7a4ddb..c383566 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -168,6 +168,37 @@ afterAll(() => { rmSync(mockDirectory, { recursive: true, force: true }); }); +describe("provider detection", () => { + it("infers gitlab on GitLab CI for a custom-domain remote", async () => { + const cwd = createRepository({ remote: "git@git.example.com:group/repo.git" }); + const result = await runCli(cwd, ["sync"], { + GITLAB_CI: "true", + CI_SERVER_HOST: "git.example.com", + CI_PROJECT_PATH: "group/repo", + }); + const mutation = requests.find((request) => request.query.includes("mutation syncReleaseByAccessKey")); + + expect(result.code).toBe(0); + expect(mutation?.variables?.input?.repository).toEqual({ + owner: "group", + name: "repo", + provider: "gitlab", + url: "https://git.example.com/group/repo", + }); + }); + + it("uses the override ahead of detection", async () => { + const cwd = createRepository({ remote: "https://git.example.com/group/repo.git" }); + const result = await runCli(cwd, ["sync"], { + LINEAR_RELEASE_REPOSITORY_PROVIDER: "gitlab", + }); + const mutation = requests.find((request) => request.query.includes("mutation syncReleaseByAccessKey")); + + expect(result.code).toBe(0); + expect((mutation?.variables?.input?.repository as Record)?.provider).toBe("gitlab"); + }); +}); + describe("provider configuration errors", () => { it("exits 2 with actionable copy before the mutation for an unknown provider", async () => { const cwd = createRepository({ remote: "https://git.example.com/acme/repo.git" }); @@ -192,19 +223,6 @@ describe("provider configuration errors", () => { expect(requests.some((request) => request.query.includes("mutation syncReleaseByAccessKey"))).toBe(false); }); - it("exits 2 with the Azure Repos-specific error", async () => { - const remote = "https://dev.azure.com/acme/project/_git/repo.git"; - const cwd = createRepository({ remote }); - const result = await runCli(cwd, ["sync"], { - BUILD_REPOSITORY_PROVIDER: "TfsGit", - BUILD_REPOSITORY_URI: remote, - }); - - expect(result.code).toBe(2); - expect(result.stderr).toContain("Azure Repos repositories are not supported"); - expect(requests.some((request) => request.query.includes("mutation syncReleaseByAccessKey"))).toBe(false); - }); - it("emits the machine-readable error code on stderr with --json", async () => { const cwd = createRepository({ remote: "https://git.example.com/acme/repo.git" }); const result = await runCli(cwd, ["sync", "--json"]); @@ -262,7 +280,6 @@ describe("existing exit behavior and no-origin compatibility", () => { const mutation = requests.find((request) => request.query.includes("mutation syncReleaseByAccessKey")); expect(result.code).toBe(0); - expect(result.stdout).toContain('warning: Could not parse remote URL "/srv/git/repo.git"'); expect(mutation).toBeDefined(); expect(mutation?.variables?.input).not.toHaveProperty("repository"); }); diff --git a/src/index.ts b/src/index.ts index 4afc7c5..3294227 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,9 +5,7 @@ import { ensureCommitAvailable, getCommitContextsBetweenShas, getCurrentGitInfo, - getRemoteUrl, - parseRepoUrl, - resolveRepoInfo, + getRepoInfo, resolveCommitRef, verifyAncestorReachable, } from "./git"; @@ -43,7 +41,7 @@ import { pluralize } from "./util"; import { buildUserAgent } from "./user-agent"; import { withRetry } from "./retry"; import { getCliVersion } from "./version"; -import { ConfigurationError } from "./ci-env"; +import { ConfigurationError, inferProviderFromCI, parseProvider, remoteHost } from "./ci-env"; if (process.argv.includes("--version") || process.argv.includes("-v")) { console.log(getCliVersion()); @@ -255,24 +253,32 @@ async function apiRequest(query: string, variables?: Record) } function getResolvedRepoInfo(): ResolvedRepoInfo | null { - const remoteUrl = getRemoteUrl(); - if (!remoteUrl) { + const repoInfo = getRepoInfo(); + if (!repoInfo) { return null; } - const parsed = parseRepoUrl(remoteUrl); - if (!parsed) { - warn(`Could not parse remote URL "${remoteUrl}"; syncing without repository information.`); - return null; + const override = process.env.LINEAR_RELEASE_REPOSITORY_PROVIDER; + if (override !== undefined) { + const provider = parseProvider(override); + if (!provider) { + throw new ConfigurationError( + `Invalid LINEAR_RELEASE_REPOSITORY_PROVIDER value "${override}". Expected github, gitlab, or bitbucket.`, + "invalid-provider-override", + { value: override }, + ); + } + return { ...repoInfo, provider }; } - const resolved = resolveRepoInfo(parsed, process.env); - if (!resolved) { + const provider = parseProvider(repoInfo.provider) ?? inferProviderFromCI(process.env, repoInfo); + if (!provider) { + const host = remoteHost(repoInfo) ?? repoInfo.url ?? "unknown"; throw new ConfigurationError( - `Could not determine the VCS provider for remote host "${parsed.host}".\nSet LINEAR_RELEASE_REPOSITORY_PROVIDER=github|gitlab|bitbucket in your CI environment.`, + `Could not determine the VCS provider for remote host "${host}".\nSet LINEAR_RELEASE_REPOSITORY_PROVIDER=github|gitlab|bitbucket in your CI environment.`, "unknown-provider", - { host: parsed.host }, + { host }, ); } - return resolved; + return { ...repoInfo, provider }; } async function syncCommand(): Promise<{ diff --git a/src/types.ts b/src/types.ts index 92e8acc..3bbffdf 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,13 +82,8 @@ export type RepositoryProvider = "github" | "gitlab" | "bitbucket"; export type RepoInfo = { owner: string | null; name: string | null; - provider: RepositoryProvider | null; + provider: string | null; url: string | null; - host: string; - port: string | null; - authority: string; - path: string; - scheme: "http" | "https" | "ssh"; }; export type ResolvedRepoInfo = { From 6d6e875e1aa4e8a79f24e283b40e7a85c9e5f7b1 Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Mon, 27 Jul 2026 09:44:05 +0200 Subject: [PATCH 3/6] Rename override to LINEAR_VCS_PROVIDER and move resolution into provider module --- README.md | 16 +-- src/ci-env.test.ts | 52 +------- src/ci-env.ts | 50 -------- src/index.test.ts | 295 ------------------------------------------- src/index.ts | 30 +---- src/provider.test.ts | 75 +++++++++++ src/provider.ts | 74 +++++++++++ 7 files changed, 158 insertions(+), 434 deletions(-) delete mode 100644 src/index.test.ts create mode 100644 src/provider.test.ts create mode 100644 src/provider.ts diff --git a/README.md b/README.md index bbc45e4..7970b14 100644 --- a/README.md +++ b/README.md @@ -143,18 +143,12 @@ linear-release update --stage="in review" --name="Release 1.2.0" ### Environment Variables -| Variable | Required | Description | -| ------------------------------------ | -------- | ------------------------------------------------------------------------------------------- | -| `LINEAR_ACCESS_KEY` | Yes | Pipeline access key from Linear | -| `LINEAR_RELEASE_REPOSITORY_PROVIDER` | No | Force repository provider detection: `github`, `gitlab`, or `bitbucket` (case-insensitive). | +| Variable | Required | Description | +| --------------------- | -------- | ---------------------------------------------------------------- | +| `LINEAR_ACCESS_KEY` | Yes | Pipeline access key from Linear | +| `LINEAR_VCS_PROVIDER` | No | Override VCS provider detection: `github`, `gitlab`, `bitbucket` | -### Provider detection - -`sync` determines the repository provider from `LINEAR_RELEASE_REPOSITORY_PROVIDER` if set, then the remote hostname, then the GitLab CI environment (`GITLAB_CI` with a matching `CI_SERVER_HOST` or `CI_PROJECT_PATH`) — so self-hosted GitLab on a custom domain works without configuration. If none of these resolve, `sync` stops before making a mutation and exits with code `2` (other errors keep code `1`); with `--json` the error on stderr includes a machine-readable code such as `{"error":"unknown-provider","host":"git.example.com"}`. Set the override for other self-hosted providers on custom domains: - -```bash -LINEAR_RELEASE_REPOSITORY_PROVIDER=gitlab linear-release sync -``` +The repository provider is detected automatically from the remote hostname and, on GitLab CI, from the job environment — self-hosted GitLab on a custom domain needs no configuration. `LINEAR_VCS_PROVIDER` covers the setups where neither signal can identify the host, such as self-hosted GitHub Enterprise on a custom domain or CI platforms that don't expose the provider. When the provider can't be determined, `sync` fails before syncing rather than record repository data Linear can't use; these configuration errors exit with code `2` so a wrapper can tell a permanent misconfiguration from a transient failure. ### CLI Options diff --git a/src/ci-env.test.ts b/src/ci-env.test.ts index f42766b..7365e5d 100644 --- a/src/ci-env.test.ts +++ b/src/ci-env.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { detectCIEnvironment, inferProviderFromCI, parseProvider } from "./ci-env"; +import { detectCIEnvironment } from "./ci-env"; describe("detectCIEnvironment", () => { const originalEnv = process.env; @@ -82,53 +82,3 @@ describe("detectCIEnvironment", () => { expect(detectCIEnvironment()).toEqual({ name: "github-actions" }); }); }); - -describe("inferProviderFromCI", () => { - const repoInfo = { - owner: "group", - name: "subgroup/repo", - provider: null, - url: "https://git.example.com/group/subgroup/repo", - }; - - it("infers gitlab when the remote host matches CI_SERVER_HOST", () => { - const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com" }; - expect(inferProviderFromCI(env, repoInfo)).toBe("gitlab"); - }); - - it("infers gitlab when clone_url rewrites the host but the project path matches", () => { - const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com", CI_PROJECT_PATH: "group/subgroup/repo" }; - const rewritten = { ...repoInfo, url: "https://192.168.1.23/group/subgroup/repo" }; - expect(inferProviderFromCI(env, rewritten)).toBe("gitlab"); - }); - - it("does not infer for a foreign clone when both host and path mismatch", () => { - const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com", CI_PROJECT_PATH: "group/subgroup/repo" }; - const foreign = { owner: "acme", name: "other", provider: null, url: "https://git.other.example/acme/other" }; - expect(inferProviderFromCI(env, foreign)).toBeNull(); - }); - - it("matches hosts case-insensitively and ignores the port", () => { - const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com" }; - const withPort = { ...repoInfo, url: "https://Git.Example.com:8443/group/subgroup/repo" }; - expect(inferProviderFromCI(env, withPort)).toBe("gitlab"); - }); - - it("does not infer outside GitLab CI", () => { - expect(inferProviderFromCI({ CI_SERVER_HOST: "git.example.com" }, repoInfo)).toBeNull(); - }); -}); - -describe("parseProvider", () => { - it("accepts the three providers case-insensitively", () => { - expect(parseProvider("GitLab")).toBe("gitlab"); - expect(parseProvider(" github ")).toBe("github"); - expect(parseProvider("bitbucket")).toBe("bitbucket"); - }); - - it("rejects anything else", () => { - expect(parseProvider("gitea")).toBeNull(); - expect(parseProvider(null)).toBeNull(); - expect(parseProvider(undefined)).toBeNull(); - }); -}); diff --git a/src/ci-env.ts b/src/ci-env.ts index 2f8a796..ca6ecea 100644 --- a/src/ci-env.ts +++ b/src/ci-env.ts @@ -1,57 +1,7 @@ -import type { RepoInfo, RepositoryProvider } from "./types"; - export interface CIEnvironment { name: string; } -export type ConfigurationErrorCode = "invalid-provider-override" | "unknown-provider"; - -export class ConfigurationError extends Error { - constructor( - message: string, - readonly code: ConfigurationErrorCode, - readonly details: Record = {}, - ) { - super(message); - this.name = "ConfigurationError"; - } -} - -export function parseProvider(value: string | null | undefined): RepositoryProvider | null { - const provider = value?.trim().toLowerCase(); - return provider === "github" || provider === "gitlab" || provider === "bitbucket" ? provider : null; -} - -export function remoteHost(repoInfo: RepoInfo): string | null { - if (!repoInfo.url) { - return null; - } - try { - return new URL(repoInfo.url).hostname.toLowerCase(); - } catch { - return null; - } -} - -export function inferProviderFromCI( - env: Record, - repoInfo: RepoInfo, -): RepositoryProvider | null { - if (env.GITLAB_CI !== "true") { - return null; - } - const host = remoteHost(repoInfo); - const serverHost = env.CI_SERVER_HOST?.trim().toLowerCase(); - const hostMatched = host !== null && !!serverHost && host === serverHost; - const projectPath = env.CI_PROJECT_PATH?.trim().replace(/^\/+|\/+$/g, ""); - const remotePath = repoInfo.owner && repoInfo.name ? `${repoInfo.owner}/${repoInfo.name}` : null; - const pathMatched = - !!projectPath && remotePath !== null && (remotePath === projectPath || remotePath.endsWith(`/${projectPath}`)); - // Host OR path suffices: GitLab runner clone_url rewrites the origin host - // while preserving the project path. Both mismatching means a foreign checkout. - return hostMatched || pathMatched ? "gitlab" : null; -} - /** * Detects the CI environment based on environment variables. * Returns null if not running in a recognized CI environment. diff --git a/src/index.test.ts b/src/index.test.ts deleted file mode 100644 index c383566..0000000 --- a/src/index.test.ts +++ /dev/null @@ -1,295 +0,0 @@ -import { execFileSync, spawn } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; - -const repositoryRoot = process.cwd(); -const tsxLoader = join(repositoryRoot, "node_modules", "tsx", "dist", "loader.mjs"); - -type GraphQLRequest = { - query: string; - variables?: { - input?: Record; - }; -}; - -type CliResult = { - code: number | null; - stdout: string; - stderr: string; -}; - -let requests: GraphQLRequest[] = []; -const repositories: string[] = []; -let mockDirectory: string; -let registerMock: string; -let requestLogSequence = 0; - -function runGit(cwd: string, ...args: string[]): string { - return execFileSync("git", args, { - cwd, - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"], - }).trim(); -} - -function createRepository(options: { remote?: string; message?: string } = {}): string { - const cwd = mkdtempSync(join(tmpdir(), "linear-release-index-")); - repositories.push(cwd); - runGit(cwd, "init"); - runGit(cwd, "config", "user.email", "test@example.com"); - runGit(cwd, "config", "user.name", "Test User"); - writeFileSync(join(cwd, "file.txt"), "content"); - runGit(cwd, "add", "."); - runGit(cwd, "commit", "-m", options.message ?? "Initial commit"); - if (options.remote) { - runGit(cwd, "remote", "add", "origin", options.remote); - } - return cwd; -} - -function runCli(cwd: string, args: string[], env: Record = {}): Promise { - return new Promise((resolve, reject) => { - const requestLog = join(mockDirectory, `requests-${requestLogSequence++}.jsonl`); - const child = spawn( - process.execPath, - ["--import", registerMock, "--import", tsxLoader, join(repositoryRoot, "src", "index.ts"), ...args], - { - cwd, - env: { - PATH: process.env.PATH, - NODE_ENV: "development", - NODE_NO_WARNINGS: "1", - LINEAR_ACCESS_KEY: "test-access-key", - LINEAR_RELEASE_TEST_REQUESTS: requestLog, - ...env, - }, - stdio: ["ignore", "pipe", "pipe"], - }, - ); - let stdout = ""; - let stderr = ""; - child.stdout.setEncoding("utf8"); - child.stderr.setEncoding("utf8"); - child.stdout.on("data", (chunk: string) => { - stdout += chunk; - }); - child.stderr.on("data", (chunk: string) => { - stderr += chunk; - }); - child.on("error", reject); - child.on("close", (code) => { - requests = existsSync(requestLog) - ? readFileSync(requestLog, "utf8") - .trim() - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as GraphQLRequest) - : []; - resolve({ code, stdout, stderr }); - }); - }); -} - -beforeAll(() => { - mockDirectory = mkdtempSync(join(tmpdir(), "linear-release-sdk-mock-")); - registerMock = join(mockDirectory, "register.mjs"); - writeFileSync( - registerMock, - `import { registerHooks } from "node:module"; -const stub = new URL("./sdk.cjs", import.meta.url).href; -registerHooks({ - resolve(specifier, context, nextResolve) { - if (specifier === "@linear/sdk") return { url: stub, shortCircuit: true }; - return nextResolve(specifier, context); - }, -}); -`, - ); - writeFileSync( - join(mockDirectory, "sdk.cjs"), - `const { appendFileSync } = require("node:fs"); -class LinearError extends Error {} -class RatelimitedLinearError extends LinearError {} -const LinearErrorType = { - AuthenticationError: "AuthenticationError", - Forbidden: "Forbidden", - FeatureNotAccessible: "FeatureNotAccessible", - GraphqlError: "GraphqlError", - InvalidInput: "InvalidInput", - UserError: "UserError", - UsageLimitExceeded: "UsageLimitExceeded", -}; -class LinearClient { - constructor() { - this.client = { - setHeader() {}, - rawRequest: async (query, variables) => { - appendFileSync(process.env.LINEAR_RELEASE_TEST_REQUESTS, JSON.stringify({ query, variables }) + "\\n"); - if (query.includes("pipelineSettingsByAccessKey")) { - return { data: { releasePipelineByAccessKey: { includePathPatterns: [] } } }; - } - if (query.includes("recentReleasesByAccessKey")) { - return { data: { recentReleasesByAccessKey: [] } }; - } - return { - data: { - releaseSyncByAccessKey: { - success: true, - release: { - id: "release-id", - name: "test-release", - version: "1.0.0", - url: "https://linear.app/release", - commitSha: variables?.input?.commitSha, - createdAt: "2026-07-27T00:00:00.000Z", - }, - }, - }, - }; - }, - }; - } -} -module.exports = { LinearClient, LinearError, LinearErrorType, RatelimitedLinearError }; -`, - ); -}); - -beforeEach(() => { - requests = []; -}); - -afterAll(() => { - for (const repository of repositories) { - rmSync(repository, { recursive: true, force: true }); - } - rmSync(mockDirectory, { recursive: true, force: true }); -}); - -describe("provider detection", () => { - it("infers gitlab on GitLab CI for a custom-domain remote", async () => { - const cwd = createRepository({ remote: "git@git.example.com:group/repo.git" }); - const result = await runCli(cwd, ["sync"], { - GITLAB_CI: "true", - CI_SERVER_HOST: "git.example.com", - CI_PROJECT_PATH: "group/repo", - }); - const mutation = requests.find((request) => request.query.includes("mutation syncReleaseByAccessKey")); - - expect(result.code).toBe(0); - expect(mutation?.variables?.input?.repository).toEqual({ - owner: "group", - name: "repo", - provider: "gitlab", - url: "https://git.example.com/group/repo", - }); - }); - - it("uses the override ahead of detection", async () => { - const cwd = createRepository({ remote: "https://git.example.com/group/repo.git" }); - const result = await runCli(cwd, ["sync"], { - LINEAR_RELEASE_REPOSITORY_PROVIDER: "gitlab", - }); - const mutation = requests.find((request) => request.query.includes("mutation syncReleaseByAccessKey")); - - expect(result.code).toBe(0); - expect((mutation?.variables?.input?.repository as Record)?.provider).toBe("gitlab"); - }); -}); - -describe("provider configuration errors", () => { - it("exits 2 with actionable copy before the mutation for an unknown provider", async () => { - const cwd = createRepository({ remote: "https://git.example.com/acme/repo.git" }); - const result = await runCli(cwd, ["sync"]); - - expect(result.code).toBe(2); - expect(result.stderr).toContain( - 'Error: Could not determine the VCS provider for remote host "git.example.com".\n' + - "Set LINEAR_RELEASE_REPOSITORY_PROVIDER=github|gitlab|bitbucket in your CI environment.\n", - ); - expect(requests.some((request) => request.query.includes("mutation syncReleaseByAccessKey"))).toBe(false); - }); - - it("exits 2 for an invalid override", async () => { - const cwd = createRepository({ remote: "https://github.com/acme/repo.git" }); - const result = await runCli(cwd, ["sync"], { - LINEAR_RELEASE_REPOSITORY_PROVIDER: "gitea", - }); - - expect(result.code).toBe(2); - expect(result.stderr).toContain("Invalid LINEAR_RELEASE_REPOSITORY_PROVIDER"); - expect(requests.some((request) => request.query.includes("mutation syncReleaseByAccessKey"))).toBe(false); - }); - - it("emits the machine-readable error code on stderr with --json", async () => { - const cwd = createRepository({ remote: "https://git.example.com/acme/repo.git" }); - const result = await runCli(cwd, ["sync", "--json"]); - const errorLine = result.stderr - .trim() - .split("\n") - .map((line) => JSON.parse(line) as Record) - .find((line) => line.error === "unknown-provider"); - - expect(result.code).toBe(2); - expect(errorLine).toEqual({ error: "unknown-provider", host: "git.example.com" }); - expect(result.stdout).toBe(""); - }); - - it("still validates provider detection during a dry run", async () => { - const cwd = createRepository({ remote: "https://git.example.com/acme/repo.git" }); - const result = await runCli(cwd, ["sync", "--dry-run"]); - - expect(result.code).toBe(2); - expect(requests.some((request) => request.query.includes("mutation syncReleaseByAccessKey"))).toBe(false); - }); -}); - -describe("existing exit behavior and no-origin compatibility", () => { - it("lists the provider override in help", async () => { - const cwd = createRepository(); - const result = await runCli(cwd, ["--help"]); - - expect(result.code).toBe(0); - expect(result.stdout).toContain("Environment:"); - expect(result.stdout).toContain("LINEAR_RELEASE_REPOSITORY_PROVIDER"); - }); - - it("keeps existing errors on exit code 1", async () => { - const cwd = createRepository(); - const result = await runCli(cwd, ["not-a-command"]); - - expect(result.code).toBe(1); - expect(result.stderr).toContain('Unknown command "not-a-command"'); - }); - - it("omits repository data and syncs when origin is absent", async () => { - const cwd = createRepository(); - const result = await runCli(cwd, ["sync"]); - const mutation = requests.find((request) => request.query.includes("mutation syncReleaseByAccessKey")); - - expect(result.code).toBe(0); - expect(mutation).toBeDefined(); - expect(mutation?.variables?.input).not.toHaveProperty("repository"); - }); - - it("omits repository data and syncs when the remote URL is unparseable", async () => { - const cwd = createRepository({ remote: "/srv/git/repo.git" }); - const result = await runCli(cwd, ["sync"]); - const mutation = requests.find((request) => request.query.includes("mutation syncReleaseByAccessKey")); - - expect(result.code).toBe(0); - expect(mutation).toBeDefined(); - expect(mutation?.variables?.input).not.toHaveProperty("repository"); - }); - - it("keeps the pull-request reference error when origin is absent", async () => { - const cwd = createRepository({ message: "Fix regression (#42)" }); - const result = await runCli(cwd, ["sync"]); - - expect(result.code).toBe(1); - expect(result.stderr).toContain("Repository info is required to sync a release with pull request references"); - expect(requests.some((request) => request.query.includes("mutation syncReleaseByAccessKey"))).toBe(false); - }); -}); diff --git a/src/index.ts b/src/index.ts index 3294227..1d31eff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,7 +41,7 @@ import { pluralize } from "./util"; import { buildUserAgent } from "./user-agent"; import { withRetry } from "./retry"; import { getCliVersion } from "./version"; -import { ConfigurationError, inferProviderFromCI, parseProvider, remoteHost } from "./ci-env"; +import { ConfigurationError, resolveRepoInfo } from "./provider"; if (process.argv.includes("--version") || process.argv.includes("-v")) { console.log(getCliVersion()); @@ -82,7 +82,7 @@ Options: Environment: LINEAR_ACCESS_KEY Pipeline access key (required) - LINEAR_RELEASE_REPOSITORY_PROVIDER Force repository provider: github|gitlab|bitbucket + LINEAR_VCS_PROVIDER Override VCS provider detection: github|gitlab|bitbucket Examples: linear-release sync @@ -254,31 +254,7 @@ async function apiRequest(query: string, variables?: Record) function getResolvedRepoInfo(): ResolvedRepoInfo | null { const repoInfo = getRepoInfo(); - if (!repoInfo) { - return null; - } - const override = process.env.LINEAR_RELEASE_REPOSITORY_PROVIDER; - if (override !== undefined) { - const provider = parseProvider(override); - if (!provider) { - throw new ConfigurationError( - `Invalid LINEAR_RELEASE_REPOSITORY_PROVIDER value "${override}". Expected github, gitlab, or bitbucket.`, - "invalid-provider-override", - { value: override }, - ); - } - return { ...repoInfo, provider }; - } - const provider = parseProvider(repoInfo.provider) ?? inferProviderFromCI(process.env, repoInfo); - if (!provider) { - const host = remoteHost(repoInfo) ?? repoInfo.url ?? "unknown"; - throw new ConfigurationError( - `Could not determine the VCS provider for remote host "${host}".\nSet LINEAR_RELEASE_REPOSITORY_PROVIDER=github|gitlab|bitbucket in your CI environment.`, - "unknown-provider", - { host }, - ); - } - return { ...repoInfo, provider }; + return repoInfo ? resolveRepoInfo(repoInfo) : null; } async function syncCommand(): Promise<{ diff --git a/src/provider.test.ts b/src/provider.test.ts new file mode 100644 index 0000000..c4cddac --- /dev/null +++ b/src/provider.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { ConfigurationError, resolveRepoInfo } from "./provider"; +import type { RepoInfo } from "./types"; + +const selfHosted: RepoInfo = { + owner: "group", + name: "subgroup/repo", + provider: null, + url: "https://git.example.com/group/subgroup/repo", +}; + +function captureError(fn: () => unknown): ConfigurationError { + try { + fn(); + } catch (error) { + if (error instanceof ConfigurationError) { + return error; + } + } + throw new Error("Expected a ConfigurationError"); +} + +describe("resolveRepoInfo", () => { + it("keeps hostname-detected providers", () => { + const detected: RepoInfo = { owner: "acme", name: "repo", provider: "github", url: "https://github.com/acme/repo" }; + expect(resolveRepoInfo(detected, {})).toEqual(detected); + }); + + it("prefers the override over detection", () => { + const detected: RepoInfo = { owner: "acme", name: "repo", provider: "github", url: "https://github.com/acme/repo" }; + expect(resolveRepoInfo(detected, { LINEAR_VCS_PROVIDER: "GitLab" }).provider).toBe("gitlab"); + }); + + it("throws on an invalid override", () => { + const error = captureError(() => resolveRepoInfo(selfHosted, { LINEAR_VCS_PROVIDER: "gitea" })); + expect(error.code).toBe("invalid-provider-override"); + expect(error.details).toEqual({ value: "gitea" }); + }); + + it("infers gitlab on GitLab CI when the remote host matches CI_SERVER_HOST", () => { + const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com" }; + expect(resolveRepoInfo(selfHosted, env).provider).toBe("gitlab"); + }); + + it("infers gitlab when clone_url rewrites the host but the project path matches", () => { + const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com", CI_PROJECT_PATH: "group/subgroup/repo" }; + const rewritten = { ...selfHosted, url: "https://192.168.1.23/group/subgroup/repo" }; + expect(resolveRepoInfo(rewritten, env).provider).toBe("gitlab"); + }); + + it("matches hosts case-insensitively and ignores the port", () => { + const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com" }; + const withPort = { ...selfHosted, url: "https://Git.Example.com:8443/group/subgroup/repo" }; + expect(resolveRepoInfo(withPort, env).provider).toBe("gitlab"); + }); + + it("throws for a foreign clone when both host and project path mismatch", () => { + const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com", CI_PROJECT_PATH: "group/subgroup/repo" }; + const foreign: RepoInfo = { + owner: "acme", + name: "other", + provider: null, + url: "https://git.other.example/acme/other", + }; + expect(captureError(() => resolveRepoInfo(foreign, env)).code).toBe("unknown-provider"); + }); + + it("throws an actionable error outside CI for an unknown host", () => { + const error = captureError(() => resolveRepoInfo(selfHosted, {})); + expect(error.code).toBe("unknown-provider"); + expect(error.details).toEqual({ host: "git.example.com" }); + expect(error.message).toContain('Could not determine the VCS provider for remote host "git.example.com"'); + expect(error.message).toContain("Set LINEAR_VCS_PROVIDER=github|gitlab|bitbucket"); + }); +}); diff --git a/src/provider.ts b/src/provider.ts new file mode 100644 index 0000000..7574a52 --- /dev/null +++ b/src/provider.ts @@ -0,0 +1,74 @@ +import type { RepoInfo, RepositoryProvider, ResolvedRepoInfo } from "./types"; + +export type ConfigurationErrorCode = "invalid-provider-override" | "unknown-provider"; + +export class ConfigurationError extends Error { + constructor( + message: string, + readonly code: ConfigurationErrorCode, + readonly details: Record = {}, + ) { + super(message); + this.name = "ConfigurationError"; + } +} + +function parseProvider(value: string | null | undefined): RepositoryProvider | null { + const provider = value?.trim().toLowerCase(); + return provider === "github" || provider === "gitlab" || provider === "bitbucket" ? provider : null; +} + +function remoteHost(repoInfo: RepoInfo): string | null { + if (!repoInfo.url) { + return null; + } + try { + return new URL(repoInfo.url).hostname.toLowerCase(); + } catch { + return null; + } +} + +function inferProviderFromCI(env: Record, repoInfo: RepoInfo): RepositoryProvider | null { + if (env.GITLAB_CI !== "true") { + return null; + } + const host = remoteHost(repoInfo); + const serverHost = env.CI_SERVER_HOST?.trim().toLowerCase(); + const hostMatched = host !== null && !!serverHost && host === serverHost; + const projectPath = env.CI_PROJECT_PATH?.trim().replace(/^\/+|\/+$/g, ""); + const remotePath = repoInfo.owner && repoInfo.name ? `${repoInfo.owner}/${repoInfo.name}` : null; + const pathMatched = + !!projectPath && remotePath !== null && (remotePath === projectPath || remotePath.endsWith(`/${projectPath}`)); + // Host OR path suffices: GitLab runner clone_url rewrites the origin host + // while preserving the project path. Both mismatching means a foreign checkout. + return hostMatched || pathMatched ? "gitlab" : null; +} + +export function resolveRepoInfo( + repoInfo: RepoInfo, + env: Record = process.env, +): ResolvedRepoInfo { + const override = env.LINEAR_VCS_PROVIDER; + if (override !== undefined) { + const provider = parseProvider(override); + if (!provider) { + throw new ConfigurationError( + `Invalid LINEAR_VCS_PROVIDER value "${override}". Expected github, gitlab, or bitbucket.`, + "invalid-provider-override", + { value: override }, + ); + } + return { ...repoInfo, provider }; + } + const provider = parseProvider(repoInfo.provider) ?? inferProviderFromCI(env, repoInfo); + if (!provider) { + const host = remoteHost(repoInfo) ?? repoInfo.url ?? "unknown"; + throw new ConfigurationError( + `Could not determine the VCS provider for remote host "${host}".\nSet LINEAR_VCS_PROVIDER=github|gitlab|bitbucket in your CI environment.`, + "unknown-provider", + { host }, + ); + } + return { ...repoInfo, provider }; +} From 9d3e093d524a8efb6bc174bfe32d0c9ef0d6717c Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Mon, 27 Jul 2026 09:59:28 +0200 Subject: [PATCH 4/6] Simplify provider resolution after cleanup review --- src/git.ts | 4 ++-- src/index.ts | 23 +++++++-------------- src/provider.test.ts | 28 +++++++++++++++++-------- src/provider.ts | 49 ++++++++++++++++++++++---------------------- src/types.ts | 9 ++------ 5 files changed, 56 insertions(+), 57 deletions(-) diff --git a/src/git.ts b/src/git.ts index c8eb6fe..b3a710c 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,5 +1,5 @@ import { execFileSync, execSync } from "node:child_process"; -import type { CommitContext, GitInfo, RepoInfo } from "./types"; +import type { CommitContext, GitInfo, RepoInfo, RepositoryProvider } from "./types"; import { error as logError, verbose, warn } from "./log"; /** Strips leading "./" or "/" so paths are clean for git pathspec. */ @@ -516,7 +516,7 @@ export function getCommitContextsBetweenShas( return commits; } -function hostToProvider(host: string): string | null { +function hostToProvider(host: string): RepositoryProvider | null { if (host === "gitlab.com" || host.includes("gitlab")) { return "gitlab"; } diff --git a/src/index.ts b/src/index.ts index 1d31eff..49af02e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -252,16 +252,13 @@ async function apiRequest(query: string, variables?: Record) return withRetry(() => linearClient.client.rawRequest(query, variables)) as Promise; } -function getResolvedRepoInfo(): ResolvedRepoInfo | null { - const repoInfo = getRepoInfo(); - return repoInfo ? resolveRepoInfo(repoInfo) : null; -} - async function syncCommand(): Promise<{ release: { id: string; name: string; version?: string; url?: string }; } | null> { logEnvironmentSummary(); + const repoInfo = resolveRepoInfo(getRepoInfo()); + // Fetch pipeline settings from API const pipelineSettings = await getPipelineSettings(); @@ -375,8 +372,6 @@ async function syncCommand(): Promise<{ info(`Reverted issue keys: ${revertedIssueReferences.map((f) => f.identifier).join(", ")}`); } - const repoInfo = getResolvedRepoInfo(); - const issueIds = issueReferences.map((f) => f.identifier); const parts: string[] = []; if (issueIds.length > 0) parts.push(`issues [${issueIds.join(", ")}]`); @@ -808,16 +803,12 @@ timeout.unref(); main() .catch((e) => { - if (e instanceof ConfigurationError) { - if (jsonOutput) { - process.stderr.write(`${JSON.stringify({ error: e.code, ...e.details })}\n`); - } else { - error(`Error: ${e.message}`); - } - process.exit(2); + if (e instanceof ConfigurationError && jsonOutput) { + process.stderr.write(`${JSON.stringify({ error: e.code, message: e.message })}\n`); + } else { + error(`Error: ${e.message}`); } - error(`Error: ${e.message}`); - process.exit(1); + process.exit(e instanceof ConfigurationError ? 2 : 1); }) .finally(() => { clearTimeout(timeout); diff --git a/src/provider.test.ts b/src/provider.test.ts index c4cddac..205fdef 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -9,6 +9,13 @@ const selfHosted: RepoInfo = { url: "https://git.example.com/group/subgroup/repo", }; +const detected: RepoInfo = { + owner: "acme", + name: "repo", + provider: "github", + url: "https://github.com/acme/repo", +}; + function captureError(fn: () => unknown): ConfigurationError { try { fn(); @@ -21,37 +28,43 @@ function captureError(fn: () => unknown): ConfigurationError { } describe("resolveRepoInfo", () => { + it("returns null without a repo", () => { + expect(resolveRepoInfo(null, {})).toBeNull(); + }); + it("keeps hostname-detected providers", () => { - const detected: RepoInfo = { owner: "acme", name: "repo", provider: "github", url: "https://github.com/acme/repo" }; expect(resolveRepoInfo(detected, {})).toEqual(detected); }); it("prefers the override over detection", () => { - const detected: RepoInfo = { owner: "acme", name: "repo", provider: "github", url: "https://github.com/acme/repo" }; - expect(resolveRepoInfo(detected, { LINEAR_VCS_PROVIDER: "GitLab" }).provider).toBe("gitlab"); + expect(resolveRepoInfo(detected, { LINEAR_VCS_PROVIDER: "GitLab" })?.provider).toBe("gitlab"); + }); + + it("treats an empty override as unset", () => { + expect(resolveRepoInfo(detected, { LINEAR_VCS_PROVIDER: "" })?.provider).toBe("github"); }); it("throws on an invalid override", () => { const error = captureError(() => resolveRepoInfo(selfHosted, { LINEAR_VCS_PROVIDER: "gitea" })); expect(error.code).toBe("invalid-provider-override"); - expect(error.details).toEqual({ value: "gitea" }); + expect(error.message).toContain('Invalid LINEAR_VCS_PROVIDER value "gitea"'); }); it("infers gitlab on GitLab CI when the remote host matches CI_SERVER_HOST", () => { const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com" }; - expect(resolveRepoInfo(selfHosted, env).provider).toBe("gitlab"); + expect(resolveRepoInfo(selfHosted, env)?.provider).toBe("gitlab"); }); it("infers gitlab when clone_url rewrites the host but the project path matches", () => { const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com", CI_PROJECT_PATH: "group/subgroup/repo" }; const rewritten = { ...selfHosted, url: "https://192.168.1.23/group/subgroup/repo" }; - expect(resolveRepoInfo(rewritten, env).provider).toBe("gitlab"); + expect(resolveRepoInfo(rewritten, env)?.provider).toBe("gitlab"); }); it("matches hosts case-insensitively and ignores the port", () => { const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com" }; const withPort = { ...selfHosted, url: "https://Git.Example.com:8443/group/subgroup/repo" }; - expect(resolveRepoInfo(withPort, env).provider).toBe("gitlab"); + expect(resolveRepoInfo(withPort, env)?.provider).toBe("gitlab"); }); it("throws for a foreign clone when both host and project path mismatch", () => { @@ -68,7 +81,6 @@ describe("resolveRepoInfo", () => { it("throws an actionable error outside CI for an unknown host", () => { const error = captureError(() => resolveRepoInfo(selfHosted, {})); expect(error.code).toBe("unknown-provider"); - expect(error.details).toEqual({ host: "git.example.com" }); expect(error.message).toContain('Could not determine the VCS provider for remote host "git.example.com"'); expect(error.message).toContain("Set LINEAR_VCS_PROVIDER=github|gitlab|bitbucket"); }); diff --git a/src/provider.ts b/src/provider.ts index 7574a52..148c1f1 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -1,20 +1,17 @@ import type { RepoInfo, RepositoryProvider, ResolvedRepoInfo } from "./types"; -export type ConfigurationErrorCode = "invalid-provider-override" | "unknown-provider"; - export class ConfigurationError extends Error { constructor( message: string, - readonly code: ConfigurationErrorCode, - readonly details: Record = {}, + readonly code: "invalid-provider-override" | "unknown-provider", ) { super(message); this.name = "ConfigurationError"; } } -function parseProvider(value: string | null | undefined): RepositoryProvider | null { - const provider = value?.trim().toLowerCase(); +function parseProvider(value: string): RepositoryProvider | null { + const provider = value.toLowerCase(); return provider === "github" || provider === "gitlab" || provider === "bitbucket" ? provider : null; } @@ -29,46 +26,50 @@ function remoteHost(repoInfo: RepoInfo): string | null { } } -function inferProviderFromCI(env: Record, repoInfo: RepoInfo): RepositoryProvider | null { +function inferProviderFromCI( + env: Record, + repoInfo: RepoInfo, + host: string | null, +): RepositoryProvider | null { if (env.GITLAB_CI !== "true") { return null; } - const host = remoteHost(repoInfo); const serverHost = env.CI_SERVER_HOST?.trim().toLowerCase(); - const hostMatched = host !== null && !!serverHost && host === serverHost; + const hostMatched = host !== null && host === serverHost; const projectPath = env.CI_PROJECT_PATH?.trim().replace(/^\/+|\/+$/g, ""); const remotePath = repoInfo.owner && repoInfo.name ? `${repoInfo.owner}/${repoInfo.name}` : null; - const pathMatched = - !!projectPath && remotePath !== null && (remotePath === projectPath || remotePath.endsWith(`/${projectPath}`)); + const pathMatched = !!projectPath && (remotePath === projectPath || !!remotePath?.endsWith(`/${projectPath}`)); // Host OR path suffices: GitLab runner clone_url rewrites the origin host // while preserving the project path. Both mismatching means a foreign checkout. return hostMatched || pathMatched ? "gitlab" : null; } -export function resolveRepoInfo( - repoInfo: RepoInfo, - env: Record = process.env, -): ResolvedRepoInfo { - const override = env.LINEAR_VCS_PROVIDER; - if (override !== undefined) { +function resolveProvider(repoInfo: RepoInfo, env: Record): RepositoryProvider { + const override = env.LINEAR_VCS_PROVIDER?.trim(); + if (override) { const provider = parseProvider(override); if (!provider) { throw new ConfigurationError( `Invalid LINEAR_VCS_PROVIDER value "${override}". Expected github, gitlab, or bitbucket.`, "invalid-provider-override", - { value: override }, ); } - return { ...repoInfo, provider }; + return provider; } - const provider = parseProvider(repoInfo.provider) ?? inferProviderFromCI(env, repoInfo); + const host = remoteHost(repoInfo); + const provider = repoInfo.provider ?? inferProviderFromCI(env, repoInfo, host); if (!provider) { - const host = remoteHost(repoInfo) ?? repoInfo.url ?? "unknown"; throw new ConfigurationError( - `Could not determine the VCS provider for remote host "${host}".\nSet LINEAR_VCS_PROVIDER=github|gitlab|bitbucket in your CI environment.`, + `Could not determine the VCS provider for remote host "${host ?? repoInfo.url ?? "unknown"}".\nSet LINEAR_VCS_PROVIDER=github|gitlab|bitbucket in your CI environment.`, "unknown-provider", - { host }, ); } - return { ...repoInfo, provider }; + return provider; +} + +export function resolveRepoInfo( + repoInfo: RepoInfo | null, + env: Record = process.env, +): ResolvedRepoInfo | null { + return repoInfo ? { ...repoInfo, provider: resolveProvider(repoInfo, env) } : null; } diff --git a/src/types.ts b/src/types.ts index 3bbffdf..2f132d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -82,16 +82,11 @@ export type RepositoryProvider = "github" | "gitlab" | "bitbucket"; export type RepoInfo = { owner: string | null; name: string | null; - provider: string | null; + provider: RepositoryProvider | null; url: string | null; }; -export type ResolvedRepoInfo = { - owner: string | null; - name: string | null; - provider: RepositoryProvider; - url: string | null; -}; +export type ResolvedRepoInfo = Omit & { provider: RepositoryProvider }; export type IssueReference = { identifier: string; From e32ba61ee955970a47962e6ed6f0c39b910788ce Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Mon, 27 Jul 2026 10:19:46 +0200 Subject: [PATCH 5/6] Tighten provider detection note in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7970b14..e2ca6b1 100644 --- a/README.md +++ b/README.md @@ -148,7 +148,7 @@ linear-release update --stage="in review" --name="Release 1.2.0" | `LINEAR_ACCESS_KEY` | Yes | Pipeline access key from Linear | | `LINEAR_VCS_PROVIDER` | No | Override VCS provider detection: `github`, `gitlab`, `bitbucket` | -The repository provider is detected automatically from the remote hostname and, on GitLab CI, from the job environment — self-hosted GitLab on a custom domain needs no configuration. `LINEAR_VCS_PROVIDER` covers the setups where neither signal can identify the host, such as self-hosted GitHub Enterprise on a custom domain or CI platforms that don't expose the provider. When the provider can't be determined, `sync` fails before syncing rather than record repository data Linear can't use; these configuration errors exit with code `2` so a wrapper can tell a permanent misconfiguration from a transient failure. +The provider is detected from the remote hostname, or on GitLab CI from the job environment. Set `LINEAR_VCS_PROVIDER` when neither identifies the host (e.g. self-hosted GitHub Enterprise on a custom domain). When the provider can't be determined, `sync` exits with code `2` before syncing anything. ### CLI Options From 1a8e38c16b3e69ea62fd04e5ea77c0f95ac72d02 Mon Sep 17 00:00:00 2001 From: Romain Cascino Date: Mon, 27 Jul 2026 10:53:55 +0200 Subject: [PATCH 6/6] Move repo URL parsing and provider knowledge into the provider module --- src/git.test.ts | 266 +------------------------------------------ src/git.ts | 61 +--------- src/index.ts | 6 +- src/provider.test.ts | 109 ++++++++++++++---- src/provider.ts | 78 ++++++++++--- src/types.ts | 4 +- 6 files changed, 165 insertions(+), 359 deletions(-) diff --git a/src/git.test.ts b/src/git.test.ts index dd3cb1c..b8d2140 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -12,10 +12,9 @@ import { getCommitContext, getCommitContextsBetweenShas, getCommitParents, - getRepoInfo, + getRemoteUrl, isAncestor, normalizePathspec, - parseRepoUrl, resolveFirstSyncBoundary, } from "./git"; @@ -128,266 +127,9 @@ describe("extractBranchName", () => { }); }); -describe("getRepoInfo", () => { - it("should return the repo info", () => { - const result = getRepoInfo(); - expect(result).toBeDefined(); - expect(result?.owner).toBe("linear"); - expect(result?.name).toBe("linear-release"); - expect(result?.provider).toBe("github"); - expect(result?.url).toBe("https://github.com/linear/linear-release"); - }); -}); - -describe("parseRepoUrl", () => { - describe("HTTPS URLs", () => { - it("should parse github.com HTTPS URL", () => { - const result = parseRepoUrl("https://github.com/linear/linear-app.git"); - expect(result).toEqual({ - owner: "linear", - name: "linear-app", - provider: "github", - url: "https://github.com/linear/linear-app", - }); - }); - - it("should parse github.com HTTPS URL without .git suffix", () => { - const result = parseRepoUrl("https://github.com/linear/linear-app"); - expect(result).toEqual({ - owner: "linear", - name: "linear-app", - provider: "github", - url: "https://github.com/linear/linear-app", - }); - }); - - it("should parse gitlab.com HTTPS URL", () => { - const result = parseRepoUrl("https://gitlab.com/myorg/myrepo.git"); - expect(result).toEqual({ - owner: "myorg", - name: "myrepo", - provider: "gitlab", - url: "https://gitlab.com/myorg/myrepo", - }); - }); - - it("should parse GitHub Enterprise HTTPS URL", () => { - const result = parseRepoUrl("https://github.mycompany.com/engineering/platform.git"); - expect(result).toEqual({ - owner: "engineering", - name: "platform", - provider: "github", - url: "https://github.mycompany.com/engineering/platform", - }); - }); - - it("should parse self-hosted GitLab HTTPS URL", () => { - const result = parseRepoUrl("https://gitlab.internal.io/team/service.git"); - expect(result).toEqual({ - owner: "team", - name: "service", - provider: "gitlab", - url: "https://gitlab.internal.io/team/service", - }); - }); - - it("should parse gitlab.com HTTPS URL with nested groups", () => { - const result = parseRepoUrl("https://gitlab.com/my-org/my-group/my-repo.git"); - expect(result).toEqual({ - owner: "my-org", - name: "my-group/my-repo", - provider: "gitlab", - url: "https://gitlab.com/my-org/my-group/my-repo", - }); - }); - - it("should parse gitlab.com HTTPS URL with deeply nested groups", () => { - const result = parseRepoUrl("https://gitlab.com/org/group/subgroup/repo.git"); - expect(result).toEqual({ - owner: "org", - name: "group/subgroup/repo", - provider: "gitlab", - url: "https://gitlab.com/org/group/subgroup/repo", - }); - }); - - it("should parse self-hosted GitLab HTTPS URL with nested groups and no .git suffix", () => { - const result = parseRepoUrl("https://gitlab.internal.io/team/platform/service"); - expect(result).toEqual({ - owner: "team", - name: "platform/service", - provider: "gitlab", - url: "https://gitlab.internal.io/team/platform/service", - }); - }); - - it("should parse bitbucket.org HTTPS URL", () => { - const result = parseRepoUrl("https://bitbucket.org/myorg/myrepo.git"); - expect(result).toEqual({ - owner: "myorg", - name: "myrepo", - provider: "bitbucket", - url: "https://bitbucket.org/myorg/myrepo", - }); - }); - - it("should parse self-hosted Bitbucket HTTPS URL", () => { - const result = parseRepoUrl("https://bitbucket.mycompany.com/team/service.git"); - expect(result).toEqual({ - owner: "team", - name: "service", - provider: "bitbucket", - url: "https://bitbucket.mycompany.com/team/service", - }); - }); - - it("should parse HTTPS URL with credentials", () => { - const result = parseRepoUrl("https://token@github.com/linear/linear-app.git"); - expect(result).toEqual({ - owner: "linear", - name: "linear-app", - provider: "github", - url: "https://github.com/linear/linear-app", - }); - }); - }); - - describe("SSH URLs", () => { - it("should parse github.com SSH URL", () => { - const result = parseRepoUrl("git@github.com:linear/linear-app.git"); - expect(result).toEqual({ - owner: "linear", - name: "linear-app", - provider: "github", - url: "https://github.com/linear/linear-app", - }); - }); - - it("should parse github.com SSH URL without .git suffix", () => { - const result = parseRepoUrl("git@github.com:linear/linear-app"); - expect(result).toEqual({ - owner: "linear", - name: "linear-app", - provider: "github", - url: "https://github.com/linear/linear-app", - }); - }); - - it("should parse gitlab.com SSH URL", () => { - const result = parseRepoUrl("git@gitlab.com:myorg/myrepo.git"); - expect(result).toEqual({ - owner: "myorg", - name: "myrepo", - provider: "gitlab", - url: "https://gitlab.com/myorg/myrepo", - }); - }); - - it("should parse GitHub Enterprise SSH URL", () => { - const result = parseRepoUrl("git@github.mycompany.com:engineering/platform.git"); - expect(result).toEqual({ - owner: "engineering", - name: "platform", - provider: "github", - url: "https://github.mycompany.com/engineering/platform", - }); - }); - - it("should parse self-hosted GitLab SSH URL", () => { - const result = parseRepoUrl("git@gitlab.internal.io:team/service.git"); - expect(result).toEqual({ - owner: "team", - name: "service", - provider: "gitlab", - url: "https://gitlab.internal.io/team/service", - }); - }); - - it("should parse gitlab.com SSH URL with nested groups", () => { - const result = parseRepoUrl("git@gitlab.com:my-org/my-group/my-repo.git"); - expect(result).toEqual({ - owner: "my-org", - name: "my-group/my-repo", - provider: "gitlab", - url: "https://gitlab.com/my-org/my-group/my-repo", - }); - }); - - it("should parse gitlab.com SSH URL with deeply nested groups", () => { - const result = parseRepoUrl("git@gitlab.com:org/group/subgroup/repo.git"); - expect(result).toEqual({ - owner: "org", - name: "group/subgroup/repo", - provider: "gitlab", - url: "https://gitlab.com/org/group/subgroup/repo", - }); - }); - - it("should parse bitbucket.org SSH URL", () => { - const result = parseRepoUrl("git@bitbucket.org:myorg/myrepo.git"); - expect(result).toEqual({ - owner: "myorg", - name: "myrepo", - provider: "bitbucket", - url: "https://bitbucket.org/myorg/myrepo", - }); - }); - - it("should parse self-hosted Bitbucket SSH URL", () => { - const result = parseRepoUrl("git@bitbucket.mycompany.com:team/service.git"); - expect(result).toEqual({ - owner: "team", - name: "service", - provider: "bitbucket", - url: "https://bitbucket.mycompany.com/team/service", - }); - }); - }); - - describe("GitHub Enterprise Cloud (*.ghe.com)", () => { - it("should detect github provider for a *.ghe.com host", () => { - const result = parseRepoUrl("https://acme.ghe.com/engineering/platform.git"); - expect(result).toEqual({ - owner: "engineering", - name: "platform", - provider: "github", - url: "https://acme.ghe.com/engineering/platform", - }); - }); - - it("should detect github provider for multi-part subdomains under .ghe.com", () => { - const result = parseRepoUrl("https://tenant-name.ghe.com/owner/repo.git"); - expect(result?.provider).toBe("github"); - }); - - it("should not match the bare ghe.com host (no subdomain)", () => { - const result = parseRepoUrl("https://ghe.com/owner/repo.git"); - expect(result?.provider).toBeNull(); - }); - - it("should not match hosts that merely contain .ghe.com as a substring", () => { - // Suffix match guards against attacker-controlled hosts that happen - // to include "ghe.com" somewhere in the middle of the hostname. - const result = parseRepoUrl("https://evil-ghe.com.attacker.com/owner/repo.git"); - expect(result?.provider).toBeNull(); - }); - }); - - describe("unknown providers", () => { - it("should return null provider for unknown hosts", () => { - const result = parseRepoUrl("https://example.com/myorg/myrepo.git"); - expect(result).toEqual({ - owner: "myorg", - name: "myrepo", - provider: null, - url: "https://example.com/myorg/myrepo", - }); - }); - - it("should return null for unrecognized URL formats", () => { - expect(parseRepoUrl("not-a-url")).toBeNull(); - expect(parseRepoUrl("")).toBeNull(); - }); +describe("getRemoteUrl", () => { + it("should return the origin remote URL", () => { + expect(getRemoteUrl()).toMatch(/github\.com[:/]linear\/linear-release/); }); }); diff --git a/src/git.ts b/src/git.ts index b3a710c..8011e7c 100644 --- a/src/git.ts +++ b/src/git.ts @@ -1,5 +1,5 @@ import { execFileSync, execSync } from "node:child_process"; -import type { CommitContext, GitInfo, RepoInfo, RepositoryProvider } from "./types"; +import type { CommitContext, GitInfo } from "./types"; import { error as logError, verbose, warn } from "./log"; /** Strips leading "./" or "/" so paths are clean for git pathspec. */ @@ -516,68 +516,13 @@ export function getCommitContextsBetweenShas( return commits; } -function hostToProvider(host: string): RepositoryProvider | null { - if (host === "gitlab.com" || host.includes("gitlab")) { - return "gitlab"; - } - if (host === "github.com" || host.endsWith(".ghe.com") || host.includes("github")) { - return "github"; - } - if (host === "bitbucket.org" || host.includes("bitbucket")) { - return "bitbucket"; - } - return null; -} - -/** - * Parses a git remote URL (HTTPS or SSH) into repo information. - * - * @param remoteUrl The raw git remote URL string. - * @returns Parsed repo info, or null if the URL could not be parsed. - */ -export function parseRepoUrl(remoteUrl: string): RepoInfo | null { - // GitLab nested groups: split on the first slash so subgroup paths fold - // into the name segment (e.g. owner=group, name=subgroup/repo). - const httpsMatch = remoteUrl.match(/^https?:\/\/(?:[^@]+@)?([^/]+)\/([^/]+)\/(.+?)(?:\.git)?$/); - if (httpsMatch) { - const host = httpsMatch[1]; - const owner = httpsMatch[2] || null; - const name = httpsMatch[3]?.replace(/\.git$/, "") || null; - return { - owner, - name, - provider: hostToProvider(host), - url: owner && name ? `https://${host}/${owner}/${name}` : null, - }; - } - - // Handle SSH URLs: git@github.com:owner/repo.git (GitLab nested groups - // follow the same first-slash split as the HTTPS case above). - const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/); - if (sshMatch) { - const host = sshMatch[1]; - const owner = sshMatch[2] || null; - const name = sshMatch[3]?.replace(/\.git$/, "") || null; - return { - owner, - name, - provider: hostToProvider(host), - url: owner && name ? `https://${host}/${owner}/${name}` : null, - }; - } - - return null; -} - -export function getRepoInfo(remote: string = "origin", cwd: string = process.cwd()): RepoInfo | null { +export function getRemoteUrl(remote: string = "origin", cwd: string = process.cwd()): string | null { try { - const url = execSync(`git remote get-url ${remote}`, { + return execSync(`git remote get-url ${remote}`, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8", }).trim(); - - return parseRepoUrl(url); } catch (error) { logError(`Failed to read repo info: ${error instanceof Error ? error.message : String(error)}`); return null; diff --git a/src/index.ts b/src/index.ts index 49af02e..497bc22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { ensureCommitAvailable, getCommitContextsBetweenShas, getCurrentGitInfo, - getRepoInfo, + getRemoteUrl, resolveCommitRef, verifyAncestorReachable, } from "./git"; @@ -41,7 +41,7 @@ import { pluralize } from "./util"; import { buildUserAgent } from "./user-agent"; import { withRetry } from "./retry"; import { getCliVersion } from "./version"; -import { ConfigurationError, resolveRepoInfo } from "./provider"; +import { ConfigurationError, parseRepoUrl, resolveRepoInfo } from "./provider"; if (process.argv.includes("--version") || process.argv.includes("-v")) { console.log(getCliVersion()); @@ -257,7 +257,7 @@ async function syncCommand(): Promise<{ } | null> { logEnvironmentSummary(); - const repoInfo = resolveRepoInfo(getRepoInfo()); + const repoInfo = resolveRepoInfo(parseRepoUrl(getRemoteUrl())); // Fetch pipeline settings from API const pipelineSettings = await getPipelineSettings(); diff --git a/src/provider.test.ts b/src/provider.test.ts index 205fdef..253fa41 100644 --- a/src/provider.test.ts +++ b/src/provider.test.ts @@ -1,21 +1,14 @@ import { describe, expect, it } from "vitest"; -import { ConfigurationError, resolveRepoInfo } from "./provider"; +import { ConfigurationError, parseRepoUrl, resolveRepoInfo } from "./provider"; import type { RepoInfo } from "./types"; const selfHosted: RepoInfo = { owner: "group", name: "subgroup/repo", - provider: null, + host: "git.example.com", url: "https://git.example.com/group/subgroup/repo", }; -const detected: RepoInfo = { - owner: "acme", - name: "repo", - provider: "github", - url: "https://github.com/acme/repo", -}; - function captureError(fn: () => unknown): ConfigurationError { try { fn(); @@ -27,20 +20,99 @@ function captureError(fn: () => unknown): ConfigurationError { throw new Error("Expected a ConfigurationError"); } +describe("parseRepoUrl", () => { + it("parses HTTPS URLs with and without .git suffix", () => { + const expected = { + owner: "linear", + name: "linear-app", + host: "github.com", + url: "https://github.com/linear/linear-app", + }; + expect(parseRepoUrl("https://github.com/linear/linear-app.git")).toEqual(expected); + expect(parseRepoUrl("https://github.com/linear/linear-app")).toEqual(expected); + }); + + it("parses SSH URLs with and without .git suffix", () => { + const expected = { + owner: "myorg", + name: "myrepo", + host: "gitlab.com", + url: "https://gitlab.com/myorg/myrepo", + }; + expect(parseRepoUrl("git@gitlab.com:myorg/myrepo.git")).toEqual(expected); + expect(parseRepoUrl("git@gitlab.com:myorg/myrepo")).toEqual(expected); + }); + + it("folds nested groups into the name segment", () => { + for (const url of [ + "https://gitlab.com/org/group/subgroup/repo.git", + "git@gitlab.com:org/group/subgroup/repo.git", + ]) { + expect(parseRepoUrl(url)).toEqual({ + owner: "org", + name: "group/subgroup/repo", + host: "gitlab.com", + url: "https://gitlab.com/org/group/subgroup/repo", + }); + } + }); + + it("strips credentials from HTTPS URLs", () => { + expect(parseRepoUrl("https://token@github.com/linear/linear-app.git")).toEqual({ + owner: "linear", + name: "linear-app", + host: "github.com", + url: "https://github.com/linear/linear-app", + }); + }); + + it("keeps custom hosts verbatim", () => { + expect(parseRepoUrl("git@git.example.com:group/repo.git")).toEqual({ + owner: "group", + name: "repo", + host: "git.example.com", + url: "https://git.example.com/group/repo", + }); + }); + + it("returns null for unparseable input", () => { + expect(parseRepoUrl("not-a-url")).toBeNull(); + expect(parseRepoUrl("")).toBeNull(); + expect(parseRepoUrl(null)).toBeNull(); + }); +}); + describe("resolveRepoInfo", () => { it("returns null without a repo", () => { expect(resolveRepoInfo(null, {})).toBeNull(); }); - it("keeps hostname-detected providers", () => { - expect(resolveRepoInfo(detected, {})).toEqual(detected); + it.each([ + ["https://github.com/acme/repo.git", "github"], + ["https://github.mycompany.com/acme/repo.git", "github"], + ["https://tenant.ghe.com/acme/repo.git", "github"], + ["git@gitlab.com:acme/repo.git", "gitlab"], + ["https://gitlab.internal.io/acme/repo.git", "gitlab"], + ["https://bitbucket.org/acme/repo.git", "bitbucket"], + ["https://bitbucket.mycompany.com/acme/repo.git", "bitbucket"], + ])("detects the provider from the hostname of %s", (url, provider) => { + expect(resolveRepoInfo(parseRepoUrl(url), {})?.provider).toBe(provider); }); + it.each(["https://ghe.com/acme/repo.git", "https://evil-ghe.com.attacker.com/acme/repo.git"])( + "does not treat %s as GitHub Enterprise Cloud", + (url) => { + expect(captureError(() => resolveRepoInfo(parseRepoUrl(url), {})).code).toBe("unknown-provider"); + }, + ); + it("prefers the override over detection", () => { + const detected = parseRepoUrl("https://github.com/acme/repo.git"); expect(resolveRepoInfo(detected, { LINEAR_VCS_PROVIDER: "GitLab" })?.provider).toBe("gitlab"); }); it("treats an empty override as unset", () => { + const detected = parseRepoUrl("https://github.com/acme/repo.git"); expect(resolveRepoInfo(detected, { LINEAR_VCS_PROVIDER: "" })?.provider).toBe("github"); }); @@ -57,24 +129,23 @@ describe("resolveRepoInfo", () => { it("infers gitlab when clone_url rewrites the host but the project path matches", () => { const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com", CI_PROJECT_PATH: "group/subgroup/repo" }; - const rewritten = { ...selfHosted, url: "https://192.168.1.23/group/subgroup/repo" }; + const rewritten = { ...selfHosted, host: "192.168.1.23", url: "https://192.168.1.23/group/subgroup/repo" }; expect(resolveRepoInfo(rewritten, env)?.provider).toBe("gitlab"); }); it("matches hosts case-insensitively and ignores the port", () => { const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com" }; - const withPort = { ...selfHosted, url: "https://Git.Example.com:8443/group/subgroup/repo" }; + const withPort = { + ...selfHosted, + host: "Git.Example.com:8443", + url: "https://Git.Example.com:8443/group/subgroup/repo", + }; expect(resolveRepoInfo(withPort, env)?.provider).toBe("gitlab"); }); it("throws for a foreign clone when both host and project path mismatch", () => { const env = { GITLAB_CI: "true", CI_SERVER_HOST: "git.example.com", CI_PROJECT_PATH: "group/subgroup/repo" }; - const foreign: RepoInfo = { - owner: "acme", - name: "other", - provider: null, - url: "https://git.other.example/acme/other", - }; + const foreign = parseRepoUrl("https://git.other.example/acme/other.git"); expect(captureError(() => resolveRepoInfo(foreign, env)).code).toBe("unknown-provider"); }); diff --git a/src/provider.ts b/src/provider.ts index 148c1f1..393b6e3 100644 --- a/src/provider.ts +++ b/src/provider.ts @@ -10,32 +10,80 @@ export class ConfigurationError extends Error { } } -function parseProvider(value: string): RepositoryProvider | null { - const provider = value.toLowerCase(); - return provider === "github" || provider === "gitlab" || provider === "bitbucket" ? provider : null; +/** + * Parses a git remote URL (HTTPS or SSH) into repository facts. Provider + * identification is `resolveRepoInfo`'s job. + */ +export function parseRepoUrl(remoteUrl: string | null): RepoInfo | null { + if (!remoteUrl) { + return null; + } + + // GitLab nested groups: split on the first slash so subgroup paths fold + // into the name segment (e.g. owner=group, name=subgroup/repo). + const httpsMatch = remoteUrl.match(/^https?:\/\/(?:[^@]+@)?([^/]+)\/([^/]+)\/(.+?)(?:\.git)?$/); + if (httpsMatch) { + const host = httpsMatch[1]; + const owner = httpsMatch[2] || null; + const name = httpsMatch[3]?.replace(/\.git$/, "") || null; + return { + owner, + name, + host, + url: owner && name ? `https://${host}/${owner}/${name}` : null, + }; + } + + // Handle SSH URLs: git@github.com:owner/repo.git (GitLab nested groups + // follow the same first-slash split as the HTTPS case above). + const sshMatch = remoteUrl.match(/^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/); + if (sshMatch) { + const host = sshMatch[1]; + const owner = sshMatch[2] || null; + const name = sshMatch[3]?.replace(/\.git$/, "") || null; + return { + owner, + name, + host, + url: owner && name ? `https://${host}/${owner}/${name}` : null, + }; + } + + return null; } -function remoteHost(repoInfo: RepoInfo): string | null { - if (!repoInfo.url) { - return null; +function normalizeHost(host: string): string { + return host.replace(/:\d+$/, "").toLowerCase(); +} + +function hostToProvider(host: string): RepositoryProvider | null { + if (host === "gitlab.com" || host.includes("gitlab")) { + return "gitlab"; } - try { - return new URL(repoInfo.url).hostname.toLowerCase(); - } catch { - return null; + if (host === "github.com" || host.endsWith(".ghe.com") || host.includes("github")) { + return "github"; + } + if (host === "bitbucket.org" || host.includes("bitbucket")) { + return "bitbucket"; } + return null; +} + +function parseProvider(value: string): RepositoryProvider | null { + const provider = value.toLowerCase(); + return provider === "github" || provider === "gitlab" || provider === "bitbucket" ? provider : null; } function inferProviderFromCI( env: Record, repoInfo: RepoInfo, - host: string | null, + host: string, ): RepositoryProvider | null { if (env.GITLAB_CI !== "true") { return null; } const serverHost = env.CI_SERVER_HOST?.trim().toLowerCase(); - const hostMatched = host !== null && host === serverHost; + const hostMatched = host === serverHost; const projectPath = env.CI_PROJECT_PATH?.trim().replace(/^\/+|\/+$/g, ""); const remotePath = repoInfo.owner && repoInfo.name ? `${repoInfo.owner}/${repoInfo.name}` : null; const pathMatched = !!projectPath && (remotePath === projectPath || !!remotePath?.endsWith(`/${projectPath}`)); @@ -56,11 +104,11 @@ function resolveProvider(repoInfo: RepoInfo, env: Record & { provider: RepositoryProvider }; +export type ResolvedRepoInfo = RepoInfo & { provider: RepositoryProvider }; export type IssueReference = { identifier: string;