From 4ed3cce0a1a2a2904a54e35d5f2261867a9f1f22 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Wed, 1 Apr 2026 01:01:37 +0200 Subject: [PATCH] feat(09-01): extract GitHub fetch helpers to shared module - Create github-skill-helpers.ts with fetchText, fetchJson, resolveGitHubDefaultBranch, resolveGitHubCommitSha, parseGitHubSourceUrl, resolveGitHubPinnedRef, resolveRawGitHubUrl - Update company-skills.ts to import from github-skill-helpers.js instead of defining locally - All existing company-skill tests pass (15/15) --- server/src/services/company-skills.ts | 93 ++---------------- server/src/services/github-skill-helpers.ts | 102 ++++++++++++++++++++ 2 files changed, 112 insertions(+), 83 deletions(-) create mode 100644 server/src/services/github-skill-helpers.ts diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index 2b97da20..de039419 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -30,6 +30,15 @@ import { normalizeAgentUrlKey } from "@paperclipai/shared"; import { findServerAdapter } from "../adapters/index.js"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { notFound, unprocessable } from "../errors.js"; +import { + fetchText, + fetchJson, + resolveGitHubDefaultBranch, + resolveGitHubCommitSha, + parseGitHubSourceUrl, + resolveGitHubPinnedRef, + resolveRawGitHubUrl, +} from "./github-skill-helpers.js"; import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; import { secretService } from "./secrets.js"; @@ -469,90 +478,8 @@ function parseFrontmatterMarkdown(raw: string): { frontmatter: Record(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; -} - -async function resolveGitHubDefaultBranch(owner: string, repo: string) { - const response = await fetchJson<{ default_branch?: string }>( - `https://api.github.com/repos/${owner}/${repo}`, - ); - return asString(response.default_branch) ?? "main"; -} - -async function resolveGitHubCommitSha(owner: string, repo: string, ref: string) { - 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; -} - -function parseGitHubSourceUrl(rawUrl: string) { - 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 }; -} - -async function resolveGitHubPinnedRef(parsed: ReturnType) { - 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 }; -} - -function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath: string) { - return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath.replace(/^\/+/, "")}`; -} function extractCommandTokens(raw: string) { const matches = raw.match(/"[^"]*"|'[^']*'|\S+/g) ?? []; 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(/^\/+/, "")}`; +}