forked from farhoodlabs/paperclip
feat(skills): support Gitea/Forgejo git hosts end-to-end
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.
This commit is contained in:
@@ -33,12 +33,6 @@ export function companySkillRoutes(db: Db) {
|
|||||||
return Boolean((agent.permissions as Record<string, unknown>).canCreateAgents);
|
return Boolean((agent.permissions as Record<string, unknown>).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 {
|
function deriveTrackedSkillRef(skill: SkillTelemetryInput): string | null {
|
||||||
if (skill.sourceType === "skills_sh") {
|
if (skill.sourceType === "skills_sh") {
|
||||||
return skill.key;
|
return skill.key;
|
||||||
@@ -46,10 +40,6 @@ export function companySkillRoutes(db: Db) {
|
|||||||
if (skill.sourceType !== "github") {
|
if (skill.sourceType !== "github") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const hostname = asString(skill.metadata?.hostname);
|
|
||||||
if (hostname !== "github.com") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return skill.key;
|
return skill.key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import type {
|
|||||||
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
import { normalizeAgentUrlKey } from "@paperclipai/shared";
|
||||||
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
|
||||||
import { notFound, unprocessable } from "../errors.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 { agentService } from "./agents.js";
|
||||||
import { projectService } from "./projects.js";
|
import { projectService } from "./projects.js";
|
||||||
import { secretService } from "./secrets.js";
|
import { secretService } from "./secrets.js";
|
||||||
@@ -577,7 +577,7 @@ async function resolveGitHubCommitSha(owner: string, repo: string, ref: string,
|
|||||||
);
|
);
|
||||||
const sha = asString(response.sha);
|
const sha = asString(response.sha);
|
||||||
if (!sha) {
|
if (!sha) {
|
||||||
throw unprocessable(`Failed to resolve GitHub ref ${ref}`);
|
throw unprocessable(`Failed to resolve ref ${ref}`);
|
||||||
}
|
}
|
||||||
return sha;
|
return sha;
|
||||||
}
|
}
|
||||||
@@ -585,26 +585,41 @@ async function resolveGitHubCommitSha(owner: string, repo: string, ref: string,
|
|||||||
function parseGitHubSourceUrl(rawUrl: string) {
|
function parseGitHubSourceUrl(rawUrl: string) {
|
||||||
const url = new URL(rawUrl);
|
const url = new URL(rawUrl);
|
||||||
if (url.protocol !== "https:") {
|
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);
|
const parts = url.pathname.split("/").filter(Boolean);
|
||||||
if (parts.length < 2) {
|
if (parts.length < 2) {
|
||||||
throw unprocessable("Invalid GitHub URL");
|
throw unprocessable("Invalid git source URL");
|
||||||
}
|
}
|
||||||
const owner = parts[0]!;
|
const owner = parts[0]!;
|
||||||
const repo = parts[1]!.replace(/\.git$/i, "");
|
const repo = parts[1]!.replace(/\.git$/i, "");
|
||||||
|
const family = inferGitHostFamily(url.hostname);
|
||||||
let ref = "main";
|
let ref = "main";
|
||||||
let basePath = "";
|
let basePath = "";
|
||||||
let filePath: string | null = null;
|
let filePath: string | null = null;
|
||||||
let explicitRef = false;
|
let explicitRef = false;
|
||||||
if (parts[2] === "tree") {
|
if (family === "github") {
|
||||||
ref = parts[3] ?? "main";
|
if (parts[2] === "tree") {
|
||||||
basePath = parts.slice(4).join("/");
|
ref = parts[3] ?? "main";
|
||||||
explicitRef = true;
|
basePath = parts.slice(4).join("/");
|
||||||
} else if (parts[2] === "blob") {
|
explicitRef = true;
|
||||||
ref = parts[3] ?? "main";
|
} else if (parts[2] === "blob") {
|
||||||
filePath = parts.slice(4).join("/");
|
ref = parts[3] ?? "main";
|
||||||
basePath = filePath ? path.posix.dirname(filePath) : "";
|
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;
|
explicitRef = true;
|
||||||
}
|
}
|
||||||
return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
|
return { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
|
||||||
@@ -2483,7 +2498,7 @@ export function companySkillService(db: Db) {
|
|||||||
name: secretName,
|
name: secretName,
|
||||||
provider: "local_encrypted",
|
provider: "local_encrypted",
|
||||||
value: authToken,
|
value: authToken,
|
||||||
description: `GitHub PAT for skill ${skill.slug}`,
|
description: `PAT for skill ${skill.slug}`,
|
||||||
});
|
});
|
||||||
secretId = created.id;
|
secretId = created.id;
|
||||||
}
|
}
|
||||||
@@ -2590,7 +2605,7 @@ export function companySkillService(db: Db) {
|
|||||||
name: secretName,
|
name: secretName,
|
||||||
provider: "local_encrypted",
|
provider: "local_encrypted",
|
||||||
value: authToken,
|
value: authToken,
|
||||||
description: `GitHub PAT for skill ${skill.slug}`,
|
description: `PAT for skill ${skill.slug}`,
|
||||||
});
|
});
|
||||||
secretId = created.id;
|
secretId = created.id;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { unprocessable } from "../errors.js";
|
import { unprocessable } from "../errors.js";
|
||||||
|
|
||||||
function isGitHubDotCom(hostname: string) {
|
export type GitHostFamily = "github" | "gitea";
|
||||||
|
|
||||||
|
export function inferGitHostFamily(hostname: string): GitHostFamily {
|
||||||
const h = hostname.toLowerCase();
|
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) {
|
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) {
|
export function resolveRawGitHubUrl(hostname: string, owner: string, repo: string, ref: string, filePath: string) {
|
||||||
const p = filePath.replace(/^\/+/, "");
|
const p = filePath.replace(/^\/+/, "");
|
||||||
return isGitHubDotCom(hostname)
|
if (inferGitHostFamily(hostname) === "github") {
|
||||||
? `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${p}`
|
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${p}`;
|
||||||
: `https://${hostname}/raw/${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<Response> {
|
export async function ghFetch(url: string, init?: RequestInit, authToken?: string): Promise<Response> {
|
||||||
@@ -24,6 +30,9 @@ export async function ghFetch(url: string, init?: RequestInit, authToken?: strin
|
|||||||
try {
|
try {
|
||||||
return await fetch(url, { ...init, headers, redirect: authToken ? "manual" : "follow" });
|
return await fetch(url, { ...init, headers, redirect: authToken ? "manual" : "follow" });
|
||||||
} catch {
|
} 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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user