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