commit 471520e6b38dc1f7a7f28bd1bffbdceca1216572 Author: Paperclip Bot Date: Tue Jun 2 02:57:49 2026 +0000 Scaffold Forgejo issue sync plugin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1eae0cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +dist/ +node_modules/ diff --git a/.paperclip-sdk/paperclipai-plugin-sdk-1.0.0.tgz b/.paperclip-sdk/paperclipai-plugin-sdk-1.0.0.tgz new file mode 100644 index 0000000..5340af3 Binary files /dev/null and b/.paperclip-sdk/paperclipai-plugin-sdk-1.0.0.tgz differ diff --git a/.paperclip-sdk/paperclipai-shared-0.3.1.tgz b/.paperclip-sdk/paperclipai-shared-0.3.1.tgz new file mode 100644 index 0000000..a696a84 Binary files /dev/null and b/.paperclip-sdk/paperclipai-shared-0.3.1.tgz differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..43dbdfa --- /dev/null +++ b/README.md @@ -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. diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql new file mode 100644 index 0000000..174f5ed --- /dev/null +++ b/migrations/001_initial.sql @@ -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 +); diff --git a/package.json b/package.json new file mode 100644 index 0000000..a156436 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..e1e7382 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1071 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@paperclipai/plugin-sdk': + specifier: file:.paperclip-sdk/paperclipai-plugin-sdk-1.0.0.tgz + version: file:.paperclip-sdk/paperclipai-plugin-sdk-1.0.0.tgz + '@paperclipai/shared': + specifier: file:.paperclip-sdk/paperclipai-shared-0.3.1.tgz + version: file:.paperclip-sdk/paperclipai-shared-0.3.1.tgz + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.12.4 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.5 + version: 3.2.4(@types/node@24.12.4) + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@paperclipai/plugin-sdk@file:.paperclip-sdk/paperclipai-plugin-sdk-1.0.0.tgz': + resolution: {integrity: sha512-VRLmuvA8/vwsgpJ8SUDW3y3+2rofKzGH8KCo3q8Ic0tTDQQHXNVle85ZHYmcta29/fF/1H3s/Tz6++9+v2CZXA==, tarball: file:.paperclip-sdk/paperclipai-plugin-sdk-1.0.0.tgz} + version: 1.0.0 + hasBin: true + peerDependencies: + react: '>=18' + peerDependenciesMeta: + react: + optional: true + + '@paperclipai/shared@0.3.1': + resolution: {integrity: sha512-ezo1DTqH2OeW4w1BvDKhtMAep7ZxO25ITD62lCw24ez7diy2sPXSHwiNDovG6OsDFqgjMXsxK63r4GndWVjhDA==} + + '@paperclipai/shared@file:.paperclip-sdk/paperclipai-shared-0.3.1.tgz': + resolution: {integrity: sha512-UEgSrCZxcpBx8uwMl7EEVWRjNT30b8gNhD2iPzbyGA9JQ+e7gD3U46QYnWxBC5oamwxFyJTkWpxIN2v5t2fjNQ==, tarball: file:.paperclip-sdk/paperclipai-shared-0.3.1.tgz} + version: 0.3.1 + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.3: + resolution: {integrity: sha512-/4XH147Ui7OGTjg3HbdWe5arnZQSbfuRzdr9Ec7TQi5I7R+ir0Rlc9GIvD4v0XZurELqA035KVXJXpR61xhiTA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@paperclipai/plugin-sdk@file:.paperclip-sdk/paperclipai-plugin-sdk-1.0.0.tgz': + dependencies: + '@paperclipai/shared': 0.3.1 + zod: 3.25.76 + + '@paperclipai/shared@0.3.1': + dependencies: + zod: 3.25.76 + + '@paperclipai/shared@file:.paperclip-sdk/paperclipai-shared-0.3.1.tgz': + dependencies: + zod: 3.25.76 + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.4(vite@7.3.3(@types/node@24.12.4))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.3(@types/node@24.12.4) + + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.4': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + assertion-error@2.0.1: {} + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + js-tokens@9.0.1: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + vite-node@3.2.4(@types/node@24.12.4): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.3(@types/node@24.12.4) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.3(@types/node@24.12.4): + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.60.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.12.4 + fsevents: 2.3.3 + + vitest@3.2.4(@types/node@24.12.4): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.3(@types/node@24.12.4)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.3(@types/node@24.12.4) + vite-node: 3.2.4(@types/node@24.12.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.4 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + zod@3.25.76: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..5ed0b5a --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + esbuild: true diff --git a/src/attachment-policy.ts b/src/attachment-policy.ts new file mode 100644 index 0000000..9ca6835 --- /dev/null +++ b/src/attachment-policy.ts @@ -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 + }; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3d11a41 --- /dev/null +++ b/src/config.ts @@ -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): 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): { 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 + }; +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..f1cb8f6 --- /dev/null +++ b/src/constants.ts @@ -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 +]; diff --git a/src/manifest.ts b/src/manifest.ts new file mode 100644 index 0000000..eed4dec --- /dev/null +++ b/src/manifest.ts @@ -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; diff --git a/src/persistence.ts b/src/persistence.ts new file mode 100644 index 0000000..319b594 --- /dev/null +++ b/src/persistence.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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" + }; +} diff --git a/src/reconciliation.ts b/src/reconciliation.ts new file mode 100644 index 0000000..a705622 --- /dev/null +++ b/src/reconciliation.ts @@ -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 { + 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}`); + }); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..c0d9343 --- /dev/null +++ b/src/types.ts @@ -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; +}; diff --git a/src/webhook-intake.ts b/src/webhook-intake.ts new file mode 100644 index 0000000..d70c2c0 --- /dev/null +++ b/src/webhook-intake.ts @@ -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, 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 | null { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : 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): 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, + 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 { + 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, 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 { + 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; +} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..448165e --- /dev/null +++ b/src/worker.ts @@ -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); diff --git a/tests/attachment-policy.spec.ts b/tests/attachment-policy.spec.ts new file mode 100644 index 0000000..d86b5b8 --- /dev/null +++ b/tests/attachment-policy.spec.ts @@ -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"); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..47028dc --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": [ + "src" + ], + "exclude": [ + "dist", + "node_modules", + "tests" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3a88640 --- /dev/null +++ b/tsconfig.json @@ -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" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e69f305 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + include: ["tests/**/*.spec.ts"] + } +});