Preserve Forgejo mappings across retry failures

This commit is contained in:
Paperclip Bot 2026-06-02 08:01:26 +00:00
parent b0c38705ce
commit fa24063483
5 changed files with 291 additions and 14 deletions

View file

@ -121,6 +121,7 @@ describe("paperclip issue sync", () => {
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 () => {
@ -163,8 +164,10 @@ describe("paperclip issue sync", () => {
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(
@ -173,22 +176,25 @@ describe("paperclip issue sync", () => {
activity: { log: activityLog }
} as never,
issue,
{ reserve, createRemoteIssue, complete, fail, queueManualReview }
{ reserve, createRemoteIssue, recordRemote, complete, fail, failAfterRemote, queueManualReview }
);
expect(result).toBe("created");
expect(createRemoteIssue).toHaveBeenCalledOnce();
expect(complete).toHaveBeenCalledWith(expect.anything(), "company-1", "issue-1", expect.objectContaining({ number: 33 }));
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(
{
@ -218,11 +224,105 @@ describe("paperclip issue sync", () => {
lastError: null
}
}),
createRemoteIssue
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();
});
});