fork: add Gitea/Forgejo source support for company skills
Reintroduce Gitea/Forgejo as a skill import source on dev only, since the fork deploys against git.farh.net. Pasting a Gitea/Forgejo repo URL into the skills sidebar mirrors the existing GitHub experience: pin to a commit SHA, check for updates, read repo files. Server: new gitea-fetch.ts (URL builders, probe-cache helpers) and gitea-skills.ts (parse, probe, pin, tree, text, branch). Dispatch in readUrlSkillImports probes /api/v1/version and routes non-github.com hosts into the new readGiteaUrlSkillImports branch. updateStatus and readFile get a gitea arm alongside the github/skills_sh arm. Audit falls through to "remote not supported" the same way github does. UI: Server icon, Gitea source label, gitea in the "external" source class, Pin/Update UI gate widened to sourceType === "gitea". CLI help text updated. Existing github code is left byte-for-byte unchanged (wrapped in isGitHubDotCom) so dev <-> master syncs stay clean. PAT support, gitea portability descriptors, and gitea audit are deliberate follow-ups. Detection requires /api/v1/version to return Gitea-shaped JSON; the per-host result is cached for process lifetime with FIFO eviction at 1024 entries. Non-Gitea hosts fall through to the existing raw-markdown url branch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,289 @@
|
||||
import path from "node:path";
|
||||
import { unprocessable } from "../errors.js";
|
||||
import {
|
||||
giteaApiBase,
|
||||
giteaFetch,
|
||||
getGiteaHostProbe,
|
||||
giteaHostProbeCache,
|
||||
resolveRawGiteaUrl,
|
||||
resolveRawGiteaUrlLegacy,
|
||||
setGiteaHostProbe,
|
||||
} from "./gitea-fetch.js";
|
||||
|
||||
export {
|
||||
giteaApiBase,
|
||||
giteaHostProbeCache,
|
||||
resolveRawGiteaUrl,
|
||||
resolveRawGiteaUrlLegacy,
|
||||
setGiteaHostProbe,
|
||||
getGiteaHostProbe,
|
||||
};
|
||||
|
||||
const PROBE_TIMEOUT_MS = 3000;
|
||||
const GITEA_TREE_PAGE_LIMIT = 1000;
|
||||
|
||||
export type GiteaSourceUrl = {
|
||||
hostname: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
ref: string;
|
||||
basePath: string;
|
||||
filePath: string | null;
|
||||
explicitRef: boolean;
|
||||
};
|
||||
|
||||
export type GiteaBranchResponse = {
|
||||
name?: string;
|
||||
commit?: { id?: string; url?: string };
|
||||
};
|
||||
|
||||
export type GiteaRepoResponse = {
|
||||
default_branch?: string;
|
||||
};
|
||||
|
||||
export type GiteaCommitResponse = {
|
||||
sha?: string;
|
||||
};
|
||||
|
||||
export type GiteaTreeEntry = {
|
||||
path?: string;
|
||||
type?: string;
|
||||
mode?: string;
|
||||
sha?: string;
|
||||
size?: number;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
export type GiteaTreeResponse = {
|
||||
sha?: string;
|
||||
tree?: GiteaTreeEntry[];
|
||||
truncated?: boolean;
|
||||
};
|
||||
|
||||
function asString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Gitea/Forgejo HTTPS repo URL into its components.
|
||||
* Mirrors parseGitHubSourceUrl (server/src/services/company-skills.ts:634-660).
|
||||
* Accepts:
|
||||
* https://{host}/{owner}/{repo}
|
||||
* https://{host}/{owner}/{repo}.git
|
||||
* https://{host}/{owner}/{repo}/tree/{ref}/{basePath...}
|
||||
* https://{host}/{owner}/{repo}/blob/{ref}/{filePath}
|
||||
*/
|
||||
export function parseGiteaSourceUrl(rawUrl: string): GiteaSourceUrl {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(rawUrl);
|
||||
} catch {
|
||||
throw unprocessable("Invalid Gitea URL");
|
||||
}
|
||||
if (url.protocol !== "https:") {
|
||||
throw unprocessable("Gitea source URL must use HTTPS");
|
||||
}
|
||||
const parts = url.pathname.split("/").filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
throw unprocessable("Invalid Gitea 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 { hostname: url.hostname, owner, repo, ref, basePath, filePath, explicitRef };
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe a hostname to determine if it hosts a Gitea/Forgejo instance.
|
||||
* GETs `https://{host}/api/v1/version` with a short timeout. Cached for
|
||||
* the process lifetime in giteaHostProbeCache.
|
||||
*/
|
||||
export async function probeGiteaHost(hostname: string): Promise<boolean> {
|
||||
const key = hostname.toLowerCase();
|
||||
const cached = getGiteaHostProbe(key);
|
||||
if (cached !== undefined) return cached;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
||||
let result = false;
|
||||
try {
|
||||
const response = await fetch(`https://${key}/api/v1/version`, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = (await response.json().catch(() => null)) as unknown;
|
||||
if (isPlainRecord(data) && typeof data.version === "string") {
|
||||
result = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// network error, abort, parse error — all treated as "not gitea"
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
setGiteaHostProbe(key, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function resolveGiteaDefaultBranch(
|
||||
owner: string,
|
||||
repo: string,
|
||||
apiBase: string,
|
||||
): Promise<string> {
|
||||
const response = await fetchGiteaJson<GiteaRepoResponse>(`${apiBase}/repos/${owner}/${repo}`);
|
||||
return asString(response.default_branch) ?? "main";
|
||||
}
|
||||
|
||||
export async function resolveGiteaCommitSha(
|
||||
owner: string,
|
||||
repo: string,
|
||||
ref: string,
|
||||
apiBase: string,
|
||||
): Promise<string> {
|
||||
const response = await fetchGiteaJson<GiteaCommitResponse>(
|
||||
`${apiBase}/repos/${owner}/${repo}/commits/${encodeURIComponent(ref)}`,
|
||||
);
|
||||
const sha = asString(response.sha);
|
||||
if (!sha) {
|
||||
throw unprocessable(`Failed to resolve Gitea ref ${ref}`);
|
||||
}
|
||||
return sha;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a parsed Gitea URL into a pinned commit SHA and a tracking ref.
|
||||
* Mirrors resolveGitHubPinnedRef (server/src/services/company-skills.ts:662-676).
|
||||
*/
|
||||
export async function resolveGiteaPinnedRef(parsed: GiteaSourceUrl): 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 apiBase = giteaApiBase(parsed.hostname);
|
||||
const trackingRef = parsed.explicitRef
|
||||
? parsed.ref
|
||||
: await resolveGiteaDefaultBranch(parsed.owner, parsed.repo, apiBase);
|
||||
const pinnedRef = await resolveGiteaCommitSha(parsed.owner, parsed.repo, trackingRef, apiBase);
|
||||
return { pinnedRef, trackingRef };
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the full list of blob paths in a repo tree at a given ref.
|
||||
* Paginates with `?page=N&limit=1000` when the response is truncated.
|
||||
*/
|
||||
export async function fetchGiteaTreeBlobPaths(
|
||||
apiBase: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
ref: string,
|
||||
): Promise<string[]> {
|
||||
const all: string[] = [];
|
||||
let page = 1;
|
||||
// hard cap so a misconfigured host can't make us loop forever
|
||||
const MAX_PAGES = 50;
|
||||
for (let i = 0; i < MAX_PAGES; i += 1) {
|
||||
const url =
|
||||
page === 1
|
||||
? `${apiBase}/repos/${owner}/${repo}/git/trees/${ref}?recursive=true&limit=${GITEA_TREE_PAGE_LIMIT}`
|
||||
: `${apiBase}/repos/${owner}/${repo}/git/trees/${ref}?recursive=true&limit=${GITEA_TREE_PAGE_LIMIT}&page=${page}`;
|
||||
const data = await fetchGiteaJson<GiteaTreeResponse>(url);
|
||||
const entries = Array.isArray(data.tree) ? data.tree : [];
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "blob" && typeof entry.path === "string") {
|
||||
all.push(entry.path);
|
||||
}
|
||||
}
|
||||
if (!data.truncated) break;
|
||||
page += 1;
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a raw file from a Gitea/Forgejo repo. Tries the modern
|
||||
* /raw/branch/{ref}/{path} URL first, falling back to legacy
|
||||
* /raw/{ref}/{path} on 404.
|
||||
*/
|
||||
export async function fetchGiteaText(
|
||||
hostname: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
ref: string,
|
||||
filePath: string,
|
||||
): Promise<string> {
|
||||
const canonical = resolveRawGiteaUrl(hostname, owner, repo, ref, filePath);
|
||||
const canonicalResponse = await giteaFetch(canonical, {
|
||||
headers: { accept: "text/plain" },
|
||||
});
|
||||
if (canonicalResponse.ok) {
|
||||
return canonicalResponse.text();
|
||||
}
|
||||
if (canonicalResponse.status !== 404) {
|
||||
throw unprocessable(
|
||||
`Failed to fetch ${canonical}: ${canonicalResponse.status}`,
|
||||
);
|
||||
}
|
||||
const legacy = resolveRawGiteaUrlLegacy(hostname, owner, repo, ref, filePath);
|
||||
const legacyResponse = await giteaFetch(legacy, {
|
||||
headers: { accept: "text/plain" },
|
||||
});
|
||||
if (!legacyResponse.ok) {
|
||||
throw unprocessable(
|
||||
`Failed to fetch ${legacy}: ${legacyResponse.status}`,
|
||||
);
|
||||
}
|
||||
return legacyResponse.text();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a branch record by name. Used for update checks to resolve
|
||||
* the latest commit SHA on the tracking branch.
|
||||
*/
|
||||
export async function fetchGiteaBranch(
|
||||
apiBase: string,
|
||||
owner: string,
|
||||
repo: string,
|
||||
branch: string,
|
||||
): Promise<GiteaBranchResponse> {
|
||||
return fetchGiteaJson<GiteaBranchResponse>(
|
||||
`${apiBase}/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchGiteaJson<T>(url: string): Promise<T> {
|
||||
const response = await giteaFetch(url, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw unprocessable(`Failed to fetch ${url}: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
Reference in New Issue
Block a user