Files
paperclip/server/src/__tests__/company-skills-service.test.ts
T
Chris Farhood e559218f98 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>
2026-06-09 15:30:44 -04:00

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",
);
});
});