229 lines
7.3 KiB
TypeScript
229 lines
7.3 KiB
TypeScript
|
|
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();
|
||
|
|
});
|
||
|
|
});
|