412 lines
14 KiB
TypeScript
412 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|