paperclip-forgejo-issue-plugin/tests/paperclip-issue-sync.spec.ts

413 lines
14 KiB
TypeScript
Raw Normal View History

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> = {}): 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<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 -->");
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<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 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("<!-- paperclip-sync:company-dry-run:issue-dry-run-1 -->");
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);
});
});