refactor: share github remote parsing (#843)

This commit is contained in:
Mohamed Boudra
2026-05-09 22:40:05 +08:00
committed by GitHub
parent 0079f2a875
commit f17fb77013
4 changed files with 101 additions and 118 deletions

View File

@@ -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);

View File

@@ -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: {

View File

@@ -0,0 +1,86 @@
const GITHUB_HOSTS = new Set(["github.com", "ssh.github.com"]);
const TRANSPORT_BY_PROTOCOL: Record<string, GitRemoteLocation["transport"]> = {
"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);
}

View File

@@ -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<string, GitRemoteLocation["transport"]> = {
"https:": "https",
"http:": "http",
"ssh:": "ssh",
};
let sshExecutableLookup: Promise<string | null> | null = null;
const sshHostnameResolutionCache = new Map<string, Promise<string | null>>();
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<string | null>;
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<GitHubRemoteIdentity | null> {
}): Promise<ResolvedGitHubRemoteIdentity | null> {
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);
}