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:
2026-06-09 15:30:44 -04:00
parent 9f3f71a199
commit e559218f98
13 changed files with 935 additions and 22 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ function mermaidEscape(s: string): string {
function skillSourceLabel(skill: CompanyPortabilityManifest["skills"][number]): string {
if (skill.sourceLocator) {
// For GitHub or URL sources, render as a markdown link
if (skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url") {
if (skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "gitea" || skill.sourceType === "url") {
return `[${skill.sourceType}](${skill.sourceLocator})`;
}
return skill.sourceLocator;
+4 -4
View File
@@ -196,7 +196,7 @@ function deriveManifestSkillKey(
const sourceKind = asString(metadata?.sourceKind);
const owner = normalizeSkillSlug(asString(metadata?.owner));
const repo = normalizeSkillSlug(asString(metadata?.repo));
if ((sourceType === "github" || sourceType === "skills_sh" || sourceKind === "github" || sourceKind === "skills_sh") && owner && repo) {
if ((sourceType === "github" || sourceType === "skills_sh" || sourceType === "gitea" || sourceKind === "github" || sourceKind === "skills_sh" || sourceKind === "gitea") && owner && repo) {
return `${owner}/${repo}/${slug}`;
}
if (sourceKind === "paperclip_bundled") {
@@ -345,10 +345,10 @@ function deriveSkillExportDirCandidates(
pushSuffix("paperclip");
}
if (skill.sourceType === "github" || skill.sourceType === "skills_sh") {
if (skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "gitea") {
pushSuffix(asString(metadata?.repo));
pushSuffix(asString(metadata?.owner));
pushSuffix(skill.sourceType === "skills_sh" ? "skills_sh" : "github");
pushSuffix(skill.sourceType === "skills_sh" ? "skills_sh" : skill.sourceType === "gitea" ? "gitea" : "github");
} else if (skill.sourceType === "url") {
try {
pushSuffix(skill.sourceLocator ? new URL(skill.sourceLocator).host : null);
@@ -2109,7 +2109,7 @@ function shouldReferenceSkillOnExport(skill: CompanySkill, expandReferencedSkill
if (expandReferencedSkills) return false;
const metadata = isPlainRecord(skill.metadata) ? skill.metadata : null;
if (asString(metadata?.sourceKind) === "paperclip_bundled") return true;
return skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url";
return skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "gitea" || skill.sourceType === "url";
}
async function buildReferencedSkillMarkdown(skill: CompanySkill) {
+165 -6
View File
@@ -37,6 +37,15 @@ import { normalizeAgentUrlKey } from "@paperclipai/shared";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
import { conflict, notFound, unprocessable } from "../errors.js";
import { ghFetch, gitHubApiBase, resolveRawGitHubUrl } from "./github-fetch.js";
import { giteaApiBase } from "./gitea-fetch.js";
import {
fetchGiteaBranch,
fetchGiteaText,
fetchGiteaTreeBlobPaths,
parseGiteaSourceUrl,
probeGiteaHost,
resolveGiteaPinnedRef,
} from "./gitea-skills.js";
import { agentService } from "./agents.js";
import { projectService } from "./projects.js";
import { normalizePortablePath } from "./portable-path.js";
@@ -1124,6 +1133,10 @@ async function readUrlSkillImports(
return segments.length >= 2 && !parsed.pathname.endsWith(".md");
} catch { return false; } })();
if (looksLikeRepoUrl) {
const repoUrl = new URL(url);
const repoHost = repoUrl.hostname.toLowerCase();
const isGitHubDotCom = repoHost === "github.com" || repoHost === "www.github.com";
if (isGitHubDotCom) {
const parsed = parseGitHubSourceUrl(url);
const apiBase = gitHubApiBase(parsed.hostname);
const { pinnedRef, trackingRef } = await resolveGitHubPinnedRef(parsed);
@@ -1215,6 +1228,11 @@ async function readUrlSkillImports(
);
}
return { skills, warnings };
}
if (await probeGiteaHost(repoHost)) {
return await readGiteaUrlSkillImports(companyId, sourceUrl, requestedSkillSlug);
}
}
if (url.startsWith("http://") || url.startsWith("https://")) {
@@ -1259,6 +1277,102 @@ async function readUrlSkillImports(
throw unprocessable("Unsupported skill source. Use a local path or URL.");
}
async function readGiteaUrlSkillImports(
companyId: string,
sourceUrl: string,
requestedSkillSlug: string | null = null,
): Promise<{ skills: ImportedSkill[]; warnings: string[] }> {
const warnings: string[] = [];
const parsed = parseGiteaSourceUrl(sourceUrl);
const apiBase = giteaApiBase(parsed.hostname);
const { pinnedRef, trackingRef } = await resolveGiteaPinnedRef(parsed);
const ref = pinnedRef;
let allPaths: string[];
try {
allPaths = await fetchGiteaTreeBlobPaths(apiBase, parsed.owner, parsed.repo, ref);
} catch {
throw unprocessable(`Failed to read Gitea tree for ${sourceUrl}`);
}
const basePrefix = parsed.basePath ? `${parsed.basePath.replace(/^\/+|\/+$/g, "")}/` : "";
const scopedPaths = basePrefix
? allPaths.filter((entry) => entry.startsWith(basePrefix))
: allPaths;
const relativePaths = scopedPaths.map((entry) => basePrefix ? entry.slice(basePrefix.length) : entry);
const filteredPaths = parsed.filePath
? relativePaths.filter((entry) => entry === path.posix.relative(parsed.basePath || ".", parsed.filePath!))
: relativePaths;
const skillPaths = filteredPaths.filter(
(entry) => path.posix.basename(entry).toLowerCase() === "skill.md",
);
if (skillPaths.length === 0) {
throw unprocessable(
"No SKILL.md files were found in the provided Gitea source.",
);
}
const skills: ImportedSkill[] = [];
for (const relativeSkillPath of skillPaths) {
const repoSkillPath = basePrefix ? `${basePrefix}${relativeSkillPath}` : relativeSkillPath;
const markdown = await fetchGiteaText(parsed.hostname, parsed.owner, parsed.repo, ref, repoSkillPath);
const parsedMarkdown = parseFrontmatterMarkdown(markdown);
const skillDir = path.posix.dirname(relativeSkillPath);
const slug = deriveImportedSkillSlug(parsedMarkdown.frontmatter, path.posix.basename(skillDir));
const skillKey = readCanonicalSkillKey(
parsedMarkdown.frontmatter,
isPlainRecord(parsedMarkdown.frontmatter.metadata) ? parsedMarkdown.frontmatter.metadata : null,
);
if (requestedSkillSlug && !matchesRequestedSkill(relativeSkillPath, requestedSkillSlug) && slug !== requestedSkillSlug) {
continue;
}
const metadata = {
...(skillKey ? { skillKey } : {}),
sourceKind: "gitea",
hostname: parsed.hostname,
owner: parsed.owner,
repo: parsed.repo,
ref,
trackingRef,
repoSkillDir: normalizeGitHubSkillDirectory(
basePrefix ? `${basePrefix}${skillDir}` : skillDir,
slug,
),
};
const inventory = filteredPaths
.filter((entry) => entry === relativeSkillPath || entry.startsWith(`${skillDir}/`))
.map((entry) => ({
path: entry === relativeSkillPath ? "SKILL.md" : entry.slice(skillDir.length + 1),
kind: classifyInventoryKind(entry === relativeSkillPath ? "SKILL.md" : entry.slice(skillDir.length + 1)),
}))
.sort((left, right) => left.path.localeCompare(right.path));
skills.push({
key: deriveCanonicalSkillKey(companyId, {
slug,
sourceType: "gitea",
sourceLocator: sourceUrl,
metadata,
}),
slug,
name: asString(parsedMarkdown.frontmatter.name) ?? slug,
description: asString(parsedMarkdown.frontmatter.description),
markdown,
sourceType: "gitea",
sourceLocator: sourceUrl,
sourceRef: ref,
trustLevel: deriveTrustLevel(inventory),
compatibility: "compatible",
fileInventory: inventory,
metadata,
});
}
if (skills.length === 0) {
throw unprocessable(
requestedSkillSlug
? `Skill ${requestedSkillSlug} was not found in the provided Gitea source.`
: "No SKILL.md files were found in the provided Gitea source.",
);
}
return { skills, warnings };
}
function toCompanySkill(row: CompanySkillRow): CompanySkill {
return {
...row,
@@ -1789,6 +1903,18 @@ function deriveSkillSourceInfo(skill: SkillSourceInfoTarget): {
};
}
if (skill.sourceType === "gitea") {
const owner = asString(metadata.owner) ?? null;
const repo = asString(metadata.repo) ?? null;
return {
editable: false,
editableReason: "Remote Gitea skills are read-only. Fork or import locally to edit them.",
sourceLabel: owner && repo ? `${owner}/${repo}` : skill.sourceLocator,
sourceBadge: "gitea",
sourcePath: null,
};
}
if (skill.sourceType === "url") {
return {
editable: false,
@@ -2211,10 +2337,10 @@ export function companySkillService(db: Db) {
};
}
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh") {
if (skill.sourceType !== "github" && skill.sourceType !== "skills_sh" && skill.sourceType !== "gitea") {
return {
supported: false,
reason: "Only GitHub-managed skills support update checks.",
reason: "Only GitHub-, Gitea-, or skills.sh-managed skills support update checks.",
trackingRef: null,
currentRef: skill.sourceRef ?? null,
latestRef: null,
@@ -2229,7 +2355,7 @@ export function companySkillService(db: Db) {
if (!owner || !repo || !trackingRef) {
return {
supported: false,
reason: "This GitHub skill does not have enough metadata to track updates.",
reason: "This skill does not have enough metadata to track updates.",
trackingRef: trackingRef ?? null,
currentRef: skill.sourceRef ?? null,
latestRef: null,
@@ -2238,9 +2364,30 @@ export function companySkillService(db: Db) {
};
}
const hostname = asString(metadata.hostname) || "github.com";
const apiBase = gitHubApiBase(hostname);
const latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase);
const hostname = asString(metadata.hostname) || (skill.sourceType === "gitea" ? "" : "github.com");
let latestRef: string;
if (skill.sourceType === "gitea") {
if (!hostname) {
return {
supported: false,
reason: "This Gitea skill does not have a hostname in its metadata.",
trackingRef,
currentRef: skill.sourceRef ?? null,
latestRef: null,
hasUpdate: false,
...statusMeta,
};
}
const branch = await fetchGiteaBranch(giteaApiBase(hostname), owner, repo, trackingRef);
const branchSha = asString(branch.commit?.id);
if (!branchSha) {
throw unprocessable(`Failed to resolve Gitea branch ${trackingRef} for ${owner}/${repo}`);
}
latestRef = branchSha;
} else {
const apiBase = gitHubApiBase(hostname);
latestRef = await resolveGitHubCommitSha(owner, repo, trackingRef, apiBase);
}
return {
supported: true,
reason: null,
@@ -2287,6 +2434,18 @@ export function companySkillService(db: Db) {
}
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
content = await fetchText(resolveRawGitHubUrl(hostname, owner, repo, ref, repoPath));
} else if (skill.sourceType === "gitea") {
const metadata = getSkillMeta(skill);
const owner = asString(metadata.owner);
const repo = asString(metadata.repo);
const hostname = asString(metadata.hostname);
const ref = skill.sourceRef ?? asString(metadata.ref) ?? "main";
const repoSkillDir = normalizeGitHubSkillDirectory(asString(metadata.repoSkillDir), skill.slug);
if (!owner || !repo || !hostname) {
throw unprocessable("Skill source metadata is incomplete.");
}
const repoPath = normalizePortablePath(path.posix.join(repoSkillDir, normalizedPath));
content = await fetchGiteaText(hostname, owner, repo, ref, repoPath);
} else if (skill.sourceType === "url") {
if (normalizedPath !== "SKILL.md") {
throw notFound("This skill source only exposes SKILL.md");
+1 -1
View File
@@ -1266,7 +1266,7 @@ async function buildAgentContext(
sourceType: skill.sourceType,
sourceLocator: skill.sourceLocator == null
? null
: skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "url"
: skill.sourceType === "github" || skill.sourceType === "skills_sh" || skill.sourceType === "gitea" || skill.sourceType === "url"
? skill.sourceLocator
: sanitizeFeedbackText(
skill.sourceLocator,
+75
View File
@@ -0,0 +1,75 @@
import { unprocessable } from "../errors.js";
const PROBE_CACHE_MAX_ENTRIES = 1024;
/**
* Process-lifetime cache of Gitea/Forgejo probe results.
* Keyed by lowercased hostname. Positive and negative results are both cached
* to avoid re-probing the same host on every import. FIFO-evicted at
* PROBE_CACHE_MAX_ENTRIES to bound memory.
*/
export const giteaHostProbeCache = new Map<string, boolean>();
function evictProbeCacheIfFull() {
if (giteaHostProbeCache.size > PROBE_CACHE_MAX_ENTRIES) {
const oldestKey = giteaHostProbeCache.keys().next().value;
if (oldestKey !== undefined) giteaHostProbeCache.delete(oldestKey);
}
}
export function setGiteaHostProbe(hostname: string, isGitea: boolean) {
giteaHostProbeCache.set(hostname.toLowerCase(), isGitea);
evictProbeCacheIfFull();
}
export function getGiteaHostProbe(hostname: string): boolean | undefined {
return giteaHostProbeCache.get(hostname.toLowerCase());
}
/**
* Gitea/Forgejo API base. There is no dotcom short-circuit — every host uses
* the same /api/v1 path, including the public gitea.com instance.
*/
export function giteaApiBase(hostname: string) {
return `https://${hostname}/api/v1`;
}
/**
* Canonical raw-content URL for Gitea/Forgejo ≥ 1.18.
* Modern format: `https://{host}/{owner}/{repo}/raw/branch/{ref}/{path}`.
*/
export function resolveRawGiteaUrl(
hostname: string,
owner: string,
repo: string,
ref: string,
filePath: string,
) {
const p = filePath.replace(/^\/+/, "");
return `https://${hostname}/${owner}/${repo}/raw/branch/${ref}/${p}`;
}
/**
* Legacy raw-content URL for Gitea < 1.18 and some Forgejo setups.
* Format: `https://{host}/{owner}/{repo}/raw/{ref}/{path}`.
*/
export function resolveRawGiteaUrlLegacy(
hostname: string,
owner: string,
repo: string,
ref: string,
filePath: string,
) {
const p = filePath.replace(/^\/+/, "");
return `https://${hostname}/${owner}/${repo}/raw/${ref}/${p}`;
}
export async function giteaFetch(url: string, init?: RequestInit): Promise<Response> {
try {
return await fetch(url, init);
} catch {
throw unprocessable(
`Could not connect to ${new URL(url).hostname} — ensure the URL points to a Gitea/Forgejo instance`,
);
}
}
+289
View File
@@ -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;
}