Implement one-way Paperclip to Forgejo issue sync

This commit is contained in:
Paperclip Bot 2026-06-02 07:00:21 +00:00
parent 471520e6b3
commit b0c38705ce
12 changed files with 746 additions and 248 deletions

View file

@ -0,0 +1,228 @@
import { describe, expect, it, vi } from "vitest";
import type { Issue } from "@paperclipai/shared";
import { ATTACHMENT_NOTE } from "../src/constants.js";
import {
buildForgejoIssuePayload,
DEFAULT_SYNC_LABEL,
isIssueSelected,
syncIssueToForgejo
} from "../src/paperclip-issue-sync.js";
function buildIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
companyId: "company-1",
projectId: null,
projectWorkspaceId: null,
goalId: null,
parentId: null,
title: "Fix Forgejo sync",
description: "See attached screenshot for the exact failure.",
status: "in_progress",
workMode: "standard",
priority: "medium",
assigneeAgentId: null,
assigneeUserId: null,
checkoutRunId: null,
executionRunId: null,
executionAgentNameKey: null,
executionLockedAt: null,
createdByAgentId: null,
createdByUserId: null,
issueNumber: 17,
identifier: "PRIA-17",
requestDepth: 0,
billingCode: null,
assigneeAdapterOverrides: null,
executionWorkspaceId: null,
executionWorkspacePreference: null,
executionWorkspaceSettings: null,
startedAt: null,
completedAt: null,
cancelledAt: null,
hiddenAt: null,
labels: [
{
id: "label-1",
companyId: "company-1",
name: DEFAULT_SYNC_LABEL,
color: "#000000",
createdAt: new Date("2026-06-02T00:00:00Z"),
updatedAt: new Date("2026-06-02T00:00:00Z")
}
],
blockedBy: [],
blocks: [],
project: {
id: "project-1",
companyId: "company-1",
name: "Forgejo Issue Sync Plugin",
description: null,
status: "planned",
primaryGoalId: null,
createdAt: new Date("2026-06-02T00:00:00Z"),
updatedAt: new Date("2026-06-02T00:00:00Z")
},
goal: null,
currentExecutionWorkspace: null,
mentionedProjects: [],
myLastTouchAt: null,
lastExternalCommentAt: null,
lastActivityAt: null,
isUnreadForMe: false,
createdAt: new Date("2026-06-02T00:00:00Z"),
updatedAt: new Date("2026-06-02T00:00:00Z"),
...overrides
} as Issue;
}
describe("paperclip issue sync", () => {
it("selects issues by the configured sync label", () => {
const issue = buildIssue();
expect(isIssueSelected(issue, {})).toBe(true);
expect(isIssueSelected(issue, { syncIssueLabel: "custom-sync" })).toBe(false);
});
it("builds a Forgejo issue body with metadata-only attachments", () => {
const issue = buildIssue({
workProducts: [
{
id: "wp-1",
companyId: "company-1",
projectId: null,
issueId: "issue-1",
executionWorkspaceId: null,
runtimeServiceId: null,
type: "artifact",
provider: "paperclip",
externalId: "attachment-1",
title: "trace.log",
url: "https://paperclip.example/artifacts/trace.log",
status: "ready_for_review",
reviewState: "none",
isPrimary: false,
healthStatus: "healthy",
summary: null,
metadata: {
attachmentId: "attachment-1",
contentType: "text/plain",
byteSize: 2048,
originalFilename: "trace.log"
},
createdByRunId: null,
createdAt: new Date("2026-06-02T00:00:00Z"),
updatedAt: new Date("2026-06-02T00:00:00Z")
}
]
} as Partial<Issue>);
const payload = buildForgejoIssuePayload(issue);
expect(payload.title).toContain("[PRIA-17]");
expect(payload.body).toContain(ATTACHMENT_NOTE);
expect(payload.body).toContain("trace.log | text/plain | 2.0 KiB");
expect(payload.body).toContain("<!-- paperclip-sync:company-1:issue-1 -->");
});
it("creates a Forgejo issue once and queues manual review when attachment context is required", async () => {
const issue = buildIssue({
workProducts: [
{
id: "wp-1",
companyId: "company-1",
projectId: null,
issueId: "issue-1",
executionWorkspaceId: null,
runtimeServiceId: null,
type: "artifact",
provider: "paperclip",
externalId: "attachment-1",
title: "screenshot.png",
url: "https://paperclip.example/artifacts/screenshot.png",
status: "ready_for_review",
reviewState: "none",
isPrimary: false,
healthStatus: "healthy",
summary: null,
metadata: {
attachmentId: "attachment-1",
contentType: "image/png",
byteSize: 1024,
originalFilename: "screenshot.png"
},
createdByRunId: null,
createdAt: new Date("2026-06-02T00:00:00Z"),
updatedAt: new Date("2026-06-02T00:00:00Z")
}
]
} as Partial<Issue>);
const activityLog = vi.fn(async () => undefined);
const reserve = vi.fn(async () => ({ kind: "reserved" as const }));
const createRemoteIssue = vi.fn(async () => ({
id: 101,
number: 33,
url: "https://forgejo.example/acme/repo/issues/33",
apiUrl: "https://forgejo.example/api/v1/repos/acme/repo/issues/33"
}));
const complete = vi.fn(async () => undefined);
const fail = vi.fn(async () => undefined);
const queueManualReview = vi.fn(async () => undefined);
const result = await syncIssueToForgejo(
{
config: { get: async () => ({ forgejoOwner: "acme", forgejoRepo: "repo" }) },
activity: { log: activityLog }
} as never,
issue,
{ reserve, createRemoteIssue, complete, fail, queueManualReview }
);
expect(result).toBe("created");
expect(createRemoteIssue).toHaveBeenCalledOnce();
expect(complete).toHaveBeenCalledWith(expect.anything(), "company-1", "issue-1", expect.objectContaining({ number: 33 }));
expect(queueManualReview).toHaveBeenCalledOnce();
expect(activityLog).toHaveBeenCalledWith(expect.objectContaining({
message: "Created Forgejo issue #33 for PRIA-17."
}));
expect(fail).not.toHaveBeenCalled();
});
it("skips remote creation when a synced mapping already exists", async () => {
const issue = buildIssue();
const createRemoteIssue = vi.fn();
const result = await syncIssueToForgejo(
{
config: { get: async () => ({ forgejoOwner: "acme", forgejoRepo: "repo" }) },
activity: { log: vi.fn(async () => undefined) }
} as never,
issue,
{
reserve: async () => ({
kind: "existing",
mapping: {
companyId: "company-1",
paperclipIssueId: "issue-1",
repoOwner: "acme",
repoName: "repo",
dedupeKey: "paperclip-issue:company-1:issue-1",
sourceTitle: "[PRIA-17] Fix Forgejo sync",
sourceBody: "body",
attachmentMetadata: [],
manualReviewRequired: false,
reviewReasonCode: null,
forgejoIssueId: 101,
forgejoIssueNumber: 33,
forgejoIssueUrl: "https://forgejo.example/acme/repo/issues/33",
forgejoApiUrl: "https://forgejo.example/api/v1/repos/acme/repo/issues/33",
syncStatus: "synced",
lastError: null
}
}),
createRemoteIssue
}
);
expect(result).toBe("existing");
expect(createRemoteIssue).not.toHaveBeenCalled();
});
});