refactor: share github remote parsing (#843)
This commit is contained in:
@@ -5,8 +5,10 @@ describe("parseGitHubRepoFromRemote", () => {
|
|||||||
it.each([
|
it.each([
|
||||||
["https://github.com/acme/repo.git", "acme/repo"],
|
["https://github.com/acme/repo.git", "acme/repo"],
|
||||||
["https://github.com/acme/repo", "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"],
|
["git@github.com:acme/repo.git", "acme/repo"],
|
||||||
["ssh://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"],
|
["https://github.com/acme/repo/", "acme/repo"],
|
||||||
])("extracts the repo from %s", (remoteUrl, expected) => {
|
])("extracts the repo from %s", (remoteUrl, expected) => {
|
||||||
expect(parseGitHubRepoFromRemote(remoteUrl)).toBe(expected);
|
expect(parseGitHubRepoFromRemote(remoteUrl)).toBe(expected);
|
||||||
|
|||||||
@@ -1,42 +1,11 @@
|
|||||||
// TODO: this duplicates parseGitHubRepoFromRemote in packages/server/src/services/github-service.ts.
|
import { parseGitHubRemoteUrl } from "@server/shared/git-remote";
|
||||||
// Consolidate into a shared package once we have a third caller.
|
|
||||||
|
|
||||||
// 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 {
|
export function parseGitHubRepoFromRemote(remoteUrl: string | null | undefined): string | null {
|
||||||
const trimmed = remoteUrl?.trim();
|
const trimmed = remoteUrl?.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return parseGitHubRemoteUrl(trimmed)?.repo ?? 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildGitHubBranchTreeUrl(input: {
|
export function buildGitHubBranchTreeUrl(input: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,49 +1,32 @@
|
|||||||
|
import {
|
||||||
|
isGitHubHost,
|
||||||
|
normalizeHost,
|
||||||
|
parseGitHubRemoteIdentity,
|
||||||
|
parseGitRemoteLocation,
|
||||||
|
type GitHubRemoteIdentity as ResolvedGitHubRemoteIdentity,
|
||||||
|
} from "../shared/git-remote.js";
|
||||||
import { findExecutable } from "./executable.js";
|
import { findExecutable } from "./executable.js";
|
||||||
import { execCommand } from "./spawn.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;
|
let sshExecutableLookup: Promise<string | null> | null = null;
|
||||||
const sshHostnameResolutionCache = new Map<string, Promise<string | 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 type SshHostnameResolver = (host: string) => Promise<string | null>;
|
||||||
|
|
||||||
export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity | null {
|
export { parseGitHubRemoteUrl, type GitHubRemoteIdentity } from "../shared/git-remote.js";
|
||||||
const location = parseGitRemoteLocation(remoteUrl);
|
|
||||||
if (!location || !GITHUB_HOSTS.has(location.host)) return null;
|
|
||||||
return parseGitHubRemoteIdentity(location.path);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function resolveGitHubRemote(input: {
|
export async function resolveGitHubRemote(input: {
|
||||||
remoteUrl: string;
|
remoteUrl: string;
|
||||||
resolveSshHostname?: SshHostnameResolver;
|
resolveSshHostname?: SshHostnameResolver;
|
||||||
}): Promise<GitHubRemoteIdentity | null> {
|
}): Promise<ResolvedGitHubRemoteIdentity | null> {
|
||||||
const location = parseGitRemoteLocation(input.remoteUrl);
|
const location = parseGitRemoteLocation(input.remoteUrl);
|
||||||
if (!location) return null;
|
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;
|
if (location.transport !== "scp" && location.transport !== "ssh") return null;
|
||||||
|
|
||||||
const resolve = input.resolveSshHostname ?? resolveSshHostname;
|
const resolve = input.resolveSshHostname ?? resolveSshHostname;
|
||||||
const resolvedHost = await resolve(location.host);
|
const resolvedHost = await resolve(location.host);
|
||||||
if (!resolvedHost || !GITHUB_HOSTS.has(resolvedHost)) return null;
|
if (!resolvedHost || !isGitHubHost(resolvedHost)) return null;
|
||||||
return parseGitHubRemoteIdentity(location.path);
|
return parseGitHubRemoteIdentity(location.path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,60 +69,3 @@ function parseSshHostname(stdout: string): string | null {
|
|||||||
}
|
}
|
||||||
return 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);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user