Implement one-way Paperclip to Forgejo issue sync
This commit is contained in:
parent
471520e6b3
commit
b0c38705ce
12 changed files with 746 additions and 248 deletions
228
tests/paperclip-issue-sync.spec.ts
Normal file
228
tests/paperclip-issue-sync.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue