From 818a8eade83b5e111de51532befb95eb742d69a2 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Thu, 14 May 2026 11:49:49 -0400 Subject: [PATCH] feat(skills): support Gitea/Forgejo git hosts end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The skills source pipeline was hardcoded to GitHub conventions, so even though the UI now accepts non-GitHub URLs, the server couldn't actually fetch from anywhere else. - github-fetch.ts: dispatch by host family (github.com → GitHub API + raw.githubusercontent.com; everything else → Gitea/Forgejo API v1 + /api/v1/repos/.../media for raw content). - parseGitHubSourceUrl: also accept Gitea/Forgejo web URLs (/{owner}/{repo}/src/{branch|commit|tag}/{ref}/{path}). - routes/company-skills.ts: drop the hostname='github.com' gate in deriveTrackedSkillRef so non-GitHub skills are still tracked. - Generalize user-facing strings ('GitHub PAT' → 'PAT', 'GitHub source URL' → 'Source URL', etc.). GitHub Enterprise (was assumed by '/api/v3') is no longer a special case — non-github.com hosts are treated as Gitea/Forgejo. If GHE support is needed later, add a per-source host-family override. --- server/src/routes/company-skills.ts | 10 ------- server/src/services/company-skills.ts | 43 ++++++++++++++++++--------- server/src/services/github-fetch.ts | 23 +++++++++----- 3 files changed, 45 insertions(+), 31 deletions(-) diff --git a/server/src/routes/company-skills.ts b/server/src/routes/company-skills.ts index 402ffcbc..2ed51987 100644 --- a/server/src/routes/company-skills.ts +++ b/server/src/routes/company-skills.ts @@ -33,12 +33,6 @@ export function companySkillRoutes(db: Db) { return Boolean((agent.permissions as Record).canCreateAgents); } - function asString(value: unknown): string | null { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; - } - function deriveTrackedSkillRef(skill: SkillTelemetryInput): string | null { if (skill.sourceType === "skills_sh") { return skill.key; @@ -46,10 +40,6 @@ export function companySkillRoutes(db: Db) { if (skill.sourceType !== "github") { return null; } - const hostname = asString(skill.metadata?.hostname); - if (hostname !== "github.com") { - return null; - } return skill.key; } diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index b9238e06..51c2ad0b 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -29,7 +29,7 @@ import type { import { normalizeAgentUrlKey } from "@paperclipai/shared"; import { resolvePaperclipInstanceRoot } from "../home-paths.js"; import { notFound, unprocessable } from "../errors.js"; -import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js"; +import { ghFetch, gitHubApiBase, inferGitHostFamily, resolveRawGitHubUrl } from "./github-fetch.js"; import { agentService } from "./agents.js"; import { projectService } from "./projects.js"; import { secretService } from "./secrets.js"; @@ -577,7 +577,7 @@ async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, ); const sha = asString(response.sha); if (!sha) { - throw unprocessable(`Failed to resolve GitHub ref ${ref}`); + throw unprocessable(`Failed to resolve ref ${ref}`); } return sha; } @@ -585,26 +585,41 @@ async function resolveGitHubCommitSha(owner: string, repo: string, ref: string, function parseGitHubSourceUrl(rawUrl: string) { const url = new URL(rawUrl); if (url.protocol !== "https:") { - throw unprocessable("GitHub source URL must use HTTPS"); + throw unprocessable("Source URL must use HTTPS"); } const parts = url.pathname.split("/").filter(Boolean); if (parts.length < 2) { - throw unprocessable("Invalid GitHub URL"); + throw unprocessable("Invalid git source URL"); } const owner = parts[0]!; const repo = parts[1]!.replace(/\.git$/i, ""); + const family = inferGitHostFamily(url.hostname); 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) : ""; + if (family === "github") { + 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; + } + } else if (parts[2] === "src" && (parts[3] === "branch" || parts[3] === "commit" || parts[3] === "tag")) { + // Gitea/Forgejo web URLs: /{owner}/{repo}/src/{branch|commit|tag}/{ref}/{path} + ref = parts[4] ?? "main"; + const tail = parts.slice(5); + const tailJoined = tail.join("/"); + if (tail.length > 0 && /\.[A-Za-z0-9]+$/.test(tail[tail.length - 1]!)) { + filePath = tailJoined; + basePath = path.posix.dirname(tailJoined); + } else { + basePath = tailJoined; + } explicitRef = true; } return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef }; @@ -2483,7 +2498,7 @@ export function companySkillService(db: Db) { name: secretName, provider: "local_encrypted", value: authToken, - description: `GitHub PAT for skill ${skill.slug}`, + description: `PAT for skill ${skill.slug}`, }); secretId = created.id; } @@ -2590,7 +2605,7 @@ export function companySkillService(db: Db) { name: secretName, provider: "local_encrypted", value: authToken, - description: `GitHub PAT for skill ${skill.slug}`, + description: `PAT for skill ${skill.slug}`, }); secretId = created.id; } diff --git a/server/src/services/github-fetch.ts b/server/src/services/github-fetch.ts index c279ace5..66ee992f 100644 --- a/server/src/services/github-fetch.ts +++ b/server/src/services/github-fetch.ts @@ -1,19 +1,25 @@ import { unprocessable } from "../errors.js"; -function isGitHubDotCom(hostname: string) { +export type GitHostFamily = "github" | "gitea"; + +export function inferGitHostFamily(hostname: string): GitHostFamily { const h = hostname.toLowerCase(); - return h === "github.com" || h === "www.github.com"; + if (h === "github.com" || h === "www.github.com") return "github"; + return "gitea"; } export function gitHubApiBase(hostname: string) { - return isGitHubDotCom(hostname) ? "https://api.github.com" : `https://${hostname}/api/v3`; + return inferGitHostFamily(hostname) === "github" + ? "https://api.github.com" + : `https://${hostname}/api/v1`; } export function resolveRawGitHubUrl(hostname: string, owner: string, repo: string, ref: string, filePath: string) { const p = filePath.replace(/^\/+/, ""); - return isGitHubDotCom(hostname) - ? `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${p}` - : `https://${hostname}/raw/${owner}/${repo}/${ref}/${p}`; + if (inferGitHostFamily(hostname) === "github") { + return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${p}`; + } + return `https://${hostname}/api/v1/repos/${owner}/${repo}/media/${p}?ref=${encodeURIComponent(ref)}`; } export async function ghFetch(url: string, init?: RequestInit, authToken?: string): Promise { @@ -24,6 +30,9 @@ export async function ghFetch(url: string, init?: RequestInit, authToken?: strin try { return await fetch(url, { ...init, headers, redirect: authToken ? "manual" : "follow" }); } catch { - throw unprocessable(`Could not connect to ${new URL(url).hostname} — ensure the URL points to a GitHub or GitHub Enterprise instance`); + const hostname = (() => { + try { return new URL(url).hostname; } catch { return url; } + })(); + throw unprocessable(`Could not connect to ${hostname}`); } }