forked from farhoodlabs/paperclip
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,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("<html>not gitea</html>"),
|
||||
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();
|
||||
});
|
||||
Reference in New Issue
Block a user