diff --git a/server/src/services/github-skill-helpers.ts b/server/src/services/github-skill-helpers.ts new file mode 100644 index 00000000..84d4aa02 --- /dev/null +++ b/server/src/services/github-skill-helpers.ts @@ -0,0 +1,102 @@ +import path from "node:path"; +import { unprocessable } from "../errors.js"; + +function asString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export async function fetchText(url: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw unprocessable(`Failed to fetch ${url}: ${response.status}`); + } + return response.text(); +} + +export async function fetchJson(url: string): Promise { + const response = await fetch(url, { + headers: { + accept: "application/vnd.github+json", + }, + }); + if (!response.ok) { + throw unprocessable(`Failed to fetch ${url}: ${response.status}`); + } + return response.json() as Promise; +} + +export async function resolveGitHubDefaultBranch(owner: string, repo: string): Promise { + const response = await fetchJson<{ default_branch?: string }>( + `https://api.github.com/repos/${owner}/${repo}`, + ); + return asString(response.default_branch) ?? "main"; +} + +export async function resolveGitHubCommitSha(owner: string, repo: string, ref: string): Promise { + const response = await fetchJson<{ sha?: string }>( + `https://api.github.com/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`, + ); + const sha = asString(response.sha); + if (!sha) { + throw unprocessable(`Failed to resolve GitHub ref ${ref}`); + } + return sha; +} + +export function parseGitHubSourceUrl(rawUrl: string): { + owner: string; + repo: string; + ref: string; + basePath: string; + filePath: string | null; + explicitRef: boolean; +} { + const url = new URL(rawUrl); + if (url.hostname !== "github.com") { + throw unprocessable("GitHub source must use github.com URL"); + } + const parts = url.pathname.split("/").filter(Boolean); + if (parts.length < 2) { + throw unprocessable("Invalid GitHub URL"); + } + const owner = parts[0]!; + const repo = parts[1]!.replace(/\.git$/i, ""); + let ref = "main"; + let basePath = ""; + let filePath: string | null = null; + let explicitRef = false; + if (parts[2] === "tree") { + ref = parts[3] ?? "main"; + basePath = parts.slice(4).join("/"); + explicitRef = true; + } else if (parts[2] === "blob") { + ref = parts[3] ?? "main"; + filePath = parts.slice(4).join("/"); + basePath = filePath ? path.posix.dirname(filePath) : ""; + explicitRef = true; + } + return { owner, repo, ref, basePath, filePath, explicitRef }; +} + +export async function resolveGitHubPinnedRef( + parsed: ReturnType, +): Promise<{ pinnedRef: string; trackingRef: string | null }> { + if (/^[0-9a-f]{40}$/i.test(parsed.ref.trim())) { + return { + pinnedRef: parsed.ref, + trackingRef: parsed.explicitRef ? parsed.ref : null, + }; + } + + const trackingRef = parsed.explicitRef + ? parsed.ref + : await resolveGitHubDefaultBranch(parsed.owner, parsed.repo); + const pinnedRef = await resolveGitHubCommitSha(parsed.owner, parsed.repo, trackingRef); + return { pinnedRef, trackingRef }; +} + +export function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string): string { + return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath.replace(/^\/+/, "")}`; +}