Scaffold Forgejo issue sync plugin
This commit is contained in:
commit
471520e6b3
21 changed files with 1970 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
dist/
|
||||
node_modules/
|
||||
BIN
.paperclip-sdk/paperclipai-plugin-sdk-1.0.0.tgz
Normal file
BIN
.paperclip-sdk/paperclipai-plugin-sdk-1.0.0.tgz
Normal file
Binary file not shown.
BIN
.paperclip-sdk/paperclipai-shared-0.3.1.tgz
Normal file
BIN
.paperclip-sdk/paperclipai-shared-0.3.1.tgz
Normal file
Binary file not shown.
40
README.md
Normal file
40
README.md
Normal 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.
|
||||
65
migrations/001_initial.sql
Normal file
65
migrations/001_initial.sql
Normal 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
34
package.json
Normal 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
1071
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load diff
2
pnpm-workspace.yaml
Normal file
2
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
allowBuilds:
|
||||
esbuild: true
|
||||
74
src/attachment-policy.ts
Normal file
74
src/attachment-policy.ts
Normal 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
43
src/config.ts
Normal 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
26
src/constants.ts
Normal 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
81
src/manifest.ts
Normal 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
125
src/persistence.ts
Normal 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
27
src/reconciliation.ts
Normal 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
49
src/types.ts
Normal 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
153
src/webhook-intake.ts
Normal 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
83
src/worker.ts
Normal 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);
|
||||
45
tests/attachment-policy.spec.ts
Normal file
45
tests/attachment-policy.spec.ts
Normal 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
14
tsconfig.build.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules",
|
||||
"tests"
|
||||
]
|
||||
}
|
||||
28
tsconfig.json
Normal file
28
tsconfig.json
Normal 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
8
vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.spec.ts"]
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue