Scaffold Forgejo issue sync plugin

This commit is contained in:
Paperclip Bot 2026-06-02 02:57:49 +00:00
commit 471520e6b3
21 changed files with 1970 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
dist/
node_modules/

Binary file not shown.

Binary file not shown.

40
README.md Normal file
View file

@ -0,0 +1,40 @@
# Forgejo Issue Sync Plugin
Scaffold for a Paperclip plugin that will sync Forgejo issues/comments while enforcing the v1 policy from `PRIA-13`:
- webhook intake stays inside the plugin worker
- scheduled reconciliation stays in plugin `jobs`
- mappings, dedupe, and review state stay in the plugin database/state
- attachment handling is metadata-only
- no managed agents or managed skills are declared in v1
## Layout
- `src/manifest.ts`: manifest, capabilities, jobs, webhook declaration, instance config schema
- `src/worker.ts`: plugin bootstrap, health, config validation, data/action registration
- `src/webhook-intake.ts`: webhook verification, normalization, dedupe recording, manual-review queueing
- `src/reconciliation.ts`: scheduled reconciliation job and instance-level last-run state
- `src/persistence.ts`: namespace-local persistence helpers for mappings, deliveries, reviews, and run snapshots
- `src/attachment-policy.ts`: metadata-only attachment policy and synced markdown formatter
- `migrations/001_initial.sql`: plugin-owned tables for mappings, dedupe, review queue, and reconciliation history
## Attachment Policy
This scaffold deliberately does not fetch attachment bytes and does not add any path that calls `/api/attachments/{id}/content`.
Instead it:
- preserves attachment metadata only
- appends `Attachments are not synced. See the source Paperclip issue.` to generated sync content
- stores a machine-readable review reason code when the issue/comment text suggests the attachment is required for context
- queues those payloads in `review_queue` for later human-routing work
## Follow-Up Needed
The plugin now emits `attachments_context_required` as a durable review signal, but the human-review destination is still a follow-up decision:
- create Paperclip-visible review issues/comments
- expose a plugin UI or scoped API route for triage
- route review-required payloads into an existing operator workflow
That routing is intentionally left out of this v1 scaffold so the sync runtime stays inside plugin jobs/webhooks/state as requested.

View file

@ -0,0 +1,65 @@
CREATE TABLE forgejo_sync_state (
key text PRIMARY KEY,
value jsonb NOT NULL DEFAULT '{}'::jsonb,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE webhook_deliveries (
request_id text PRIMARY KEY,
delivery_key text NOT NULL,
event_name text NOT NULL,
company_id uuid NOT NULL,
payload jsonb NOT NULL,
status text NOT NULL,
received_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE issue_mappings (
company_id uuid NOT NULL,
source_id text NOT NULL,
dedupe_key text NOT NULL,
title text,
body text NOT NULL,
attachment_metadata jsonb NOT NULL DEFAULT '[]'::jsonb,
manual_review_required boolean NOT NULL DEFAULT false,
review_reason_code text,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (company_id, source_id)
);
CREATE TABLE comment_mappings (
company_id uuid NOT NULL,
source_id text NOT NULL,
dedupe_key text NOT NULL,
title text,
body text NOT NULL,
attachment_metadata jsonb NOT NULL DEFAULT '[]'::jsonb,
manual_review_required boolean NOT NULL DEFAULT false,
review_reason_code text,
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (company_id, source_id)
);
CREATE TABLE review_queue (
company_id uuid NOT NULL,
source_kind text NOT NULL,
source_id text NOT NULL,
dedupe_key text NOT NULL,
review_reason_code text NOT NULL,
review_payload jsonb NOT NULL,
status text NOT NULL DEFAULT 'pending',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (company_id, source_kind, source_id)
);
CREATE TABLE reconciliation_runs (
id bigserial PRIMARY KEY,
completed_at timestamptz NOT NULL,
trigger text NOT NULL,
pending_reviews integer NOT NULL,
pending_deliveries integer NOT NULL,
mapped_issues integer NOT NULL,
mapped_comments integer NOT NULL,
snapshot jsonb NOT NULL
);

34
package.json Normal file
View file

@ -0,0 +1,34 @@
{
"name": "@private-adoption/forgejo-issue-sync-plugin",
"version": "0.1.0",
"type": "module",
"private": true,
"description": "Scaffold for a Paperclip Forgejo issue sync plugin with metadata-only attachment policy.",
"scripts": {
"build": "tsc -p tsconfig.build.json",
"dev": "tsc -p tsconfig.build.json --watch",
"test": "vitest run --config ./vitest.config.ts",
"typecheck": "tsc --noEmit"
},
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js"
},
"keywords": [
"paperclip",
"plugin",
"forgejo",
"connector"
],
"author": "Private Adoption Company",
"license": "MIT",
"dependencies": {
"@paperclipai/plugin-sdk": "file:.paperclip-sdk/paperclipai-plugin-sdk-1.0.0.tgz",
"@paperclipai/shared": "file:.paperclip-sdk/paperclipai-shared-0.3.1.tgz"
},
"devDependencies": {
"@types/node": "^24.6.0",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
}
}

1071
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

2
pnpm-workspace.yaml Normal file
View file

@ -0,0 +1,2 @@
allowBuilds:
esbuild: true

74
src/attachment-policy.ts Normal file
View file

@ -0,0 +1,74 @@
import { ATTACHMENT_CONTEXT_PATTERNS, ATTACHMENT_NOTE, REVIEW_REASON_CODES } from "./constants.js";
import type { AttachmentMetadata, ForgejoSyncContent, ReviewSignal } from "./types.js";
function formatBytes(sizeBytes: number | null): string | null {
if (typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || sizeBytes < 0) return null;
if (sizeBytes < 1024) return `${sizeBytes} B`;
if (sizeBytes < 1024 * 1024) return `${(sizeBytes / 1024).toFixed(1)} KiB`;
return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MiB`;
}
export function assessAttachmentReview(body: string, attachments: AttachmentMetadata[]): ReviewSignal {
const trimmedBody = body.trim();
const reasons: string[] = [];
const attachmentCount = attachments.length;
if (attachmentCount === 0) {
return {
manualReviewRequired: false,
reasonCode: null,
reasons,
attachmentCount
};
}
reasons.push(`attachment metadata present (${attachmentCount})`);
const mentionsAttachmentContext = ATTACHMENT_CONTEXT_PATTERNS.some((pattern) => pattern.test(trimmedBody));
if (mentionsAttachmentContext) {
reasons.push("body indicates the attachment may be required to understand the payload");
}
return {
manualReviewRequired: mentionsAttachmentContext,
reasonCode: mentionsAttachmentContext
? REVIEW_REASON_CODES.attachmentContextRequired
: REVIEW_REASON_CODES.attachmentMetadataPresent,
reasons,
attachmentCount
};
}
export function buildAttachmentMetadataLines(attachments: AttachmentMetadata[]): string[] {
if (attachments.length === 0) return [];
return attachments.map((attachment, index) => {
const parts = [
attachment.filename ?? `attachment-${index + 1}`,
attachment.mimeType ?? null,
formatBytes(attachment.sizeBytes)
].filter((value): value is string => Boolean(value));
return `- ${parts.join(" | ")}`;
});
}
export function buildForgejoSyncContent(body: string, attachments: AttachmentMetadata[]): ForgejoSyncContent {
const reviewSignal = assessAttachmentReview(body, attachments);
const metadataLines = buildAttachmentMetadataLines(attachments);
const attachmentSection = metadataLines.length === 0
? []
: [
"",
"Attachment metadata preserved for manual reference:",
...metadataLines
];
return {
markdown: [
body.trimEnd(),
"",
ATTACHMENT_NOTE,
...attachmentSection
].join("\n").trim(),
attachmentMetadata: attachments,
reviewSignal
};
}

43
src/config.ts Normal file
View file

@ -0,0 +1,43 @@
import type { ForgejoPluginConfig } from "./types.js";
function optionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
}
export function readConfig(raw: Record<string, unknown>): ForgejoPluginConfig {
const lookback = raw.reconciliationLookbackMinutes;
return {
forgejoBaseUrl: optionalString(raw.forgejoBaseUrl),
forgejoTokenRef: optionalString(raw.forgejoTokenRef),
webhookSecretRef: optionalString(raw.webhookSecretRef),
defaultCompanyId: optionalString(raw.defaultCompanyId),
reconciliationLookbackMinutes:
typeof lookback === "number" && Number.isFinite(lookback) && lookback > 0
? Math.floor(lookback)
: undefined
};
}
export function validateConfig(raw: Record<string, unknown>): { ok: boolean; errors: string[]; warnings: string[] } {
const errors: string[] = [];
const warnings: string[] = [];
const config = readConfig(raw);
if (config.forgejoBaseUrl && !/^https?:\/\//.test(config.forgejoBaseUrl)) {
errors.push("forgejoBaseUrl must start with http:// or https://");
}
if (config.webhookSecretRef && !config.forgejoBaseUrl) {
warnings.push("webhookSecretRef is configured before forgejoBaseUrl; webhook auth is ready but outbound sync is not.");
}
if (!config.defaultCompanyId) {
warnings.push("defaultCompanyId is not set; webhook payloads must provide companyId metadata.");
}
return {
ok: errors.length === 0,
errors,
warnings
};
}

26
src/constants.ts Normal file
View file

@ -0,0 +1,26 @@
export const PLUGIN_ID = "private-adoption.forgejo-issue-sync";
export const PLUGIN_VERSION = "0.1.0";
export const JOB_KEYS = {
reconcile: "reconcile-drift"
} as const;
export const WEBHOOK_KEYS = {
forgejo: "forgejo-events"
} as const;
export const ATTACHMENT_NOTE = "Attachments are not synced. See the source Paperclip issue.";
export const REVIEW_REASON_CODES = {
attachmentContextRequired: "attachments_context_required",
attachmentMetadataPresent: "attachment_metadata_present"
} as const;
export const ATTACHMENT_CONTEXT_PATTERNS = [
/\bsee (the )?attach(ed|ment)\b/i,
/\battached (image|file|screenshot|log|trace)\b/i,
/\bscreenshot(s)?\b/i,
/\bstack trace\b/i,
/\bcrash dump\b/i,
/\blog(s)? attached\b/i
];

81
src/manifest.ts Normal file
View file

@ -0,0 +1,81 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
import { JOB_KEYS, PLUGIN_ID, PLUGIN_VERSION, WEBHOOK_KEYS } from "./constants.js";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: PLUGIN_VERSION,
displayName: "Forgejo Issue Sync",
description: "Scaffold for Forgejo issue sync with webhook intake, scheduled reconciliation, and metadata-only attachment handling.",
author: "Private Adoption Company",
categories: ["connector", "automation"],
capabilities: [
"activity.log.write",
"database.namespace.migrate",
"database.namespace.read",
"database.namespace.write",
"http.outbound",
"instance.settings.register",
"jobs.schedule",
"plugin.state.read",
"plugin.state.write",
"secrets.read-ref",
"webhooks.receive"
],
entrypoints: {
worker: "./dist/worker.js"
},
instanceConfigSchema: {
type: "object",
properties: {
forgejoBaseUrl: {
type: "string",
title: "Forgejo Base URL",
description: "Base URL for outbound Forgejo API calls."
},
forgejoTokenRef: {
type: "string",
title: "Forgejo Token Secret Ref",
description: "Secret reference for outbound Forgejo API authentication."
},
webhookSecretRef: {
type: "string",
title: "Webhook Secret Ref",
description: "Secret reference used to verify webhook signatures."
},
defaultCompanyId: {
type: "string",
title: "Default Company ID",
description: "Fallback company used when webhook payloads do not carry explicit Paperclip company metadata."
},
reconciliationLookbackMinutes: {
type: "number",
title: "Reconciliation Lookback Minutes",
default: 60,
minimum: 1,
description: "Default lookback window for drift repair and replay jobs."
}
}
},
database: {
namespaceSlug: "forgejo_issue_sync",
migrationsDir: "migrations"
},
jobs: [
{
jobKey: JOB_KEYS.reconcile,
displayName: "Reconcile Forgejo Sync Drift",
description: "Reconciles stored webhook deliveries, mapping rows, and pending manual-review items.",
schedule: "0 * * * *"
}
],
webhooks: [
{
endpointKey: WEBHOOK_KEYS.forgejo,
displayName: "Forgejo Events",
description: "Receives Forgejo issue and comment webhook deliveries for normalization and dedupe."
}
]
};
export default manifest;

125
src/persistence.ts Normal file
View file

@ -0,0 +1,125 @@
import type { PluginContext } from "@paperclipai/plugin-sdk";
import type { NormalizedSyncCandidate, ReconciliationSnapshot } from "./types.js";
function tableName(ctx: PluginContext, name: string): string {
return `${ctx.db.namespace}.${name}`;
}
export async function recordWebhookDelivery(
ctx: PluginContext,
input: {
requestId: string;
deliveryKey: string;
eventName: string;
companyId: string;
payload: unknown;
}
): Promise<void> {
await ctx.db.execute(
`INSERT INTO ${tableName(ctx, "webhook_deliveries")}
(request_id, delivery_key, event_name, company_id, payload, status, received_at)
VALUES ($1, $2, $3, $4, $5::jsonb, 'received', now())
ON CONFLICT (request_id) DO UPDATE SET
delivery_key = EXCLUDED.delivery_key,
event_name = EXCLUDED.event_name,
company_id = EXCLUDED.company_id,
payload = EXCLUDED.payload,
status = EXCLUDED.status,
received_at = now()`,
[input.requestId, input.deliveryKey, input.eventName, input.companyId, JSON.stringify(input.payload)]
);
}
export async function recordSyncCandidate(ctx: PluginContext, candidate: NormalizedSyncCandidate): Promise<void> {
const targetTable = candidate.sourceKind === "issue" ? "issue_mappings" : "comment_mappings";
await ctx.db.execute(
`INSERT INTO ${tableName(ctx, targetTable)}
(company_id, source_id, dedupe_key, title, body, attachment_metadata, manual_review_required, review_reason_code, updated_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, now())
ON CONFLICT (company_id, source_id) DO UPDATE SET
dedupe_key = EXCLUDED.dedupe_key,
title = EXCLUDED.title,
body = EXCLUDED.body,
attachment_metadata = EXCLUDED.attachment_metadata,
manual_review_required = EXCLUDED.manual_review_required,
review_reason_code = EXCLUDED.review_reason_code,
updated_at = now()`,
[
candidate.companyId,
candidate.sourceId,
candidate.dedupeKey,
candidate.title,
candidate.body,
JSON.stringify(candidate.attachmentMetadata),
candidate.reviewSignal.manualReviewRequired,
candidate.reviewSignal.reasonCode
]
);
}
export async function enqueueManualReview(ctx: PluginContext, candidate: NormalizedSyncCandidate): Promise<void> {
await ctx.db.execute(
`INSERT INTO ${tableName(ctx, "review_queue")}
(company_id, source_kind, source_id, dedupe_key, review_reason_code, review_payload, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, 'pending', now(), now())
ON CONFLICT (company_id, source_kind, source_id) DO UPDATE SET
dedupe_key = EXCLUDED.dedupe_key,
review_reason_code = EXCLUDED.review_reason_code,
review_payload = EXCLUDED.review_payload,
status = 'pending',
updated_at = now()`,
[
candidate.companyId,
candidate.sourceKind,
candidate.sourceId,
candidate.dedupeKey,
candidate.reviewSignal.reasonCode,
JSON.stringify({
reasons: candidate.reviewSignal.reasons,
attachmentMetadata: candidate.attachmentMetadata,
title: candidate.title
})
]
);
}
export async function recordReconciliationRun(ctx: PluginContext, snapshot: ReconciliationSnapshot): Promise<void> {
await ctx.db.execute(
`INSERT INTO ${tableName(ctx, "reconciliation_runs")}
(completed_at, trigger, pending_reviews, pending_deliveries, mapped_issues, mapped_comments, snapshot)
VALUES ($1::timestamptz, $2, $3, $4, $5, $6, $7::jsonb)`,
[
snapshot.completedAt,
snapshot.trigger,
snapshot.pendingReviews,
snapshot.pendingDeliveries,
snapshot.mappedIssues,
snapshot.mappedComments,
JSON.stringify(snapshot)
]
);
}
export async function readReconciliationSnapshot(ctx: PluginContext): Promise<ReconciliationSnapshot> {
const [pendingReviewsRow] = await ctx.db.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM ${tableName(ctx, "review_queue")} WHERE status = 'pending'`
);
const [pendingDeliveriesRow] = await ctx.db.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM ${tableName(ctx, "webhook_deliveries")} WHERE status = 'received'`
);
const [issueMappingsRow] = await ctx.db.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM ${tableName(ctx, "issue_mappings")}`
);
const [commentMappingsRow] = await ctx.db.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM ${tableName(ctx, "comment_mappings")}`
);
return {
pendingReviews: Number(pendingReviewsRow?.count ?? 0),
pendingDeliveries: Number(pendingDeliveriesRow?.count ?? 0),
mappedIssues: Number(issueMappingsRow?.count ?? 0),
mappedComments: Number(commentMappingsRow?.count ?? 0),
completedAt: new Date().toISOString(),
trigger: "snapshot"
};
}

27
src/reconciliation.ts Normal file
View file

@ -0,0 +1,27 @@
import type { PluginContext, PluginJobContext } from "@paperclipai/plugin-sdk";
import { JOB_KEYS } from "./constants.js";
import { recordReconciliationRun, readReconciliationSnapshot } from "./persistence.js";
import type { ReconciliationSnapshot } from "./types.js";
function instanceStateKey() {
return {
scopeKind: "instance" as const,
namespace: "reconciliation",
stateKey: "last-run"
};
}
export async function runReconciliation(ctx: PluginContext, trigger: string): Promise<ReconciliationSnapshot> {
const snapshot = await readReconciliationSnapshot(ctx);
snapshot.trigger = trigger;
snapshot.completedAt = new Date().toISOString();
await recordReconciliationRun(ctx, snapshot);
await ctx.state.set(instanceStateKey(), snapshot);
return snapshot;
}
export function registerReconciliationJob(ctx: PluginContext): void {
ctx.jobs.register(JOB_KEYS.reconcile, async (job: PluginJobContext) => {
await runReconciliation(ctx, `job:${job.trigger}`);
});
}

49
src/types.ts Normal file
View file

@ -0,0 +1,49 @@
export type AttachmentMetadata = {
filename: string | null;
mimeType: string | null;
sizeBytes: number | null;
sourceUrl: string | null;
sourceId: string | null;
};
export type ReviewSignal = {
manualReviewRequired: boolean;
reasonCode: string | null;
reasons: string[];
attachmentCount: number;
};
export type NormalizedSyncCandidate = {
companyId: string;
sourceKind: "issue" | "comment";
sourceId: string;
dedupeKey: string;
title: string | null;
body: string;
attachmentMetadata: AttachmentMetadata[];
reviewSignal: ReviewSignal;
rawPayload: unknown;
};
export type ForgejoSyncContent = {
markdown: string;
attachmentMetadata: AttachmentMetadata[];
reviewSignal: ReviewSignal;
};
export type ReconciliationSnapshot = {
pendingReviews: number;
pendingDeliveries: number;
mappedIssues: number;
mappedComments: number;
completedAt: string;
trigger: string;
};
export type ForgejoPluginConfig = {
forgejoBaseUrl?: string;
forgejoTokenRef?: string;
webhookSecretRef?: string;
defaultCompanyId?: string;
reconciliationLookbackMinutes?: number;
};

153
src/webhook-intake.ts Normal file
View file

@ -0,0 +1,153 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { PluginContext, PluginWebhookInput } from "@paperclipai/plugin-sdk";
import { buildForgejoSyncContent } from "./attachment-policy.js";
import { readConfig } from "./config.js";
import { WEBHOOK_KEYS } from "./constants.js";
import { enqueueManualReview, recordSyncCandidate, recordWebhookDelivery } from "./persistence.js";
import type { AttachmentMetadata, NormalizedSyncCandidate } from "./types.js";
function firstHeader(headers: Record<string, string | string[]>, key: string): string | null {
const match = Object.entries(headers).find(([headerKey]) => headerKey.toLowerCase() === key.toLowerCase());
if (!match) return null;
const value = match[1];
return Array.isArray(value) ? value[0] ?? null : value;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
function asString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value : null;
}
function asNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function readAttachments(payload: Record<string, unknown>): AttachmentMetadata[] {
const sources = [
payload.attachments,
asRecord(payload.comment)?.attachments,
asRecord(payload.issue)?.attachments
];
const attachments = sources.find(Array.isArray);
if (!attachments) return [];
return attachments.map((item) => {
const record = asRecord(item) ?? {};
return {
filename: asString(record.name) ?? asString(record.filename),
mimeType: asString(record.content_type) ?? asString(record.mimeType),
sizeBytes: asNumber(record.size) ?? asNumber(record.sizeBytes),
sourceUrl: asString(record.browser_download_url) ?? asString(record.url),
sourceId: asString(record.uuid) ?? asString(record.id)
};
});
}
function normalizePayload(
payload: Record<string, unknown>,
companyId: string
): NormalizedSyncCandidate {
const issue = asRecord(payload.issue);
const comment = asRecord(payload.comment);
const sourceKind = comment ? "comment" : "issue";
const sourceRecord = comment ?? issue ?? payload;
const sourceId = asString(sourceRecord.id) ?? asString(sourceRecord.uuid) ?? "unknown-source";
const body = asString(sourceRecord.body) ?? asString(issue?.body) ?? "";
const title = sourceKind === "issue"
? asString(sourceRecord.title) ?? asString(payload.title)
: asString(issue?.title);
const attachments = readAttachments(payload);
const syncContent = buildForgejoSyncContent(body, attachments);
const dedupeKey = [
asString(payload.repository?.toString?.()) ?? asString(asRecord(payload.repository)?.full_name) ?? "unknown-repo",
sourceKind,
sourceId,
asString(payload.action) ?? "unknown-action"
].join(":");
return {
companyId,
sourceKind,
sourceId,
dedupeKey,
title,
body: syncContent.markdown,
attachmentMetadata: syncContent.attachmentMetadata,
reviewSignal: syncContent.reviewSignal,
rawPayload: payload
};
}
async function verifyWebhookSignature(ctx: PluginContext, input: PluginWebhookInput): Promise<void> {
const config = readConfig(await ctx.config.get());
if (!config.webhookSecretRef) return;
const signatureHeader = firstHeader(input.headers, "x-hub-signature-256");
if (!signatureHeader) {
throw new Error("Webhook signature is required when webhookSecretRef is configured.");
}
const secret = await ctx.secrets.resolve(config.webhookSecretRef);
const expected = `sha256=${createHmac("sha256", secret).update(input.rawBody).digest("hex")}`;
const actualBuffer = Buffer.from(signatureHeader);
const expectedBuffer = Buffer.from(expected);
if (actualBuffer.length !== expectedBuffer.length || !timingSafeEqual(actualBuffer, expectedBuffer)) {
throw new Error("Webhook signature verification failed.");
}
}
function resolveCompanyId(payload: Record<string, unknown>, fallbackCompanyId?: string): string {
const payloadCompanyId = asString(payload.companyId)
?? asString(asRecord(payload.paperclip)?.companyId)
?? asString(asRecord(payload.meta)?.companyId);
if (payloadCompanyId) return payloadCompanyId;
if (fallbackCompanyId) return fallbackCompanyId;
throw new Error("Webhook payload does not include companyId and defaultCompanyId is not configured.");
}
export async function handleForgejoWebhook(ctx: PluginContext, input: PluginWebhookInput): Promise<NormalizedSyncCandidate> {
if (input.endpointKey !== WEBHOOK_KEYS.forgejo) {
throw new Error(`Unsupported webhook endpoint "${input.endpointKey}"`);
}
await verifyWebhookSignature(ctx, input);
const payload = asRecord(input.parsedBody) ?? {};
const config = readConfig(await ctx.config.get());
const companyId = resolveCompanyId(payload, config.defaultCompanyId);
const eventName = firstHeader(input.headers, "x-gitea-event")
?? firstHeader(input.headers, "x-forgejo-event")
?? "unknown";
const deliveryKey = firstHeader(input.headers, "x-gitea-delivery")
?? firstHeader(input.headers, "x-forgejo-delivery")
?? input.requestId;
await recordWebhookDelivery(ctx, {
requestId: input.requestId,
deliveryKey,
eventName,
companyId,
payload
});
const candidate = normalizePayload(payload, companyId);
await recordSyncCandidate(ctx, candidate);
if (candidate.reviewSignal.manualReviewRequired) {
await enqueueManualReview(ctx, candidate);
await ctx.activity.log({
companyId,
entityType: "plugin_review",
entityId: `${candidate.sourceKind}:${candidate.sourceId}`,
message: "Queued Forgejo sync payload for manual review because attachment context appears required.",
metadata: {
reasonCode: candidate.reviewSignal.reasonCode,
attachmentCount: candidate.reviewSignal.attachmentCount
}
});
}
return candidate;
}

83
src/worker.ts Normal file
View file

@ -0,0 +1,83 @@
import { definePlugin, runWorker, type PluginContext, type PluginWebhookInput } from "@paperclipai/plugin-sdk";
import { validateConfig } from "./config.js";
import { readReconciliationSnapshot } from "./persistence.js";
import { runReconciliation, registerReconciliationJob } from "./reconciliation.js";
import { handleForgejoWebhook } from "./webhook-intake.js";
let currentContext: PluginContext | null = null;
const plugin = definePlugin({
async setup(ctx) {
currentContext = ctx;
registerReconciliationJob(ctx);
ctx.data.register("sync-health", async () => {
const snapshot = await readReconciliationSnapshot(ctx);
const lastRun = await ctx.state.get({
scopeKind: "instance",
namespace: "reconciliation",
stateKey: "last-run"
});
return {
databaseNamespace: ctx.db.namespace,
snapshot,
lastRun
};
});
ctx.actions.register("run-reconciliation", async () => {
return runReconciliation(ctx, "action:manual");
});
},
async onValidateConfig(config) {
return validateConfig(config);
},
async onConfigChanged(newConfig) {
if (!currentContext) return;
currentContext.logger.info("Forgejo sync config changed", {
hasForgejoBaseUrl: Boolean(newConfig.forgejoBaseUrl),
hasTokenRef: Boolean(newConfig.forgejoTokenRef),
hasWebhookSecretRef: Boolean(newConfig.webhookSecretRef)
});
},
async onWebhook(input: PluginWebhookInput) {
if (!currentContext) {
throw new Error("Plugin context is not ready.");
}
const candidate = await handleForgejoWebhook(currentContext, input);
currentContext.logger.info("Processed Forgejo webhook delivery", {
sourceKind: candidate.sourceKind,
sourceId: candidate.sourceId,
manualReviewRequired: candidate.reviewSignal.manualReviewRequired
});
},
async onHealth() {
if (!currentContext) {
return {
status: "degraded" as const,
message: "Worker started without initialized context"
};
}
const snapshot = await readReconciliationSnapshot(currentContext);
return {
status: "ok" as const,
message: "Forgejo sync scaffold is ready",
details: {
pendingReviews: snapshot.pendingReviews,
pendingDeliveries: snapshot.pendingDeliveries,
mappedIssues: snapshot.mappedIssues,
mappedComments: snapshot.mappedComments,
policy: {
attachmentMode: "metadata-only",
manualReviewQueue: true
}
}
};
}
});
export default plugin;
runWorker(plugin, import.meta.url);

View file

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { ATTACHMENT_NOTE } from "../src/constants.js";
import { assessAttachmentReview, buildForgejoSyncContent } from "../src/attachment-policy.js";
import manifest from "../src/manifest.js";
describe("attachment policy", () => {
it("appends the visible attachment note and metadata summary", () => {
const result = buildForgejoSyncContent("Bug report body", [
{
filename: "screenshot.png",
mimeType: "image/png",
sizeBytes: 2048,
sourceUrl: "https://forgejo.example/files/1",
sourceId: "1"
}
]);
expect(result.markdown).toContain(ATTACHMENT_NOTE);
expect(result.markdown).toContain("screenshot.png | image/png | 2.0 KiB");
expect(result.reviewSignal.reasonCode).toBe("attachment_metadata_present");
});
it("marks payloads for manual review when the text depends on attachments", () => {
const review = assessAttachmentReview("See attached screenshot for the only repro details.", [
{
filename: "repro.png",
mimeType: "image/png",
sizeBytes: 1024,
sourceUrl: null,
sourceId: "abc"
}
]);
expect(review.manualReviewRequired).toBe(true);
expect(review.reasonCode).toBe("attachments_context_required");
});
it("keeps managed resources out of the manifest", () => {
expect(manifest).not.toHaveProperty("agents");
expect(manifest).not.toHaveProperty("skills");
expect(manifest).not.toHaveProperty("routines");
expect(manifest.capabilities).not.toContain("agents.managed");
expect(manifest.capabilities).not.toContain("skills.managed");
});
});

14
tsconfig.build.json Normal file
View file

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist"
},
"include": [
"src"
],
"exclude": [
"dist",
"node_modules",
"tests"
]
}

28
tsconfig.json Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022",
"DOM"
],
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"rootDir": ".",
"types": [
"node"
]
},
"include": [
"src",
"tests"
],
"exclude": [
"dist",
"node_modules"
]
}

8
vitest.config.ts Normal file
View file

@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.spec.ts"]
}
});