mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
refactor: share github remote parsing (#843)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
86
packages/server/src/shared/git-remote.ts
Normal file
86
packages/server/src/shared/git-remote.ts
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user