diff --git a/src/git.test.ts b/src/git.test.ts index e687ae1..b9d105b 100644 --- a/src/git.test.ts +++ b/src/git.test.ts @@ -12,6 +12,7 @@ import { getRepoInfo, isMergeCommit, normalizePathspec, + parseRepoUrl, } from "./git"; describe("normalizePathspec", () => { @@ -124,12 +125,146 @@ describe("extractBranchName", () => { }); describe("getRepoInfo", () => { - // Skip: reads from actual git remote, will pass once in linear-release repo - it.skip("should return the repo info", () => { + 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 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", + }); + }); + }); + + 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(); + }); }); }); diff --git a/src/git.ts b/src/git.ts index 1b48bd6..1c06fe2 100644 --- a/src/git.ts +++ b/src/git.ts @@ -303,6 +303,54 @@ 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.includes("github")) { + return "github"; + } + 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 { + // Handle HTTPS URLs: https://github.com/owner/repo.git + 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 + 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 { try { const url = execSync(`git remote get-url ${remote}`, { @@ -311,27 +359,7 @@ export function getRepoInfo(remote: string = "origin", cwd: string = process.cwd encoding: "utf8", }).trim(); - // Handle HTTPS URLs: https://github.com/owner/repo.git or https://github.com/owner/repo - const httpsMatch = url.match( - /^https?:\/\/(?:[^@]+@)?(?:github\.com|gitlab\.com|bitbucket\.org)[/:]([^/]+)\/([^/]+?)(?:\.git)?$/, - ); - if (httpsMatch) { - return { - owner: httpsMatch[1] || null, - name: httpsMatch[2]?.replace(/\.git$/, "") || null, - }; - } - - // Handle SSH URLs: git@github.com:owner/repo.git or git@github.com:owner/repo - const sshMatch = url.match(/^git@(?:github\.com|gitlab\.com|bitbucket\.org):([^/]+)\/([^/]+?)(?:\.git)?$/); - if (sshMatch) { - return { - owner: sshMatch[1] || null, - name: sshMatch[2]?.replace(/\.git$/, "") || null, - }; - } - - return null; + return parseRepoUrl(url); } catch (error) { console.error(`Error getting repo info: ${error}`); return null; diff --git a/src/index.ts b/src/index.ts index 3eee147..f4edea7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { AccessKeyUpdateByPipelineResponse, CommitContext, DebugSink, + IssueReference, IssueSource, PullRequestSource, RepoInfo, @@ -113,11 +114,11 @@ function scanCommits( commits: CommitContext[], includePaths: string[] | null, ): { - issueIdentifiers: string[]; + issueReferences: IssueReference[]; prNumbers: number[]; debugSink: DebugSink; } { - const seen = new Set(); + const seen = new Map(); const prNumbersSet = new Set(); const debugSink: DebugSink = { inspectedShas: [], @@ -151,7 +152,7 @@ function scanCommits( continue; } - seen.add(key); + seen.set(key, { identifier: key, commitSha: commit.sha }); log(`Detected issue key ${key} from branch name "${commit.branchName ?? ""}"`); } @@ -177,7 +178,7 @@ function scanCommits( continue; } - seen.add(key); + seen.set(key, { identifier: key, commitSha: commit.sha }); log(`Detected issue key ${key} from commit message "${commit.message ?? ""}"`); } @@ -198,7 +199,7 @@ function scanCommits( } return { - issueIdentifiers: Array.from(seen), + issueReferences: Array.from(seen.values()), prNumbers: Array.from(prNumbersSet), debugSink, }; @@ -276,21 +277,21 @@ async function syncCommand(): Promise<{ return null; } - const { issueIdentifiers, prNumbers, debugSink } = scanCommits(commits, effectiveIncludePaths); + const { issueReferences, prNumbers, debugSink } = scanCommits(commits, effectiveIncludePaths); log(`Debug sink: ${JSON.stringify(debugSink, null, 2)}`); - if (issueIdentifiers.length === 0) { + if (issueReferences.length === 0) { log("No issue keys found"); } else { - log(`Retrieved issue keys: ${issueIdentifiers.join(", ")}`); + log(`Retrieved issue keys: ${issueReferences.map((f) => f.identifier).join(", ")}`); } const repoInfo = getRepoInfo(); - const release = await syncRelease(issueIdentifiers, prNumbers, repoInfo, debugSink); + const release = await syncRelease(issueReferences, prNumbers, repoInfo, debugSink); log( - `Issues [${issueIdentifiers.join(", ")}] and pull requests [${prNumbers.join( + `Issues [${issueReferences.map((f) => f.identifier).join(", ")}] and pull requests [${prNumbers.join( ", ", )}] have been added to release ${release.name}`, ); @@ -426,7 +427,7 @@ async function getPipelineSettings(): Promise<{ includePathPatterns: string[] }> } async function syncRelease( - issueIdentifiers: string[], + issueReferences: IssueReference[], prNumbers: number[], repoInfo: RepoInfo | null, debugSink: DebugSink, @@ -463,12 +464,20 @@ async function syncRelease( name: releaseName, version: releaseVersion, commitSha: currentSha, - issueIdentifiers, + issueReferences, pullRequestReferences: prNumbers.map((number) => ({ repositoryOwner: owner, repositoryName: name, number, })), + repository: repoInfo + ? { + owner: repoInfo.owner, + name: repoInfo.name, + provider: repoInfo.provider, + url: repoInfo.url, + } + : undefined, debugSink, }, }, diff --git a/src/types.ts b/src/types.ts index f9139f2..478cae5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -79,6 +79,13 @@ export type GitInfo = { export type RepoInfo = { owner: string | null; name: string | null; + provider: string | null; + url: string | null; +}; + +export type IssueReference = { + identifier: string; + commitSha: string; }; // Debug sink types