From e559218f9896762527df483d7d84430f5257681d Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 9 Jun 2026 15:30:44 -0400 Subject: [PATCH] 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 --- cli/src/commands/client/skills.ts | 2 +- packages/shared/src/types/company-skill.ts | 4 +- .../shared/src/validators/company-skill.ts | 4 +- .../__tests__/company-skills-routes.test.ts | 47 +++ .../__tests__/company-skills-service.test.ts | 35 ++ server/src/__tests__/gitea-skills.test.ts | 305 ++++++++++++++++++ server/src/services/company-export-readme.ts | 2 +- server/src/services/company-portability.ts | 8 +- server/src/services/company-skills.ts | 171 +++++++++- server/src/services/feedback.ts | 2 +- server/src/services/gitea-fetch.ts | 75 +++++ server/src/services/gitea-skills.ts | 289 +++++++++++++++++ ui/src/pages/CompanySkills.tsx | 13 +- 13 files changed, 935 insertions(+), 22 deletions(-) create mode 100644 server/src/__tests__/gitea-skills.test.ts create mode 100644 server/src/services/gitea-fetch.ts create mode 100644 server/src/services/gitea-skills.ts diff --git a/cli/src/commands/client/skills.ts b/cli/src/commands/client/skills.ts index aada01c8..e2f510c1 100644 --- a/cli/src/commands/client/skills.ts +++ b/cli/src/commands/client/skills.ts @@ -255,7 +255,7 @@ export function registerSkillsCommands(program: Command): void { addCommonClientOptions( skills .command("import") - .description("Import company skills from a local path, GitHub, skills.sh, or URL source") + .description("Import company skills from a local path, GitHub, Gitea/Forgejo, skills.sh, or URL source") .argument("", "Skill source") .action(async (source: string, opts: SkillsOptions) => { try { diff --git a/packages/shared/src/types/company-skill.ts b/packages/shared/src/types/company-skill.ts index b244b71c..63e79843 100644 --- a/packages/shared/src/types/company-skill.ts +++ b/packages/shared/src/types/company-skill.ts @@ -1,10 +1,10 @@ -export type CompanySkillSourceType = "local_path" | "github" | "url" | "catalog" | "skills_sh"; +export type CompanySkillSourceType = "local_path" | "github" | "gitea" | "url" | "catalog" | "skills_sh"; export type CompanySkillTrustLevel = "markdown_only" | "assets" | "scripts_executables"; export type CompanySkillCompatibility = "compatible" | "unknown" | "invalid"; -export type CompanySkillSourceBadge = "paperclip" | "github" | "local" | "url" | "catalog" | "skills_sh"; +export type CompanySkillSourceBadge = "paperclip" | "github" | "gitea" | "local" | "url" | "catalog" | "skills_sh"; export interface CompanySkillFileInventoryEntry { path: string; diff --git a/packages/shared/src/validators/company-skill.ts b/packages/shared/src/validators/company-skill.ts index 2d813ec0..954885c8 100644 --- a/packages/shared/src/validators/company-skill.ts +++ b/packages/shared/src/validators/company-skill.ts @@ -1,9 +1,9 @@ import { z } from "zod"; -export const companySkillSourceTypeSchema = z.enum(["local_path", "github", "url", "catalog", "skills_sh"]); +export const companySkillSourceTypeSchema = z.enum(["local_path", "github", "gitea", "url", "catalog", "skills_sh"]); export const companySkillTrustLevelSchema = z.enum(["markdown_only", "assets", "scripts_executables"]); export const companySkillCompatibilitySchema = z.enum(["compatible", "unknown", "invalid"]); -export const companySkillSourceBadgeSchema = z.enum(["paperclip", "github", "local", "url", "catalog", "skills_sh"]); +export const companySkillSourceBadgeSchema = z.enum(["paperclip", "github", "gitea", "local", "url", "catalog", "skills_sh"]); export const companySkillFileInventoryEntrySchema = z.object({ path: z.string().min(1), diff --git a/server/src/__tests__/company-skills-routes.test.ts b/server/src/__tests__/company-skills-routes.test.ts index 1545ef63..1981cba0 100644 --- a/server/src/__tests__/company-skills-routes.test.ts +++ b/server/src/__tests__/company-skills-routes.test.ts @@ -444,6 +444,53 @@ describe("company skill mutation permissions", () => { }); }); + it("imports Gitea skills and tracks the import with skillRef null (non-github.com)", async () => { + mockCompanySkillService.importFromSource.mockResolvedValue({ + imported: [ + { + id: "skill-1", + companyId: "company-1", + key: "git.example.com/acme/remote-skill/web", + slug: "web", + name: "Remote Skill", + description: null, + markdown: "# Remote", + sourceType: "gitea", + sourceLocator: "https://git.example.com/acme/remote-skill", + sourceRef: "abc123abc123abc123abc123abc123abc123abcd", + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [], + metadata: { + hostname: "git.example.com", + owner: "acme", + repo: "remote-skill", + sourceKind: "gitea", + }, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + warnings: [], + }); + + const res = await request(await createApp({ + type: "board", + userId: "local-board", + companyIds: ["company-1"], + source: "local_implicit", + isInstanceAdmin: false, + })) + .post("/api/companies/company-1/skills/import") + .send({ source: "https://git.example.com/acme/remote-skill" }); + + expect([200, 201], JSON.stringify(res.body)).toContain(res.status); + expect(mockTrackSkillImported).toHaveBeenCalledWith(expect.anything(), { + sourceType: "gitea", + skillRef: null, + }); + }); + it("blocks same-company agents without management permission from mutating company skills", async () => { mockAgentService.getById.mockResolvedValue({ id: "agent-1", diff --git a/server/src/__tests__/company-skills-service.test.ts b/server/src/__tests__/company-skills-service.test.ts index 769bbea3..3cee2d2d 100644 --- a/server/src/__tests__/company-skills-service.test.ts +++ b/server/src/__tests__/company-skills-service.test.ts @@ -143,6 +143,41 @@ describeEmbeddedPostgres("companySkillService.list", () => { }); }); + it("does not persist audit failures for gitea-source skills", async () => { + const companyId = randomUUID(); + const skillId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`, + requireBoardApprovalForNewAgents: false, + }); + await db.insert(companySkills).values({ + id: skillId, + companyId, + key: "git.example.com/acme/remote-skill", + slug: "remote-skill", + name: "Remote Skill", + description: null, + markdown: "# Remote Skill\n", + sourceType: "gitea", + sourceLocator: "https://git.example.com/acme/remote-skill", + sourceRef: "main", + trustLevel: "markdown_only", + compatibility: "compatible", + fileInventory: [{ path: "SKILL.md", kind: "skill" }], + metadata: { sourceKind: "gitea", hostname: "git.example.com", owner: "acme", repo: "remote-skill" }, + }); + + await expect(svc.auditSkill(companyId, skillId)).rejects.toMatchObject({ + status: 422, + message: "Only local-path and catalog-managed company skills support audit.", + }); + await expect(svc.getById(companyId, skillId)).resolves.toMatchObject({ + metadata: { sourceKind: "gitea", owner: "acme", repo: "remote-skill" }, + }); + }); + it("preserves missing local-path skills that active agents still desire", async () => { const companyId = randomUUID(); const skillId = randomUUID(); diff --git a/server/src/__tests__/gitea-skills.test.ts b/server/src/__tests__/gitea-skills.test.ts new file mode 100644 index 00000000..8e512996 --- /dev/null +++ b/server/src/__tests__/gitea-skills.test.ts @@ -0,0 +1,305 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + fetchGiteaBranch, + fetchGiteaText, + fetchGiteaTreeBlobPaths, + giteaApiBase, + giteaHostProbeCache, + parseGiteaSourceUrl, + probeGiteaHost, + resolveGiteaCommitSha, + resolveGiteaPinnedRef, + resolveRawGiteaUrl, + resolveRawGiteaUrlLegacy, + setGiteaHostProbe, +} from "../services/gitea-skills.js"; + +function jsonResponse(body: unknown, init?: { status?: number; ok?: boolean }) { + const status = init?.status ?? 200; + return { + ok: init?.ok ?? (status >= 200 && status < 300), + status, + statusText: status === 200 ? "OK" : "Error", + text: () => Promise.resolve(JSON.stringify(body)), + json: () => Promise.resolve(body), + } as unknown as Response; +} + +function textResponse(body: string, init?: { status?: number; ok?: boolean }) { + const status = init?.status ?? 200; + return { + ok: init?.ok ?? (status >= 200 && status < 300), + status, + statusText: status === 200 ? "OK" : "Error", + text: () => Promise.resolve(body), + json: () => Promise.reject(new Error("not json")), + } as unknown as Response; +} + +describe("giteaApiBase", () => { + it("returns the Gitea API base", () => { + expect(giteaApiBase("git.example.com")).toBe("https://git.example.com/api/v1"); + }); +}); + +describe("resolveRawGiteaUrl", () => { + it("builds the modern /raw/branch/ URL", () => { + expect(resolveRawGiteaUrl("git.example.com", "acme", "skills", "main", "foo/SKILL.md")) + .toBe("https://git.example.com/acme/skills/raw/branch/main/foo/SKILL.md"); + }); + + it("strips leading slashes from the file path", () => { + expect(resolveRawGiteaUrl("git.example.com", "acme", "skills", "abc123", "/nested/file.md")) + .toBe("https://git.example.com/acme/skills/raw/branch/abc123/nested/file.md"); + }); +}); + +describe("resolveRawGiteaUrlLegacy", () => { + it("builds the legacy /raw/ URL", () => { + expect(resolveRawGiteaUrlLegacy("git.example.com", "acme", "skills", "main", "SKILL.md")) + .toBe("https://git.example.com/acme/skills/raw/main/SKILL.md"); + }); +}); + +describe("parseGiteaSourceUrl", () => { + it("parses a plain repo URL", () => { + expect(parseGiteaSourceUrl("https://git.example.com/acme/skills")).toEqual({ + hostname: "git.example.com", + owner: "acme", + repo: "skills", + ref: "main", + basePath: "", + filePath: null, + explicitRef: false, + }); + }); + + it("strips .git suffix", () => { + expect(parseGiteaSourceUrl("https://git.example.com/acme/skills.git").repo).toBe("skills"); + }); + + it("parses a /tree/{ref}/{basePath} URL", () => { + expect(parseGiteaSourceUrl("https://git.example.com/acme/skills/tree/dev/skills/web")).toEqual({ + hostname: "git.example.com", + owner: "acme", + repo: "skills", + ref: "dev", + basePath: "skills/web", + filePath: null, + explicitRef: true, + }); + }); + + it("parses a /blob/{ref}/{filePath} URL", () => { + expect(parseGiteaSourceUrl("https://git.example.com/acme/skills/blob/main/SKILL.md")).toEqual({ + hostname: "git.example.com", + owner: "acme", + repo: "skills", + ref: "main", + basePath: ".", + filePath: "SKILL.md", + explicitRef: true, + }); + }); + + it("rejects non-HTTPS URLs", () => { + expect(() => parseGiteaSourceUrl("http://git.example.com/acme/skills")).toThrow(/HTTPS/); + }); + + it("rejects URLs with fewer than 2 path segments", () => { + expect(() => parseGiteaSourceUrl("https://git.example.com/acme")).toThrow(/Invalid Gitea URL/); + }); +}); + +describe("probeGiteaHost", () => { + beforeEach(() => { + giteaHostProbeCache.clear(); + }); + + it("returns true when /api/v1/version returns Gitea-shaped JSON", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({ version: "1.21.0" })), + ); + const result = await probeGiteaHost("git.example.com"); + expect(result).toBe(true); + }); + + it("returns false on 200 with non-JSON body", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: true, + status: 200, + text: () => Promise.resolve("not gitea"), + json: () => Promise.reject(new Error("not json")), + } as unknown as Response), + ); + const result = await probeGiteaHost("git.example.com"); + expect(result).toBe(false); + }); + + it("returns false on 404", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({}, { status: 404, ok: false })), + ); + const result = await probeGiteaHost("git.example.com"); + expect(result).toBe(false); + }); + + it("returns false on network error", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("ECONNREFUSED"))); + const result = await probeGiteaHost("git.example.com"); + expect(result).toBe(false); + }); + + it("caches positive and negative results", async () => { + const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ version: "1.21.0" })); + vi.stubGlobal("fetch", fetchMock); + await probeGiteaHost("git.example.com"); + await probeGiteaHost("git.example.com"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("reuses a manually-set cache entry without hitting fetch", async () => { + setGiteaHostProbe("git.example.com", true); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const result = await probeGiteaHost("git.example.com"); + expect(result).toBe(true); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("resolveGiteaPinnedRef", () => { + beforeEach(() => { + giteaHostProbeCache.clear(); + }); + + it("returns the 40-hex ref as the pinned ref", async () => { + const sha = "0123456789abcdef0123456789abcdef01234567"; + vi.stubGlobal("fetch", vi.fn()); + const result = await resolveGiteaPinnedRef( + parseGiteaSourceUrl(`https://git.example.com/acme/skills/tree/${sha}`), + ); + expect(result).toEqual({ pinnedRef: sha, trackingRef: sha }); + }); + + it("resolves a branch ref to its commit SHA via the commits endpoint", async () => { + const branchSha = "fedcba9876543210fedcba9876543210fedcba98"; + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse({ default_branch: "main" })) + .mockResolvedValueOnce(jsonResponse({ sha: branchSha })); + vi.stubGlobal("fetch", fetchMock); + const result = await resolveGiteaPinnedRef( + parseGiteaSourceUrl("https://git.example.com/acme/skills"), + ); + expect(result).toEqual({ pinnedRef: branchSha, trackingRef: "main" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe("resolveGiteaCommitSha", () => { + it("returns the sha from a commit response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(jsonResponse({ sha: "abc123abc123abc123abc123abc123abc123abcd" })), + ); + const sha = await resolveGiteaCommitSha("acme", "skills", "main", giteaApiBase("git.example.com")); + expect(sha).toBe("abc123abc123abc123abc123abc123abc123abcd"); + }); +}); + +describe("fetchGiteaTreeBlobPaths", () => { + it("returns blob paths from a single-page tree", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ + tree: [ + { path: "README.md", type: "blob" }, + { path: "skills", type: "tree" }, + { path: "skills/web/SKILL.md", type: "blob" }, + { path: "skills/cli/SKILL.md", type: "blob" }, + ], + truncated: false, + }), + ), + ); + const paths = await fetchGiteaTreeBlobPaths(giteaApiBase("git.example.com"), "acme", "skills", "main"); + expect(paths).toEqual(["README.md", "skills/web/SKILL.md", "skills/cli/SKILL.md"]); + }); + + it("paginates when the tree is truncated", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + tree: [{ path: "a.md", type: "blob" }], + truncated: true, + }), + ) + .mockResolvedValueOnce( + jsonResponse({ + tree: [{ path: "b.md", type: "blob" }], + truncated: false, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const paths = await fetchGiteaTreeBlobPaths(giteaApiBase("git.example.com"), "acme", "skills", "main"); + expect(paths).toEqual(["a.md", "b.md"]); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[1]?.[0])).toContain("page=2"); + }); +}); + +describe("fetchGiteaText", () => { + it("returns the body from the canonical URL on 200", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(textResponse("---\nname: x\n---\n# body\n"))); + const content = await fetchGiteaText("git.example.com", "acme", "skills", "main", "SKILL.md"); + expect(content).toContain("# body"); + }); + + it("falls back to the legacy /raw/ URL on 404", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(textResponse("", { status: 404, ok: false })) + .mockResolvedValueOnce(textResponse("legacy body")); + vi.stubGlobal("fetch", fetchMock); + const content = await fetchGiteaText("git.example.com", "acme", "skills", "main", "SKILL.md"); + expect(content).toBe("legacy body"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/raw/branch/"); + expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/raw/main/"); + }); + + it("throws unprocessable when both URLs fail with non-404", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(textResponse("", { status: 500, ok: false })); + vi.stubGlobal("fetch", fetchMock); + await expect( + fetchGiteaText("git.example.com", "acme", "skills", "main", "SKILL.md"), + ).rejects.toThrow(/500/); + }); +}); + +describe("fetchGiteaBranch", () => { + it("returns the branch response", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + jsonResponse({ name: "main", commit: { id: "deadbeef".repeat(5) } }), + ), + ); + const branch = await fetchGiteaBranch(giteaApiBase("git.example.com"), "acme", "skills", "main"); + expect(branch.commit?.id).toBe("deadbeef".repeat(5)); + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); diff --git a/server/src/services/company-export-readme.ts b/server/src/services/company-export-readme.ts index df8766e1..30888ced 100644 --- a/server/src/services/company-export-readme.ts +++ b/server/src/services/company-export-readme.ts @@ -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; diff --git a/server/src/services/company-portability.ts b/server/src/services/company-portability.ts index 8646f664..49544b21 100644 --- a/server/src/services/company-portability.ts +++ b/server/src/services/company-portability.ts @@ -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) { diff --git a/server/src/services/company-skills.ts b/server/src/services/company-skills.ts index d78d274f..33dea1d1 100644 --- a/server/src/services/company-skills.ts +++ b/server/src/services/company-skills.ts @@ -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"); diff --git a/server/src/services/feedback.ts b/server/src/services/feedback.ts index 4df0ca62..726c66d7 100644 --- a/server/src/services/feedback.ts +++ b/server/src/services/feedback.ts @@ -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, diff --git a/server/src/services/gitea-fetch.ts b/server/src/services/gitea-fetch.ts new file mode 100644 index 00000000..fd9a6a1e --- /dev/null +++ b/server/src/services/gitea-fetch.ts @@ -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(); + +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 { + 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`, + ); + } +} diff --git a/server/src/services/gitea-skills.ts b/server/src/services/gitea-skills.ts new file mode 100644 index 00000000..294b36f4 --- /dev/null +++ b/server/src/services/gitea-skills.ts @@ -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 { + 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 { + 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 { + const response = await fetchGiteaJson(`${apiBase}/repos/${owner}/${repo}`); + return asString(response.default_branch) ?? "main"; +} + +export async function resolveGiteaCommitSha( + owner: string, + repo: string, + ref: string, + apiBase: string, +): Promise { + const response = await fetchGiteaJson( + `${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 { + 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(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 { + 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 { + return fetchGiteaJson( + `${apiBase}/repos/${owner}/${repo}/branches/${encodeURIComponent(branch)}`, + ); +} + +export async function fetchGiteaJson(url: string): Promise { + 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; +} diff --git a/ui/src/pages/CompanySkills.tsx b/ui/src/pages/CompanySkills.tsx index ff2d3095..7909792b 100644 --- a/ui/src/pages/CompanySkills.tsx +++ b/ui/src/pages/CompanySkills.tsx @@ -86,6 +86,7 @@ import { RefreshCw, Save, Search, + Server, ShieldCheck, Trash2, Users, @@ -192,6 +193,8 @@ function sourceMeta(sourceBadge: CompanySkillSourceBadge, sourceLabel: string | return isSkillsShManaged ? { icon: VercelMark, label: sourceLabel ?? "skills.sh", managedLabel: "skills.sh managed" } : { icon: Github, label: sourceLabel ?? "GitHub", managedLabel: "GitHub managed" }; + case "gitea": + return { icon: Server, label: sourceLabel ?? "Gitea", managedLabel: "Gitea managed" }; case "url": return { icon: Link2, label: sourceLabel ?? "URL", managedLabel: "URL managed" }; case "local": @@ -329,7 +332,7 @@ function classifySource(skill: { if (kind === "optional") return "optional"; return "company"; } - if (skill.sourceBadge === "github" || skill.sourceBadge === "skills_sh" || skill.sourceBadge === "url" || skill.sourceBadge === "local") { + if (skill.sourceBadge === "github" || skill.sourceBadge === "skills_sh" || skill.sourceBadge === "gitea" || skill.sourceBadge === "url" || skill.sourceBadge === "local") { return "external"; } return "company"; @@ -1512,7 +1515,7 @@ function SkillPane({ )} - {detail.sourceType === "github" && ( + {(detail.sourceType === "github" || detail.sourceType === "gitea") && (
Pin {currentPin ?? "untracked"} @@ -1802,7 +1805,7 @@ export function CompanySkills() { enabled: Boolean( selectedCompanyId && selectedSkillId - && (detailQuery.data?.sourceType === "github" || displayedDetail?.sourceType === "github"), + && (detailQuery.data?.sourceType === "github" || detailQuery.data?.sourceType === "gitea" || displayedDetail?.sourceType === "github" || displayedDetail?.sourceType === "gitea"), ), staleTime: 60_000, }); @@ -2297,7 +2300,7 @@ export function CompanySkills() { Add a skill source - Paste a local path, GitHub URL, or `skills.sh` command into the field first. + Paste a local path, GitHub or Gitea/Forgejo URL, or `skills.sh` command into the field first.
@@ -2437,7 +2440,7 @@ export function CompanySkills() { setSource(event.target.value)} - placeholder="Paste path, GitHub URL, or skills.sh command" + placeholder="Paste path, GitHub or Gitea/Forgejo URL, or skills.sh command" className="w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground" />