From f17fb7701392250f342f310e65b19799370e05f7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 9 May 2026 22:40:05 +0800 Subject: [PATCH] refactor: share github remote parsing (#843) --- packages/app/src/git/github-url.test.ts | 2 + packages/app/src/git/github-url.ts | 35 +------- packages/server/src/shared/git-remote.ts | 86 +++++++++++++++++++ packages/server/src/utils/github-remote.ts | 96 +++------------------- 4 files changed, 101 insertions(+), 118 deletions(-) create mode 100644 packages/server/src/shared/git-remote.ts diff --git a/packages/app/src/git/github-url.test.ts b/packages/app/src/git/github-url.test.ts index 43b4771c6..e26ab8b52 100644 --- a/packages/app/src/git/github-url.test.ts +++ b/packages/app/src/git/github-url.test.ts @@ -5,8 +5,10 @@ describe("parseGitHubRepoFromRemote", () => { it.each([ ["https://github.com/acme/repo.git", "acme/repo"], ["https://github.com/acme/repo", "acme/repo"], + ["http://github.com/acme/repo.git", "acme/repo"], ["git@github.com:acme/repo.git", "acme/repo"], ["ssh://git@github.com/acme/repo.git", "acme/repo"], + ["ssh://git@ssh.github.com/acme/repo.git", "acme/repo"], ["https://github.com/acme/repo/", "acme/repo"], ])("extracts the repo from %s", (remoteUrl, expected) => { expect(parseGitHubRepoFromRemote(remoteUrl)).toBe(expected); diff --git a/packages/app/src/git/github-url.ts b/packages/app/src/git/github-url.ts index c936b535d..922cba3bc 100644 --- a/packages/app/src/git/github-url.ts +++ b/packages/app/src/git/github-url.ts @@ -1,42 +1,11 @@ -// TODO: this duplicates parseGitHubRepoFromRemote in packages/server/src/services/github-service.ts. -// Consolidate into a shared package once we have a third caller. +import { parseGitHubRemoteUrl } from "@server/shared/git-remote"; -// Note: SSH host aliases (e.g. `git@github-work:acme/repo.git` resolved via ~/.ssh/config) -// are not detected here, so the GitHub action will silently not appear for those remotes. export function parseGitHubRepoFromRemote(remoteUrl: string | null | undefined): string | null { const trimmed = remoteUrl?.trim(); if (!trimmed) { return null; } - - let cleaned = trimmed; - if (cleaned.startsWith("git@github.com:")) { - cleaned = cleaned.slice("git@github.com:".length); - } else { - let parsed: URL; - try { - parsed = new URL(cleaned); - } catch { - return null; - } - if (parsed.hostname !== "github.com") { - return null; - } - try { - cleaned = decodeURIComponent(parsed.pathname.replace(/^\/+/, "")); - } catch { - return null; - } - } - - cleaned = cleaned.replace(/\/+$/, ""); - if (cleaned.endsWith(".git")) { - cleaned = cleaned.slice(0, -".git".length); - } - if (!cleaned.includes("/")) { - return null; - } - return cleaned; + return parseGitHubRemoteUrl(trimmed)?.repo ?? null; } export function buildGitHubBranchTreeUrl(input: { diff --git a/packages/server/src/shared/git-remote.ts b/packages/server/src/shared/git-remote.ts new file mode 100644 index 000000000..fae141b03 --- /dev/null +++ b/packages/server/src/shared/git-remote.ts @@ -0,0 +1,86 @@ +const GITHUB_HOSTS = new Set(["github.com", "ssh.github.com"]); + +const TRANSPORT_BY_PROTOCOL: Record = { + "https:": "https", + "http:": "http", + "ssh:": "ssh", +}; + +export interface GitRemoteLocation { + transport: "scp" | "ssh" | "http" | "https"; + host: string; + path: string; +} + +export interface GitHubRemoteIdentity { + owner: string; + name: string; + repo: string; +} + +export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | null { + const location = parseGitRemoteLocation(remoteUrl); + if (!location || !isGitHubHost(location.host)) return null; + return parseGitHubRemoteIdentity(location.path); +} + +export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null { + const trimmed = remoteUrl.trim(); + if (!trimmed) return null; + + const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u); + if (scpLike) { + const host = normalizeHost(scpLike[1] ?? ""); + const path = normalizeRemotePath(scpLike[2] ?? ""); + if (!isValidRemoteHost(host) || !path) return null; + return { transport: "scp", host, path }; + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + + const transport = TRANSPORT_BY_PROTOCOL[parsed.protocol.toLowerCase()]; + if (!transport) return null; + + const host = normalizeHost(parsed.hostname); + let path: string; + try { + path = decodeURIComponent(parsed.pathname); + } catch { + return null; + } + const normalizedPath = normalizeRemotePath(path); + if (!isValidRemoteHost(host) || !normalizedPath) return null; + + return { transport, host, path: normalizedPath }; +} + +export function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null { + const segments = path.split("/").filter(Boolean); + if (segments.length !== 2) return null; + const [owner, name] = segments; + if (!owner || !name) return null; + return { owner, name, repo: `${owner}/${name}` }; +} + +export function isGitHubHost(host: string): boolean { + return GITHUB_HOSTS.has(host); +} + +export function normalizeHost(host: string): string { + return host.trim().replace(/\.+$/u, "").toLowerCase(); +} + +function normalizeRemotePath(path: string): string | null { + let normalized = path.trim().replace(/^\/+|\/+$/gu, ""); + if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4); + return normalized || null; +} + +function isValidRemoteHost(host: string): boolean { + return /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(host); +} diff --git a/packages/server/src/utils/github-remote.ts b/packages/server/src/utils/github-remote.ts index bc72f7ab8..8725db224 100644 --- a/packages/server/src/utils/github-remote.ts +++ b/packages/server/src/utils/github-remote.ts @@ -1,49 +1,32 @@ +import { + isGitHubHost, + normalizeHost, + parseGitHubRemoteIdentity, + parseGitRemoteLocation, + type GitHubRemoteIdentity as ResolvedGitHubRemoteIdentity, +} from "../shared/git-remote.js"; import { findExecutable } from "./executable.js"; import { execCommand } from "./spawn.js"; -const GITHUB_HOSTS = new Set(["github.com", "ssh.github.com"]); - -const TRANSPORT_BY_PROTOCOL: Record = { - "https:": "https", - "http:": "http", - "ssh:": "ssh", -}; - let sshExecutableLookup: Promise | null = null; const sshHostnameResolutionCache = new Map>(); -interface GitRemoteLocation { - transport: "scp" | "ssh" | "http" | "https"; - host: string; - path: string; -} - -export interface GitHubRemoteIdentity { - owner: string; - name: string; - repo: string; -} - export type SshHostnameResolver = (host: string) => Promise; -export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | null { - const location = parseGitRemoteLocation(remoteUrl); - if (!location || !GITHUB_HOSTS.has(location.host)) return null; - return parseGitHubRemoteIdentity(location.path); -} +export { parseGitHubRemoteUrl, type GitHubRemoteIdentity } from "../shared/git-remote.js"; export async function resolveGitHubRemote(input: { remoteUrl: string; resolveSshHostname?: SshHostnameResolver; -}): Promise { +}): Promise { const location = parseGitRemoteLocation(input.remoteUrl); if (!location) return null; - if (GITHUB_HOSTS.has(location.host)) return parseGitHubRemoteIdentity(location.path); + if (isGitHubHost(location.host)) return parseGitHubRemoteIdentity(location.path); if (location.transport !== "scp" && location.transport !== "ssh") return null; const resolve = input.resolveSshHostname ?? resolveSshHostname; const resolvedHost = await resolve(location.host); - if (!resolvedHost || !GITHUB_HOSTS.has(resolvedHost)) return null; + if (!resolvedHost || !isGitHubHost(resolvedHost)) return null; return parseGitHubRemoteIdentity(location.path); } @@ -86,60 +69,3 @@ function parseSshHostname(stdout: string): string | null { } return null; } - -function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null { - const trimmed = remoteUrl.trim(); - if (!trimmed) return null; - - const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/u); - if (scpLike) { - const host = normalizeHost(scpLike[1] ?? ""); - const path = normalizeRemotePath(scpLike[2] ?? ""); - if (!isValidRemoteHost(host) || !path) return null; - return { transport: "scp", host, path }; - } - - let parsed: URL; - try { - parsed = new URL(trimmed); - } catch { - return null; - } - - const transport = TRANSPORT_BY_PROTOCOL[parsed.protocol.toLowerCase()]; - if (!transport) return null; - - const host = normalizeHost(parsed.hostname); - let path: string; - try { - path = decodeURIComponent(parsed.pathname); - } catch { - return null; - } - const normalizedPath = normalizeRemotePath(path); - if (!isValidRemoteHost(host) || !normalizedPath) return null; - - return { transport, host, path: normalizedPath }; -} - -function parseGitHubRemoteIdentity(path: string): GitHubRemoteIdentity | null { - const segments = path.split("/").filter(Boolean); - if (segments.length !== 2) return null; - const [owner, name] = segments; - if (!owner || !name) return null; - return { owner, name, repo: `${owner}/${name}` }; -} - -function normalizeRemotePath(path: string): string | null { - let normalized = path.trim().replace(/^\/+|\/+$/gu, ""); - if (normalized.endsWith(".git")) normalized = normalized.slice(0, -4); - return normalized || null; -} - -function normalizeHost(host: string): string { - return host.trim().replace(/\.+$/u, "").toLowerCase(); -} - -function isValidRemoteHost(host: string): boolean { - return /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/u.test(host); -}