import { readFileSync } from "node:fs"; import { resolve } from "node:path"; 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 { 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; } function readExampleIssue(name: string): Issue { const filePath = resolve(import.meta.dirname, "..", "examples", name); return JSON.parse(readFileSync(filePath, "utf8")) 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); 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(""); expect(payload.body).not.toContain("https://paperclip.example/artifacts/trace.log"); }); 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); 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 recordRemote = vi.fn(async () => undefined); const complete = vi.fn(async () => undefined); const fail = vi.fn(async () => undefined); const failAfterRemote = 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, recordRemote, complete, fail, failAfterRemote, queueManualReview } ); expect(result).toBe("created"); expect(createRemoteIssue).toHaveBeenCalledOnce(); expect(recordRemote).toHaveBeenCalledWith(expect.anything(), "company-1", "issue-1", expect.objectContaining({ number: 33 })); expect(complete).toHaveBeenCalledWith(expect.anything(), "company-1", "issue-1"); expect(queueManualReview).toHaveBeenCalledOnce(); expect(activityLog).toHaveBeenCalledWith(expect.objectContaining({ message: "Created Forgejo issue #33 for PRIA-17." })); expect(fail).not.toHaveBeenCalled(); expect(failAfterRemote).not.toHaveBeenCalled(); }); it("skips remote creation when a synced mapping already exists", async () => { const issue = buildIssue(); const createRemoteIssue = vi.fn(); const complete = vi.fn(async () => undefined); 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, complete, failAfterRemote: vi.fn(async () => undefined) } ); expect(result).toBe("existing"); expect(createRemoteIssue).not.toHaveBeenCalled(); expect(complete).toHaveBeenCalledOnce(); }); it("does not create a duplicate Forgejo issue after remote success and local mapping failure", async () => { const issue = buildIssue(); const createdRemoteIssue = { id: 101, number: 33, url: "https://forgejo.example/acme/repo/issues/33", apiUrl: "https://forgejo.example/api/v1/repos/acme/repo/issues/33" }; const activityLog = vi.fn(async () => undefined); const createRemoteIssue = vi.fn(async () => createdRemoteIssue); const recordRemote = vi.fn(async () => { throw new Error("failed to complete local mapping"); }); const failAfterRemote = vi.fn(async () => undefined); await expect(syncIssueToForgejo( { config: { get: async () => ({ forgejoOwner: "acme", forgejoRepo: "repo" }) }, activity: { log: activityLog } } as never, issue, { reserve: async () => ({ kind: "reserved" as const }), createRemoteIssue, recordRemote, complete: vi.fn(async () => undefined), fail: vi.fn(async () => undefined), failAfterRemote } )).rejects.toThrow("failed to complete local mapping"); expect(createRemoteIssue).toHaveBeenCalledTimes(1); expect(failAfterRemote).toHaveBeenCalledWith( expect.anything(), "company-1", "issue-1", expect.objectContaining({ number: 33 }), "failed to complete local mapping" ); const retryCreateRemoteIssue = vi.fn(async () => { throw new Error("should not create a duplicate remote issue"); }); const retryRecordRemote = vi.fn(async () => undefined); const retryQueueManualReview = vi.fn(async () => undefined); const retryComplete = vi.fn(async () => undefined); const result = await syncIssueToForgejo( { config: { get: async () => ({ forgejoOwner: "acme", forgejoRepo: "repo" }) }, activity: { log: activityLog } } as never, issue, { reserve: async () => ({ kind: "existing" as const, 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: "remote_created", lastError: "failed to complete local mapping" } }), createRemoteIssue: retryCreateRemoteIssue, recordRemote: retryRecordRemote, complete: retryComplete, fail: vi.fn(async () => undefined), failAfterRemote: vi.fn(async () => undefined), queueManualReview: retryQueueManualReview } ); expect(result).toBe("existing"); expect(retryCreateRemoteIssue).not.toHaveBeenCalled(); expect(retryRecordRemote).not.toHaveBeenCalled(); expect(retryComplete).toHaveBeenCalledOnce(); expect(retryQueueManualReview).not.toHaveBeenCalled(); }); it("reproduces the synthetic dry-run payload and reuses the stored mapping on retry", async () => { const issue = readExampleIssue("first-dry-run-paperclip-issue.json"); const payload = buildForgejoIssuePayload(issue); const createdRemoteIssue = { id: 401, number: 77, url: "https://forgejo.example/acme/repo/issues/77", apiUrl: "https://forgejo.example/api/v1/repos/acme/repo/issues/77" }; const createRemoteIssue = vi.fn(async () => createdRemoteIssue); const complete = vi.fn(async () => undefined); expect(payload.title).toBe("[PRIA-DRY-1] Synthetic dry-run: reproduce outbound issue creation"); expect(payload.body).toContain("A controlled dry-run payload for the first Forgejo sync rehearsal."); expect(payload.body).toContain(ATTACHMENT_NOTE); expect(payload.body).toContain("trace.log | text/plain | 2.0 KiB"); expect(payload.body).toContain(""); const firstResult = await syncIssueToForgejo( { config: { get: async () => ({ forgejoOwner: "acme", forgejoRepo: "repo" }) }, activity: { log: vi.fn(async () => undefined) } } as never, issue, { reserve: async () => ({ kind: "reserved" as const }), createRemoteIssue, recordRemote: vi.fn(async () => undefined), complete, fail: vi.fn(async () => undefined), failAfterRemote: vi.fn(async () => undefined), queueManualReview: vi.fn(async () => undefined) } ); const retryResult = await syncIssueToForgejo( { config: { get: async () => ({ forgejoOwner: "acme", forgejoRepo: "repo" }) }, activity: { log: vi.fn(async () => undefined) } } as never, issue, { reserve: async () => ({ kind: "existing" as const, mapping: { companyId: "company-dry-run", paperclipIssueId: "issue-dry-run-1", repoOwner: "acme", repoName: "repo", dedupeKey: "paperclip-issue:company-dry-run:issue-dry-run-1", sourceTitle: payload.title, sourceBody: payload.body, attachmentMetadata: [], manualReviewRequired: false, reviewReasonCode: null, forgejoIssueId: createdRemoteIssue.id, forgejoIssueNumber: createdRemoteIssue.number, forgejoIssueUrl: createdRemoteIssue.url, forgejoApiUrl: createdRemoteIssue.apiUrl, syncStatus: "synced", lastError: null } }), createRemoteIssue, complete, fail: vi.fn(async () => undefined), failAfterRemote: vi.fn(async () => undefined), queueManualReview: vi.fn(async () => undefined) } ); expect(firstResult).toBe("created"); expect(retryResult).toBe("existing"); expect(createRemoteIssue).toHaveBeenCalledTimes(1); expect(complete).toHaveBeenCalledTimes(2); }); });