diff --git a/README.md b/README.md index 235ddc0..e2ca6b1 100644 --- a/README.md +++ b/README.md @@ -143,9 +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 | +| Variable | Required | Description | +| --------------------- | -------- | ---------------------------------------------------------------- | +| `LINEAR_ACCESS_KEY` | Yes | Pipeline access key from Linear | +| `LINEAR_VCS_PROVIDER` | No | Override VCS provider detection: `github`, `gitlab`, `bitbucket` | + +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 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 c8eb6fe..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 } 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): string | 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 ae6a256..497bc22 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { ensureCommitAvailable, getCommitContextsBetweenShas, getCurrentGitInfo, - getRepoInfo, + getRemoteUrl, resolveCommitRef, verifyAncestorReachable, } from "./git"; @@ -26,7 +26,7 @@ import { AccessKeyUpdateByPipelineResponse, DebugSink, IssueReference, - RepoInfo, + ResolvedRepoInfo, } from "./types"; import { getCLIWarnings, @@ -41,6 +41,7 @@ import { pluralize } from "./util"; import { buildUserAgent } from "./user-agent"; import { withRetry } from "./retry"; import { getCliVersion } from "./version"; +import { ConfigurationError, parseRepoUrl, resolveRepoInfo } from "./provider"; if (process.argv.includes("--version") || process.argv.includes("-v")) { console.log(getCliVersion()); @@ -81,6 +82,7 @@ Options: Environment: LINEAR_ACCESS_KEY Pipeline access key (required) + LINEAR_VCS_PROVIDER Override VCS provider detection: github|gitlab|bitbucket Examples: linear-release sync @@ -255,6 +257,8 @@ async function syncCommand(): Promise<{ } | null> { logEnvironmentSummary(); + const repoInfo = resolveRepoInfo(parseRepoUrl(getRemoteUrl())); + // Fetch pipeline settings from API const pipelineSettings = await getPipelineSettings(); @@ -368,8 +372,6 @@ async function syncCommand(): Promise<{ info(`Reverted issue keys: ${revertedIssueReferences.map((f) => f.identifier).join(", ")}`); } - const repoInfo = getRepoInfo(); - const issueIds = issueReferences.map((f) => f.identifier); const parts: string[] = []; if (issueIds.length > 0) parts.push(`issues [${issueIds.join(", ")}]`); @@ -584,7 +586,7 @@ async function syncRelease( issueReferences: IssueReference[], revertedIssueReferences: IssueReference[], prNumbers: number[], - repoInfo: RepoInfo | null, + repoInfo: ResolvedRepoInfo | null, debugSink: DebugSink, releaseLinks: ReleaseLink[], releaseDocuments: ReleaseDocument[], @@ -801,8 +803,12 @@ timeout.unref(); main() .catch((e) => { - error(`Error: ${e.message}`); - process.exit(1); + if (e instanceof ConfigurationError && jsonOutput) { + process.stderr.write(`${JSON.stringify({ error: e.code, message: e.message })}\n`); + } else { + error(`Error: ${e.message}`); + } + process.exit(e instanceof ConfigurationError ? 2 : 1); }) .finally(() => { clearTimeout(timeout); diff --git a/src/provider.test.ts b/src/provider.test.ts new file mode 100644 index 0000000..253fa41 --- /dev/null +++ b/src/provider.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import { ConfigurationError, parseRepoUrl, resolveRepoInfo } from "./provider"; +import type { RepoInfo } from "./types"; + +const selfHosted: RepoInfo = { + owner: "group", + name: "subgroup/repo", + host: "git.example.com", + 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("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.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"); + }); + + it("throws on an invalid override", () => { + const error = captureError(() => resolveRepoInfo(selfHosted, { LINEAR_VCS_PROVIDER: "gitea" })); + expect(error.code).toBe("invalid-provider-override"); + 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"); + }); + + 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, 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, + 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 = parseRepoUrl("https://git.other.example/acme/other.git"); + 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.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..393b6e3 --- /dev/null +++ b/src/provider.ts @@ -0,0 +1,123 @@ +import type { RepoInfo, RepositoryProvider, ResolvedRepoInfo } from "./types"; + +export class ConfigurationError extends Error { + constructor( + message: string, + readonly code: "invalid-provider-override" | "unknown-provider", + ) { + super(message); + this.name = "ConfigurationError"; + } +} + +/** + * 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 normalizeHost(host: string): string { + return host.replace(/:\d+$/, "").toLowerCase(); +} + +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; +} + +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, +): RepositoryProvider | null { + if (env.GITLAB_CI !== "true") { + return null; + } + const serverHost = env.CI_SERVER_HOST?.trim().toLowerCase(); + 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}`)); + // 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; +} + +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", + ); + } + return provider; + } + const host = normalizeHost(repoInfo.host); + const provider = hostToProvider(host) ?? inferProviderFromCI(env, repoInfo, host); + if (!provider) { + 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", + ); + } + 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 6f5d5f5..01f2b41 100644 --- a/src/types.ts +++ b/src/types.ts @@ -77,13 +77,17 @@ export type GitInfo = { message: string | null; }; +export type RepositoryProvider = "github" | "gitlab" | "bitbucket"; + export type RepoInfo = { owner: string | null; name: string | null; - provider: string | null; + host: string; url: string | null; }; +export type ResolvedRepoInfo = RepoInfo & { provider: RepositoryProvider }; + export type IssueReference = { identifier: string; commitSha: string;