forked from farhoodlabs/paperclip
e559218f98
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>
433 lines
15 KiB
TypeScript
433 lines
15 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { promises as fs } from "node:fs";
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
|
import { agents, companies, companySkills, createDb } from "@paperclipai/db";
|
|
import {
|
|
getEmbeddedPostgresTestSupport,
|
|
startEmbeddedPostgresTestDatabase,
|
|
} from "./helpers/embedded-postgres.js";
|
|
import { companySkillService } from "../services/company-skills.ts";
|
|
|
|
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
|
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
|
|
|
if (!embeddedPostgresSupport.supported) {
|
|
console.warn(
|
|
`Skipping embedded Postgres company skill service tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
|
);
|
|
}
|
|
|
|
describeEmbeddedPostgres("companySkillService.list", () => {
|
|
let db!: ReturnType<typeof createDb>;
|
|
let svc!: ReturnType<typeof companySkillService>;
|
|
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
|
let oldPaperclipHome: string | undefined;
|
|
let paperclipHome: string | null = null;
|
|
const cleanupDirs = new Set<string>();
|
|
|
|
beforeAll(async () => {
|
|
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-company-skills-service-");
|
|
oldPaperclipHome = process.env.PAPERCLIP_HOME;
|
|
paperclipHome = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-company-skills-home-"));
|
|
process.env.PAPERCLIP_HOME = paperclipHome;
|
|
db = createDb(tempDb.connectionString);
|
|
svc = companySkillService(db);
|
|
}, 20_000);
|
|
|
|
afterEach(async () => {
|
|
await db.delete(agents);
|
|
await db.delete(companySkills);
|
|
await db.delete(companies);
|
|
await Promise.all(Array.from(cleanupDirs, (dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
cleanupDirs.clear();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
if (oldPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
|
|
else process.env.PAPERCLIP_HOME = oldPaperclipHome;
|
|
if (paperclipHome) {
|
|
await fs.rm(paperclipHome, { recursive: true, force: true });
|
|
}
|
|
await tempDb?.cleanup();
|
|
});
|
|
|
|
it("lists skills without exposing markdown content", async () => {
|
|
const companyId = randomUUID();
|
|
const skillId = randomUUID();
|
|
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-heavy-skill-"));
|
|
cleanupDirs.add(skillDir);
|
|
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Heavy Skill\n", "utf8");
|
|
|
|
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: `company/${companyId}/heavy-skill`,
|
|
slug: "heavy-skill",
|
|
name: "Heavy Skill",
|
|
description: "Large skill used for list projection regression coverage.",
|
|
markdown: `# Heavy Skill\n\n${"x".repeat(250_000)}`,
|
|
sourceType: "local_path",
|
|
sourceLocator: skillDir,
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
|
metadata: { sourceKind: "local_path" },
|
|
});
|
|
|
|
const listed = await svc.list(companyId);
|
|
const skill = listed.find((entry) => entry.id === skillId);
|
|
|
|
expect(skill).toBeDefined();
|
|
expect(skill).not.toHaveProperty("markdown");
|
|
expect(skill).toMatchObject({
|
|
id: skillId,
|
|
key: `company/${companyId}/heavy-skill`,
|
|
slug: "heavy-skill",
|
|
name: "Heavy Skill",
|
|
sourceType: "local_path",
|
|
sourceLocator: skillDir,
|
|
attachedAgentCount: 0,
|
|
sourceBadge: "local",
|
|
editable: true,
|
|
});
|
|
});
|
|
|
|
it("rejects skill inventory refresh for a missing company", async () => {
|
|
await expect(svc.list(randomUUID())).rejects.toMatchObject({
|
|
status: 404,
|
|
message: "Company not found",
|
|
});
|
|
});
|
|
|
|
it("does not persist audit failures for remote-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: "github.com/acme/remote-skill",
|
|
slug: "remote-skill",
|
|
name: "Remote Skill",
|
|
description: null,
|
|
markdown: "# Remote Skill\n",
|
|
sourceType: "github",
|
|
sourceLocator: "https://github.com/acme/remote-skill",
|
|
sourceRef: "main",
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
|
metadata: { sourceKind: "github", 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: "github", owner: "acme", repo: "remote-skill" },
|
|
});
|
|
});
|
|
|
|
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();
|
|
const skillKey = `company/${companyId}/reflection-coach`;
|
|
const missingSkillDir = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-missing-used-skill-")), "gone");
|
|
cleanupDirs.add(path.dirname(missingSkillDir));
|
|
|
|
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: skillKey,
|
|
slug: "reflection-coach",
|
|
name: "Reflection Coach",
|
|
description: null,
|
|
markdown: "# Reflection Coach\n",
|
|
sourceType: "local_path",
|
|
sourceLocator: missingSkillDir,
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
|
metadata: { sourceKind: "local_path" },
|
|
});
|
|
await db.insert(agents).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
name: "Reviewer",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {
|
|
paperclipSkillSync: {
|
|
desiredSkills: [skillKey],
|
|
},
|
|
},
|
|
});
|
|
|
|
const listed = await svc.list(companyId);
|
|
const listedSkill = listed.find((skill) => skill.id === skillId);
|
|
const detail = await svc.detail(companyId, skillId);
|
|
const stored = await svc.getById(companyId, skillId);
|
|
const marker = stored?.metadata?.missingSource;
|
|
|
|
expect(listedSkill).toMatchObject({
|
|
id: skillId,
|
|
attachedAgentCount: 1,
|
|
});
|
|
expect(detail?.usedByAgents).toEqual([
|
|
expect.objectContaining({
|
|
name: "Reviewer",
|
|
desired: true,
|
|
}),
|
|
]);
|
|
expect(marker).toMatchObject({
|
|
reason: "local_source_missing",
|
|
sourceType: "local_path",
|
|
sourceLocator: missingSkillDir,
|
|
sourcePath: missingSkillDir,
|
|
});
|
|
expect(Number.isNaN(Date.parse(String((marker as Record<string, unknown>).detectedAt)))).toBe(false);
|
|
});
|
|
|
|
it("continues pruning missing local-path skills that no active agent desires", async () => {
|
|
const companyId = randomUUID();
|
|
const skillId = randomUUID();
|
|
const missingSkillDir = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-missing-unused-skill-")), "gone");
|
|
cleanupDirs.add(path.dirname(missingSkillDir));
|
|
|
|
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: `company/${companyId}/unused-skill`,
|
|
slug: "unused-skill",
|
|
name: "Unused Skill",
|
|
description: null,
|
|
markdown: "# Unused Skill\n",
|
|
sourceType: "local_path",
|
|
sourceLocator: missingSkillDir,
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
|
metadata: { sourceKind: "local_path" },
|
|
});
|
|
|
|
const listed = await svc.list(companyId);
|
|
|
|
expect(listed.find((skill) => skill.id === skillId)).toBeUndefined();
|
|
await expect(svc.getById(companyId, skillId)).resolves.toBeNull();
|
|
});
|
|
|
|
it("clears the missing-source marker when a local-path skill source returns", async () => {
|
|
const companyId = randomUUID();
|
|
const skillId = randomUUID();
|
|
const skillDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-restored-skill-"));
|
|
cleanupDirs.add(skillDir);
|
|
await fs.writeFile(path.join(skillDir, "SKILL.md"), "# Restored Skill\n", "utf8");
|
|
|
|
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: `company/${companyId}/restored-skill`,
|
|
slug: "restored-skill",
|
|
name: "Restored Skill",
|
|
description: null,
|
|
markdown: "# Restored Skill\n",
|
|
sourceType: "local_path",
|
|
sourceLocator: skillDir,
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
|
metadata: {
|
|
sourceKind: "local_path",
|
|
missingSource: {
|
|
reason: "local_source_missing",
|
|
sourceType: "local_path",
|
|
sourceLocator: skillDir,
|
|
sourcePath: skillDir,
|
|
detectedAt: "2026-05-28T00:00:00.000Z",
|
|
},
|
|
},
|
|
});
|
|
|
|
await svc.list(companyId);
|
|
const stored = await svc.getById(companyId, skillId);
|
|
|
|
expect(stored?.metadata).toEqual({ sourceKind: "local_path" });
|
|
});
|
|
|
|
it("marks source-missing company skills as unavailable during read-only runtime listing", async () => {
|
|
const companyId = randomUUID();
|
|
const skillId = randomUUID();
|
|
const skillKey = `company/${companyId}/reflection-coach`;
|
|
const missingSkillDir = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-readonly-missing-skill-")), "gone");
|
|
cleanupDirs.add(path.dirname(missingSkillDir));
|
|
|
|
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: skillKey,
|
|
slug: "reflection-coach",
|
|
name: "Reflection Coach",
|
|
description: null,
|
|
markdown: "# Reflection Coach\n",
|
|
sourceType: "local_path",
|
|
sourceLocator: missingSkillDir,
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
|
metadata: { sourceKind: "local_path" },
|
|
});
|
|
await db.insert(agents).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
name: "Reviewer",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {
|
|
paperclipSkillSync: {
|
|
desiredSkills: [skillKey],
|
|
},
|
|
},
|
|
});
|
|
|
|
const entries = await svc.listRuntimeSkillEntries(companyId, { materializeMissing: false });
|
|
const entry = entries.find((candidate) => candidate.key === skillKey);
|
|
|
|
expect(entry).toMatchObject({
|
|
key: skillKey,
|
|
sourceStatus: "missing",
|
|
missingDetail: expect.stringContaining(missingSkillDir),
|
|
});
|
|
await expect(fs.stat(entry!.source)).rejects.toMatchObject({ code: "ENOENT" });
|
|
});
|
|
|
|
it("materializes source-missing company skills from the stored markdown during runtime listing", async () => {
|
|
const companyId = randomUUID();
|
|
const skillId = randomUUID();
|
|
const skillKey = `company/${companyId}/runtime-coach`;
|
|
const missingSkillDir = path.join(await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-runtime-missing-skill-")), "gone");
|
|
cleanupDirs.add(path.dirname(missingSkillDir));
|
|
|
|
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: skillKey,
|
|
slug: "runtime-coach",
|
|
name: "Runtime Coach",
|
|
description: null,
|
|
markdown: "# Runtime Coach\n\nRecovered from DB.\n",
|
|
sourceType: "local_path",
|
|
sourceLocator: missingSkillDir,
|
|
trustLevel: "markdown_only",
|
|
compatibility: "compatible",
|
|
fileInventory: [{ path: "SKILL.md", kind: "skill" }],
|
|
metadata: { sourceKind: "local_path" },
|
|
});
|
|
await db.insert(agents).values({
|
|
id: randomUUID(),
|
|
companyId,
|
|
name: "Runner",
|
|
role: "engineer",
|
|
status: "active",
|
|
adapterType: "codex_local",
|
|
adapterConfig: {
|
|
paperclipSkillSync: {
|
|
desiredSkills: [skillKey],
|
|
},
|
|
},
|
|
});
|
|
|
|
const entries = await svc.listRuntimeSkillEntries(companyId);
|
|
const entry = entries.find((candidate) => candidate.key === skillKey);
|
|
|
|
expect(entry).toMatchObject({
|
|
key: skillKey,
|
|
sourceStatus: "available",
|
|
});
|
|
await expect(fs.readFile(path.join(entry!.source, "SKILL.md"), "utf8")).resolves.toBe(
|
|
"# Runtime Coach\n\nRecovered from DB.\n",
|
|
);
|
|
});
|
|
});
|