diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 54424929..aec3d370 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,5 @@ Status: Active -Last updated: 2026-01-02 +Last updated: 2026-04-28 # Pull Request Checklist @@ -13,10 +13,9 @@ Last updated: 2026-01-02 ## Testing - [ ] Not run (explain why) -- [ ] `npm run check` -- [ ] `npm test` -- [ ] `npm run build` -- [ ] `npm run ci:verify` +- [ ] Targeted proof for changed surface: +- [ ] `CHERRY_TMP_ROOT="$HOME/.cherry-tmp" CHERRY_VINE_SIGNATURE_MODE=enforce npm run verify:repo-closure` +- [ ] `npm run test:db` (only for DB/env changes) ## Engine Impact diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81697c7d..dc16caa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,11 +43,5 @@ jobs: - name: Guardrails run: npm run check:guardrails - - name: Node runtime tests - run: npm run check:tests:node - - - name: Next runtime tests - run: npm run check:tests:next - - name: Verify CI truth run: npm run ci:verify diff --git a/.github/workflows/n8n-notify.yml b/.github/workflows/n8n-notify.yml new file mode 100644 index 00000000..bdd332e9 --- /dev/null +++ b/.github/workflows/n8n-notify.yml @@ -0,0 +1,31 @@ +name: Notify n8n + +on: + workflow_run: + workflows: + - CI + types: + - completed + +jobs: + notify-n8n: + runs-on: ubuntu-latest + + steps: + - name: Send workflow result to n8n + env: + N8N_WEBHOOK_URL: ${{ secrets.N8N_WEBHOOK_URL }} + N8N_WEBHOOK_TOKEN: ${{ secrets.N8N_WEBHOOK_TOKEN }} + run: | + curl -X POST "$N8N_WEBHOOK_URL" \ + -H "Authorization: Bearer $N8N_WEBHOOK_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "event": "github.workflow.completed", + "repo": "${{ github.repository }}", + "workflow": "${{ github.event.workflow_run.name }}", + "status": "${{ github.event.workflow_run.conclusion }}", + "branch": "${{ github.event.workflow_run.head_branch }}", + "sha": "${{ github.event.workflow_run.head_sha }}", + "url": "${{ github.event.workflow_run.html_url }}" + }' diff --git a/AGENTS.md b/AGENTS.md index 9984fe6e..de967a45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ Status: Active -Last updated: 2026-01-29 +Last updated: 2026-04-28 # Cherry Agents — Canonical Operating Guide @@ -93,14 +93,19 @@ Forbidden framings: “fronting card,” “proxy BIN,” “tap to pay with Che - `npx prisma format` - `npx prisma migrate dev --name ` - `npx prisma generate` - - Run `npm run check`, `npm test`, and `npm run build`. + - Run the issue's acceptance commands, or the narrowest proof that covers schema, runtime, and build impact. - For docs: add `Status` + `Last updated`, split Current vs Future, add Related docs. ## PR Checklist (what each command proves) - `npm run check` → guardrails + lint + typecheck are green. -- `npm test` → unit/guardrail tests green (Prisma mocked by loader). +- `npm test` → partitioned full runtime suite: root legacy tests, `tests/node`, then `tests/next` (Prisma mocked by loader). - `npm run build` → Next.js build passes. - `npm run ci:verify` → mirrors CI entrypoint. +- `npm run test:db` → DB/env tests only; not part of standard mocked runtime proof. +- `npm run check:fast` → local guardrails + script typecheck only. +- `npm run check:local` → `check:fast` plus partitioned runtime suite. +- Full repo proof → `CHERRY_TMP_ROOT="$HOME/.cherry-tmp" CHERRY_VINE_SIGNATURE_MODE=enforce npm run verify:repo-closure`. +- Agents must not run both `npm test` and `verify:repo-closure` unless explicitly required. - If schema changed: migrations apply and Prisma client is regenerated. ## Drift Policy diff --git a/README.md b/README.md index 7960d5d7..7ce9be0a 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,17 @@ npm run dev The repo runtime is Node 24.15.0. Use `.nvmrc` / `engines.node` as the source of truth, and keep PATH stable (e.g. `/usr/bin:/bin:/usr/local/bin`) so `rg`, `git`, and `node` resolve deterministically. -## Health Gates (must pass before pushing) +## Health Gates ```bash -npm run check -npm test -npm run build +CHERRY_TMP_ROOT="$HOME/.cherry-tmp" CHERRY_VINE_SIGNATURE_MODE=enforce npm run verify:repo-closure ``` +For day-to-day development, use the narrowest proof that covers the changed surface. +`npm test` is the partitioned full runtime runner: root legacy tests, `tests/node`, +then `tests/next`. Runtime ownership is enforced by +`tests/node/guardrails/test-runner-ownership.test.ts`. DB tests remain separate under +`npm run test:db`. + --- ## Key Commands and Scripts @@ -144,3 +148,4 @@ Always import Prisma from `@/lib/prisma` and validate inputs with Zod schemas in - Third-party typing gaps must be patched via `types/compat/**` with a documented audit boundary. - Tailwind tokens live in `app/globals.css`; prefer semantic utilities. - Keep lint/typecheck green; add focused tests when touching engine/sessions/ledger logic. +test diff --git a/app/api/automation/_auth.ts b/app/api/automation/_auth.ts new file mode 100644 index 00000000..db01081c --- /dev/null +++ b/app/api/automation/_auth.ts @@ -0,0 +1,34 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { getStandardBearerHeader } from '../../../lib/http/bearer-token.js'; + +export type AutomationAuthResult = { ok: true } | { ok: false; response: NextResponse }; + +export function requireAutomationToken(request: NextRequest): AutomationAuthResult { + const expected = process.env['CHERRY_AUTOMATION_TOKEN']; + if (typeof expected !== 'string' || expected.trim().length === 0) { + return { + ok: false, + response: NextResponse.json( + { error: 'automation_token_not_configured' }, + { status: 503 } + ), + }; + } + + const bearerHeader = getStandardBearerHeader(request.headers); + const headerToken = request.headers.get('x-cherry-automation-token'); + const bearerToken = + bearerHeader !== null && bearerHeader.startsWith('Bearer ') + ? bearerHeader.slice('Bearer '.length) + : null; + const provided = bearerToken ?? headerToken; + if (provided !== expected) { + return { + ok: false, + response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }; + } + + return { ok: true }; +} diff --git a/app/api/automation/classify/pr/route.ts b/app/api/automation/classify/pr/route.ts new file mode 100644 index 00000000..c5bea22d --- /dev/null +++ b/app/api/automation/classify/pr/route.ts @@ -0,0 +1,41 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + AutomationEventIdempotencyConflictError, + classifyAndStorePrAutomation, +} from '../../../../../lib/automation/events.js'; +import { ensureRouteConfigFromEnv } from '../../../../../lib/config/route.js'; +import { PrAutomationClassifySchema } from '../../../../../lib/schemas/automation.js'; +import { parseJsonBody } from '../../../../../lib/validation.js'; +import { requireAutomationToken } from '../../_auth.js'; + +export async function POST(request: NextRequest): Promise { + ensureRouteConfigFromEnv(process.env); + + const auth = requireAutomationToken(request); + if (auth.ok === false) return auth.response; + + const parsed = await parseJsonBody(request, PrAutomationClassifySchema); + if (parsed.ok === false) return parsed.response; + + let result: Awaited>; + try { + result = await classifyAndStorePrAutomation(parsed.data); + } catch (error: unknown) { + if (error instanceof AutomationEventIdempotencyConflictError) { + return NextResponse.json( + { error: 'automation_event_idempotency_conflict' }, + { status: 409 } + ); + } + throw error; + } + + return NextResponse.json({ + ok: true, + created: result.created, + automationEventId: result.event.id, + outputHash: result.event.outputHash, + classifierOutput: result.classifierOutput, + }); +} diff --git a/app/api/automation/events/route.ts b/app/api/automation/events/route.ts new file mode 100644 index 00000000..07af501b --- /dev/null +++ b/app/api/automation/events/route.ts @@ -0,0 +1,40 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { AutomationEventIngestSchema } from '../../../../lib/schemas/automation.js'; +import { + AutomationEventIdempotencyConflictError, + storeAutomationEvent, +} from '../../../../lib/automation/events.js'; +import { ensureRouteConfigFromEnv } from '../../../../lib/config/route.js'; +import { parseJsonBody } from '../../../../lib/validation.js'; +import { requireAutomationToken } from '../_auth.js'; + +export async function POST(request: NextRequest): Promise { + ensureRouteConfigFromEnv(process.env); + + const auth = requireAutomationToken(request); + if (auth.ok === false) return auth.response; + + const parsed = await parseJsonBody(request, AutomationEventIngestSchema); + if (parsed.ok === false) return parsed.response; + + let result: Awaited>; + try { + result = await storeAutomationEvent(parsed.data); + } catch (error: unknown) { + if (error instanceof AutomationEventIdempotencyConflictError) { + return NextResponse.json( + { error: 'automation_event_idempotency_conflict' }, + { status: 409 } + ); + } + throw error; + } + + return NextResponse.json({ + ok: true, + created: result.created, + automationEventId: result.event.id, + outputHash: result.event.outputHash, + }); +} diff --git a/app/api/automation/replay/route.ts b/app/api/automation/replay/route.ts new file mode 100644 index 00000000..dc8184f7 --- /dev/null +++ b/app/api/automation/replay/route.ts @@ -0,0 +1,33 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { replayAutomationEvent } from '../../../../lib/automation/events.js'; +import { ensureRouteConfigFromEnv } from '../../../../lib/config/route.js'; +import { AutomationReplaySchema } from '../../../../lib/schemas/automation.js'; +import { parseJsonBody } from '../../../../lib/validation.js'; +import { requireAutomationToken } from '../_auth.js'; + +export async function POST(request: NextRequest): Promise { + ensureRouteConfigFromEnv(process.env); + + const auth = requireAutomationToken(request); + if (auth.ok === false) return auth.response; + + const parsed = await parseJsonBody(request, AutomationReplaySchema); + if (parsed.ok === false) return parsed.response; + + const result = await replayAutomationEvent( + parsed.data.automationEventId, + parsed.data.classifierVersion + ); + if (result === null) { + return NextResponse.json({ error: 'automation_event_not_found' }, { status: 404 }); + } + + return NextResponse.json({ + ok: true, + automationEventId: result.event.id, + outputHash: result.outputHash, + matches: result.matches, + reason: result.reason, + }); +} diff --git a/app/api/automation/simulation-snapshots/compare/route.ts b/app/api/automation/simulation-snapshots/compare/route.ts new file mode 100644 index 00000000..aaffa2a9 --- /dev/null +++ b/app/api/automation/simulation-snapshots/compare/route.ts @@ -0,0 +1,41 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + SimulationSnapshotIdempotencyConflictError, + compareAndStoreSimulationSnapshot, +} from '../../../../../lib/automation/events.js'; +import { ensureRouteConfigFromEnv } from '../../../../../lib/config/route.js'; +import { SimulationSnapshotCompareSchema } from '../../../../../lib/schemas/automation.js'; +import { parseJsonBody } from '../../../../../lib/validation.js'; +import { requireAutomationToken } from '../../_auth.js'; + +export async function POST(request: NextRequest): Promise { + ensureRouteConfigFromEnv(process.env); + + const auth = requireAutomationToken(request); + if (auth.ok === false) return auth.response; + + const parsed = await parseJsonBody(request, SimulationSnapshotCompareSchema); + if (parsed.ok === false) return parsed.response; + + let result: Awaited>; + try { + result = await compareAndStoreSimulationSnapshot(parsed.data); + } catch (error: unknown) { + if (error instanceof SimulationSnapshotIdempotencyConflictError) { + return NextResponse.json( + { error: 'simulation_snapshot_idempotency_conflict' }, + { status: 409 } + ); + } + throw error; + } + + return NextResponse.json({ + ok: true, + created: result.created, + snapshotId: result.snapshot.id, + outputHash: result.snapshot.outputHash, + comparisonOutput: result.comparisonOutput, + }); +} diff --git a/app/api/automation/statuses/github/retry/route.ts b/app/api/automation/statuses/github/retry/route.ts new file mode 100644 index 00000000..f5011404 --- /dev/null +++ b/app/api/automation/statuses/github/retry/route.ts @@ -0,0 +1,50 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + GithubStatusRetryNotFoundError, + retryGithubStatus, +} from '../../../../../../lib/automation/github-status.js'; +import { ensureRouteConfigFromEnv } from '../../../../../../lib/config/route.js'; +import { GithubStatusRetrySchema } from '../../../../../../lib/schemas/automation.js'; +import { parseJsonBody } from '../../../../../../lib/validation.js'; +import { requireAutomationToken } from '../../../_auth.js'; + +export async function POST(request: NextRequest): Promise { + ensureRouteConfigFromEnv(process.env); + + const auth = requireAutomationToken(request); + if (auth.ok === false) return auth.response; + + const parsed = await parseJsonBody(request, GithubStatusRetrySchema); + if (parsed.ok === false) return parsed.response; + + try { + const result = await retryGithubStatus(parsed.data, { + githubToken: process.env['GITHUB_TOKEN'] ?? '', + }); + return NextResponse.json({ + ok: true, + retried: result.retried, + statusCheck: result.statusCheck, + }); + } catch (error: unknown) { + if (error instanceof GithubStatusRetryNotFoundError) { + return NextResponse.json({ error: 'github_status_not_found' }, { status: 404 }); + } + const statusCheck = (error as { statusCheck?: unknown }).statusCheck; + const message = error instanceof Error ? error.message : 'github_status_retry_failed'; + const status = /forbidden Cherry finance endpoint|Unsupported GitHub status context/.test( + message + ) + ? 400 + : 502; + return NextResponse.json( + { + ok: false, + error: message, + statusCheck, + }, + { status } + ); + } +} diff --git a/app/api/automation/statuses/github/route.ts b/app/api/automation/statuses/github/route.ts new file mode 100644 index 00000000..055bcbfa --- /dev/null +++ b/app/api/automation/statuses/github/route.ts @@ -0,0 +1,39 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { postGithubStatus } from '../../../../../lib/automation/github-status.js'; +import { ensureRouteConfigFromEnv } from '../../../../../lib/config/route.js'; +import { GithubStatusPostSchema } from '../../../../../lib/schemas/automation.js'; +import { parseJsonBody } from '../../../../../lib/validation.js'; +import { requireAutomationToken } from '../../_auth.js'; + +export async function POST(request: NextRequest): Promise { + ensureRouteConfigFromEnv(process.env); + + const auth = requireAutomationToken(request); + if (auth.ok === false) return auth.response; + + const parsed = await parseJsonBody(request, GithubStatusPostSchema); + if (parsed.ok === false) return parsed.response; + + try { + const result = await postGithubStatus(parsed.data, { + githubToken: process.env['GITHUB_TOKEN'] ?? '', + }); + return NextResponse.json({ + ok: true, + posted: result.posted, + idempotent: result.idempotent, + statusCheckId: result.statusCheck.id, + }); + } catch (error: unknown) { + const statusCheck = (error as { statusCheck?: { id?: string } }).statusCheck; + return NextResponse.json( + { + ok: false, + error: error instanceof Error ? error.message : 'github_status_failed', + statusCheckId: statusCheck?.id, + }, + { status: 502 } + ); + } +} diff --git a/app/api/automation/statuses/route.ts b/app/api/automation/statuses/route.ts new file mode 100644 index 00000000..b08e09fc --- /dev/null +++ b/app/api/automation/statuses/route.ts @@ -0,0 +1,33 @@ +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; +import { + isAllowedGithubStatusContext, + listLatestGithubStatuses, +} from '../../../../lib/automation/github-status.js'; +import { ensureRouteConfigFromEnv } from '../../../../lib/config/route.js'; +import { requireAutomationToken } from '../_auth.js'; + +export async function GET(request: NextRequest): Promise { + ensureRouteConfigFromEnv(process.env); + + const auth = requireAutomationToken(request); + if (auth.ok === false) return auth.response; + + const url = new URL(request.url); + const repo = url.searchParams.get('repo') ?? undefined; + const sha = url.searchParams.get('sha') ?? undefined; + const contextParam = url.searchParams.get('context') ?? undefined; + if ( + contextParam !== undefined && + isAllowedGithubStatusContext(contextParam) === false + ) { + return NextResponse.json({ error: 'invalid_status_context' }, { status: 400 }); + } + + const params: Parameters[0] = {}; + if (repo !== undefined) params.repo = repo; + if (sha !== undefined) params.sha = sha; + if (contextParam !== undefined) params.context = contextParam; + const statuses = await listLatestGithubStatuses(params); + return NextResponse.json({ ok: true, statuses }); +} diff --git a/cherry-diff.patch b/cherry-diff.patch index 9573f8c3..b4d0cdd8 100644 --- a/cherry-diff.patch +++ b/cherry-diff.patch @@ -1,20 +1,29840 @@ -diff --git a/README.md b/README.md -index 6158518673cc78ff3ec5e2ccfc98c774eccc213b..7960d5d784b96ba24c34521d7d952018a2f58470 100644 ---- a/README.md -+++ b/README.md +diff --git a/AGENTS.md b/AGENTS.md +index 9804f4620cc8bdfecea458c1318d688239f1b6b7..de967a454828cca83ada13b8156ed9984ffcba31 100644 +--- a/AGENTS.md ++++ b/AGENTS.md +@@ -102,7 +102,8 @@ Forbidden framings: “fronting card,” “proxy BIN,” “tap to pay with Che + - `npm run build` → Next.js build passes. + - `npm run ci:verify` → mirrors CI entrypoint. + - `npm run test:db` → DB/env tests only; not part of standard mocked runtime proof. +-- `npm run check:fast` → local guardrails + script typecheck + partitioned runtime suite. ++- `npm run check:fast` → local guardrails + script typecheck only. ++- `npm run check:local` → `check:fast` plus partitioned runtime suite. + - Full repo proof → `CHERRY_TMP_ROOT="$HOME/.cherry-tmp" CHERRY_VINE_SIGNATURE_MODE=enforce npm run verify:repo-closure`. + - Agents must not run both `npm test` and `verify:repo-closure` unless explicitly required. + - If schema changed: migrations apply and Prisma client is regenerated. +diff --git a/app/api/automation/_auth.ts b/app/api/automation/_auth.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..db01081c5c2378edd3669dea467944ec6d8d3a7d +--- /dev/null ++++ b/app/api/automation/_auth.ts +@@ -0,0 +1,34 @@ ++import type { NextRequest } from 'next/server'; ++import { NextResponse } from 'next/server'; ++import { getStandardBearerHeader } from '../../../lib/http/bearer-token.js'; ++ ++export type AutomationAuthResult = { ok: true } | { ok: false; response: NextResponse }; ++ ++export function requireAutomationToken(request: NextRequest): AutomationAuthResult { ++ const expected = process.env['CHERRY_AUTOMATION_TOKEN']; ++ if (typeof expected !== 'string' || expected.trim().length === 0) { ++ return { ++ ok: false, ++ response: NextResponse.json( ++ { error: 'automation_token_not_configured' }, ++ { status: 503 } ++ ), ++ }; ++ } ++ ++ const bearerHeader = getStandardBearerHeader(request.headers); ++ const headerToken = request.headers.get('x-cherry-automation-token'); ++ const bearerToken = ++ bearerHeader !== null && bearerHeader.startsWith('Bearer ') ++ ? bearerHeader.slice('Bearer '.length) ++ : null; ++ const provided = bearerToken ?? headerToken; ++ if (provided !== expected) { ++ return { ++ ok: false, ++ response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), ++ }; ++ } ++ ++ return { ok: true }; ++} +diff --git a/app/api/automation/classify/pr/route.ts b/app/api/automation/classify/pr/route.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..c5bea22d0959a5886693c86d67a5731c91b90fd3 +--- /dev/null ++++ b/app/api/automation/classify/pr/route.ts +@@ -0,0 +1,41 @@ ++import type { NextRequest } from 'next/server'; ++import { NextResponse } from 'next/server'; ++import { ++ AutomationEventIdempotencyConflictError, ++ classifyAndStorePrAutomation, ++} from '../../../../../lib/automation/events.js'; ++import { ensureRouteConfigFromEnv } from '../../../../../lib/config/route.js'; ++import { PrAutomationClassifySchema } from '../../../../../lib/schemas/automation.js'; ++import { parseJsonBody } from '../../../../../lib/validation.js'; ++import { requireAutomationToken } from '../../_auth.js'; ++ ++export async function POST(request: NextRequest): Promise { ++ ensureRouteConfigFromEnv(process.env); ++ ++ const auth = requireAutomationToken(request); ++ if (auth.ok === false) return auth.response; ++ ++ const parsed = await parseJsonBody(request, PrAutomationClassifySchema); ++ if (parsed.ok === false) return parsed.response; ++ ++ let result: Awaited>; ++ try { ++ result = await classifyAndStorePrAutomation(parsed.data); ++ } catch (error: unknown) { ++ if (error instanceof AutomationEventIdempotencyConflictError) { ++ return NextResponse.json( ++ { error: 'automation_event_idempotency_conflict' }, ++ { status: 409 } ++ ); ++ } ++ throw error; ++ } ++ ++ return NextResponse.json({ ++ ok: true, ++ created: result.created, ++ automationEventId: result.event.id, ++ outputHash: result.event.outputHash, ++ classifierOutput: result.classifierOutput, ++ }); ++} +diff --git a/app/api/automation/events/route.ts b/app/api/automation/events/route.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..07af501bac1c8804f1d5df63bfc669beeda06361 +--- /dev/null ++++ b/app/api/automation/events/route.ts +@@ -0,0 +1,40 @@ ++import type { NextRequest } from 'next/server'; ++import { NextResponse } from 'next/server'; ++import { AutomationEventIngestSchema } from '../../../../lib/schemas/automation.js'; ++import { ++ AutomationEventIdempotencyConflictError, ++ storeAutomationEvent, ++} from '../../../../lib/automation/events.js'; ++import { ensureRouteConfigFromEnv } from '../../../../lib/config/route.js'; ++import { parseJsonBody } from '../../../../lib/validation.js'; ++import { requireAutomationToken } from '../_auth.js'; ++ ++export async function POST(request: NextRequest): Promise { ++ ensureRouteConfigFromEnv(process.env); ++ ++ const auth = requireAutomationToken(request); ++ if (auth.ok === false) return auth.response; ++ ++ const parsed = await parseJsonBody(request, AutomationEventIngestSchema); ++ if (parsed.ok === false) return parsed.response; ++ ++ let result: Awaited>; ++ try { ++ result = await storeAutomationEvent(parsed.data); ++ } catch (error: unknown) { ++ if (error instanceof AutomationEventIdempotencyConflictError) { ++ return NextResponse.json( ++ { error: 'automation_event_idempotency_conflict' }, ++ { status: 409 } ++ ); ++ } ++ throw error; ++ } ++ ++ return NextResponse.json({ ++ ok: true, ++ created: result.created, ++ automationEventId: result.event.id, ++ outputHash: result.event.outputHash, ++ }); ++} +diff --git a/app/api/automation/replay/route.ts b/app/api/automation/replay/route.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..dc8184f779eb98cce5883985a4fd33f08a001d50 +--- /dev/null ++++ b/app/api/automation/replay/route.ts +@@ -0,0 +1,33 @@ ++import type { NextRequest } from 'next/server'; ++import { NextResponse } from 'next/server'; ++import { replayAutomationEvent } from '../../../../lib/automation/events.js'; ++import { ensureRouteConfigFromEnv } from '../../../../lib/config/route.js'; ++import { AutomationReplaySchema } from '../../../../lib/schemas/automation.js'; ++import { parseJsonBody } from '../../../../lib/validation.js'; ++import { requireAutomationToken } from '../_auth.js'; ++ ++export async function POST(request: NextRequest): Promise { ++ ensureRouteConfigFromEnv(process.env); ++ ++ const auth = requireAutomationToken(request); ++ if (auth.ok === false) return auth.response; ++ ++ const parsed = await parseJsonBody(request, AutomationReplaySchema); ++ if (parsed.ok === false) return parsed.response; ++ ++ const result = await replayAutomationEvent( ++ parsed.data.automationEventId, ++ parsed.data.classifierVersion ++ ); ++ if (result === null) { ++ return NextResponse.json({ error: 'automation_event_not_found' }, { status: 404 }); ++ } ++ ++ return NextResponse.json({ ++ ok: true, ++ automationEventId: result.event.id, ++ outputHash: result.outputHash, ++ matches: result.matches, ++ reason: result.reason, ++ }); ++} +diff --git a/app/api/automation/simulation-snapshots/compare/route.ts b/app/api/automation/simulation-snapshots/compare/route.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..aaffa2a9c73dafade704e760d53c29229275764e +--- /dev/null ++++ b/app/api/automation/simulation-snapshots/compare/route.ts +@@ -0,0 +1,41 @@ ++import type { NextRequest } from 'next/server'; ++import { NextResponse } from 'next/server'; ++import { ++ SimulationSnapshotIdempotencyConflictError, ++ compareAndStoreSimulationSnapshot, ++} from '../../../../../lib/automation/events.js'; ++import { ensureRouteConfigFromEnv } from '../../../../../lib/config/route.js'; ++import { SimulationSnapshotCompareSchema } from '../../../../../lib/schemas/automation.js'; ++import { parseJsonBody } from '../../../../../lib/validation.js'; ++import { requireAutomationToken } from '../../_auth.js'; ++ ++export async function POST(request: NextRequest): Promise { ++ ensureRouteConfigFromEnv(process.env); ++ ++ const auth = requireAutomationToken(request); ++ if (auth.ok === false) return auth.response; ++ ++ const parsed = await parseJsonBody(request, SimulationSnapshotCompareSchema); ++ if (parsed.ok === false) return parsed.response; ++ ++ let result: Awaited>; ++ try { ++ result = await compareAndStoreSimulationSnapshot(parsed.data); ++ } catch (error: unknown) { ++ if (error instanceof SimulationSnapshotIdempotencyConflictError) { ++ return NextResponse.json( ++ { error: 'simulation_snapshot_idempotency_conflict' }, ++ { status: 409 } ++ ); ++ } ++ throw error; ++ } ++ ++ return NextResponse.json({ ++ ok: true, ++ created: result.created, ++ snapshotId: result.snapshot.id, ++ outputHash: result.snapshot.outputHash, ++ comparisonOutput: result.comparisonOutput, ++ }); ++} +diff --git a/app/api/automation/statuses/github/retry/route.ts b/app/api/automation/statuses/github/retry/route.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..f5011404b8c2de6826a92694f85b3799fcb270f8 +--- /dev/null ++++ b/app/api/automation/statuses/github/retry/route.ts +@@ -0,0 +1,50 @@ ++import type { NextRequest } from 'next/server'; ++import { NextResponse } from 'next/server'; ++import { ++ GithubStatusRetryNotFoundError, ++ retryGithubStatus, ++} from '../../../../../../lib/automation/github-status.js'; ++import { ensureRouteConfigFromEnv } from '../../../../../../lib/config/route.js'; ++import { GithubStatusRetrySchema } from '../../../../../../lib/schemas/automation.js'; ++import { parseJsonBody } from '../../../../../../lib/validation.js'; ++import { requireAutomationToken } from '../../../_auth.js'; ++ ++export async function POST(request: NextRequest): Promise { ++ ensureRouteConfigFromEnv(process.env); ++ ++ const auth = requireAutomationToken(request); ++ if (auth.ok === false) return auth.response; ++ ++ const parsed = await parseJsonBody(request, GithubStatusRetrySchema); ++ if (parsed.ok === false) return parsed.response; ++ ++ try { ++ const result = await retryGithubStatus(parsed.data, { ++ githubToken: process.env['GITHUB_TOKEN'] ?? '', ++ }); ++ return NextResponse.json({ ++ ok: true, ++ retried: result.retried, ++ statusCheck: result.statusCheck, ++ }); ++ } catch (error: unknown) { ++ if (error instanceof GithubStatusRetryNotFoundError) { ++ return NextResponse.json({ error: 'github_status_not_found' }, { status: 404 }); ++ } ++ const statusCheck = (error as { statusCheck?: unknown }).statusCheck; ++ const message = error instanceof Error ? error.message : 'github_status_retry_failed'; ++ const status = /forbidden Cherry finance endpoint|Unsupported GitHub status context/.test( ++ message ++ ) ++ ? 400 ++ : 502; ++ return NextResponse.json( ++ { ++ ok: false, ++ error: message, ++ statusCheck, ++ }, ++ { status } ++ ); ++ } ++} +diff --git a/app/api/automation/statuses/github/route.ts b/app/api/automation/statuses/github/route.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..055bcbfad405daaa3a2d2d93d66466bacc364223 +--- /dev/null ++++ b/app/api/automation/statuses/github/route.ts +@@ -0,0 +1,39 @@ ++import type { NextRequest } from 'next/server'; ++import { NextResponse } from 'next/server'; ++import { postGithubStatus } from '../../../../../lib/automation/github-status.js'; ++import { ensureRouteConfigFromEnv } from '../../../../../lib/config/route.js'; ++import { GithubStatusPostSchema } from '../../../../../lib/schemas/automation.js'; ++import { parseJsonBody } from '../../../../../lib/validation.js'; ++import { requireAutomationToken } from '../../_auth.js'; ++ ++export async function POST(request: NextRequest): Promise { ++ ensureRouteConfigFromEnv(process.env); ++ ++ const auth = requireAutomationToken(request); ++ if (auth.ok === false) return auth.response; ++ ++ const parsed = await parseJsonBody(request, GithubStatusPostSchema); ++ if (parsed.ok === false) return parsed.response; ++ ++ try { ++ const result = await postGithubStatus(parsed.data, { ++ githubToken: process.env['GITHUB_TOKEN'] ?? '', ++ }); ++ return NextResponse.json({ ++ ok: true, ++ posted: result.posted, ++ idempotent: result.idempotent, ++ statusCheckId: result.statusCheck.id, ++ }); ++ } catch (error: unknown) { ++ const statusCheck = (error as { statusCheck?: { id?: string } }).statusCheck; ++ return NextResponse.json( ++ { ++ ok: false, ++ error: error instanceof Error ? error.message : 'github_status_failed', ++ statusCheckId: statusCheck?.id, ++ }, ++ { status: 502 } ++ ); ++ } ++} +diff --git a/app/api/automation/statuses/route.ts b/app/api/automation/statuses/route.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..b08e09fc809b393cde75a435931f3cd3f0cf6c45 +--- /dev/null ++++ b/app/api/automation/statuses/route.ts +@@ -0,0 +1,33 @@ ++import type { NextRequest } from 'next/server'; ++import { NextResponse } from 'next/server'; ++import { ++ isAllowedGithubStatusContext, ++ listLatestGithubStatuses, ++} from '../../../../lib/automation/github-status.js'; ++import { ensureRouteConfigFromEnv } from '../../../../lib/config/route.js'; ++import { requireAutomationToken } from '../_auth.js'; ++ ++export async function GET(request: NextRequest): Promise { ++ ensureRouteConfigFromEnv(process.env); ++ ++ const auth = requireAutomationToken(request); ++ if (auth.ok === false) return auth.response; ++ ++ const url = new URL(request.url); ++ const repo = url.searchParams.get('repo') ?? undefined; ++ const sha = url.searchParams.get('sha') ?? undefined; ++ const contextParam = url.searchParams.get('context') ?? undefined; ++ if ( ++ contextParam !== undefined && ++ isAllowedGithubStatusContext(contextParam) === false ++ ) { ++ return NextResponse.json({ error: 'invalid_status_context' }, { status: 400 }); ++ } ++ ++ const params: Parameters[0] = {}; ++ if (repo !== undefined) params.repo = repo; ++ if (sha !== undefined) params.sha = sha; ++ if (contextParam !== undefined) params.context = contextParam; ++ const statuses = await listLatestGithubStatuses(params); ++ return NextResponse.json({ ok: true, statuses }); ++} +diff --git a/cherry-n8n-workflows.zip b/cherry-n8n-workflows.zip +new file mode 100644 +index 0000000000000000000000000000000000000000..d695b420f23f56dcf75f7832eb8b5665b9584378 +GIT binary patch +literal 39964 +zcmb5VQ;;Z7v!z*fow9A)wr$(CZQHhO+vX|Tw%O&pCA%1Z%* +zKmq(`5nE+z|If$&bD#iV0~nc`I68XJ*s|NwxY;>cnOfVqInk*oLjnLhUEf(a|JyF^ +z&;WoS&wu~`Ajtnd%Kr=ZpBv(ThT|Tb`DKCz0FY(@0AT#@!qGG8+1Z=e8d)2-=~*~A +zxtQoV+POHJIMP}=+1dWTxsw0?xsEZvoi^BCUcXQ{YnkFvu@kqiUCxX2o2z2p*OVr;~P&V9F}N!1sVc9uSU6K`H2C>)$brC=2~a};;` +zh*%cy$@yHSwHE&b#wXTdL0zO(BCfPp0Q9TP;~(k%gxStL86fx%Hk6}%lnJ`ym8W;I +z61HDGeK=;gr=}bBks#nnr>BP&0f&lP#c~w>X-V4Sh2>5Ie*&KOUn+b7ZAG%LB}0EA +zB7xrS3J;Z%R&_fq;+Znb +z6&Lh|lzo;&y_16L#0TI~fE2PMa%r`2MB +zFi+6D`N>2QJWY-Qqo69lQiKJx96YD-fW4mVCD?MUTSDSJOq%R1o@BbkUTsE^X(THIN|)!u?9@+1O+S`r +zm`XFPU_IJl4!;wsL?@F*0HYskroMzYAy_<1EZS?H61M$-drG2U^YWtD#n$qido%%R$aXAmotLH^SiW> +zZM-mKZ983V1Pk4ZpZ}dnJXGLKSn3$kiOZ~zNVDtqjUQSy`jN|)z>Go?3PhOyZImQ|*TfwD +zjD&(b+pXLkoe_F6=Z}@?1|X0-66NE7Q3sIv!0VBHe}=P^iE6N7%WDDQ*a3BaEpt*< +zR3if>Y}drjJobGao^v)z!S531SZo%HmB>pAdobVd7F-$jNmBzeNi&)m1P|!0EtQO@ +zA9a+?oS;_mcd>cI(eP07C_{x*A!=2fz$|6s@Y74Nf?FVjo{%eriaCZnHDzitbP@r| +z{P)MpH1!Xwqoe<0E!d~%-fX|5NglKOdx@&92BNZaLdjpVBnxTl)JH|zWi|pJGY~W; +zyfzL;ov=&|(_v`y1^h^Ed}gvRQb>&ejVqKEFsjC!81z+;G-+G9zxV|pa1(e;jB}V~ +zpE5z&8GsRc;~mLYqF5$}Vwz`tx?ebfnajoiDzj>rZb}sHzOxlhXy@_YkvBkSNQpW +zk|ef5!WsIErCCMuodtxpgRG|YIqfzXy*jwYkYD;x;z|j%C7`a)BbRe)!>`XPw{lBC+N{@|iF0wM1;uH|72%J8bvVtQR5!YWXDM2O +zHkh+Bx=*GW9ssNvsa{7uKqwL=@Pt?6frPGyWsLX4e^L*jbS2k;D>C@g9DHW> +znZs48@OONN +zSHw+;iV-+nnam4WucyC41*DFNeIw7e%}Kyr@{tPaUf;bF73uB+qOezz%XtO%mO8C> +zvh(o0bAN4lx~!acsswELdDuWA4nX|kVMeNjgk17^kzNZ +zk8c2-3|F@rjQPt@6(P^OR(w0!*{%S4U))#~>lE31KL)$;Ce*`^b?9;CgbrsB)(5DT +zDs9?VQJd<(Byb22lw2U^-cN%)j?@JFq@kF#+xlsSrw7ZGN2jUdteufyd)Y_?UQD;Z +zlwhW|>QU;egNcqoff=DKP+Rh?B>z(cdrAzV`f#WShU5(T8w$u>? +zUT#3hqIoFAtUPmk5zlxjC5r-bZtueT&f`5HH`fd77s~Dc20Bq#xIO4?Rvi|NmnXp +zGIAUygiGm1a`F$fMY%Wzaev{QDKg~mMM@#4rC=RE!C`_%^>T_P{fK3G=m(Zv+g7)d +z19W0#Gx}^0shpm+wOhf3h|JLVOKFu593cHZLu>i1UuB7u>)aNRo=Ac(6T|fm?O$`1!jFg+v^f!Z2#{K?Spdv^{lU8DB;TD&Dpw +zvCmbFV?!8ZC-uR`qq4rh8aZ#!#Wzj<5y@m!3|IgNAA5(lXD(0s>fBc?UJx0(1e +zNc4rXkT=jq@@+Ls?A9EF9j$dk(6_vf@NQweJ6zb5^$u#ej41#+p(mej1jpLid8|^$Yx8<@p>5pvLw;@gK?z0Kop=%5xSy +z6I(M2TN6EF6EjBxV*_UkJ6k;iYZFIj3tO}Qtw_Jt^m4-FK>Xd!6BH|fDyM&Ikf0Tr +zoI(g2J~GhWBX%?CDh`!(whj%3V3AxEQ +z2Cj(zU`Krw{3V7l%QI)c$2NA_z{`bXoT#=B{BG6nyA8lZRBaNeC+TI{~Fm&N+=(^E!umS+1MHk$GvU%pJ9iaL?O(syTJjZbMtN$fp`> +zc~ddgfjnZn9L3z=%?jZ>(6TQkU(X+jr{|3lbQI4G$D3hz460YPW9tLe$G&DIfBy@b +zHlJ`h4M-7C2zXeS!rx7vd~`$0PVf}^P%l!s2#{&XC2hl12|Em`d$h?6I4PSq+nu_% +zKAe}YG?&+wbvW)Kv&Chc8*Ny0QJ=+=-&Ca}5Gr6UV-~5rC|kPg`SZN?Y%2p_w;gSY +zkM1H(B@MuIbhhLtM&E$kABbQ0jv#9{fn~mWF*AP&`#CfZPFM9eM48Fz#xs@31d*o! +zIX}4PloZ9cl4`O!Id9U$W0eN+10m%Cx`rM~ztm*Y8H|v;Ms%s-c&^}g_^~W?%D3o` +zMlXsC$5dm8SHQKduKku*UTDJH#5;~alKF^Mce}TW#(^)lX&F~8L3F{HZ-jM5`SMI) +zT6=L#SSa4(SPI(*-A>{u~|UWNBu3Xw;-hU`C)v;5LAGVL(1~RK;K6 +zCfPrJDZn%OCyn*0Hma9+q<>&R6wqK5k>ND^9-i^=#vFTAbkQM&P+ +z{TC64Axv^5h)5*`n@sbei#Yh>OTlYwarc<^6)O`%*djIQn3lJZJNF_4;}*F0Z?yMc +zdv$w=?Z*6*IVzqu&}Hx(ga@lzUB`bx@BkLg_P>%?kVY$6(FhO8nF)qNk=C9dyE5}R +zJZSx8#+jD;3bjkA)2FScLJZVTryprnjRRSs1^cI)&^Hfa*A$X`+qNOxzyOpk>dm2s +zQk{l?#+@kXmBjFj`Ei4>I+lgC<{VxJcFHl-WJs%xEJIppd#>N%?|oSRC{-FO%nK`c +zO+c8-FRgM|J}Z`^FsePHBsg2=1={mSNAgpaK=G1^5cK`#UdpC6aC)1cAHk@%4V$td +zYQpBQltce|!9t=+BVYK>i-hD%EpG_&$r`&$R;CR$k?^cyDxfjxs|3bK4**%PLiJcI +z9wc)HFhrE1vUz(Bo!PLUKn@;>i_5F9v=^7x2~&I?1Pu5l!W_&FGV@m=Gy5WpIE!x|(-CAH?RGjZ(^U|;5eb(EL0a87nXREUO;>v|+r +zhv)B!k1+riGMIG>@kwaOE{^NNvH(psh-^Mkugg-fn)#Nc5;i#Kz^%xnHU}g-lfI1Wy320+- +zmK8%!JF1|o8^b+!A`}7Px`s) +zP`DhWz<-W+Og;reBfhwx9xge&gGS|R-;uPHr)H?ofe)ZH2}6Nxoj)}W;hvM9noPYy +z#==6{E{q8lmd)(04|8)ng4_^dLP{k#J7AnJRLH&sE(+#Xzz|GXZrfv5oi{0jGv;~V +zPSX?tL;{l38ZU$iwpc6a#OIhcG}E|bEW0@mcObC`MYQi^+UVUnH6-YR74bXou_37y +z^yMLju&aB^9f(3_yCfwH8w8zJS#1>RO3rF+m2F%e#pzdZ!H?AWPn5 +z-hG~p)`aPV)!ll6DZnIQ$ZDK15Y& +zhrJRJ(vqDS?rDLoXJ5hZZuD!z$7k!-kDd>XH!x?t29us!s1E3e +za(z~Ai0jB7Fn8HwR;N8STp9U0yi#N&dO;Du#bID6Z3jx$Ku8eVF@7(|NZ&Y8;R$*D +zar!8aFG9E9_b8H|pEnJ!5b&?%YA+B#*jKc;uGeXAlJlFg(>$*3dx&GKF%DHwmW%{O +z1!FL1 +zLWJft{3g1naArVtGI>;lw*^A2lP3If#rK2qT*G5&jg}g^24eb8(JEn78UaByUCr4P +zkipMxtI;&S$SCgxi?&C(EO{Jhv)p#wU;fkl@y7nH(7fXrYInLu1^&pWw3c)!q7C< +ztW2Yza=7zqt)VcJyYLYQfZQQe8nd;l!TvCGkW<&n_&~{5hlRs`hgi`Grv$aS$ +znPJ*;c&52y0!wjDohSr6~+K79(VK{(X{qH0Q7>|MH+TmnuBR +zxGDcYQu6fg<*>PYk%oq;mC}dK@WTzgt`JoL!aOX6Qv*`CTY+P+F#n1}*<0VSKK9|+ +z(6sgr_h04oS6hHaJq!RqA3p#9>whb!+4LMutW6A@O!S;wY-|i1J@m{>Y)u>uobCSi +z9q(%_E2mA?hb})**_r6#3h}8Ms-oCSCu|ajLv`E|{Fx0RRkYflbQ3G#U;kVdGk3IZ^rGYbP+N4=TC+s$odz)Hu +zJF!S-)TAYHn6L0&;0Jah559p{7+rWMu*31;9NZqMepu3s_R_RR{B}lWmLhQ)l?m{W +zc98Had7!V;UV>+R?LnXu7nB_@c~EF>RcqI$ +z1Drt%^e18_QeHwNnJ}78fUT8ePGR~u(ZL77n2cVcPPw`XDXDqYP2_c{~~fbs?dg0v6|mJ_S2>z@*U)8x(><}5R&SX=86HXD{S)Mz~Ckvrnt@11tJ +zL!a$MZ$bAxJcyrvav1YCuam7w^QD)P+i|r01LUkyrPDr`7}BVmS>!$S^B(=hD0UW8 +zsz?R3r~b->1UK-sq-VLkev)0l`)8C*3|}ydRGk-|ZLk1vi*NGG68VA?CNEUTB4mj1*4V+BT68TXx%# +zpHi5ftYJ_>k?6N*VHXjYV}ef{np(fY2P3h6>2f}XA;XjbQ>ctaTRuomDzrBXYWqe) +zCT9DredPah6zT_OK^3gtMiR6My}MxghWY(sf&6RKZF(T%ZGjy;EzVt=`(x|owz{^? +z;F^avZn86a?i3mkmYkwQdDT&nO)$ZEJnk8yexzOATyUvMT4ECKmU@pT!ggt5%I26yC2}cBFYAW_QSkaoN^omvJ^bT8o$Is +z9zz(9fG&@(^u5l81d!Z>nTTOgx{cw?3!m*Z!8uQjb2v`WE?AZ08q(k+(r}QxM#`Ry +z6*SbGDS1R%N$LT1joiSG;r}p(M*?srd$t~~#Gq19=h-@DT3Mq=WxgCKVth^LPz+ir +z-;c{eJ@|5NfxBR?l5~xE-~ZV@G%)tp*}`I-KuRbGGWju^>N$3XKK7($7{1f2y&d3w +zw#%q~L}wAeKVNMv!5^uS!lFV&y5zU`&u@hDCZa-3WsT4k2MB0P;zbrax(@E625~xU +z5wvJs*7a7WWSz@puB5re_A;xn07uqEPJ2D$$#|v-oNSz=m86}5z_PZh5Y!ZGJuH^C +zZ*ePt41W_Gn7r|Zg_i4|h0v^F^;Yc%2C3brYWR42v>5`{z7I)LIuLzX(WHJo7QNX4 +zu@K96kmVJbXlDHrkzTTZ6035wi{+Aclm)S5iL9SQEu{jofzFze`aRp3B>e3L}v7rDrXRzY+$|7X_p5ibve%!nWj=Y-%yV0ZVj)e +z1?(x;zM`P|Cg+Q+(brz5E-|E~@ChIW;BgC_$SC6V +zD@1KNwdNyvBrjm|-4hdPcs*yh5IWsI4wK4}y7r#?4i-fmw8hOiu-=zoeE3;}LWz$0 +zlv?kT7qc3J!di5w*|=y?!|KMAR)yfei=50VWVB;eSCHzIPv +zh+a<>E$r*RIX)Ep9?a<9xjszYp=*PYb*1{G2w>yjVfWG$8MwMJnSZ;1i;bBu^YLey +z-pPWQi3KGw9#BJ=(&EfejQlW)`0=23^WMmb+I@UCDR`hJGuLB%PuG7-pJLq7Q%`P>^JTdx}GNT1`t!7vgrj%skD~C(Ibqh~2JfmI-lpuoOYxrt(i%5vQ +z?`vN}8`ivGW1_g(QV8K?UE8KB&IAcabUymqQ`9_KbGdXxD-o+Isa;s|%ADQnh>pQ+ +z)inoBCkwwAmODLwiMP_Gl +z$LE=1t*qvt|}DAJBeygP$N+$<|wjpQcJW`lta{UYf|2CPoY&d;&)?H +zimv3u4Oqs%X`Q=7UNud*wHHr!*|n14=$aA2Kduqv97r=yybubquD64EN*wUiw(7=4 +zn?Bra!h6Pk=pm=%8rNef-?-dy8K>t0!6zm=nt1kSa^7gEOpo%LY>RRxTXVn0&_xpT +zLr5)0(^mL$u0B;y*3pgWQRMqUO7aJn)bOvi9PO5*>f;mDESKK}IQ;wdthn#A{7W7h +zMA$sdAe%kW`}?chFUcU<@4`=!pQgeWZJPy~q~ANjNG<)AzUm9{%({GG{GtA{NtxV< +zwA?cZ3hFF)sV_0HjW3Ak3qDG|)ZecgvaWTqgpwSKwoRD=m#)rI%kX~k?|L$xer0xH +zkfQ=V#Koyx&=e%xFQH63SJzwEC7PTYdTVdX)0D)YLW_zLFkh)Th4B^gkc){`f{qU| +zBv2i3LQwQaoDVF3u8e(?6H9P(KQ)F6$T|vz>z;2|NX5c7(%;ve*qwNo6lT5% +zY%1CeTjgBL4XeqP2h`C}@ +z92H79P1a%0fQ`)V-PKxKaevB0D@lbV<{CQwQwHs2v@NVfV}UCdwuBGFSEa|k5)*S_ +z<-Na&(gTuyMXu2zSN>d$dsfu0^^l#Lotqzc|Fu49l5~M9hX(-QF#KQDN6dPrc8-P? +z#>OVLdPe33wr2kU56&h=|3~!^wv`eV$Agz|NbIKOri(j0aa-CW>KNhyu{EMJU{?o~ +zU`Hqhh8+6bo9Sph^^{-V&5BX6#~S*fj4hi=g}ww$MTu{D*Wb)dvgjI-oAf%D^^Kiy +zvI$tLCN&2~+&A91wVm-8T6V7qrW+H7##Y(8hB>7(dnnLR+jGD2+;&Kq}!Wt!!< +zM(c-Mv!b;zi9erXWX#bwDKYgsMH6haf!54CyXn$H^*MLHvL2Z&VNf_tdV@<6*d<>) +z_(9()?*LO^+T0UBwP(u_z4N}Xh45Bi^ssqL%QSw2X@NfswCLBuneH8`hAm}1su +z;4d$qaR~s#5C9>6W<9QU^_hNh+u01RJ{lqRI)U-Q@46%ocGv|Z{jv;kttGXr!!Smc +zW-%@sEsh=y;2n0+%@gad;}DYi#|42W1nDal;>!tTt((<_aHPjhwg0e7WQ!xuI8aua +z&sKg2uV5+_6RE}V9@O?7>c^~Cb*|u;hAaRMqOli^1^CWJS+oyMoigtP=|z|1sen}T=-5HD|92u%$e^a%58d|G$IpwIG;L>@<DK$0#6W}NW=m(=vp=IucD3|^EW8T +z`ygMDJf=2eA-TNbI9mIi3c3;g1R!vpPI)1*Ou3;TR3t_=Ua|$6UuDrgu+j62!Mcbm +z*ZG`yex$oEaRm@ti)+^TpbO&_oR-oyd%8A0OdwO%1A+Bm^@=1pizYto`>aOx%+wz6Oc=7UCjK*U^KY$R+6ft&(T@&eaV;JEIu`ceWSV13z?YO{yqEK3Q` +zQmWo`?9RtcR7>%BaYr5vjST`o>0vYs>BY^FX;LttNK$Mjtco0IDj;07JV*#-AfnR9 +zlJwg3a1Vaoavb^|6a?5;v1b@}=eVddz1xE;n#8;i7Ukdk<;rGL6*qnM2wcF1K%BDY +zi>qEBC@wrnJfUl>NaJ{aMqDuNCk2k?Q`}1%Ul$wbW(y9 +z+ufZ;FfiD~o6%4%NNkTp&wKYEn!?o>QW5a7fzE~H!-Qv8X{teijPuUtqi$znfP^Oc4S#z^RZe=OOnb!{DpWMfcG +z2Q-+sa|TIC2I`1a!AKEd(hVn{O?v=}a8nhvza)NbZUN8?pL@0X$iR$`rqefa{JBmM +z0~3WPTMx!R-rG2}w614nURE9lokh>3e_7+(<)qEW +zne3KPT?=+e!6=}2s6Y}K81s4>11ycxtXm;|4n0yJE_m~2oA5Iqe4n6{ +zD~?v^4)(H$d0d|ML$ft)oe!h`@cZCOY*S!d`Hq!b3?=n)&RiK}BL?&Mzd#soIdw4? +zD}gkC!=I)X+pUAr#+VKj0kVP%d$gh~4Op{MU#T3iWi8T@tZH%)juU^SNxQEyK2@rb +zuJ6$QsSRXz9>Gx4Ry5$t#zt0C+O?!BCN-A7%H6E^OY@R{YH+0!sBfE=?~=g^0DAQ4 +zcqDgU#Y@%OCg|3w5xi?zO%6#sjd5<@wM2~z?FG5{GSEs{Zvh$j5izjaeqO#zU`Hq} +zu+RfUna?sY&mfRCPdZM`JrHH6_R*MvX~`1dk}{0d^4nQ7+N-) +z;0{qvXl2Cv!nA|>L97t`OiJ_Zc2C~!$LC!fMLY}7JZ;pO2Uc$EAz_7^|1(`Id>)0a +zqgjyU&fulVu@%ZUUA-vw6CY$}!9C+VQo}bd<_MckJ)RDIwoIF)^$Z1?Z?`r67icB7 +zm&0_|R|^_hW{BRdY|T+dZ`;&O0I9*2b=}x)yhTQ*4=8y1dxx^Z_Gttxf1(CHf>;Pq +zJDM41Qd)$10twAYB9gn}J#yC4Aww;b9}d6cPgyyTAPJ%)QUcKQc2eh}Mu5%OsQ@v8 +zt`ia8=^dyL)T@D6P56tR3r2irez@z2Wug9?JkQfrML`&A-)CjSg?L%lt`;N;cXbZb +z?_(7`k9K-xi5S*Z6>_E+d-qsNg+oY2{&956FzdWy&fTV7V1j?JOG_q6$^ +zS(+czs9!eH&09Mu&{Jz+JV8!fGHB|}^;Gi#UP_=ZK +zwM*{rAI{^)v7d*mc*62R=b!1;&!iZdT4^~8LZm@SG@YVersV+N5oZuZ}R;bclpSH +zb%z24}A|rZ6D@>j0Bkx^`&m$e;8Er +zg?9r$q~?Ga#fH;yCt~L3OdycA+z0^rMjt)8aspl7v?b_K-drVwuyc{3yIK!bc03_Yy!?0_-PjRgzB@-6s48_C)cLuH_WR&Z)9(dfK+g6C5 +zB&~lHma(^-;4lD5@9a-X`$f>Fm;*RnJ`73w)7YnHxJ{ZuWOi<>PUK^aC847)b$LwL +z=_6%D&5FKu>2#Xs&A^lN|CO%88aw+(Pl4|YT>PUEw#MPtIupN#8iz9t)>|1@YK8UH7L!zeGeHLG=b0?v&s +z`pDQ{Ry<=KQH@c05N76bsXz^?H0$BxfZxD4Hlt?eYuyEYX?GD7ofI +zkdr7auAA@3OE)S8ONTcLb1G$|qazs^UonIU9~e5bP^=JMbhFY+2&V}%HRfz}bJ(7A +zPN5^}r|29dbCStw*XRV{ +zFH{I<{x}i!NJ(pE2iI%$2Fcb-%2v3~-t(&|A7xg=Aub=M;-qZg?SlGrYM>4OOp3mm +z6$Mp-NTtkmDB?aj{!V&*(dpsJjk0OPTli?9BkqVKl$W8u!qyems#@e8RV8& +ziV?HfxNvs1+{~)aZpyo{UcPlZ5E77a +zr|g=%W70DwyceaLssIu_>kD|rKU@cTvm1ud#9pb9aCQ$=4U|Xt^M{`K({DLrEd%8L +z$;i^ayDB5D23wsGMLrdhNiV&j2=%Y@4QSA}FTmC!?>QtFWij=#OJ~xOB&4Eh5Rx94 +zfz3MvTD!QJs&P*Lo8Bby8PE%vya{KE&MG3yqV5*GbA5b%TJKbC;q6S0%lIBrpf#~M +zFBm$;%2JMIfgFa_So+NliKRt@(oj$#oY{6LFD4AUGQzG-%Ce1f!D0mu=@3`o&E{kl +zD-19aVBWIpJN2afB#{!S5REe7Y~9iU6t26sC+F$CR1CQ6uJch6CRP+qdAF(8nXifI +z>Yls-;{skjOtmMq}X0!Vqyg(Z*S!-1ZE8vrrY +z#$qY5BVG*z%s}()km+qzT|?jWeddIS6Fx~pKp23ueglLyIaUR +zZ+J8-f##@luCy%~;}GKvUc(5enl>idJ9rK0QixR+;r(X;!Z&|IV(%~T8VH8UyGDgyr=T +zi%L^*Tb9<)qIITiEtmbCL;}dg$`F +z&)zO_#ggxyqi3yWW0-tkJE@E8|7*is43V*i{%6DNWds0V`ER)~`#)}MZ>MKr>uh3e +zZSjv88~tmH{JWi;{|`Kvze<~&h`%{~f@zo9iCztm-K}+EVQJ9usm;m8h##J8{l?KW +zh$v$(6Rh3bnZMtzB=21~=1bj|E*s|nsl1D|s$EsvDK~8p6&$14D=*^L_M#km@G;Tl +zr-{8t@|&hg7wO5^19p7nEg)Mh<#2CKZ~um+zJg8X_UPsKIbK9+=4XYnOf~ak2DZLd +z>JK#v{a!QZ_;r~qwZ^w_O4xe!@t5%IS#uP3U}K|23|PO~lB_zaa}U;FIi~O9EQh&H +zf&ptwa{{Q9^wPB6S4^1oVn(PM&@jOXtW*ir0>WDjt!K+Y1BuKModY3IbNSinuw|}- +zT}|GUU;p|me%aMiOuOi>$!^_r@Io6K5jMLvKADF&@>s&c*}h=23*NY&!SLJZc63{9 +ze*4cZX{j~Anqgup1$>!rVHzbd(TNlBeuP<)F1dX9(~m&|H_GLP^F3LOe&;V-o#FYc +z66E~p(Ch8CCtijHj_4tKO645Dt;iB=aG`jEgz_Z>)w4suW8E$W$i$L0Uz-p7zO!lA +zWPP%p8kone=YU~2*~v8)i^GZs4%ela8s+gwSBr`=ws85K6x?mnYN4oy2 +z;(FF=)|9WXdMT3=v?a4)?q4QC(^&u=fRu;xjonM9mB?qlyGvGJv|x22U!`Fx(^(5` +z4Naz>KNm@|T{_F09)Yn@xzvwy;c(-~H%elu%^L4aOx6j4Fn?(kw639|$0J{8e?@#!#ep2>0b}V}5@H+_wnD@KU+V{I +zF$6rt1{$>47F)u4OtySLSM)}A%>o@eL{)RiK(p9q`O$T~tz%;*w&{~Vq=t)A!aDst +z91;AfjKZw5)Sqopj+Qz0T^Q0c=*#Hu%ZU9Bw}D1^3z02I`Zk%lzVz4hWh#o1DQe9B +z6$W3u82A*$ouQ;?w-+H$3MAvJeh^PJ{hC&@U^zJ@bZt(6)ijFhx@@gTuli|(Il8CfH2Hs`Q+;E^riILUPuCS7&-pXnS%|L^Ks;M6e;wP +z^e)^7n##cfaMTmWP3_h{;(Lj?zJ0V*!j|G#3{Qk|cI95h{G_d49-wEE65Dw0&ORG1Jl?P{v0xh>5i< +zhV(t*r~JF|%b?dJ%o{l(mu7{0#0)Y8&R<-s(=8+zlTfuHv7*h$!{(G!Bgvf)$w2f|y?wYL +zS9Eb|iaHeuDua`NWQ{uFm?FE!05u*Z2&K)SKapZ<6+Cm7w2gh2<}oLHi?L;hi^GGo +z{X0@RH +zSMah?1C&;CHk`SOl-bK6emv>jeHOE#1~Tgv!CR=wik|M{FZ47 +zhz1u4-1rokq$ETqh8Ff@ZtJ5{8B3PqhYoklpKc}G*7C5O$9O=g~8a +z7@>bg5797wuQCcDoB3w|JSNakY10*675k{Ai6vH;~h|&0JVJYS*Xf~ +z2HGhNs*%}A{f@m}{c?Upqk8?&$A@TaMu +zc3#UN$+9l0Av6Ew;#9z!lz0>II41{k)XCmG(|%NohXCo@ibv@FBN{~iUT}lU$+@yX +zrhVdogOif46cCO54kC*kkqrQk6yycuca;FUq=ATRp+qgE^LH}gVc2ueJ%OAe`3zC* +zo~n2Ft@$X?NqP|1FBTG!@g8f#SQxi~>>>Q` +zhCG0+X_llv;o9YyEPEf&*mj6Z8C184XY#P$ +z;-+3=bC=p24*aeR6!BtH0jM%n?{qgyN>obE_JFT=meY5Z(sv%4Z@{Ar$?9996zS3p +z?EBOYZwIS&o@&{K#6H=hC@GdNaY+7^7rJnYa;UW1%{uchsq`O}Jocw3yomLi=0<9# +z1KHo;!s(mG#Y>0^HZ93MdpsP=QBgY2s9QQDlgb&`)RH7ueT`R=oyir|zR)Qez3JRb +zqgWBYIupGd(%dV?E_#mE=z~nFa_`~h0u(UDob;$gWlkOzscjq*@k^n*EW9omrfPnH{wtAtomNlf|3~E9bpQb9 +z|6AqBp=WGo&Dp@WS|%+{mZFlNCtN8;L*q2%*pI +zC*6~ra{0NLL6m&m?%rHDEb>IhIDAb0XF6Y!$^vggr{?Aa!3e8>Yah#|wSn)%!Fwk2 +z%7R&XZD|d!oj&FuMkX+tI~U-L0vWxl;J3i@Vw7HZ(#Tq=MV?P~>D($emIyA4 +z?*b=pp1xC!bM%9tV7v2f>Hf;wiW54}bo!U=P-5drpHK#l%+NL3gE3|>xG!{l51`UJ +z+yt05vy^xFFx%GTy2f(n&O%o0%Ntt+Zxa68#)C?~G4#n(5_bQvUV>|p+YRYPJ7wS- +zEDIwZ{5@<{ySjU^wPt^D1;TLVXz|@IxK(60nbdkTATF*b7Bt +zdJV+i?Ml13r++D1x#^XAITjaM>rNiILD@9X``C9*_rn-Bor)GY^{5jZR{IG|zyfvSk`zZ`Cw^dAlvl_mef;Uvo;Ox3-qEn!i{{w@{7 +zw4FO+S2B1et+W>~vvKmH?+<%n@&veJ19_&t`#<*DDR^z#i!#fB(2oc}R1m_e-hWJ} +zavx|4s#=0AFPu;w+>+|^0bAgf)cvsjsWIkEBg_}`u4WZWc{$&~Gu_fGn_4Vk#Cs>y +zkc&j9NCMxcG-`TOZ?lFtEMhOECE~J->3G(PC@sDn9T_$)k=qaj1Z@GO^ASL(CqEF+ +z9SN0W|9*{p^c_;4_})VBbPWvgN$qrrOZsp!o1O3JRfQbb0`;9bddajA!|(WiFx;n@ +zJ+rFY<@}P?tOZQGU|~D31?D7GB<$(-YeqYm^!Br#1OZ8im`r +zIAd=pJ1*Y9YAx+TOw%Ti3nWgv<+d8F`Kv^}f`yL>KEhKLkqALki6OsUcq@IGsmgL? +zH(Fv|Y!$z1v^Ts`>~izh%f>-@Tl()bihm?-@ENj3QC7*eqLXH(L*(u*@X2Xv`%=@N +z{7cDK)}RcG2t^|CCeunPvdBNCpqeevk+Q{L3nPlMT57G8iR5)%`ozi43A!$pR`Q^a +zN3*sPP_#k=l$2XJ(fkq;@`~T`&|;%TG~_JRRPTr&KFOC!GLyGZfoiPeqR-6yA#s@^B`Oym|B^Tf+kZ%0AzpwE +z?ko3=d_+UdquwAG8tI{@^G~;6SSg--$?6zQ2>bjf +zOtfs5k?HUBDPD(i9&}vb?Fm$`*QY>D`Jbk-7q3vTI|yX#&KVzw)4GQ*yYPm;nRDJx +zBUarVZO_<#iK*^yn_pfqx3pw%#Wk)_N5A4%TKd87cuUdIC~f +zt_kuxk96RJy>f&7@SF2`E9eFuKCayiu;vdfaA1x#x{|_Vp@}j!0X3C6JvCKw9=1pW +z!hi{w4LHEg$FESxC8J=~nh1+uQ7DnUpgtZ`CIlzTfuJA#u;>a*VAA-Q4vLe%Ziv1M +zloGIVVdT2dFZ=c@KcMZDp@NHpRo;a&gw7YSf+uR!XsCzFHk(Y=*ObxaEQ6caZwCCI +zR4whGM#=xEAuS`8Rq_8CZOnb)N0elSK3k&q>V}E-T(3B0;=4&VFtTq#5+b87jW|9= +z1~GQ13H|!yJ{i!gUEgl}@~_UjyhyXi-+4-la%V%lJof^x)a=h<&Se}*0iV{m^B!+9 +z9AR%GDY_Rz1&DkP&Xjs)vfRAiBZwdjUoF4eWJ6YOmA~t7axb~pRt00=bzL5xp@YN +zkH9>$lKtm&4g)Um(8CHwe{F<&5K{Z@PYHP=5MEIa1s>_~Rw&Dv9hBgX$|yYx5B5Y> +zJ(+umBL(?!%n?$RSN@49)XZu#Uk?D0)b@CNO?Np6R3G5X8ij`D-0zlE?ODpIe$tE! +zxU}f9vy_0^4!X+Fa#39y06`m29AXTEZ-dHH`2(^&QQVHy;y6>WDMcsRi1T_NARa#> +zc3}muz;dFUBVh;nmY8x$Q;z7Y%=f&^n_w&t3`e@t!tK5>jZbWDOQu{0*-xjdMZT|- +z?lseQaG`fA-*5HrMER(T<>ITJTA+Da1+wE4Z`7RkFgcMkWJ#H1m}XgXcZi5i#s#Y^ +z1>^S6Y8WbL*AGYX*KH)pX+#VnIuR%`C0-D4EmoUZB$^m{C>71(JEz1Ed;y(wNRw** +zpCxL#bRnNWiw#mK%_XksOC17Wz4nWcp?OVQk{T}1IHvmuGHkX?qXdq +znFZla;){sx*w?M5+-xv@z*|s4+wBi{t0{*^1H`{pFi!8xq)aNUXft00B<)4z?45+x +zW*U^vx>&TD4M{KunPl(u8LQFXKa1GUquOBI^q(Q~1+?RFq5w=ByMF6JtO!y_pxwSx +zV(edMmqynPn|$#oAUM$q6>dp^(AMv@{O}U&>K*@Q>&6HzIfGTHf +zxJ$VV##g)0!6}2Wx2*Gc1Sv+2=&IhC!8#`yx!Y*R@1f0uuY894L2tKu^Y}Sfy4?;* +zc2iKN1o!5~wpn>4lxj?dWL&W%D6y30G>s4B#U2zHGX51JTq+q%43yys>P840ajv_U +z*m&Y=&N2qc`M(;;($!aNbTWDx?x$M5S$)B4wv3Vbz}xYVpKq4XZr+?~k;X3<2s#2= +zm`1oL7bG^w{L1fut~ktgDk>i1g|9u+R|)w?51>A*>P9gb%QFqw?D^ME6q7l5=R|-H@~7%n!Kcq(TRLI2e$n&TEafY +zUq(x5p}ku%EIz6NdE*o}A!!wmM{~~sl*8y+A5%19aO|-0@*&1#c&&=V8k?IHJQ||? +zp=A^(D(H3XpNfE3Sg?mdNR)BmP+zc3pTg5v`+C00POhaZodU#zKtk>|XVW-g0C&K1 +z7Cmp_=2{P7LGl04vP6Y{Xj%8EAK&SeL>8F*e$#0RF0Kq=d2bBB=^u--)jnJcrb_o( +zR)t8T%8^H-JB18L@R;JA6N`&^3vpOrYz2^|C9qNm)dK+IB0$z5S19YpIpn7NUA0Oe +z?aAOTHIZhPWz|%SF<0N7{4bI^%S|8lC3fqqG}XFrYJ?wJhU#A9J!07cbLv31rCQ72 +zyYY37A$SHkw9Ncv1ybkRhL{LCeBqZuuP}&)tBj2RrA(t^5Vp2)jI{`k8Ci*U%5OvI +zU9MSH>*vfwx52YLET6fRIV{7+ob!-q1R}2Xfif>ZzJP!`cwl#RC#1Q(^~`fY1Q|k4 +zfVZ|)^!1=gT7vbmGa+a*Zch7)Ezv~s6phYdU2y+s?PGQyFR#v6*>pb7Xh1QTwffhw +z_N$6N%T$-2?a=FtXJ%J1Nn)NC{ncwB)!6Y@b!+(e%Ik +zpS_p{sL)YMA8`-RAZN6aFR(f(yR@Mn!I+Y*1N8LMs-|eTf37pBkNN^Jpq-OC252@O +zYJ``OllX1VFU=0@5#{u!Krz**yq(BK +zaG+F@$Z2ZV%H!3mjnI-ovxQ;lqk8Xf*rw4F+%>7S8X_lP$tj+6q7x}&$N#7}t75~d +zSY1b%4F}zFj;H54dKt^a5mC>g*IRSa_jv`h1G_pmi%11Vo@Vlb<2;A~l1qhC;Ex#% +zrvX%0YB~k%oIwrqf(p!f0b;b-L*b#PfeX<_IEMBXO{k&~j-0Q`atzs0HyFqPHoq*L +zQ5|;gf11W46=K*|&TyQ!6B2w(QSHcziCm;A$>z?R$zLN>Qst`_>Q@Ozzp+BhGix&_ +zfvCfEq3tp9j746VS0r^GW9$F@)zI?(MlSW{U>6-?3-I(~@94 +zo8G@LVEW*5VT;_q=8WmF%3gs*oc#H!kIVtzDiH$H%9H8Pwpe)3c|#u8e&%#OTmg4- +z)o;|A_QILn=Nl2toC)k2p6KXv8y8sk|eTbCt=Xoi769$jF-4 +z@5B=SmOYN5F)EI%{z^TE0OqMmyh5~gCmQW54-OmViBGc!OnIADCkHY{djGX~`LY9}EFJN+~1@o^;;1eg0q>CdOX)p9-uFNolj1kK-G+ +zUC3}$@Eg%*OOT9n(^283sw%XSrY8^yf%A)>xRj^9MC0Z+{Pdfw;#*Qa@I+(>1`=>} +zjRJFRI*P%(dk>y1~`ETPOT +zTNSt!ZZph0u36dS{nz8D`iChd5q5vmX~H#2stlv4JU1SOPPo+I#L9k7 +zAibHxk`+9jW>F^v6D;q2eu4L$dM7;bk(u_Ab)$|UT2ffM3N%!NeVIDioqeF2JIk99 +zS4ihk(+B|}MRg=IiT2%Mnl-$I#Ceh|t?>oFQ1qED?X;!>hp+G37-2~Q&G@CKI|umw +zH#uqwAK??wj~q4i$G$}KKfTfz=yeVB4K1x~O?6EjY;CRo-;_(%j%(sbUst*e6Gd>X +z`2JZJI>{*K1FT_Le}_S*ue4XOS(3q0)gqmU#&q^>wrh?>uK%zTSp8gsCrQpuGSO{* +zI>|kY!*fBXpl2sYVyVEH2Ht}*&H^^u;JAkCIId(Vu~sn?Pd0_Yw^F(Bp9p+GPvIWX +z^Wtkg{7d>njDAQzWACOuSvJYreXKjsK_JoyYDPQR!Ey@L=mU7BY)PPN08uCTB9Boa09;roE^-I)u3f#wg<>^V(PoZNV!gbMl}%I-6wMFmsg_WW)u1qsbuH +zWu8=%&p)JJxE5_P_Q-G5%izs}pW@537RDI^W<6@1XQjm7LYjF+@*rkVSw`NuCV`&t^OC$7{7L2uV78}q69$tF!a0*eOtc-pg)@_#T<|BsO|XpRBiP+d +zzkOYm7|U$ege_ULC%olF^Dv2~gY7!`4uSs^C_98VfWZWz4!>31t{v})7hn{kh*hH? +zH$(OSIdZVR)L+b_c1(giO4&cLOiqt}fne)#^T`D%Nxv42X>$xieDFnun+Ppuh+r^L +z>8ac-A(?9M&$m44uRd=&iYkb=_D?7aGU5MLoQLmhIUA!qPnBIm3%M{ukvi~)YRK>| +zD;$$8>Az(Q1!yc)F3Z4qw1`)M5vbaoBvTX1@?K^9zTvhg{S@#l2@o_%fHWSgPH~Sl +zPe)ouK$h35+loI$V1+et2u{8l>=)#j*1j5P4IDHEp6gxo)K4MXfNi!`Vhwi$#U{Rjh1VUS48!bPR7rY&aacD~#(^ +zlf@A0m#Cc12ZAh5B6QNDD23}hN%G6%;hzwLtM8ip82}E2wR5YvYd${jy1b?v@1(&6 +z7GU->ihEe4f-p%h>xnRwpg;ujOmXa_FQfae;X$-26OvBi+Y=JaKis{|`Y`v%+mCEh +z)W!+Z+9Od!W_rdo`2`sZ$!Gakii3^i@Hbkg)yRZlR=l39N%kbH)jY-?e_C?M#6I34 +zK;PL>@{iPLpM~=Svd6NZuaM`=ZVsNt93O&!#;vU2(GC`2eGyB?+__XC-Ete|64S9P +z_h!_-M$0*wrN!ikCk%?gLJTWla=!w6w&WCmQ|Vl+1XwPKS~b)NVxS0ykkfXQC7sjn +zuIy6HAyAkDwU(gkboM?#uH1~hnC1zH_8i0~h3t>`?a?g!Io!+2RyQj}pz7r~Qmz3V +z0}C!?|4?k9AhLT5y^+Dg(&W8tHPOpvxUrx!zetc-PJLQRkh@c*=j{y|_VU<~cR? +z9s)0r1px3+l$D2Hq|ktaj0OR-O5iHrf1ExuYqIC$4=GkwV7@RZbtptiJB2gk9bwt= +z^@UD(%`=@!xi7RQFPWK5yl{PT+qjU+<<(@CxbLJ(6iKKR_veKt;d#TAqZ2ulCJrm( +zFd%$G2oh^~-8_|>@!8c8o3Bf+1+$c*$iedxlqOQb%Grr!8y_%?Srez%FqT-5E*qi4 +zErid^jvwYj=SPH32Gnp!%FPZytZNXwFd!3UI&(sW&dW8Z%gc$>#d~>wO-(1_uEh!N +z9jH?t0E|MGMAQSF=qf2|iwG;_7Mdvw5wNN1BlLT*`05fMnPUIkDIDRbkdqgJQ|GN6 +zUU?dTUK@e*gi3CQ6i%x)S*UMfsXM-w^*`$kCC|w~Aj%JCDyGC^hov*I*#GL!F0BAi +zxL7r5t6TT@`gI-^@pZ#S%l<`8{iO>Pkr^jKO|9|wIaGuUdQ&})(4^ihMZ~)wpHgZ% +zuDKOE?IoMwaMWO2 +zt%KHdxUx_`+Pm6I`}g;Gsr_&NwNIw%M5$}rglD+3BW90eW7XFTh*5JJaC!`G|Iif} +zf{eH134($OQ8*7LM(zvvXoqMDFrhS;3?MkUju6`jdPZ=b61}d5t1)xLVJ-b}l&el( +z_jx`#ppt4X=TSS+P-@ma_WA1`-pi~hEmv`p~H~B +zFQzjnGI}5$V51@3H<+2gf#_c_j40~}UKDNl$S8mIL=0x>BUS}%-u5iFf_%@sD3w}A +znI^2Ct70mn=BLFMT~j_As-3#07-#uLUn%zUJfWY+cY&iM{FYnQ)`;A+~XbEpE +zG#~O@dQ_nKtuu&0%B!Niztt-~F+J;eXUSKrByCcIzw>6v_d9z9H4tm`hSF|))m0=8 +z-FkZ!B&bvAX!Y;GgI;7C1U(&gVFgdmSl7H>N)T*mnH|YMeZ6po^Jy +zk))$o(ba!SEOR>ZuC_@pk`t4wGxU9;HjklMmx*Jj*JUknjkh4TIbujSiPCB~OqUcq +zmIx*m8gouL6Ya8kUZxD`T3+6MpXoutef?*H3uQqQtr>uA^tvqlHx3j|DrCeo9BZK2PTe +zBl|H+5msArik2FHZAO)IUq!p&!n_@*jZwFUkM9|wJ3)F-KFKguod9RHYDdavTV44b +z%|OBd +zp!yM{FXjpuA*Y=QEvE0a`#!CCMrVagkqI|q3MA7wuDjDpKz==))&oK7YHRguVY14W +z4me1|Z$Y{Oz!p8l^M;Dv*R*>N$xF6lH3D7?%H?YDK$}@j-!~20rSVoz1-WAdAGU4q`5js%^M +zJBZgP>|4a83ytN@LQzU$c+tIk?hNPw_@@RQ7Omq4@gR6j2|WfrZw~zx2rmczb{O6i +zh9ggp>NPW;8!K5BocQx$pwz6?x)xv|2nswsT{a;C*9}SgIwV=3j?z_&gbHOh2BiCcMcB$$qumzB+pwjHnKkeJ;1Ny(xw6!= +zdTooFzan$~UMaTx#akpUk~A9^X(zyjihMHYS2(481Wrk~*Zk8w3+L?VdRwo!XUAPw +zEMbtoX0~-o*ga}_ga?{(zznUv7bMCE!#xf9obzp*CC@%SkZCtuYU}~v$y-K#TWp0+ +zJP1q0A16&2g_Ii9(^%|pDMN{dxJUArA(dP&nb;2+npnOfY=o2JMO~JeXT-p#YOQj) +z?|HH}w`r}(#$P0!svXI0uWo@m3V9(wYY>gS%ZlNRDfc@G-63w*$7jt}3 +zr#u9&kR)KV{U8|&|Mcs+`%U<%7YD}j-Qx!vd&WLPxdR&7$)I_u=JyWP1l_nxOR>6& +zB$5vvwoYoEAt}IwRc9#`w$uVoa64NA-_b*`n?oYtl~bzxj+1nOJBM|I!qs87Jkk3= +z4bBZ!GxH}?+Y=XpuE>gQfFRXzJ_RZc`lBnqnOPopz{rufk&>gr_0|6LcTB>)Nve++K2_=>?+llXc;%`e=RMd}LviQJU +zN<|>t?ofwYb3}NF#_3#TzEycR9q%9rriMrRt#<^ +zd{h#UR17d>-;CPKN{}4Ud$FbP+_o#&vNVt@*d>J?J5ZGS`SmNgU +zPGd0zMRa-D=za&Qd@JB^hDZUod_Mm$kYYMDZg`u!_gTQ8}Urd1$@-3*PdN_z-P@d>2Mm+C0;={ge#bycz_P6 +zY6&Ly?Ed5Z2Tq%~jm<-bR;%t%`2$dbLGe78 +zy3I^%B6a**DRrbH2w{lC!dPKMgluu@bR%M5#}=h0r#QsNJEZ&&qt<*!re#S%GWfQV +z2$fjZ_&&PJSw7BZPKXSiIi4pCJ{zCnuxbI_XQh^0ge0MgvOVvzm95PsoMmM+M*IrPZgCVnmVt36AlW9JkBWc-9NYjOvJTV^>8sKrLYS1UK1-4}Svn^JM%hv_*iYv~} +zoiV82D@FkNZWW4T+rDy0GrOP5Glh>d>O7q4MJ&ph4TRUoHZ4<&j|=xmww?x0miRDm^5<-w7b8 +z3XIvu_n1%E7QLquGOB~9O1}qK;g5y;qNiPW$FkZ2hfih7-k~8J1Z}%x_k9^^ +z&{TQ3FLNHEJFZKv-$TR(b~8>p=5AA5)^RWJh@LShcl4{3cIbS)fn{}QYU=cj#3OX+ +z%(|8`RvQ~{Cdb?0=mZ?2^9u3~X$9sCIPWylB*qg?B!6WcSJlk4&1VLnG +zRg3x|g~v2ojWDK}{;ggmU7D5Cg&pqNJy98!v9Bw_`VHB8Yv1xr3NM<%&k4BfyN-i> +z+}BE@dnRm0Hqg*_Lk!Q}V$FUXg_iiH1N8A|_wv@thlBU{Zo2c +z^P!{Qv?f5I#V>j^q@?=OAJbftktZiQxaY}Jq;V1J&tUwPf-m#ib)zR|Dz3|jH)y!X +zjLk>9#c%Q%V30_x$-r$>D~Y&UF+(Usv+jNX&^hF7g;oGT7F$4)@Upxvlnv56NCiez +zdZWzXRl%6$20XrX^Je8EHA~crYpLVfD9IVSitFiM+m8Nk)(XL%_&^UV*Nkzl&?eX- +zgjCC>o5L8h)L3{0XoYrdl&D-KptLJ)SWo&|qC-^OZuSk;vtgPmV|pU@rS>L2NQIP)5yDANqEY-%J~b`xUTfUw>pph +zL|$xHLJ57aS-Bfex5Uhv=4ZsJ585Ep3BOlgFV(*z>Q}^cFIO>pa^p?&qdG^xDTC&2rEoeT>Xl?aE*yTjq +zUgdlfJ)2v;C88a4!!A)+BvI2PQKF?y_NKyWvr=xP(x7i2sm5C@c*B=Ge~MMRyb&Bl +z9g^7=G7e%#o}_%zzcldt(o-U5=1L{I>19l2<;ll0pERq?nk!BD6^6K$H+~n+@k9g? +zbj#qriIfX^nxN5!l61Jn6!2;;V7C`A0(!ufh-whdwKUX)Qc7hN&5=b!N{hk$#&y45 +z@x$PNz)a&9mU%-Y)ePHhJ!&PdPj+a>x+;0FtfaXl60;ThlHy8e8T5_b3jRQ2jj{=Q +zMyY~*^-(+CI>IvN4leT){7fEf*FnkYfhH5F*r>r;bPvC@L@{OpW;yfa*RSNh4};@9`Nvku3WBM +z|9#ZxIvn0L{xfRyVE@0#o>+Aq&8?lS{-Y`J|9fDr;VwtSf%=^zw;QD)nV%Ql`|!}r +zXwU#SFY|^^p4Z_DN?xwaF(_YWdG0rs>-Fr-TR!f5;1_$fniYp2xytv>!`HL$v=1lv +z%RJnIwE7p4=v9yMAohgHE8;09w +zjPGT;$$ex9)}wM~T-V>Bdi#Jeq>-B#6g)&?bchpdkTP8TX=I!ynidf1KUIm|BO#0{ +zs!(83@E3vkt2)2H`ddc*_PD!k`JQT5=IP(z?wHUd`8*c;u9LPMHf)0n{Af{@a~Y%U +zh@+b4TbO@&Zy5!U8a43ubZbfjqf?oH +z3qiu+PtbHB14Z&OuWrK$1FsoiGfFAhwx!t&$=M3pClml9ys*gVS5FLMSU^P*zCpqm +zeU}+W;?Ch-r|st@=)Oxi54r*GFHRc*jlu(F(>*N854bSCdOteqCUArCSCTQ@qkkEg +zngv1j?xT2rnHvm<2b?1Ancw>w5iloGZQVB6_ZM;6#B!QZU85*3U>-48x#lCUe02wwqr`#}0 +zal74zG>Pcbo&Qwy^j07NJl$0k-i&~_w2#Xjviyp3Gjd)0;_XuBf|eF7_8Ow$z6rP{ +zdmSazD$wyJitXvs4;g_r`7@oFBCcm8h9Yf4#L1LQK01RzfZy=ZP?`R7y~OvIT%PH06F7n4jzieYV_XuBMpgV +zOHq&t?z77AV@kUC@$-=+Y%&U3@)v1{%`~7i8CaE0QKgUk4&py{ADmbpqyzU=%ZS}F +z^OB|(WB22rD>i@yws7wY4AQP88?uaL9z!4~=4kpO@kcxZM6fpf>rBcf +zouMXQOt_gSR6%A8E~X@Jg6Og6Xlv-jQXhxKTv@V6(?rh@YQk7MgpO(D^c7|UG1*(i +zju3Sl4TAvDp$gH(ox1!;!oEVvxUYwNSXNIuzicRAil+~L$&zJC+)h`e%$750jR!-m +z&QM2N;bq@a9*Djq`FF(_{4PKRm}JNKIN%L~vsu;n?d|Z%nJ|!@e&l=H91I1o;*%>P +zC_8%njpLxG-fgn_;p7a{t@hpe{2uqS`a$qrnIi*V9~^2`OV~q^#dKvF28yLh=O1t& +zxI{*fPQx6v>P(g#-oXR93Pl(<^cf~%C-f52*ek$K1o)ujFqy|o$hHxoJhCOrMJ)~? +zn?c70RFT2ZFck0GI^kRaz}vg-mup+Lr=cAw?D$1-3D0Jy`Se$W9S=W>j7&D6GIym5680HTb{BO=*j10!M& +zXU!*Rb6z^wrwJJnjE${c1l$*zozl}%ba4P=oCcFas&P$5KqPpw`7nd`&}iq82lV$@ +zBDyOafO2Ail)+S}&huTfE(Tc^oTy%N^zGN_cQAQP^TeYsX<&8RZe8v!^*4TmcbjS| +z=T$xD2+vDL^H$*>M|A5!XxgljCasl`YgAkO+;#)ay~8NGDkXWDQAdaV#fs^>9ihr +zT&7+G>WAyv3BZVr$+6AcrTkXz^iFa(B%e90m%K51J(17E+)<*U>qMfJ77?d-%y4j# +zpWgdB*3p?Z+Ei0yEo0oylvj2;`Wca?#?t>LuZi+(wUF@iJMT9HZbKQzc=M4g){)gQdJqc6y}GGfY)H}#RVuCGa56S +zEX;D{Hq0c%(Os`(v=t+J_qiTnVhi6b#H^|LKEVw)!!wb!62lr9r8wtSrc3LQvrhPC +zm7Mg|qh{`*OIl@pnuKoj({`XFLk$^=dC;PD+#O2FZ30N^;`)(B1Dr#|6N2!in@u}_NxPb{$JGM)eL4L0wbU(>CKy?`tl>lm#~Xkvu2Jik(Gpu58JBE-&VgshJ<0Y3wn%uC%u< +z0@%L3Ef5_lSKsLrltGH$!$CLet}{a3&j?2_zrRq`eUi=OV>x56iA+gA8)YF^<&92q +zCXkC(pVVInFChiX2OBudCvTP}TyAODILx}en^rWwT0*F+H*h_)3HtXl4=t`?0C=0`{1B%~zY;)G%s^B&@CGeLJM2>=T!cQ~Yffo> +zqvFbbe7uQ3b@VKje0W$_KnbUDqH7gNm`Z2C`M@WQXT$LRE;xwKv=!!MLE+jCs>(+4 +z82Dw`w=U3?99ERCe3m;W?t!KqPF^lL8ri?ldvG9PAU`_WJ_EDVh*s&uPr3S;RFl~~ +zQ#7U&E>Ghb;mr3}sinp@shE?=cYL0?HZ4SCDnVk!VMe^(`>;mbNTxSu;P4nJDn5O_ +z7h8}DyoaY_OEa69^cq&t5q~i@UhVD)9~N7?TqoWuB$4qRdp8X7FuLC!X2@mvo)o@D +zX}wCCCcdb8-R5ctNM+79%e0V#0FtqdR}kj|%Zat|XwlE$evEy#sE|!-sei>+Q$y1K +z=aagos7X^pYPV@R*pkBipq6BsMKWXD_g3WksPmdPC_%;iFj-ig&<|Fm{Q%Qi%91>< +zwXT7>i)Iz+o5KR@PRA#;o@BXUkoCYzAgY^W*>RC|>1~K)QuURNGxEDFh&sxj?w9U8 +zouaM>4VMLvJWAjPL3ncS?shHCt?{E*=`efKyLa~ewCfSqH|T%G6;zEQ10(PN06r=J +z0CfK&u3*x&bI^4#ceK>~2`B%63KR4Hhhp-jmZuVy*gr8ggZ?7wvOj_NbUBKFderNK +z=n6n1u0a$_^<09MsvYf{zXB1u-<_YiT}v +z;xsq2?b&+`q+gWIy!kkcJpqwPeB{mSH#q}|;>rQI|LaBlmgliII3YM>nE%I%I`4Fs +zTv9MPuodGmLbe!XlN4RMRiwa1`{DGj7d0RkPUq +zVFHXcz3^v_Y4wj6m9XZOIpZHMYW~C#akJQ5#;pzjqo4;9f#3_Z)x0S4Ojck^^@Lqd +zuTC!vKzGqOnZjO4{++U1Q`S^Fjc*nY!;$ATZp@OZ^nlLEU5Y{{7RWmV9@$Yah^!lf +zGcCmHT$@Am=HHw`(mlg^GEWEnV?|W1f5fP#Kd10(m~Zy+%UE1hF%l^CSvY@}@puD) +z#3B3y1D4$nHt{ihrlU6tfm{+*)BYg~c!*u$&;rw|fKYKm&TFJXt&0)>6A?V))0#)Kd +zmbkymaXP$&Ld~YoADP?X+&g6pa{A2w_aIYajQD23!@6KOH~A}esYjk`Q?G?xcyhrU +zyIzTsKKNrvzrI(^X;oi{O#G$2Ttbm7ixfu{i_@pKE1gtJ?Dmj8wAz31%L4%1Pm#y2 +z31}j=Hjy0c?_Y35H-_1eBmsk~Sz(J11#TChZ?ZN=!`F>mkk8lgTL`U`LV+IAP6(X91zb9+huZ(3j*pk=eM-qgUc08gKmIm)| +zZbLr5Ji{^(%}ag>qxpnFknop-54wy;16I_MhaP4n=oyqKVn!9WZ6!wj7*VlbsU8;q +zhW&u^bW1Zx +z!Qd>cuz!rGE&TD)?zmWYmQO(Ak6@z%U-oimcqDB17Qo8%=G-b+B~20g?OyRFZzO5a +zBeGtG79cT6cJfjzqlIVEIN^Xy%9-+bSs!UMPOA4gsD^H1y*Exco2MG~;aQRbtVO9m +zLrfm=Pm`Y^rt=>;P%2lbNf}bmg%vqjVYSv$BXWf9HPuZZu$9pdFXbZs(vT9BnZD-t;UQgs(YRNRTkLiA?o9*>#xRUCT)g$^_h7H +zK>AHXBSM9HRTFXv81i%@<(e`N#&Q_$T0vYWl|UpRkp*S7vwxMS(dau+a4;_tO>k~* +zanUEDHl|MCF}V@LO8I%hDjE%y9RCb4{ne%&7Ce5=x%~Tw#pWyI--8KHM{*+tD!_5e +zZJv^@E({MsoM*vU>}kxhxE<-k1W)~V%GZ7O0udB-D%DZl7+WL_2|yB)2J-9F{R0fTqY`g<+n_k!19NlnS|)MnVWGPXL; +zm#U{_MO)kIhDS2iG0Z8+AJ)^^yCcj*l{QDJIMoQ*U@jw*t?v(vTS^sAkDVaNz +zCG;z7(JBMfA3tgrcW@g;x}CDcUDHk}T_7b^?c=Tx#5c~)Fm!-)CZN`!ym#a= +zWqHwpEZJu40q|*`JQE_z_#}JSDfHD@uUw#n3Gy6?0zP0Fi@K=Is7{PReoTU>Y?12b#ZUh9HWoj}oAGX;_GuUrY +z$~_E&FSQW1$0_~`G!|4cQhfs+csBW&5}zHGA()dskn~<3l$wcS2qO%`hx^_Uw%=^) +z+aIu>{Ob)&Ma?_=X@i||p~FtyfXg&HT$rWO_Xm9#B-_PWJeTMzjohbRDWHD0p|SVd +zK1P?|-K0kPf()7}-QR5l3yAUSMZR2VmpRR9Iq)UI5=6oA)L6~+)e(U1@op3s>Vu+i +z`oq>vUc7Yse~?W>TQkYJf>?*-&we$NA9(|UHHlk(fyka7ty~rrVS`NFH)-m%+XlDj +zy<%9>O24Ouu;)?f*g*%W?hHJj9o8OrVbqD@a0!55lG#|aGbLXe$=%_~DQ0H!WxAUk +zB8!wLwvIKgzJj95zm+BNcxymH^{r2zprlusRhYXVGgp?2CJ=WB6dRVW9U{2gxBXI3 +zxsGt=570yebBC$(Wtczyj~ynLISSfeg$9((u|32}dcP}vMr=3*Fu^56J4Zr}9}8;v +zyn3|Z$gvMOS?5up&hQ4b$HB8*y(-^39T$u&M~RQi)e_&=P2QU6z30yk)2GEsnK}ou +zVZ0W-D_doFsBq|o1N8|$Ea(spgQtBDk_`Fk&pttzWux%Kc$A>X#1-Nt52jcwn%$jZ +z`k&))gRTf=m)L@8>QPmFMQ)SCOL#OlsDAHKhY8-wu+YpZq{8zTbqX(4s0s2Q4)qxb +znkji~B$6rMU5{VCMzi_D7MJ$9Eu6y-*5^}BdiYy?{9lAoFDG@PsWwf+V^j_{>FK%Y +zNYKQ$H|`mLPWJ7xc&)7NFC$JQAdLnia^4wOIZ#ZrySBV^VbafSh7Sy(!wNFLyj%bXm}u1(PWgspeHj +zZ9qUX7l-2{UU|Gx1Znb}IAx>7z^8-3A|A4m^0@D@GOfakv~4+Cs7RX3lVJ;B&;^Z+ +zS;Fqy9{5@Q^4p#-hTjyHQ}Pc+QFCiL9wq2);0>j6lh4-}Eq|3>EY=Dh$)k@`cyR#t +z;k7Mzy$92TD46L!B+-x=L$S>=Oq6=1IK!&{t_#x?#0V*Et_WfBIu}^40uTYNoB%&6aLaj> +z$vu>TD36F#+q70UDSpsg8hn$?D{YuuY|9PV}RlTjd4wxP)2+>58ev- +z{s#d&pENdtP?VGhvTfZCr47*rkOk^Pi;mAE>s1k%-lET1x$#x&ICy=<)T#*0!^bQb +z@O=CTsuc~A2sSZFYi?mQXJ)ttYxS&&-&Z +zsN++OBHo#J9I3*yKJ2|#Bv$WqWO1Q{c5#CMp2om6sYst-80sA_^D$b!`M{*|kw4aY +z(wqLlk+Ij)ugK6r8JGy+q$((ZH!Yvj0QiFBnmS!~+1JEn>u)ZjkLR{7 +zvEhH{CKbAElPde<9W|<9PHbxpODAMIDYlHIA|$6NMp!rYXA@JIY>n|BOPe0r$j|qg +z-8}VA6w%aa4pYaZmUu3b-vmM!jfQ)%KXpvS$iwpreT}`!R>&Nho1+@nTo;d@mjpIp +zkZez~7mmXEOuJo4;J4j;n*N;Sf@sMvny)UcdRKh@CZWUVAA8?=BJvhsEktqMU-P`jLzL7(1RJ(PJ1&|^&T#J!Zgg)S +zDC8b6gu6Q&eZKsAy}-paQ6~_+LJt=c2`LR935ouy^#Usy}kj>lLSd1^yl#N{B^Zr7KGyS%kxy +zh_J^;I*21dnx*wt>*R!$ew_2!CzjCaU2fYGXq_yzLWC!vH#;>%koS|@DgPynHfd7+ +z7>Oap_WPi^v))KZgycFn$|R)Lz5Oz?XHjE)*^;WwqX$~wRJ>RZB$L3ubJ1QU{Lx-H +zcb-8Nk!T(TUkxc&TRp@}n?Dk8?c>=#A-*%uFplRKA?jSpYPGAwVCAk!I?Zos(ZwD1 +zY#q6hk?%37DLY!4#z8g;U05cl8NO}-U97S!ry!XjmICAE=9*|nnGd9xD9g~xy*+Pv +zL;yaF3&-2hrJ9U&e*dpm2va7`c3B!BJm>aGTMBji7*EH?b0yhEKscn8`C=y?4?Z`b{|@qoT~{&KJ+}enOaR_9Tvo19i1j|f5zd6G*RywohfS5t9{H_{Z4{sd>RZ-TP3B1HzI)s&Db(E3 +zldD0(Ziyg^_WH}YvKkG)X(!rC#2+igPMUm7(YxrZ@UTgh@}o*YYh3Cx199z?Wsa8h +z0UyC7WhLmg+PNYdPZ*)FyhA(Xgfy}3nKYWh6L*YhH!;_k9d^({BwVBBMli-WD^3m% +z=U|eFQ#Qo)lcXpSpuk@aqnBCpi*7;JTnp$K +z6~GM9g>Db{8EeFNRW)GS3p3>s)xyY}Ip40PU3AWKZlSvI8&S5m>#@kFLz!KrfdFA6 +zjP+QmeqcQ9-6xgOQduqcFDIGrsAVZ9mx-*gAlF|w@XRpK+=ZttW~{G~Pq9j9&uWyW +z0%y}hB@IG3Fz1Ah$IS%eYcIM>ZesfzC%T}xRJ)OKH-3>;ognRtD3rlWG(o9KP|S(S +zdozUC#2jbYOdbG;Ce#oZHT#rMzHKg?q_v}+WG))s?cjdD_zo;#-iO(ki7!Z(C1(u7 +z4pIU1c5{zUw*;@j2M~h3fbkOoWgi*cZa^oVyk2pWhvnsRRlDuoh7;yj&!YIDDO6lw +zb2Vt2gl1PS6&*%Zn%s +zi!#)NplHiwn`X#j36dX?udiy&4c$Dmey{6Ad=fbxX0S)rZAuzatQLFdfpjX)mr +zv2<*l$YHuu*bOEQ>0odfPlIaF&G$FCcd{ML?MMko={^TzL4AnlZ+wDT3R#tGdU#7p +zLXoR#chtJNB$p|dUxw5$oqTw87~!ibxEmjLSQqHM{qU+W*a(@w=%iX!H^j +zZm#5>u{Fu#9mN6^DK+c!2oY|6P?0i~dfG(wJ~Pr3x~8ZWrayC*k;<^#%_&J_e+VuE +z1;gDfE_4OQhzynCSNC%$uv%l`vC{8w0eRQ=!iBrxRYFGmklo(>tdPyQ^6J{gh%xRd +zH}CX&($S2cQ((w!SeFp%rgw2ySoo+9NhF>X(s +zNwKj2skRSJUu)Rvn_sfR{U`;!PZB6g$uRho_T@2zEWtR&Y~SRBMIN9$AC?c2Rq{}3 +zD&^F_T_P#K`EGARmf1e|{F*l@a5wjwrJ1N^%n7CV>|-D68`C$+tEH6TCD46}b&Ba* +zE4cU@-7W#-r6zTUepm%aeZe>w3bBN23?Og1P%Flgck)JCNMh +z8{{Svso5llgd%Imy!$>oK}|VJZ$eeK^a4Y3og9)rRJ^>V94i=FM{J>R%f~?%GkoO9}T(eWb+krIkxx3kBy`9dT7=CX*=wkPTZXd3{qmKrdIrv8s4!hP^!q@#4xq}f)?eI4Y9N=z)E@?MD&Cj9v&2bCJ5W@cka%y(i5P5Xks39 +zNZ~>&XP+3F()v<(HQ7&P=T=`wNq7snH4fb!z~V+wlhaxTtGvNpPMca0_8ft7_FBo4 +z7*0&$%R;d{XDc~uFSw#s>MQG%-UzLF^l4wFoQf|SpOjb2yoi>n>KUoqT*rydq3fFs +z7}mVexd}6-W-3BlD8^ocB8RU-n>E+F$C8zN+EM0_u2HJbT}M-XLR7B6bwC!rkiN3X +zcb-FY7U)}o=VCO6=2cLFir1&ZL}eGfeV^+q(;5|(M?~jwmsf7bQZHu2sO=KqRQT_3 +zqt`1@$2s~d9wRCeQVj+Y6600jhMI`HjJSxZjG}_RiiDD)it2v@j+Vxs1{|>HM~^3g +zH~qFR0Q}A4AQ{H~DU0I`B?AIKN`S-+&Ap({vJbE?>zF(3ZVIMoxu6z&F^petUK8seh)uJjPwA#-F2JcPLj!ZBeErn +z<(vwWlV*%qQJ=4gBij>jI96F93!l^oUTX4y2+-h-C@Ybt>0mWkJXeL(WLten*8}G1 +zpm0BxbUCX3Kanaa6Cr&iDLw2e4>V6Si~iZj|!#a&faMMuf_E2J*^%pePY3rcrRnP^|4 +zV%A9fF%H6SoOaqiHlh=+{%&EtrOMUuzz#Jw@Igpr)cYX!CV_h&>Q4AFkF#lU%yCu=tp_vw_8%JB7T6o%M7EF>a_7$mGokWO +zjM-5dI&&?HpGFD^6WY9cYDYf^(k3grCwyvpO*bH$op_LaU2}J$CQv{_OL@dWop3XW +zStv7f153R^rMk7AD>M-U>PcmpJTow^(`TM +zNS$0ZkqB|~B!TB+R||^FN3*z)vm_9ns;NFxB+>_d-bAl-TvpicVhwecK!8m*u7M@TLzPrs{2R>E=D)NE&^Dk^|aNnfV^h +z=KWg%HlPxm1fttM++>McT0<&u@nzNfq>RX`O)pzl1fiZHV-3pDHc|yjj<0A%zbTo*gBYmTrU`!m(g{xVPQnCL{skk9W-8 +zJCLZ6T}!t;#;bB#vO-B%YyG0iVsO@~e=M${c@lNVnMRXplg+^#vuBz~?0x1`Pnd09 +z4PEjZ;*844oO2L +zb0MTte|3$Fc=WZm;#FM(AL!fKAu1DC8#?P-Iyyp3^&M;>PXF7O{5@_TEwbh`VuXGO +zUj)z(+%3er`RR~=T>7Rq=9V_!N=2GG7@GWfrRZVZ!gx&#JsNi?NZA>K}f~ +zTl{lE{~Wa-$yIb7L}>R|YH$;#hj|5zaNd$umkRqS7v +z%ltj|k3}fHXG0L5|IcUrmnA8F5B;MH@%QL$;j7U9=1Kf(>OUQYzNf}3T|xcMUkJhY +z<}dVrQo#TJ3H@mC@jb&DvAO@#NxjJfr{s + +literal 0 +HcmV?d00001 + +diff --git a/cherry-n8n-workflows/01_ci_failure_compression.json b/cherry-n8n-workflows/01_ci_failure_compression.json +new file mode 100644 +index 0000000000000000000000000000000000000000..f4ed582dbada241baed45d6edde23d632256ac9f +--- /dev/null ++++ b/cherry-n8n-workflows/01_ci_failure_compression.json +@@ -0,0 +1,570 @@ ++{ ++ "name": "Cherry - CI Failure Compression", ++ "nodes": [ ++ { ++ "parameters": { ++ "httpMethod": "POST", ++ "path": "cherry/github/workflow-completed", ++ "responseMode": "responseNode", ++ "options": {} ++ }, ++ "id": "webhook-ci-completed", ++ "name": "Webhook", ++ "type": "n8n-nodes-base.webhook", ++ "typeVersion": 2, ++ "position": [ ++ 0, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.workflow_completed',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '01_ci_failure_compression',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.workflow_completed.',\n actions: []\n};\nreturn [{ json: output }];" ++ }, ++ "id": "normalize-github-payload", ++ "name": "Normalize GitHub Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 260, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.workflow_run.conclusion\",\"payload.workflow_run.html_url\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++ }, ++ "id": "validate-payload", ++ "name": "Validate Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 520, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst run = event.payload?.workflow_run ?? {};\nconst conclusion = run.conclusion ?? 'unknown';\nconst text = [run.name, run.display_title, run.path, run.html_url].filter(Boolean).join(' ').toLowerCase();\nconst category = text.includes('lint') ? 'lint' : text.includes('typecheck') || text.includes('typescript') ? 'typecheck' : text.includes('test') ? 'test' : text.includes('build') ? 'build' : text.includes('closure') || text.includes('guardrail') ? 'repo-closure' : text.includes('migration') || text.includes('prisma') ? 'migration' : 'unknown';\nconst shouldProcess = conclusion === 'failure' || conclusion === 'timed_out' || conclusion === 'cancelled';\nconst workflowName = run.name ?? 'unknown workflow';\nconst branch = run.head_branch ?? 'unknown branch';\nconst sha = run.head_sha ?? 'unknown sha';\nconst url = run.html_url ?? '';\nconst title = '[ci:' + category + '] ' + workflowName + ' failed on ' + branch;\nconst output = { ...event, shouldProcess, failureCategory: category, workflowName, branch, sha, url, searchQuery: encodeURIComponent('repo:' + event.repo + ' is:issue is:open in:title \"[ci:' + category + ']\" \"' + workflowName + '\"'), issueBody: { title, labels: ['ci-failure', 'automation', 'needs-triage', category], body: 'Cherry CI failure compression.\\n\\nWorkflow: ' + workflowName + '\\nBranch: ' + branch + '\\nSHA: ' + sha + '\\nCategory: ' + category + '\\nRun: ' + url + '\\n\\nSuggested verification: npm run ci:verify\\n\\nAdvisory automation only.' }, commentBody: { body: 'Repeated CI failure.\\n\\nWorkflow: ' + workflowName + '\\nBranch: ' + branch + '\\nSHA: ' + sha + '\\nCategory: ' + category + '\\nRun: ' + url }, openclawTask: { source: 'ci_failure', repo: event.repo, title, category, branch, sha, url, guardrails: ['npm run ci:verify', 'no Cherry finance truth mutation'] }, status: shouldProcess ? 'accepted' : 'ignored', summary: shouldProcess ? title : 'Workflow conclusion was ' + conclusion + '; ignoring.', actions: shouldProcess ? ['search_existing_issue', 'comment_or_create_issue', 'optional_openclaw_task', 'archive_event'] : [] };\nreturn [{ json: output }];" ++ }, ++ "id": "classify-failure", ++ "name": "Classify Failure", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 780, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "conditions": { ++ "options": { ++ "caseSensitive": true, ++ "leftValue": "", ++ "typeValidation": "strict" ++ }, ++ "conditions": [ ++ { ++ "id": "if-failure-condition", ++ "leftValue": "={{ $json.shouldProcess === true }}", ++ "rightValue": true, ++ "operator": { ++ "type": "boolean", ++ "operation": "true", ++ "singleValue": true ++ } ++ } ++ ], ++ "combinator": "and" ++ }, ++ "options": {} ++ }, ++ "id": "if-failure", ++ "name": "IF: Failure?", ++ "type": "n8n-nodes-base.if", ++ "typeVersion": 2, ++ "position": [ ++ 1040, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "GET", ++ "url": "={{ 'https://api.github.com/search/issues?q=' + $json.searchQuery }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {} ++ }, ++ "id": "search-existing-issues", ++ "name": "Search Existing GitHub Issues", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1300, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "conditions": { ++ "options": { ++ "caseSensitive": true, ++ "leftValue": "", ++ "typeValidation": "strict" ++ }, ++ "conditions": [ ++ { ++ "id": "if-existing-issue-condition", ++ "leftValue": "={{ Array.isArray($json.items) && $json.items.length > 0 }}", ++ "rightValue": true, ++ "operator": { ++ "type": "boolean", ++ "operation": "true", ++ "singleValue": true ++ } ++ } ++ ], ++ "combinator": "and" ++ }, ++ "options": {} ++ }, ++ "id": "if-existing-issue", ++ "name": "IF: Existing Issue?", ++ "type": "n8n-nodes-base.if", ++ "typeVersion": 2, ++ "position": [ ++ 1560, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.items[0].number + '/comments' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $node[\"Classify Failure\"].json.commentBody }}" ++ }, ++ "id": "comment-existing-issue", ++ "name": "Comment Existing Issue", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1820, ++ -320 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $node[\"Classify Failure\"].json.issueBody }}" ++ }, ++ "id": "create-new-issue", ++ "name": "Create New Issue", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1820, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.OPENCLAW_WEBHOOK_URL }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $node[\"Classify Failure\"].json.openclawTask }}" ++ }, ++ "id": "send-ci-failure-to-openclaw", ++ "name": "Send CI Failure To OpenClaw", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2080, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '01_ci_failure_compression', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++ }, ++ "id": "route-shared-sinks", ++ "name": "Route Shared Sinks", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 2340, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '01_ci_failure_compression.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '01_ci_failure_compression',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '01_ci_failure_compression') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '01_ci_failure_compression.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++ }, ++ "id": "archive-event", ++ "name": "Archive Event", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2600, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++ }, ++ "id": "notify-discord", ++ "name": "Notify Discord", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2860, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'CI failure compressed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-response", ++ "name": "Build Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 3120, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: 'ignored', summary: event.summary ?? 'CI workflow did not fail; no action taken.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-ignored-response", ++ "name": "Build Ignored Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1300, ++ 160 ++ ] ++ }, ++ { ++ "parameters": { ++ "respondWith": "firstIncomingItem", ++ "options": { ++ "responseCode": 200 ++ } ++ }, ++ "id": "respond-to-webhook", ++ "name": "Respond to Webhook", ++ "type": "n8n-nodes-base.respondToWebhook", ++ "typeVersion": 1, ++ "position": [ ++ 3380, ++ 0 ++ ] ++ } ++ ], ++ "connections": { ++ "Webhook": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize GitHub Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Normalize GitHub Payload": { ++ "main": [ ++ [ ++ { ++ "node": "Validate Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Validate Payload": { ++ "main": [ ++ [ ++ { ++ "node": "Classify Failure", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Classify Failure": { ++ "main": [ ++ [ ++ { ++ "node": "IF: Failure?", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "IF: Failure?": { ++ "main": [ ++ [ ++ { ++ "node": "Search Existing GitHub Issues", ++ "type": "main", ++ "index": 0 ++ } ++ ], ++ [ ++ { ++ "node": "Build Ignored Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Search Existing GitHub Issues": { ++ "main": [ ++ [ ++ { ++ "node": "IF: Existing Issue?", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "IF: Existing Issue?": { ++ "main": [ ++ [ ++ { ++ "node": "Comment Existing Issue", ++ "type": "main", ++ "index": 0 ++ } ++ ], ++ [ ++ { ++ "node": "Create New Issue", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Comment Existing Issue": { ++ "main": [ ++ [ ++ { ++ "node": "Send CI Failure To OpenClaw", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Create New Issue": { ++ "main": [ ++ [ ++ { ++ "node": "Send CI Failure To OpenClaw", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Send CI Failure To OpenClaw": { ++ "main": [ ++ [ ++ { ++ "node": "Route Shared Sinks", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Route Shared Sinks": { ++ "main": [ ++ [ ++ { ++ "node": "Archive Event", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Archive Event": { ++ "main": [ ++ [ ++ { ++ "node": "Notify Discord", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Notify Discord": { ++ "main": [ ++ [ ++ { ++ "node": "Build Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Ignored Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ } ++ }, ++ "settings": { ++ "executionOrder": "v1" ++ } ++} +diff --git a/cherry-n8n-workflows/02_openclaw_issue_router.json b/cherry-n8n-workflows/02_openclaw_issue_router.json +new file mode 100644 +index 0000000000000000000000000000000000000000..45c76c26fc731b6275b4e8121694063e91d88c16 +--- /dev/null ++++ b/cherry-n8n-workflows/02_openclaw_issue_router.json +@@ -0,0 +1,389 @@ ++{ ++ "name": "Cherry - OpenClaw Issue Router", ++ "nodes": [ ++ { ++ "parameters": { ++ "httpMethod": "POST", ++ "path": "cherry/github/issue-labeled", ++ "responseMode": "responseNode", ++ "options": {} ++ }, ++ "id": "webhook-issue-labeled", ++ "name": "Webhook", ++ "type": "n8n-nodes-base.webhook", ++ "typeVersion": 2, ++ "position": [ ++ 0, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.issue_labeled',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '02_openclaw_issue_router',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.issue_labeled.',\n actions: []\n};\nreturn [{ json: output }];" ++ }, ++ "id": "normalize-issue-event", ++ "name": "Normalize Issue Event", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 260, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.issue.number\",\"payload.issue.title\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++ }, ++ "id": "validate-payload", ++ "name": "Validate Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 520, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "conditions": { ++ "options": { ++ "caseSensitive": true, ++ "leftValue": "", ++ "typeValidation": "strict" ++ }, ++ "conditions": [ ++ { ++ "id": "if-openclaw-label-condition", ++ "leftValue": "={{ (($json.payload.label?.name ?? '') === 'openclaw') || (($json.payload.issue?.labels ?? []).some((label) => label.name === 'openclaw')) }}", ++ "rightValue": true, ++ "operator": { ++ "type": "boolean", ++ "operation": "true", ++ "singleValue": true ++ } ++ } ++ ], ++ "combinator": "and" ++ }, ++ "options": {} ++ }, ++ "id": "if-openclaw-label", ++ "name": "IF: Has openclaw Label?", ++ "type": "n8n-nodes-base.if", ++ "typeVersion": 2, ++ "position": [ ++ 780, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst issue = event.payload?.issue ?? {};\nconst body = String(issue.body ?? '');\nconst forbiddenPatterns = ['.env', '.env.local', 'secrets', 'production db config', '/api/session', '/api/ledger', '/api/bucket', '/api/payment', '/api/card'];\nconst forbiddenMatches = forbiddenPatterns.filter((pattern) => body.toLowerCase().includes(pattern.toLowerCase()));\nconst task = { source: 'github_issue_openclaw', repo: event.repo, issueNumber: issue.number, title: issue.title, url: issue.html_url, body, constraints: { advisoryOnly: true, forbiddenFiles: ['.env', '.env.local'], forbiddenEndpointPatterns: ['/api/session*', '/api/ledger*', '/api/bucket*', '/api/payment*', '/api/card*', '/api/debt*/mutate'], requiredReviewLabels: ['needs-human-review'] }, forbiddenMatches };\nconst output = { ...event, openclawTask: task, commentBody: { body: 'OpenClaw task prepared.\\n\\nIssue: #' + issue.number + '\\nForbidden hints: ' + (forbiddenMatches.join(', ') || 'none') + '\\nHuman review remains required before merge.' }, status: forbiddenMatches.length > 0 ? 'failed' : 'accepted', summary: forbiddenMatches.length > 0 ? 'OpenClaw issue contains forbidden-change hints.' : 'OpenClaw task routed for issue #' + issue.number + '.', actions: forbiddenMatches.length > 0 ? ['block_openclaw_task', 'comment_issue', 'archive_event'] : ['send_openclaw_task', 'comment_issue', 'archive_event'] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-openclaw-task", ++ "name": "Build OpenClaw Task", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1040, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.OPENCLAW_WEBHOOK_URL }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $json.openclawTask }}" ++ }, ++ "id": "send-to-openclaw", ++ "name": "Send To OpenClaw", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1300, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build OpenClaw Task\"].json.openclawTask.issueNumber + '/comments' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $node[\"Build OpenClaw Task\"].json.commentBody }}" ++ }, ++ "id": "comment-on-issue", ++ "name": "Comment On Issue", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1560, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '02_openclaw_issue_router', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++ }, ++ "id": "route-shared-sinks", ++ "name": "Route Shared Sinks", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1820, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '02_openclaw_issue_router.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '02_openclaw_issue_router',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '02_openclaw_issue_router') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '02_openclaw_issue_router.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++ }, ++ "id": "archive-event", ++ "name": "Archive Event", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2080, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'OpenClaw issue routed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-response", ++ "name": "Build Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 2340, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: 'ignored', summary: event.summary ?? 'Issue was not labeled openclaw; no action taken.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-ignored-response", ++ "name": "Build Ignored Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1040, ++ 160 ++ ] ++ }, ++ { ++ "parameters": { ++ "respondWith": "firstIncomingItem", ++ "options": { ++ "responseCode": 200 ++ } ++ }, ++ "id": "respond-to-webhook", ++ "name": "Respond to Webhook", ++ "type": "n8n-nodes-base.respondToWebhook", ++ "typeVersion": 1, ++ "position": [ ++ 2600, ++ 0 ++ ] ++ } ++ ], ++ "connections": { ++ "Webhook": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize Issue Event", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Normalize Issue Event": { ++ "main": [ ++ [ ++ { ++ "node": "Validate Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Validate Payload": { ++ "main": [ ++ [ ++ { ++ "node": "IF: Has openclaw Label?", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "IF: Has openclaw Label?": { ++ "main": [ ++ [ ++ { ++ "node": "Build OpenClaw Task", ++ "type": "main", ++ "index": 0 ++ } ++ ], ++ [ ++ { ++ "node": "Build Ignored Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build OpenClaw Task": { ++ "main": [ ++ [ ++ { ++ "node": "Send To OpenClaw", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Send To OpenClaw": { ++ "main": [ ++ [ ++ { ++ "node": "Comment On Issue", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Comment On Issue": { ++ "main": [ ++ [ ++ { ++ "node": "Route Shared Sinks", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Route Shared Sinks": { ++ "main": [ ++ [ ++ { ++ "node": "Archive Event", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Archive Event": { ++ "main": [ ++ [ ++ { ++ "node": "Build Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Ignored Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ } ++ }, ++ "settings": { ++ "executionOrder": "v1" ++ } ++} +diff --git a/cherry-n8n-workflows/03_pr_risk_classifier.json b/cherry-n8n-workflows/03_pr_risk_classifier.json +new file mode 100644 +index 0000000000000000000000000000000000000000..cc6a09f366a16029c66517b15836f0978bef1c44 +--- /dev/null ++++ b/cherry-n8n-workflows/03_pr_risk_classifier.json +@@ -0,0 +1,551 @@ ++{ ++ "name": "Cherry - PR Risk Classifier", ++ "nodes": [ ++ { ++ "parameters": { ++ "httpMethod": "POST", ++ "path": "cherry/github/pull-request-risk", ++ "responseMode": "responseNode", ++ "options": {} ++ }, ++ "id": "webhook-pr-risk", ++ "name": "Webhook", ++ "type": "n8n-nodes-base.webhook", ++ "typeVersion": 2, ++ "position": [ ++ 0, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const raw = $input.first()?.json ?? {};\nconst input = raw.body ?? raw.payload ?? raw;\n\nconst item = Array.isArray(input.items) ? input.items[0] : undefined;\n\nconst pr =\n input.pull_request ??\n input.pr ??\n item ??\n input;\n\nconst prNumber =\n pr.number ??\n input.number ??\n input.pull_number ??\n input.pr_number;\n\nconst sha =\n pr.head?.sha ??\n input.head?.sha ??\n input.after ??\n input.sha;\n\nconst repoFullName =\n input.repository?.full_name ??\n input.repo ??\n input.full_name ??\n 'div0rce/cherry';\n\nconst [owner, repo] = String(repoFullName).split('/');\n\nif (!prNumber) {\n return [{\n json: {\n ok: false,\n status: 'failed',\n workflow: '03_pr_risk_classifier',\n error: 'missing_pr_number',\n reason: 'Normalize PR could not derive a PR number from webhook, search, or flat payload',\n receivedKeys: Object.keys(input),\n totalCount: input.total_count,\n searchType: input.search_type,\n repoFullName,\n actions: ['do_not_classify_pr']\n },\n }];\n}\n\nconst labels = Array.isArray(pr.labels)\n ? pr.labels.map((label) => typeof label === 'string' ? label : label.name).filter(Boolean)\n : [];\n\nreturn [{\n json: {\n ...input,\n event: 'github.pull_request',\n source: 'github',\n owner: owner || input.repository?.owner?.login || 'div0rce',\n repo: repoFullName,\n repoName: repo || input.repository?.name || 'cherry',\n repoFullName,\n prNumber,\n sha,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n labels,\n timestamp: raw.timestamp ?? input.workflow_run?.updated_at ?? pr.updated_at ?? input.issue?.updated_at ?? new Date().toISOString(),\n workflow: '03_pr_risk_classifier',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.pull_request.',\n actions: [],\n pull_request: {\n number: prNumber,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n head: { sha },\n labels: Array.isArray(pr.labels) ? pr.labels : [],\n },\n payload: {\n ...input,\n repository: input.repository ?? { full_name: repoFullName, name: repo || input.repository?.name || 'cherry', owner: { login: owner || input.repository?.owner?.login || 'div0rce' } },\n pull_request: {\n number: prNumber,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n head: { sha },\n labels: Array.isArray(pr.labels) ? pr.labels : [],\n }\n }\n },\n}];" ++ }, ++ "id": "normalize-pr", ++ "name": "Normalize PR", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 260, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\nif (!event.prNumber) missing.push('prNumber');\nif (!event.sha) missing.push('sha');\nconst output = {\n ...event,\n valid: missing.length === 0,\n validationErrors: missing,\n status: missing.length === 0 ? event.status : 'failed',\n summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', '),\n actions: missing.length === 0 ? event.actions : [...(event.actions ?? []), 'do_not_classify_pr']\n};\nreturn [{ json: output }];" ++ }, ++ "id": "validate-payload", ++ "name": "Validate Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 520, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "GET", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls/' + $json.prNumber + '/files?per_page=100' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {} ++ }, ++ "id": "fetch-changed-files", ++ "name": "Fetch Changed Files", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 780, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const prEvent = $node['Normalize PR'].json;\nconst classification = $input.first()?.json ?? {};\nconst classifierOutput = classification.classifierOutput ?? {};\nconst risk = classifierOutput.risk ?? {};\nconst statusRequests = Array.isArray(classifierOutput.statusRequests) ? classifierOutput.statusRequests : [];\nconst statusRequest = statusRequests.find((request) => request.context === 'cherry/risk-gate');\nconst labels = Array.isArray(risk.labels) ? risk.labels : [];\nconst reasons = Array.isArray(risk.reasons) ? risk.reasons : [];\nconst score = typeof risk.score === 'number' ? risk.score : 'unknown';\nconst level = typeof risk.level === 'string' ? risk.level : 'unknown';\nconst prNumber = prEvent.prNumber;\nconst sha = prEvent.sha;\nconst output = {\n ...prEvent,\n sha,\n automationEventId: classification.automationEventId,\n outputHash: classification.outputHash,\n cherryClassifierOutput: classifierOutput,\n statusRequest,\n labels,\n labelBody: { labels },\n commentBody: { body: 'Cherry PR risk classifier.\\n\\nLevel: ' + level + '\\nScore: ' + String(score) + '\\nLabels: ' + labels.join(', ') + '\\nReasons:\\n' + reasons.map((reason) => '- ' + reason).join('\\n') },\n status: 'accepted',\n summary: 'PR #' + String(prNumber ?? 'unknown') + ' risk ' + level + ' from Cherry classifier.',\n actions: ['fetch_changed_files', 'classify_pr_in_cherry', 'post_risk_status', 'apply_labels', 'comment_risk_summary']\n};\nreturn [{ json: output }];" ++ }, ++ "id": "build-cherry-pr-routing", ++ "name": "Build Cherry PR Routing", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1040, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\n\nif (!event.statusRequest) missing.push('statusRequest');\nif (!event.repo) missing.push('repo');\nif (!event.sha) missing.push('sha');\nif (!event.outputHash) missing.push('outputHash');\nif (!event.cherryClassifierOutput?.classifierVersion) missing.push('classifierVersion');\n\nif (missing.length > 0) {\n return [{\n json: {\n ok: false,\n workflow: event.workflow,\n status: 'failed',\n summary: 'Cherry status payload missing required fields. Refusing to post status.',\n actions: ['do_not_post_status'],\n missing\n }\n }];\n}\n\nreturn [{ json: event }];" ++ }, ++ "id": "require-risk-status-request", ++ "name": "Require Status Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1300, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "conditions": { ++ "options": { ++ "caseSensitive": true, ++ "leftValue": "", ++ "typeValidation": "strict" ++ }, ++ "conditions": [ ++ { ++ "id": "if-risk-status-request", ++ "leftValue": "={{ $json.statusRequest !== undefined && $json.statusRequest !== null && $json.repo !== undefined && $json.repo !== null && $json.sha !== undefined && $json.sha !== null && $json.outputHash !== undefined && $json.outputHash !== null && $json.cherryClassifierOutput?.classifierVersion !== undefined && $json.cherryClassifierOutput?.classifierVersion !== null }}", ++ "rightValue": true, ++ "operator": { ++ "type": "boolean", ++ "operation": "true", ++ "singleValue": true ++ } ++ } ++ ], ++ "combinator": "and" ++ }, ++ "options": {} ++ }, ++ "id": "if-risk-status-request", ++ "name": "IF: Has Status Payload?", ++ "type": "n8n-nodes-base.if", ++ "typeVersion": 2, ++ "position": [ ++ 1170, ++ 160 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.prNumber + '/labels' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $json.labelBody }}" ++ }, ++ "id": "apply-labels", ++ "name": "Apply Labels", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1300, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build Cherry PR Routing\"].json.prNumber + '/comments' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $node[\"Build Cherry PR Routing\"].json.commentBody }}" ++ }, ++ "id": "comment-risk-summary", ++ "name": "Comment Risk Summary", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1560, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '03_pr_risk_classifier', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++ }, ++ "id": "route-shared-sinks", ++ "name": "Route Shared Sinks", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1820, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '03_pr_risk_classifier.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '03_pr_risk_classifier',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '03_pr_risk_classifier') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? 'no-sha') + ':' + ($json.outputHash ?? $now.toISO())),\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '03_pr_risk_classifier.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json.cherryClassifierOutput ?? $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++ }, ++ "id": "archive-event", ++ "name": "Archive Event", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2080, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'PR risk classified.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-response", ++ "name": "Build Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 2340, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "respondWith": "firstIncomingItem", ++ "options": { ++ "responseCode": 200 ++ } ++ }, ++ "id": "respond-to-webhook", ++ "name": "Respond to Webhook", ++ "type": "n8n-nodes-base.respondToWebhook", ++ "typeVersion": 1, ++ "position": [ ++ 2600, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/statuses/github' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n context: $json.statusRequest.context,\n state: $json.statusRequest.state,\n description: $json.statusRequest.description,\n targetUrl: $json.statusRequest.targetUrl,\n sourceWorkflow: $json.workflow,\n automationEventId: $json.automationEventId,\n classifierVersion: $json.cherryClassifierOutput.classifierVersion,\n outputHash: $json.outputHash\n} }}" ++ }, ++ "id": "post-risk-status", ++ "name": "Post Risk Status", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1300, ++ 160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "jsCode": "const items = $input.all();\n\nlet files = [];\n\nfor (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n files.push(...json);\n } else if (Array.isArray(json.files)) {\n files.push(...json.files);\n } else if (Array.isArray(json.data)) {\n files.push(...json.data);\n } else if (json && json.filename) {\n files.push(json);\n }\n}\n\nfiles = files.map((file) => ({\n filename: file.filename ?? file.path ?? file.name ?? '',\n status: file.status ?? 'modified',\n additions: Number(file.additions ?? 0),\n deletions: Number(file.deletions ?? 0),\n changes: Number(file.changes ?? 0),\n patch: String(file.patch ?? '')\n})).filter((file) => file.filename.length > 0);\n\nreturn [{\n json: {\n ...($items('Normalize PR')[0]?.json ?? {}),\n files\n }\n}];" ++ }, ++ "id": "normalize-changed-files", ++ "name": "Normalize Changed Files", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 910, ++ 160 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/classify/pr' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n prNumber: $json.prNumber,\n title: $json.title,\n body: $json.body ?? '',\n labels: $json.labels ?? [],\n files: $json.files,\n sourceWorkflow: $json.workflow ?? '03_pr_risk_classifier'\n} }}" ++ }, ++ "id": "classify-pr-in-cherry", ++ "name": "Classify PR In Cherry", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1040, ++ 160 ++ ], ++ "continueOnFail": true ++ } ++ ], ++ "connections": { ++ "Webhook": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize PR", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Normalize PR": { ++ "main": [ ++ [ ++ { ++ "node": "Validate Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Validate Payload": { ++ "main": [ ++ [ ++ { ++ "node": "Fetch Changed Files", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Fetch Changed Files": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize Changed Files", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Apply Labels": { ++ "main": [ ++ [ ++ { ++ "node": "Comment Risk Summary", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Comment Risk Summary": { ++ "main": [ ++ [ ++ { ++ "node": "Route Shared Sinks", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Route Shared Sinks": { ++ "main": [ ++ [ ++ { ++ "node": "Archive Event", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Archive Event": { ++ "main": [ ++ [ ++ { ++ "node": "Build Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Post Risk Status": { ++ "main": [ ++ [ ++ { ++ "node": "Apply Labels", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Classify PR In Cherry": { ++ "main": [ ++ [ ++ { ++ "node": "Build Cherry PR Routing", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Cherry PR Routing": { ++ "main": [ ++ [ ++ { ++ "node": "Require Status Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Normalize Changed Files": { ++ "main": [ ++ [ ++ { ++ "node": "Classify PR In Cherry", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Require Status Payload": { ++ "main": [ ++ [ ++ { ++ "node": "IF: Has Status Payload?", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "IF: Has Status Payload?": { ++ "main": [ ++ [ ++ { ++ "node": "Post Risk Status", ++ "type": "main", ++ "index": 0 ++ } ++ ], ++ [ ++ { ++ "node": "Build Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ } ++ }, ++ "settings": { ++ "executionOrder": "v1" ++ } ++} +diff --git a/cherry-n8n-workflows/04_forbidden_change_detector.json b/cherry-n8n-workflows/04_forbidden_change_detector.json +new file mode 100644 +index 0000000000000000000000000000000000000000..b75a8c3663823eb3b742a16a1037440fdcd9f746 +--- /dev/null ++++ b/cherry-n8n-workflows/04_forbidden_change_detector.json +@@ -0,0 +1,667 @@ ++{ ++ "name": "Cherry - Forbidden Change Detector", ++ "nodes": [ ++ { ++ "parameters": { ++ "httpMethod": "POST", ++ "path": "cherry/github/pull-request-forbidden", ++ "responseMode": "responseNode", ++ "options": {} ++ }, ++ "id": "webhook-pr-forbidden", ++ "name": "Webhook", ++ "type": "n8n-nodes-base.webhook", ++ "typeVersion": 2, ++ "position": [ ++ 0, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const raw = $input.first()?.json ?? {};\nconst input = raw.body ?? raw.payload ?? raw;\n\nconst item = Array.isArray(input.items) ? input.items[0] : undefined;\n\nconst pr =\n input.pull_request ??\n input.pr ??\n item ??\n input;\n\nconst prNumber =\n pr.number ??\n input.number ??\n input.pull_number ??\n input.pr_number;\n\nconst sha =\n pr.head?.sha ??\n input.head?.sha ??\n input.after ??\n input.sha;\n\nconst repoFullName =\n input.repository?.full_name ??\n input.repo ??\n input.full_name ??\n 'div0rce/cherry';\n\nconst [owner, repo] = String(repoFullName).split('/');\n\nif (!prNumber) {\n return [{\n json: {\n ok: false,\n status: 'failed',\n workflow: '04_forbidden_change_detector',\n error: 'missing_pr_number',\n reason: 'Normalize PR could not derive a PR number from webhook, search, or flat payload',\n receivedKeys: Object.keys(input),\n totalCount: input.total_count,\n searchType: input.search_type,\n repoFullName,\n actions: ['do_not_classify_pr']\n },\n }];\n}\n\nconst labels = Array.isArray(pr.labels)\n ? pr.labels.map((label) => typeof label === 'string' ? label : label.name).filter(Boolean)\n : [];\n\nreturn [{\n json: {\n ...input,\n event: 'github.pull_request',\n source: 'github',\n owner: owner || input.repository?.owner?.login || 'div0rce',\n repo: repoFullName,\n repoName: repo || input.repository?.name || 'cherry',\n repoFullName,\n prNumber,\n sha,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n labels,\n timestamp: raw.timestamp ?? input.workflow_run?.updated_at ?? pr.updated_at ?? input.issue?.updated_at ?? new Date().toISOString(),\n workflow: '04_forbidden_change_detector',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.pull_request.',\n actions: [],\n pull_request: {\n number: prNumber,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n head: { sha },\n labels: Array.isArray(pr.labels) ? pr.labels : [],\n },\n payload: {\n ...input,\n repository: input.repository ?? { full_name: repoFullName, name: repo || input.repository?.name || 'cherry', owner: { login: owner || input.repository?.owner?.login || 'div0rce' } },\n pull_request: {\n number: prNumber,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n head: { sha },\n labels: Array.isArray(pr.labels) ? pr.labels : [],\n }\n }\n },\n}];" ++ }, ++ "id": "normalize-pr", ++ "name": "Normalize PR", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 260, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\nif (!event.prNumber) missing.push('prNumber');\nif (!event.sha) missing.push('sha');\nconst output = {\n ...event,\n valid: missing.length === 0,\n validationErrors: missing,\n status: missing.length === 0 ? event.status : 'failed',\n summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', '),\n actions: missing.length === 0 ? event.actions : [...(event.actions ?? []), 'do_not_classify_pr']\n};\nreturn [{ json: output }];" ++ }, ++ "id": "validate-payload", ++ "name": "Validate Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 520, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "GET", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls/' + $json.prNumber + '/files?per_page=100' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {} ++ }, ++ "id": "fetch-changed-files", ++ "name": "Fetch Changed Files", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 780, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const prEvent = $node['Normalize PR'].json;\nconst classification = $input.first()?.json ?? {};\nconst classifierOutput = classification.classifierOutput ?? {};\nconst forbiddenChange = classifierOutput.forbiddenChange ?? {};\nconst statusRequests = Array.isArray(classifierOutput.statusRequests) ? classifierOutput.statusRequests : [];\nconst statusRequest = statusRequests.find((request) => request.context === 'cherry/forbidden-change');\nconst violations = Array.isArray(forbiddenChange.violations) ? forbiddenChange.violations : [];\nconst labels = Array.isArray(forbiddenChange.labels) ? forbiddenChange.labels : [];\nconst blocked = forbiddenChange.forbidden === true;\nconst prNumber = prEvent.prNumber;\nconst sha = prEvent.sha;\nconst output = {\n ...prEvent,\n sha,\n automationEventId: classification.automationEventId,\n outputHash: classification.outputHash,\n cherryClassifierOutput: classifierOutput,\n forbiddenChange,\n statusRequest,\n labelBody: { labels },\n commentBody: { body: 'Cherry forbidden-change detector.\\n\\n' + (blocked ? 'Blocking patterns detected by Cherry:\\n' + violations.map((violation) => '- ' + violation).join('\\n') : 'Cherry found no blocking patterns.') },\n status: blocked ? 'failed' : 'accepted',\n summary: blocked ? 'Cherry detected forbidden changes in PR #' + String(prNumber ?? 'unknown') + '.' : 'Cherry found no forbidden changes in PR #' + String(prNumber ?? 'unknown') + '.',\n actions: blocked ? ['classify_pr_in_cherry', 'post_forbidden_status', 'add_blocking_label', 'comment_violation'] : ['classify_pr_in_cherry', 'post_forbidden_status']\n};\nreturn [{ json: output }];" ++ }, ++ "id": "build-cherry-forbidden-routing", ++ "name": "Build Cherry Forbidden Routing", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1300, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\n\nif (!event.statusRequest) missing.push('statusRequest');\nif (!event.repo) missing.push('repo');\nif (!event.sha) missing.push('sha');\nif (!event.outputHash) missing.push('outputHash');\nif (!event.cherryClassifierOutput?.classifierVersion) missing.push('classifierVersion');\n\nif (missing.length > 0) {\n return [{\n json: {\n ok: false,\n workflow: event.workflow,\n status: 'failed',\n summary: 'Cherry status payload missing required fields. Refusing to post status.',\n actions: ['do_not_post_status'],\n missing\n }\n }];\n}\n\nreturn [{ json: event }];" ++ }, ++ "id": "require-forbidden-status-request", ++ "name": "Require Status Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1560, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "conditions": { ++ "options": { ++ "caseSensitive": true, ++ "leftValue": "", ++ "typeValidation": "strict" ++ }, ++ "conditions": [ ++ { ++ "id": "if-forbidden-status-request", ++ "leftValue": "={{ $json.statusRequest !== undefined && $json.statusRequest !== null && $json.repo !== undefined && $json.repo !== null && $json.sha !== undefined && $json.sha !== null && $json.outputHash !== undefined && $json.outputHash !== null && $json.cherryClassifierOutput?.classifierVersion !== undefined && $json.cherryClassifierOutput?.classifierVersion !== null }}", ++ "rightValue": true, ++ "operator": { ++ "type": "boolean", ++ "operation": "true", ++ "singleValue": true ++ } ++ } ++ ], ++ "combinator": "and" ++ }, ++ "options": {} ++ }, ++ "id": "if-forbidden-status-request", ++ "name": "IF: Has Status Payload?", ++ "type": "n8n-nodes-base.if", ++ "typeVersion": 2, ++ "position": [ ++ 1430, ++ 160 ++ ] ++ }, ++ { ++ "parameters": { ++ "conditions": { ++ "options": { ++ "caseSensitive": true, ++ "leftValue": "", ++ "typeValidation": "strict" ++ }, ++ "conditions": [ ++ { ++ "id": "if-forbidden-condition", ++ "leftValue": "={{ $json.forbiddenChange?.forbidden === true }}", ++ "rightValue": true, ++ "operator": { ++ "type": "boolean", ++ "operation": "true", ++ "singleValue": true ++ } ++ } ++ ], ++ "combinator": "and" ++ }, ++ "options": {} ++ }, ++ "id": "if-forbidden", ++ "name": "IF: Forbidden?", ++ "type": "n8n-nodes-base.if", ++ "typeVersion": 2, ++ "position": [ ++ 1560, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.prNumber + '/labels' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $json.labelBody }}" ++ }, ++ "id": "add-blocking-label", ++ "name": "Add blocking label", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1820, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build Cherry Forbidden Routing\"].json.prNumber + '/comments' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $node[\"Build Cherry Forbidden Routing\"].json.commentBody }}" ++ }, ++ "id": "comment-violation", ++ "name": "Comment Violation", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2080, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '04_forbidden_change_detector', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++ }, ++ "id": "route-shared-sinks", ++ "name": "Route Shared Sinks", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 2340, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '04_forbidden_change_detector.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '04_forbidden_change_detector',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '04_forbidden_change_detector') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? 'no-sha') + ':' + ($json.outputHash ?? $now.toISO())),\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '04_forbidden_change_detector.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json.cherryClassifierOutput ?? $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++ }, ++ "id": "archive-event", ++ "name": "Archive Event", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2600, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++ }, ++ "id": "notify-discord", ++ "name": "Notify Discord", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2860, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Forbidden change check completed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-response", ++ "name": "Build Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 3120, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'No forbidden changes detected.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-safe-response", ++ "name": "Build Safe Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1820, ++ 160 ++ ] ++ }, ++ { ++ "parameters": { ++ "respondWith": "firstIncomingItem", ++ "options": { ++ "responseCode": 200 ++ } ++ }, ++ "id": "respond-to-webhook", ++ "name": "Respond to Webhook", ++ "type": "n8n-nodes-base.respondToWebhook", ++ "typeVersion": 1, ++ "position": [ ++ 3380, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/statuses/github' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n context: $json.statusRequest.context,\n state: $json.statusRequest.state,\n description: $json.statusRequest.description,\n targetUrl: $json.statusRequest.targetUrl,\n sourceWorkflow: $json.workflow,\n automationEventId: $json.automationEventId,\n classifierVersion: $json.cherryClassifierOutput.classifierVersion,\n outputHash: $json.outputHash\n} }}" ++ }, ++ "id": "post-forbidden-status", ++ "name": "Post Forbidden Status", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1560, ++ 160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "jsCode": "const items = $input.all();\n\nlet files = [];\n\nfor (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n files.push(...json);\n } else if (Array.isArray(json.files)) {\n files.push(...json.files);\n } else if (Array.isArray(json.data)) {\n files.push(...json.data);\n } else if (json && json.filename) {\n files.push(json);\n }\n}\n\nfiles = files.map((file) => ({\n filename: file.filename ?? file.path ?? file.name ?? '',\n status: file.status ?? 'modified',\n additions: Number(file.additions ?? 0),\n deletions: Number(file.deletions ?? 0),\n changes: Number(file.changes ?? 0),\n patch: String(file.patch ?? '')\n})).filter((file) => file.filename.length > 0);\n\nreturn [{\n json: {\n ...($items('Normalize PR')[0]?.json ?? {}),\n files\n }\n}];" ++ }, ++ "id": "normalize-changed-files", ++ "name": "Normalize Changed Files", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 910, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/classify/pr' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n prNumber: $json.prNumber,\n title: $json.title,\n body: $json.body ?? '',\n labels: $json.labels ?? [],\n files: $json.files,\n sourceWorkflow: $json.workflow ?? '04_forbidden_change_detector'\n} }}" ++ }, ++ "id": "classify-pr-in-cherry-forbidden", ++ "name": "Classify PR In Cherry", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4.2, ++ "position": [ ++ 1040, ++ 0 ++ ], ++ "continueOnFail": true ++ } ++ ], ++ "connections": { ++ "Webhook": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize PR", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Normalize PR": { ++ "main": [ ++ [ ++ { ++ "node": "Validate Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Validate Payload": { ++ "main": [ ++ [ ++ { ++ "node": "Fetch Changed Files", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "IF: Forbidden?": { ++ "main": [ ++ [ ++ { ++ "node": "Add blocking label", ++ "type": "main", ++ "index": 0 ++ } ++ ], ++ [ ++ { ++ "node": "Build Safe Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Add blocking label": { ++ "main": [ ++ [ ++ { ++ "node": "Comment Violation", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Comment Violation": { ++ "main": [ ++ [ ++ { ++ "node": "Route Shared Sinks", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Route Shared Sinks": { ++ "main": [ ++ [ ++ { ++ "node": "Archive Event", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Archive Event": { ++ "main": [ ++ [ ++ { ++ "node": "Notify Discord", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Notify Discord": { ++ "main": [ ++ [ ++ { ++ "node": "Build Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Safe Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Post Forbidden Status": { ++ "main": [ ++ [ ++ { ++ "node": "IF: Forbidden?", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Cherry Forbidden Routing": { ++ "main": [ ++ [ ++ { ++ "node": "Require Status Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Fetch Changed Files": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize Changed Files", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Classify PR In Cherry": { ++ "main": [ ++ [ ++ { ++ "node": "Build Cherry Forbidden Routing", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Normalize Changed Files": { ++ "main": [ ++ [ ++ { ++ "node": "Classify PR In Cherry", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Require Status Payload": { ++ "main": [ ++ [ ++ { ++ "node": "IF: Has Status Payload?", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "IF: Has Status Payload?": { ++ "main": [ ++ [ ++ { ++ "node": "Post Forbidden Status", ++ "type": "main", ++ "index": 0 ++ } ++ ], ++ [ ++ { ++ "node": "Build Safe Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ } ++ }, ++ "settings": { ++ "executionOrder": "v1" ++ } ++} +diff --git a/cherry-n8n-workflows/05_engine_degradation_alerting.json b/cherry-n8n-workflows/05_engine_degradation_alerting.json +new file mode 100644 +index 0000000000000000000000000000000000000000..636e81cec95a879b9b4a1a84ec9f76998add5f8e +--- /dev/null ++++ b/cherry-n8n-workflows/05_engine_degradation_alerting.json +@@ -0,0 +1,389 @@ ++{ ++ "name": "Cherry - Engine Degradation Alerting", ++ "nodes": [ ++ { ++ "parameters": { ++ "httpMethod": "POST", ++ "path": "cherry/runtime/degradation", ++ "responseMode": "responseNode", ++ "options": {} ++ }, ++ "id": "webhook-runtime-degradation", ++ "name": "Webhook", ++ "type": "n8n-nodes-base.webhook", ++ "typeVersion": 2, ++ "position": [ ++ 0, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'cherry.runtime_degradation',\n source: 'cherry',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '05_engine_degradation_alerting',\n ok: true,\n status: 'accepted',\n summary: 'Normalized cherry.runtime_degradation.',\n actions: []\n};\nreturn [{ json: output }];" ++ }, ++ "id": "normalize-degradation-event", ++ "name": "Normalize Degradation Event", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 260, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.type\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++ }, ++ "id": "validate-payload", ++ "name": "Validate Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 520, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst type = event.payload?.type ?? 'unknown';\nconst severityMap = { missing_debt_truth: 'high', solver_divergence: 'critical', temporal_inconsistency: 'critical', candidate_exclusion: 'medium', advisory_degraded: 'medium', impossible_state: 'critical', route_response_mismatch: 'high', score_drift: 'medium' };\nconst severity = event.payload?.severity ?? severityMap[type] ?? 'low'; const createIssue = severity === 'high' || severity === 'critical';\nconst output = { ...event, degradationType: type, severity, createIssue, issueBody: { title: '[engine:' + severity + '] ' + type, labels: ['engine-degradation', 'automation', 'needs-human-review', severity], body: 'Cherry engine degradation event.\\n\\nType: ' + type + '\\nSeverity: ' + severity + '\\nTimestamp: ' + event.timestamp + '\\n\\nPayload JSON:\\n' + JSON.stringify(event.payload, null, 2) + '\\n\\nAdvisory alert only.' }, notification: { type, severity, repo: event.repo, payload: event.payload }, status: createIssue ? 'accepted' : 'ignored', summary: createIssue ? 'High-severity engine degradation alert for ' + type + '.' : 'Archived medium/low degradation event for ' + type + '.', actions: createIssue ? ['create_github_issue', 'notify_discord', 'archive_event'] : ['notify_discord', 'archive_event'] };\nreturn [{ json: output }];" ++ }, ++ "id": "classify-severity", ++ "name": "Classify Severity", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 780, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "conditions": { ++ "options": { ++ "caseSensitive": true, ++ "leftValue": "", ++ "typeValidation": "strict" ++ }, ++ "conditions": [ ++ { ++ "id": "if-high-severity-condition", ++ "leftValue": "={{ $json.createIssue === true }}", ++ "rightValue": true, ++ "operator": { ++ "type": "boolean", ++ "operation": "true", ++ "singleValue": true ++ } ++ } ++ ], ++ "combinator": "and" ++ }, ++ "options": {} ++ }, ++ "id": "if-high-severity", ++ "name": "IF: Severity >= high?", ++ "type": "n8n-nodes-base.if", ++ "typeVersion": 2, ++ "position": [ ++ 1040, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $json.issueBody }}" ++ }, ++ "id": "create-github-issue", ++ "name": "Create GitHub Issue", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1300, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '05_engine_degradation_alerting', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++ }, ++ "id": "route-shared-sinks", ++ "name": "Route Shared Sinks", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1560, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++ }, ++ "id": "notify-discord", ++ "name": "Notify Discord", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1820, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '05_engine_degradation_alerting.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '05_engine_degradation_alerting',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '05_engine_degradation_alerting') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '05_engine_degradation_alerting.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++ }, ++ "id": "archive-event", ++ "name": "Archive Event", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2080, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Engine degradation handled.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-response", ++ "name": "Build Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 2340, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Engine degradation archived.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-archived-response", ++ "name": "Build Archived Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1300, ++ 160 ++ ] ++ }, ++ { ++ "parameters": { ++ "respondWith": "firstIncomingItem", ++ "options": { ++ "responseCode": 200 ++ } ++ }, ++ "id": "respond-to-webhook", ++ "name": "Respond to Webhook", ++ "type": "n8n-nodes-base.respondToWebhook", ++ "typeVersion": 1, ++ "position": [ ++ 2600, ++ 0 ++ ] ++ } ++ ], ++ "connections": { ++ "Webhook": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize Degradation Event", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Normalize Degradation Event": { ++ "main": [ ++ [ ++ { ++ "node": "Validate Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Validate Payload": { ++ "main": [ ++ [ ++ { ++ "node": "Classify Severity", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Classify Severity": { ++ "main": [ ++ [ ++ { ++ "node": "IF: Severity >= high?", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "IF: Severity >= high?": { ++ "main": [ ++ [ ++ { ++ "node": "Create GitHub Issue", ++ "type": "main", ++ "index": 0 ++ } ++ ], ++ [ ++ { ++ "node": "Build Archived Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Create GitHub Issue": { ++ "main": [ ++ [ ++ { ++ "node": "Route Shared Sinks", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Route Shared Sinks": { ++ "main": [ ++ [ ++ { ++ "node": "Notify Discord", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Notify Discord": { ++ "main": [ ++ [ ++ { ++ "node": "Archive Event", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Archive Event": { ++ "main": [ ++ [ ++ { ++ "node": "Build Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Archived Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ } ++ }, ++ "settings": { ++ "executionOrder": "v1" ++ } ++} +diff --git a/cherry-n8n-workflows/06_simulation_drift_detector.json b/cherry-n8n-workflows/06_simulation_drift_detector.json +new file mode 100644 +index 0000000000000000000000000000000000000000..86fb8c1d197d0023e0fb64a5a42ac1ddef30c381 +--- /dev/null ++++ b/cherry-n8n-workflows/06_simulation_drift_detector.json +@@ -0,0 +1,432 @@ ++{ ++ "name": "Cherry - Simulation Drift Detector", ++ "nodes": [ ++ { ++ "parameters": { ++ "httpMethod": "POST", ++ "path": "cherry/simulation/result", ++ "responseMode": "responseNode", ++ "options": {} ++ }, ++ "id": "webhook-simulation-result", ++ "name": "Webhook", ++ "type": "n8n-nodes-base.webhook", ++ "typeVersion": 2, ++ "position": [ ++ 0, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'cherry.simulation_result',\n source: 'cherry',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '06_simulation_drift_detector',\n ok: true,\n status: 'accepted',\n summary: 'Normalized cherry.simulation_result.',\n actions: []\n};\nreturn [{ json: output }];" ++ }, ++ "id": "normalize-simulation-result", ++ "name": "Normalize Simulation Result", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 260, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.runId\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++ }, ++ "id": "validate-payload", ++ "name": "Validate Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 520, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const result = $input.first()?.json ?? {};\nconst event = $node['Normalize Simulation Result'].json;\nconst comparison = result.comparisonOutput ?? {};\nconst drift = comparison.drift === true;\nconst reasons = comparison.reasons ?? [];\nconst output = {\n ...event,\n automationSnapshotId: result.snapshotId,\n outputHash: result.outputHash,\n drift,\n driftReasons: reasons,\n issueBody: {\n title: '[simulation-drift] ' + (event.payload?.scenarioId ?? event.payload?.profileId ?? 'default'),\n labels: ['simulation-drift', 'automation', 'needs-human-review'],\n body: 'Cherry simulation drift detected.\\n\\n' + reasons.map((reason) => '- ' + reason).join('\\n') + '\\n\\nSnapshot comparison is stored by Cherry automation, not n8n static data.'\n },\n status: drift ? 'accepted' : 'ignored',\n summary: drift ? 'Simulation drift detected: ' + reasons.join(', ') : 'Simulation snapshot stored with no material drift.',\n actions: drift ? ['compare_snapshot_in_cherry', 'create_issue', 'archive_event'] : ['compare_snapshot_in_cherry', 'archive_event']\n};\nreturn [{ json: output }];" ++ }, ++ "id": "compare-snapshot", ++ "name": "Compare Snapshot", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 780, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "conditions": { ++ "options": { ++ "caseSensitive": true, ++ "leftValue": "", ++ "typeValidation": "strict" ++ }, ++ "conditions": [ ++ { ++ "id": "if-drift-condition", ++ "leftValue": "={{ $json.drift === true }}", ++ "rightValue": true, ++ "operator": { ++ "type": "boolean", ++ "operation": "true", ++ "singleValue": true ++ } ++ } ++ ], ++ "combinator": "and" ++ }, ++ "options": {} ++ }, ++ "id": "if-drift", ++ "name": "IF: Drift?", ++ "type": "n8n-nodes-base.if", ++ "typeVersion": 2, ++ "position": [ ++ 1040, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $json.issueBody }}" ++ }, ++ "id": "create-drift-issue", ++ "name": "Create Drift Issue", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1300, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '06_simulation_drift_detector', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++ }, ++ "id": "route-shared-sinks", ++ "name": "Route Shared Sinks", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1560, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++ }, ++ "id": "notify-discord", ++ "name": "Notify Discord", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1820, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '06_simulation_drift_detector.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '06_simulation_drift_detector',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '06_simulation_drift_detector') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'simulation-drift@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '06_simulation_drift_detector.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++ }, ++ "id": "archive-event", ++ "name": "Archive Event", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2080, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Simulation drift handled.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-response", ++ "name": "Build Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 2340, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Simulation snapshot stored.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-no-drift-response", ++ "name": "Build No Drift Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1300, ++ 160 ++ ] ++ }, ++ { ++ "parameters": { ++ "respondWith": "firstIncomingItem", ++ "options": { ++ "responseCode": 200 ++ } ++ }, ++ "id": "respond-to-webhook", ++ "name": "Respond to Webhook", ++ "type": "n8n-nodes-base.respondToWebhook", ++ "typeVersion": 1, ++ "position": [ ++ 2600, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/simulation-snapshots/compare' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n scopeKey: $json.payload.scenarioId ?? $json.payload.profileId ?? 'default',\n runId: $json.payload.runId,\n snapshot: $json.payload,\n sourceWorkflow: $json.workflow ?? '06_simulation_drift_detector'\n} }}" ++ }, ++ "id": "compare-simulation-in-cherry", ++ "name": "Compare Simulation In Cherry", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 780, ++ 160 ++ ], ++ "continueOnFail": true ++ } ++ ], ++ "connections": { ++ "Webhook": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize Simulation Result", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Normalize Simulation Result": { ++ "main": [ ++ [ ++ { ++ "node": "Validate Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Validate Payload": { ++ "main": [ ++ [ ++ { ++ "node": "Compare Simulation In Cherry", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Compare Snapshot": { ++ "main": [ ++ [ ++ { ++ "node": "IF: Drift?", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "IF: Drift?": { ++ "main": [ ++ [ ++ { ++ "node": "Create Drift Issue", ++ "type": "main", ++ "index": 0 ++ } ++ ], ++ [ ++ { ++ "node": "Build No Drift Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Create Drift Issue": { ++ "main": [ ++ [ ++ { ++ "node": "Route Shared Sinks", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Route Shared Sinks": { ++ "main": [ ++ [ ++ { ++ "node": "Notify Discord", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Notify Discord": { ++ "main": [ ++ [ ++ { ++ "node": "Archive Event", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Archive Event": { ++ "main": [ ++ [ ++ { ++ "node": "Build Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build No Drift Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Compare Simulation In Cherry": { ++ "main": [ ++ [ ++ { ++ "node": "Compare Snapshot", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ } ++ }, ++ "settings": { ++ "executionOrder": "v1" ++ } ++} +diff --git a/cherry-n8n-workflows/07_release_summary_generator.json b/cherry-n8n-workflows/07_release_summary_generator.json +new file mode 100644 +index 0000000000000000000000000000000000000000..01234741099126e95a9d7ba6c40443f1a58ad0b5 +--- /dev/null ++++ b/cherry-n8n-workflows/07_release_summary_generator.json +@@ -0,0 +1,488 @@ ++{ ++ "name": "Cherry - Release Summary Generator", ++ "nodes": [ ++ { ++ "parameters": { ++ "httpMethod": "POST", ++ "path": "cherry/release/summary", ++ "responseMode": "responseNode", ++ "options": {} ++ }, ++ "id": "webhook-release-summary", ++ "name": "Webhook", ++ "type": "n8n-nodes-base.webhook", ++ "typeVersion": 2, ++ "position": [ ++ 0, ++ -160 ++ ] ++ }, ++ { ++ "parameters": {}, ++ "id": "manual-release-summary", ++ "name": "Manual Trigger", ++ "type": "n8n-nodes-base.manualTrigger", ++ "typeVersion": 1, ++ "position": [ ++ 0, ++ 160 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const input = $input.first()?.json ?? {}; const body = input.body ?? input; const source = input.body ? 'cherry' : 'manual'; const output = { event: 'cherry.release_summary', source, repo: body.repo ?? 'div0rce/cherry', timestamp: body.timestamp ?? new Date().toISOString(), payload: body, workflow: '07_release_summary_generator', ok: true, status: 'accepted', summary: 'Release summary generation started.', actions: [] }; return [{ json: output }];" ++ }, ++ "id": "normalize-release-request", ++ "name": "Normalize Release Request", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 260, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"repo\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++ }, ++ "id": "validate-payload", ++ "name": "Validate Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 520, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "GET", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/releases/latest' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {} ++ }, ++ "id": "fetch-latest-release", ++ "name": "Fetch Latest Release", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 780, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "GET", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/commits?per_page=100' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {} ++ }, ++ "id": "fetch-commits", ++ "name": "Fetch Commits Since Last Tag", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1040, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const commits = Array.isArray($json) ? $json : []; const groups = { engine: [], api: [], prisma: [], tests: [], docs: [], infra: [], other: [] };\nfor (const commit of commits) { const message = commit.commit?.message ?? commit.message ?? ''; const lower = message.toLowerCase(); const bucket = lower.includes('engine') ? 'engine' : lower.includes('api') || lower.includes('route') ? 'api' : lower.includes('prisma') || lower.includes('migration') ? 'prisma' : lower.includes('test') ? 'tests' : lower.includes('doc') || lower.includes('readme') ? 'docs' : lower.includes('ci') || lower.includes('guardrail') ? 'infra' : 'other'; groups[bucket].push(message.split('\\n')[0]); }\nconst risk = []; if (groups.engine.length > 0) risk.push('engine changes require deterministic review'); if (groups.prisma.length > 0) risk.push('Prisma changes require migration verification'); if (groups.api.length > 0) risk.push('API changes require route tests');\nconst changelog = Object.entries(groups).filter(([, items]) => items.length > 0).map(([name, items]) => '## ' + name + '\\n' + items.map((item) => '- ' + item).join('\\n')).join('\\n\\n'); const releaseBody = '# Cherry Release Draft\\n\\n' + (changelog || 'No commits returned by GitHub API.') + '\\n\\n## Risk Summary\\n' + (risk.length ? risk.map((r) => '- ' + r).join('\\n') : '- Low automation-detected release risk.') + '\\n\\n## Verification\\n- npm run check\\n- npm test\\n- npm run build\\n- npm run ci:verify';\nconst output = { ...$node['Normalize Release Request'].json, groups, changelog, riskSummary: risk, linkedInDraft: 'Cherry release update: guardrails, repo quality, and development automation advanced. Details remain advisory until verified in CI.', releaseBody, releaseDraftBody: { tag_name: $node['Normalize Release Request'].json.payload.tagName ?? 'v-next', name: 'Cherry v-next', body: releaseBody, draft: true, prerelease: true }, status: 'accepted', summary: 'Generated release summary from ' + commits.length + ' commits.', actions: ['fetch_commits', 'group_changes', 'generate_changelog', 'archive_event'] };\nreturn [{ json: output }];" ++ }, ++ "id": "generate-release-summary", ++ "name": "Generate Release Summary", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1300, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/releases' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $json.releaseDraftBody }}" ++ }, ++ "id": "create-github-release-draft", ++ "name": "Create GitHub Release Draft", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1560, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '07_release_summary_generator', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++ }, ++ "id": "route-shared-sinks", ++ "name": "Route Shared Sinks", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1820, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '07_release_summary_generator.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '07_release_summary_generator',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '07_release_summary_generator') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '07_release_summary_generator.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++ }, ++ "id": "archive-event", ++ "name": "Archive Event", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2080, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++ }, ++ "id": "notify-discord", ++ "name": "Notify Discord", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2340, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "conditions": { ++ "options": { ++ "caseSensitive": true, ++ "leftValue": "", ++ "typeValidation": "strict" ++ }, ++ "conditions": [ ++ { ++ "id": "if-webhook-source-condition", ++ "leftValue": "={{ $node['Normalize Release Request'].json.source !== 'manual' }}", ++ "rightValue": true, ++ "operator": { ++ "type": "boolean", ++ "operation": "true", ++ "singleValue": true ++ } ++ } ++ ], ++ "combinator": "and" ++ }, ++ "options": {} ++ }, ++ "id": "if-webhook-source", ++ "name": "IF: Webhook Source?", ++ "type": "n8n-nodes-base.if", ++ "typeVersion": 2, ++ "position": [ ++ 2600, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Release summary generated.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-response", ++ "name": "Build Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 2860, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Manual release summary generated.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "manual-result-log", ++ "name": "Manual Result Log", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 2860, ++ 160 ++ ] ++ }, ++ { ++ "parameters": { ++ "respondWith": "firstIncomingItem", ++ "options": { ++ "responseCode": 200 ++ } ++ }, ++ "id": "respond-to-webhook", ++ "name": "Respond to Webhook", ++ "type": "n8n-nodes-base.respondToWebhook", ++ "typeVersion": 1, ++ "position": [ ++ 3120, ++ -160 ++ ] ++ } ++ ], ++ "connections": { ++ "Webhook": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize Release Request", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Manual Trigger": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize Release Request", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Normalize Release Request": { ++ "main": [ ++ [ ++ { ++ "node": "Validate Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Validate Payload": { ++ "main": [ ++ [ ++ { ++ "node": "Fetch Latest Release", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Fetch Latest Release": { ++ "main": [ ++ [ ++ { ++ "node": "Fetch Commits Since Last Tag", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Fetch Commits Since Last Tag": { ++ "main": [ ++ [ ++ { ++ "node": "Generate Release Summary", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Generate Release Summary": { ++ "main": [ ++ [ ++ { ++ "node": "Create GitHub Release Draft", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Create GitHub Release Draft": { ++ "main": [ ++ [ ++ { ++ "node": "Route Shared Sinks", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Route Shared Sinks": { ++ "main": [ ++ [ ++ { ++ "node": "Archive Event", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Archive Event": { ++ "main": [ ++ [ ++ { ++ "node": "Notify Discord", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Notify Discord": { ++ "main": [ ++ [ ++ { ++ "node": "IF: Webhook Source?", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "IF: Webhook Source?": { ++ "main": [ ++ [ ++ { ++ "node": "Build Response", ++ "type": "main", ++ "index": 0 ++ } ++ ], ++ [ ++ { ++ "node": "Manual Result Log", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ } ++ }, ++ "settings": { ++ "executionOrder": "v1" ++ } ++} +diff --git a/cherry-n8n-workflows/08_repo_intelligence_digest.json b/cherry-n8n-workflows/08_repo_intelligence_digest.json +new file mode 100644 +index 0000000000000000000000000000000000000000..7a69895b6b9bf041036a48551af8c422a7df5a5f +--- /dev/null ++++ b/cherry-n8n-workflows/08_repo_intelligence_digest.json +@@ -0,0 +1,378 @@ ++{ ++ "name": "Cherry - Repo Intelligence Digest", ++ "nodes": [ ++ { ++ "parameters": { ++ "rule": { ++ "interval": [ ++ { ++ "field": "weeks", ++ "triggerAtDay": [ ++ 1 ++ ], ++ "triggerAtHour": 9, ++ "triggerAtMinute": 0 ++ } ++ ] ++ } ++ }, ++ "id": "weekly-repo-digest", ++ "name": "Schedule Trigger", ++ "type": "n8n-nodes-base.scheduleTrigger", ++ "typeVersion": 1, ++ "position": [ ++ 0, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const input = $input.first()?.json ?? {};\nconst output = { event: 'cherry.weekly_repo_digest', source: 'manual', repo: 'div0rce/cherry', timestamp: input.timestamp ?? new Date().toISOString(), payload: input, workflow: '08_repo_intelligence_digest', ok: true, status: 'accepted', summary: 'Scheduled cherry.weekly_repo_digest started.', actions: [] };\nreturn [{ json: output }];" ++ }, ++ "id": "normalize-schedule", ++ "name": "Normalize Schedule", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 260, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"repo\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++ }, ++ "id": "validate-payload", ++ "name": "Validate Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 520, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "GET", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls?state=open&per_page=100' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {} ++ }, ++ "id": "fetch-open-prs", ++ "name": "Fetch Open PRs", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 780, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "GET", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues?state=open&per_page=100' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {} ++ }, ++ "id": "fetch-open-issues", ++ "name": "Fetch Open Issues", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1040, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "GET", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/commits?per_page=50' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {} ++ }, ++ "id": "fetch-recent-commits", ++ "name": "Fetch Recent Commits", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1300, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const prs = Array.isArray($items('Fetch Open PRs')[0]?.json) ? $items('Fetch Open PRs')[0].json : []; const issues = Array.isArray($items('Fetch Open Issues')[0]?.json) ? $items('Fetch Open Issues')[0].json : []; const commits = Array.isArray($json) ? $json : []; const now = Date.now(); const stalePrs = prs.filter((pr) => now - new Date(pr.updated_at ?? pr.created_at ?? now).getTime() > 7 * 24 * 60 * 60 * 1000); const dependabot = prs.filter((pr) => /dependabot/i.test(pr.user?.login ?? '')); const highRisk = prs.filter((pr) => /engine|prisma|migration|api/i.test((pr.title ?? '') + ' ' + (pr.body ?? ''))); const digest = '# Cherry Weekly Repo Intelligence Digest\\n\\n- Open PRs: ' + prs.length + '\\n- Stale PRs: ' + stalePrs.length + '\\n- Open issues: ' + issues.length + '\\n- Recent commits: ' + commits.length + '\\n- Dependabot PRs: ' + dependabot.length + '\\n- High-risk hints: ' + highRisk.length + '\\n\\nAdvisory automation only.'; const output = { ...$node['Normalize Schedule'].json, digest, metrics: { openPrs: prs.length, stalePrs: stalePrs.length, openIssues: issues.length, recentCommits: commits.length, dependabotPrs: dependabot.length, highRiskHints: highRisk.length }, status: 'accepted', summary: 'Weekly repo digest built: ' + prs.length + ' PRs, ' + issues.length + ' issues.', actions: ['fetch_open_prs', 'fetch_open_issues', 'fetch_recent_commits', 'archive_digest', 'notify_discord'] }; return [{ json: output }];" ++ }, ++ "id": "build-digest", ++ "name": "Build Digest", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1560, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '08_repo_intelligence_digest', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++ }, ++ "id": "route-shared-sinks", ++ "name": "Route Shared Sinks", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1820, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '08_repo_intelligence_digest.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '08_repo_intelligence_digest',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '08_repo_intelligence_digest') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '08_repo_intelligence_digest.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++ }, ++ "id": "archive-event", ++ "name": "Archive Event", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2080, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++ }, ++ "id": "notify-discord", ++ "name": "Notify Discord", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2340, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Weekly repo digest completed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "log-digest", ++ "name": "Log Digest", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 2600, ++ 0 ++ ] ++ } ++ ], ++ "connections": { ++ "Schedule Trigger": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize Schedule", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Normalize Schedule": { ++ "main": [ ++ [ ++ { ++ "node": "Validate Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Validate Payload": { ++ "main": [ ++ [ ++ { ++ "node": "Fetch Open PRs", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Fetch Open PRs": { ++ "main": [ ++ [ ++ { ++ "node": "Fetch Open Issues", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Fetch Open Issues": { ++ "main": [ ++ [ ++ { ++ "node": "Fetch Recent Commits", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Fetch Recent Commits": { ++ "main": [ ++ [ ++ { ++ "node": "Build Digest", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Digest": { ++ "main": [ ++ [ ++ { ++ "node": "Route Shared Sinks", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Route Shared Sinks": { ++ "main": [ ++ [ ++ { ++ "node": "Archive Event", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Archive Event": { ++ "main": [ ++ [ ++ { ++ "node": "Notify Discord", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Notify Discord": { ++ "main": [ ++ [ ++ { ++ "node": "Log Digest", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ } ++ }, ++ "settings": { ++ "executionOrder": "v1" ++ } ++} +diff --git a/cherry-n8n-workflows/09_docs_drift_detector.json b/cherry-n8n-workflows/09_docs_drift_detector.json +new file mode 100644 +index 0000000000000000000000000000000000000000..f0cd67923ff107067b82b3ab509c695bf0f94d45 +--- /dev/null ++++ b/cherry-n8n-workflows/09_docs_drift_detector.json +@@ -0,0 +1,628 @@ ++{ ++ "name": "Cherry - Docs Drift Detector", ++ "nodes": [ ++ { ++ "parameters": { ++ "httpMethod": "POST", ++ "path": "cherry/github/pull-request-docs-drift", ++ "responseMode": "responseNode", ++ "options": {} ++ }, ++ "id": "webhook-docs-drift", ++ "name": "Webhook", ++ "type": "n8n-nodes-base.webhook", ++ "typeVersion": 2, ++ "position": [ ++ 0, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const raw = $input.first()?.json ?? {};\nconst input = raw.body ?? raw.payload ?? raw;\n\nconst item = Array.isArray(input.items) ? input.items[0] : undefined;\n\nconst pr =\n input.pull_request ??\n input.pr ??\n item ??\n input;\n\nconst prNumber =\n pr.number ??\n input.number ??\n input.pull_number ??\n input.pr_number;\n\nconst sha =\n pr.head?.sha ??\n input.head?.sha ??\n input.after ??\n input.sha;\n\nconst repoFullName =\n input.repository?.full_name ??\n input.repo ??\n input.full_name ??\n 'div0rce/cherry';\n\nconst [owner, repo] = String(repoFullName).split('/');\n\nif (!prNumber) {\n return [{\n json: {\n ok: false,\n status: 'failed',\n workflow: '09_docs_drift_detector',\n error: 'missing_pr_number',\n reason: 'Normalize PR could not derive a PR number from webhook, search, or flat payload',\n receivedKeys: Object.keys(input),\n totalCount: input.total_count,\n searchType: input.search_type,\n repoFullName,\n actions: ['do_not_classify_pr']\n },\n }];\n}\n\nconst labels = Array.isArray(pr.labels)\n ? pr.labels.map((label) => typeof label === 'string' ? label : label.name).filter(Boolean)\n : [];\n\nreturn [{\n json: {\n ...input,\n event: 'github.pull_request_docs_drift',\n source: 'github',\n owner: owner || input.repository?.owner?.login || 'div0rce',\n repo: repoFullName,\n repoName: repo || input.repository?.name || 'cherry',\n repoFullName,\n prNumber,\n sha,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n labels,\n timestamp: raw.timestamp ?? input.workflow_run?.updated_at ?? pr.updated_at ?? input.issue?.updated_at ?? new Date().toISOString(),\n workflow: '09_docs_drift_detector',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.pull_request_docs_drift.',\n actions: [],\n pull_request: {\n number: prNumber,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n head: { sha },\n labels: Array.isArray(pr.labels) ? pr.labels : [],\n },\n payload: {\n ...input,\n repository: input.repository ?? { full_name: repoFullName, name: repo || input.repository?.name || 'cherry', owner: { login: owner || input.repository?.owner?.login || 'div0rce' } },\n pull_request: {\n number: prNumber,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n head: { sha },\n labels: Array.isArray(pr.labels) ? pr.labels : [],\n }\n }\n },\n}];" ++ }, ++ "id": "normalize-pr", ++ "name": "Normalize PR", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 260, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\nif (!event.prNumber) missing.push('prNumber');\nif (!event.sha) missing.push('sha');\nconst output = {\n ...event,\n valid: missing.length === 0,\n validationErrors: missing,\n status: missing.length === 0 ? event.status : 'failed',\n summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', '),\n actions: missing.length === 0 ? event.actions : [...(event.actions ?? []), 'do_not_classify_pr']\n};\nreturn [{ json: output }];" ++ }, ++ "id": "validate-payload", ++ "name": "Validate Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 520, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "GET", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls/' + $json.prNumber + '/files?per_page=100' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {} ++ }, ++ "id": "fetch-changed-files", ++ "name": "Fetch Changed Files", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 780, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const prEvent = $node['Normalize PR'].json;\nconst classification = $input.first()?.json ?? {};\nconst classifierOutput = classification.classifierOutput ?? {};\nconst docsDrift = classifierOutput.docsDrift ?? {};\nconst statusRequests = Array.isArray(classifierOutput.statusRequests) ? classifierOutput.statusRequests : [];\nconst statusRequest = statusRequests.find((request) => request.context === 'cherry/docs-drift');\nconst sha = prEvent.sha;\nconst domains = Array.isArray(docsDrift.domains) ? docsDrift.domains : [];\nconst labels = Array.isArray(docsDrift.labels) ? docsDrift.labels : [];\nconst drift = docsDrift.drift === true;\nconst output = {\n ...prEvent,\n sha,\n automationEventId: classification.automationEventId,\n outputHash: classification.outputHash,\n cherryClassifierOutput: classifierOutput,\n docsDrift,\n statusRequest,\n labelBody: { labels },\n commentBody: { body: 'Cherry docs drift detector.\\n\\n' + (drift ? 'Docs update required for changed domains: ' + domains.join(', ') : 'Cherry found no docs drift.') + '\\n\\nDocs must match code reality unless legal constraints require a code fix.' },\n status: drift ? 'accepted' : 'ignored',\n summary: drift ? 'Cherry detected docs drift for ' + domains.join(', ') + '.' : 'Cherry found no docs drift.',\n actions: drift ? ['classify_pr_in_cherry', 'post_docs_status', 'label_docs_drift', 'comment_required_docs_update'] : ['classify_pr_in_cherry', 'post_docs_status']\n};\nreturn [{ json: output }];" ++ }, ++ "id": "build-cherry-docs-routing", ++ "name": "Build Cherry Docs Routing", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1300, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\n\nif (!event.statusRequest) missing.push('statusRequest');\nif (!event.repo) missing.push('repo');\nif (!event.sha) missing.push('sha');\nif (!event.outputHash) missing.push('outputHash');\nif (!event.cherryClassifierOutput?.classifierVersion) missing.push('classifierVersion');\n\nif (missing.length > 0) {\n return [{\n json: {\n ok: false,\n workflow: event.workflow,\n status: 'failed',\n summary: 'Cherry status payload missing required fields. Refusing to post status.',\n actions: ['do_not_post_status'],\n missing\n }\n }];\n}\n\nreturn [{ json: event }];" ++ }, ++ "id": "require-docs-status-request", ++ "name": "Require Status Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1560, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "conditions": { ++ "options": { ++ "caseSensitive": true, ++ "leftValue": "", ++ "typeValidation": "strict" ++ }, ++ "conditions": [ ++ { ++ "id": "if-docs-status-request", ++ "leftValue": "={{ $json.statusRequest !== undefined && $json.statusRequest !== null && $json.repo !== undefined && $json.repo !== null && $json.sha !== undefined && $json.sha !== null && $json.outputHash !== undefined && $json.outputHash !== null && $json.cherryClassifierOutput?.classifierVersion !== undefined && $json.cherryClassifierOutput?.classifierVersion !== null }}", ++ "rightValue": true, ++ "operator": { ++ "type": "boolean", ++ "operation": "true", ++ "singleValue": true ++ } ++ } ++ ], ++ "combinator": "and" ++ }, ++ "options": {} ++ }, ++ "id": "if-docs-status-request", ++ "name": "IF: Has Status Payload?", ++ "type": "n8n-nodes-base.if", ++ "typeVersion": 2, ++ "position": [ ++ 1170, ++ 160 ++ ] ++ }, ++ { ++ "parameters": { ++ "conditions": { ++ "options": { ++ "caseSensitive": true, ++ "leftValue": "", ++ "typeValidation": "strict" ++ }, ++ "conditions": [ ++ { ++ "id": "if-docs-drift-condition", ++ "leftValue": "={{ $json.docsDrift?.drift === true }}", ++ "rightValue": true, ++ "operator": { ++ "type": "boolean", ++ "operation": "true", ++ "singleValue": true ++ } ++ } ++ ], ++ "combinator": "and" ++ }, ++ "options": {} ++ }, ++ "id": "if-docs-drift", ++ "name": "IF: Docs Drift?", ++ "type": "n8n-nodes-base.if", ++ "typeVersion": 2, ++ "position": [ ++ 1300, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.prNumber + '/labels' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $json.labelBody }}" ++ }, ++ "id": "label-docs-drift", ++ "name": "Label Docs Drift", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1560, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build Cherry Docs Routing\"].json.prNumber + '/comments' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $node[\"Build Cherry Docs Routing\"].json.commentBody }}" ++ }, ++ "id": "comment-docs-drift", ++ "name": "Comment Required Docs Update", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1820, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '09_docs_drift_detector', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++ }, ++ "id": "route-shared-sinks", ++ "name": "Route Shared Sinks", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 2080, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '09_docs_drift_detector.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '09_docs_drift_detector',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '09_docs_drift_detector') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? 'no-sha') + ':' + ($json.outputHash ?? $now.toISO())),\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '09_docs_drift_detector.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json.cherryClassifierOutput ?? $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++ }, ++ "id": "archive-event", ++ "name": "Archive Event", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2340, ++ -160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Docs drift handled.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-response", ++ "name": "Build Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 2600, ++ -160 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: 'ignored', summary: event.summary ?? 'No docs drift detected.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "build-no-drift-response", ++ "name": "Build No Drift Response", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1560, ++ 160 ++ ] ++ }, ++ { ++ "parameters": { ++ "respondWith": "firstIncomingItem", ++ "options": { ++ "responseCode": 200 ++ } ++ }, ++ "id": "respond-to-webhook", ++ "name": "Respond to Webhook", ++ "type": "n8n-nodes-base.respondToWebhook", ++ "typeVersion": 1, ++ "position": [ ++ 2860, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/statuses/github' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n context: $json.statusRequest.context,\n state: $json.statusRequest.state,\n description: $json.statusRequest.description,\n targetUrl: $json.statusRequest.targetUrl,\n sourceWorkflow: $json.workflow,\n automationEventId: $json.automationEventId,\n classifierVersion: $json.cherryClassifierOutput.classifierVersion,\n outputHash: $json.outputHash\n} }}" ++ }, ++ "id": "post-docs-status", ++ "name": "Post Docs Status", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1300, ++ 160 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "jsCode": "const items = $input.all();\n\nlet files = [];\n\nfor (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n files.push(...json);\n } else if (Array.isArray(json.files)) {\n files.push(...json.files);\n } else if (Array.isArray(json.data)) {\n files.push(...json.data);\n } else if (json && json.filename) {\n files.push(json);\n }\n}\n\nfiles = files.map((file) => ({\n filename: file.filename ?? file.path ?? file.name ?? '',\n status: file.status ?? 'modified',\n additions: Number(file.additions ?? 0),\n deletions: Number(file.deletions ?? 0),\n changes: Number(file.changes ?? 0),\n patch: String(file.patch ?? '')\n})).filter((file) => file.filename.length > 0);\n\nreturn [{\n json: {\n ...($items('Normalize PR')[0]?.json ?? {}),\n files\n }\n}];" ++ }, ++ "id": "normalize-changed-files", ++ "name": "Normalize Changed Files", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 910, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/classify/pr' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n prNumber: $json.prNumber,\n title: $json.title,\n body: $json.body ?? '',\n labels: $json.labels ?? [],\n files: $json.files,\n sourceWorkflow: $json.workflow ?? '09_docs_drift_detector'\n} }}" ++ }, ++ "id": "classify-pr-in-cherry-docs", ++ "name": "Classify PR In Cherry", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4.2, ++ "position": [ ++ 1040, ++ 0 ++ ], ++ "continueOnFail": true ++ } ++ ], ++ "connections": { ++ "Webhook": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize PR", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Normalize PR": { ++ "main": [ ++ [ ++ { ++ "node": "Validate Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Validate Payload": { ++ "main": [ ++ [ ++ { ++ "node": "Fetch Changed Files", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Fetch Changed Files": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize Changed Files", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "IF: Docs Drift?": { ++ "main": [ ++ [ ++ { ++ "node": "Label Docs Drift", ++ "type": "main", ++ "index": 0 ++ } ++ ], ++ [ ++ { ++ "node": "Build No Drift Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Label Docs Drift": { ++ "main": [ ++ [ ++ { ++ "node": "Comment Required Docs Update", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Comment Required Docs Update": { ++ "main": [ ++ [ ++ { ++ "node": "Route Shared Sinks", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Route Shared Sinks": { ++ "main": [ ++ [ ++ { ++ "node": "Archive Event", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Archive Event": { ++ "main": [ ++ [ ++ { ++ "node": "Build Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build No Drift Response": { ++ "main": [ ++ [ ++ { ++ "node": "Respond to Webhook", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Post Docs Status": { ++ "main": [ ++ [ ++ { ++ "node": "IF: Docs Drift?", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Build Cherry Docs Routing": { ++ "main": [ ++ [ ++ { ++ "node": "Require Status Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Classify PR In Cherry": { ++ "main": [ ++ [ ++ { ++ "node": "Build Cherry Docs Routing", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Normalize Changed Files": { ++ "main": [ ++ [ ++ { ++ "node": "Classify PR In Cherry", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Require Status Payload": { ++ "main": [ ++ [ ++ { ++ "node": "IF: Has Status Payload?", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "IF: Has Status Payload?": { ++ "main": [ ++ [ ++ { ++ "node": "Post Docs Status", ++ "type": "main", ++ "index": 0 ++ } ++ ], ++ [ ++ { ++ "node": "Build Response", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ } ++ }, ++ "settings": { ++ "executionOrder": "v1" ++ } ++} +diff --git a/cherry-n8n-workflows/10_backlog_grooming.json b/cherry-n8n-workflows/10_backlog_grooming.json +new file mode 100644 +index 0000000000000000000000000000000000000000..4807a3816e12088afc2a1b77c9a68f5ef85fc79f +--- /dev/null ++++ b/cherry-n8n-workflows/10_backlog_grooming.json +@@ -0,0 +1,376 @@ ++{ ++ "name": "Cherry - Backlog Grooming", ++ "nodes": [ ++ { ++ "parameters": { ++ "rule": { ++ "interval": [ ++ { ++ "field": "weeks", ++ "triggerAtDay": [ ++ 1 ++ ], ++ "triggerAtHour": 9, ++ "triggerAtMinute": 0 ++ } ++ ] ++ } ++ }, ++ "id": "weekly-backlog-grooming", ++ "name": "Schedule Trigger", ++ "type": "n8n-nodes-base.scheduleTrigger", ++ "typeVersion": 1, ++ "position": [ ++ 0, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const input = $input.first()?.json ?? {};\nconst output = { event: 'cherry.backlog_grooming', source: 'manual', repo: 'div0rce/cherry', timestamp: input.timestamp ?? new Date().toISOString(), payload: input, workflow: '10_backlog_grooming', ok: true, status: 'accepted', summary: 'Scheduled cherry.backlog_grooming started.', actions: [] };\nreturn [{ json: output }];" ++ }, ++ "id": "normalize-schedule", ++ "name": "Normalize Schedule", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 260, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"repo\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++ }, ++ "id": "validate-payload", ++ "name": "Validate Payload", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 520, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "GET", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues?state=open&per_page=100' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {} ++ }, ++ "id": "fetch-open-issues", ++ "name": "Fetch Open Issues", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 780, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const issues = Array.isArray($json) ? $json.filter((issue) => !issue.pull_request) : []; const now = Date.now(); const stale = issues.filter((issue) => now - new Date(issue.updated_at ?? issue.created_at ?? now).getTime() > 30 * 24 * 60 * 60 * 1000); const unlabeled = issues.filter((issue) => (issue.labels ?? []).length === 0); const blocked = issues.filter((issue) => /blocked|waiting|depends on/i.test((issue.title ?? '') + ' ' + (issue.body ?? ''))); const noAcceptance = issues.filter((issue) => !/acceptance criteria|done when|definition of done/i.test(issue.body ?? '')); const seen = new Map(); const duplicateHints = []; for (const issue of issues) { const key = String(issue.title ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); if (seen.has(key)) duplicateHints.push([seen.get(key), issue.number]); else seen.set(key, issue.number); } const body = '# Cherry Backlog Grooming Summary\\n\\n- Open issues: ' + issues.length + '\\n- Stale issues: ' + stale.length + '\\n- Unlabeled issues: ' + unlabeled.length + '\\n- Blocked hints: ' + blocked.length + '\\n- Missing acceptance criteria: ' + noAcceptance.length + '\\n- Duplicate title hints: ' + duplicateHints.length + '\\n\\nSuggested actions are advisory.'; const metrics = { openIssues: issues.length, stale: stale.length, unlabeled: unlabeled.length, blocked: blocked.length, missingAcceptanceCriteria: noAcceptance.length, duplicateHints: duplicateHints.length }; const output = { ...$node['Normalize Schedule'].json, backlogMetrics: metrics, summaryIssueBody: { title: 'Cherry weekly backlog grooming summary', labels: ['backlog-grooming', 'automation'], body }, projectUpdatePayload: { source: 'cherry_backlog_grooming', metrics }, status: 'accepted', summary: 'Backlog grooming summary built for ' + issues.length + ' open issues.', actions: ['find_stale_issues', 'find_duplicates', 'find_unlabeled', 'find_missing_acceptance_criteria', 'archive_event'] }; return [{ json: output }];" ++ }, ++ "id": "analyze-backlog", ++ "name": "Analyze Backlog", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1040, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++ }, ++ { ++ "name": "Accept", ++ "value": "application/vnd.github+json" ++ }, ++ { ++ "name": "X-GitHub-Api-Version", ++ "value": "2022-11-28" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $json.summaryIssueBody }}" ++ }, ++ "id": "create-grooming-summary-issue", ++ "name": "Create Grooming Summary Issue", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1300, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ $node[\"Analyze Backlog\"].json.projectUpdatePayload }}" ++ }, ++ "id": "update-github-project", ++ "name": "Update GitHub Project", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 1560, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '10_backlog_grooming', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++ }, ++ "id": "route-shared-sinks", ++ "name": "Route Shared Sinks", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 1820, ++ 0 ++ ] ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Authorization", ++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++ }, ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '10_backlog_grooming.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '10_backlog_grooming',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '10_backlog_grooming') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '10_backlog_grooming.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++ }, ++ "id": "archive-event", ++ "name": "Archive Event", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2080, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "method": "POST", ++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++ "sendHeaders": true, ++ "headerParameters": { ++ "parameters": [ ++ { ++ "name": "Content-Type", ++ "value": "application/json" ++ } ++ ] ++ }, ++ "options": {}, ++ "sendBody": true, ++ "specifyBody": "json", ++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++ }, ++ "id": "notify-discord", ++ "name": "Notify Discord", ++ "type": "n8n-nodes-base.httpRequest", ++ "typeVersion": 4, ++ "position": [ ++ 2340, ++ 0 ++ ], ++ "continueOnFail": true ++ }, ++ { ++ "parameters": { ++ "mode": "runOnceForAllItems", ++ "language": "javaScript", ++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Backlog grooming completed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++ }, ++ "id": "log-grooming-result", ++ "name": "Log Grooming Result", ++ "type": "n8n-nodes-base.code", ++ "typeVersion": 2, ++ "position": [ ++ 2600, ++ 0 ++ ] ++ } ++ ], ++ "connections": { ++ "Schedule Trigger": { ++ "main": [ ++ [ ++ { ++ "node": "Normalize Schedule", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Normalize Schedule": { ++ "main": [ ++ [ ++ { ++ "node": "Validate Payload", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Validate Payload": { ++ "main": [ ++ [ ++ { ++ "node": "Fetch Open Issues", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Fetch Open Issues": { ++ "main": [ ++ [ ++ { ++ "node": "Analyze Backlog", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Analyze Backlog": { ++ "main": [ ++ [ ++ { ++ "node": "Create Grooming Summary Issue", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Create Grooming Summary Issue": { ++ "main": [ ++ [ ++ { ++ "node": "Update GitHub Project", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Update GitHub Project": { ++ "main": [ ++ [ ++ { ++ "node": "Route Shared Sinks", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Route Shared Sinks": { ++ "main": [ ++ [ ++ { ++ "node": "Archive Event", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Archive Event": { ++ "main": [ ++ [ ++ { ++ "node": "Notify Discord", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ }, ++ "Notify Discord": { ++ "main": [ ++ [ ++ { ++ "node": "Log Grooming Result", ++ "type": "main", ++ "index": 0 ++ } ++ ] ++ ] ++ } ++ }, ++ "settings": { ++ "executionOrder": "v1" ++ } ++} +diff --git a/cherry-n8n-workflows/COVERAGE_MATRIX.md b/cherry-n8n-workflows/COVERAGE_MATRIX.md +new file mode 100644 +index 0000000000000000000000000000000000000000..6463046d9e44091c490367f9303971521380a493 +--- /dev/null ++++ b/cherry-n8n-workflows/COVERAGE_MATRIX.md +@@ -0,0 +1,163 @@ ++# Cherry n8n Coverage Matrix ++ ++Status: Generated ++Last updated: 2026-04-27 ++ ++## Workflow Coverage ++ ++| Workflow | Covers | ++| --- | --- | ++| `01_ci_failure_compression` | 1, 2, 3, 4, 21-29 | ++| `02_openclaw_issue_router` | 41-50 | ++| `03_pr_risk_classifier` | 11-17, 30-32, 46-49 | ++| `04_forbidden_change_detector` | 32-39, 48 | ++| `05_engine_degradation_alerting` | 51-60 | ++| `06_simulation_drift_detector` | 61-70 | ++| `07_release_summary_generator` | 71-80 | ++| `08_repo_intelligence_digest` | 5-10, 20, 91-100 | ++| `09_docs_drift_detector` | 81-90 | ++| `10_backlog_grooming` | 18-20, 40, 93-100, 106-107 | ++| `Shared sink pattern` | 101-110 | ++ ++## Use Case Map ++ ++### Repo Automation ++ ++1. CI failure -> structured issue ++2. CI failure -> existing issue comment ++3. CI failure -> OpenClaw task ++4. flaky test detector ++5. dependency update triage ++6. Dependabot PR classifier ++7. CodeQL alert router ++8. secret scan alert router ++9. stale branch detector ++10. stale PR detector ++11. PR size classifier ++12. PR risk score ++13. PR domain classifier ++14. PR checklist generator ++15. PR summary generator ++16. PR merge-block reminder ++17. issue deduplication ++18. issue severity labeling ++19. issue owner/domain labeling ++20. backlog grooming automation ++ ++### Verification Automation ++ ++21. run full verification on demand ++22. rerun failed workflow ++23. collect failed logs ++24. summarize failure cause ++25. compare failure to last passing run ++26. detect changed files causing failure ++27. enforce required scripts exist ++28. verify migrations apply cleanly ++29. verify Prisma schema drift ++30. verify test coverage changed ++31. verify route tests are in correct folder ++32. verify no forbidden imports ++33. verify no production secrets touched ++34. verify no .env diff ++35. verify no snapshot fraud ++36. verify no deleted tests ++37. verify no skipped tests added ++38. verify no console.log leaks ++39. verify no TODO introduced without issue ++40. verify issue acceptance criteria updated ++ ++### OpenClaw Automation ++ ++41. issue labeled openclaw -> create OpenClaw task ++42. OpenClaw result -> validate schema ++43. OpenClaw patch -> attach summary ++44. OpenClaw failure -> request retry ++45. OpenClaw command log -> archive ++46. OpenClaw changed engine -> require tests ++47. OpenClaw changed docs only -> lighter checks ++48. OpenClaw touched forbidden files -> block ++49. OpenClaw PR -> mark needs-human-review ++50. OpenClaw output -> generate commit message ++ ++### Cherry Engine Observability ++ ++51. degradation event -> issue ++52. missing truth event -> issue ++53. solver divergence event -> issue ++54. impossible state event -> issue ++55. temporal inconsistency event -> issue ++56. candidate exclusion spike -> alert ++57. simulation instability -> alert ++58. score drift -> alert ++59. route response mismatch -> alert ++60. advisory output degradation -> alert ++ ++### Simulation Automation ++ ++61. scheduled simulation run ++62. compare simulation to previous snapshot ++63. detect major allocation delta ++64. detect paydown strategy flip ++65. detect runway collapse ++66. detect debt relief regression ++67. detect reward-over-safety bias ++68. detect malformed candidate set ++69. detect empty viable candidates ++70. store simulation audit artifact ++ ++### Release Automation ++ ++71. changelog generation ++72. release notes generation ++73. LinkedIn draft generation ++74. GitHub release draft ++75. semantic version suggestion ++76. breaking-change detector ++77. migration warning generator ++78. issue closure report ++79. release risk summary ++80. deployment summary ++ ++### Documentation Automation ++ ++81. docs drift detector ++82. README update reminder ++83. architecture doc update reminder ++84. API contract doc generator ++85. endpoint inventory generator ++86. env var inventory generator ++87. Prisma model change summary ++88. test inventory summary ++89. issue-to-doc linkage ++90. glossary update automation ++ ++### Project Management ++ ++91. weekly progress digest ++92. daily issue digest ++93. blocked issue detector ++94. orphaned issue detector ++95. milestone progress report ++96. PR-to-issue linkage checker ++97. acceptance criteria completeness checker ++98. roadmap update generator ++99. duplicate backlog detector ++100. priority decay detector ++ ++### External Integrations ++ ++101. Discord notifications ++102. Slack notifications ++103. email summaries ++104. Notion sync ++105. Google Sheets metrics export ++106. Linear/Jira sync ++107. GitHub Projects update ++108. calendar reminder for releases ++109. webhook archive to database ++110. incident timeline export ++ ++## Coverage Status ++ ++All use cases 1-110 are mapped to at least one workflow or to the shared sink pattern. +diff --git a/cherry-n8n-workflows/README.md b/cherry-n8n-workflows/README.md +new file mode 100644 +index 0000000000000000000000000000000000000000..274ffb8e9dc1d7cdc0aab48cb1a4d0fbc10cc6b8 +--- /dev/null ++++ b/cherry-n8n-workflows/README.md +@@ -0,0 +1,73 @@ ++# Cherry n8n Minmax Workflow Pack ++ ++Status: Generated ++Last updated: 2026-04-27 ++ ++This directory contains 10 importable n8n workflow JSON files for Cherry development automation. The workflows are advisory and development-facing only. They do not touch Cherry payment rails and must not mutate Sessions, Ledger, Buckets, cards, payments, or other financial truth. ++ ++## Import ++ ++Import each JSON file as a single workflow in n8n. Each file contains exactly one workflow object, not an array of workflows. ++ ++The zip is expected to preserve this root folder: ++ ++```bash ++cd /Users/nasr/repos/cherry ++zip -r cherry-n8n-workflows.zip cherry-n8n-workflows ++``` ++ ++## Required Environment Variables ++ ++- `GITHUB_OWNER` ++- `GITHUB_REPO` ++- `GITHUB_TOKEN` ++- `OPENCLAW_WEBHOOK_URL` ++- `DISCORD_WEBHOOK_URL` ++- `SLACK_WEBHOOK_URL` ++- `EMAIL_WEBHOOK_URL` ++- `NOTION_WEBHOOK_URL` ++- `GOOGLE_SHEETS_WEBHOOK_URL` ++- `LINEAR_JIRA_WEBHOOK_URL` ++- `GITHUB_PROJECTS_WEBHOOK_URL` ++- `CHERRY_API_BASE_URL` ++- `CHERRY_AUTOMATION_TOKEN` ++ ++These workflows use n8n `$env.*` expressions. HTTP Request nodes use header ++parameters with placeholder expressions only. No credentials are required at ++import time. ++ ++## Webhook Paths ++ ++- `POST /cherry/github/workflow-completed` -> CI failure compression ++- `POST /cherry/github/issue-labeled` -> OpenClaw issue router ++- `POST /cherry/github/pull-request-risk` -> PR risk classifier ++- `POST /cherry/github/pull-request-forbidden` -> forbidden-change detector ++- `POST /cherry/runtime/degradation` -> engine degradation alerting ++- `POST /cherry/simulation/result` -> simulation drift detector ++- `POST /cherry/release/summary` -> release summary generator ++- `POST /cherry/github/pull-request-docs-drift` -> docs drift detector ++ ++## GitHub Webhook Event Mapping ++ ++- `workflow_run.completed` -> `/cherry/github/workflow-completed` ++- `issues.labeled` -> `/cherry/github/issue-labeled` ++- `pull_request` -> PR risk, forbidden-change, and docs-drift workflows ++ ++## Scheduled Workflows ++ ++- `08_repo_intelligence_digest.json` runs weekly. ++- `10_backlog_grooming.json` runs weekly. ++ ++## Safety Boundary ++ ++Forbidden Cherry endpoint patterns: ++ ++- `/api/session*` ++- `/api/ledger*` ++- `/api/bucket*` ++- `/api/payment*` ++- `/api/card*` ++- `/api/debt*/mutate` ++- any `POST`, `PATCH`, or `DELETE` endpoint that changes financial truth ++ ++Workflow `06_simulation_drift_detector` calls Cherry's `/api/automation/simulation-snapshots/compare` endpoint so snapshot history is durable in Cherry automation storage, not n8n static data. +diff --git a/cherry-n8n-workflows/VALIDATION_REPORT.md b/cherry-n8n-workflows/VALIDATION_REPORT.md +new file mode 100644 +index 0000000000000000000000000000000000000000..a7d0ddccbbce216e6c0f3ad975ceeb80a918c027 +--- /dev/null ++++ b/cherry-n8n-workflows/VALIDATION_REPORT.md +@@ -0,0 +1,83 @@ ++# Cherry n8n Validation Report ++ ++Status: Passed ++Last updated: 2026-04-27 ++ ++## Parsed Files ++ ++- 01_ci_failure_compression.json ++- 02_openclaw_issue_router.json ++- 03_pr_risk_classifier.json ++- 04_forbidden_change_detector.json ++- 05_engine_degradation_alerting.json ++- 06_simulation_drift_detector.json ++- 07_release_summary_generator.json ++- 08_repo_intelligence_digest.json ++- 09_docs_drift_detector.json ++- 10_backlog_grooming.json ++ ++## Workflow Names ++ ++- Cherry - CI Failure Compression ++- Cherry - OpenClaw Issue Router ++- Cherry - PR Risk Classifier ++- Cherry - Forbidden Change Detector ++- Cherry - Engine Degradation Alerting ++- Cherry - Simulation Drift Detector ++- Cherry - Release Summary Generator ++- Cherry - Repo Intelligence Digest ++- Cherry - Docs Drift Detector ++- Cherry - Backlog Grooming ++ ++## Webhook Paths ++ ++- /cherry/github/workflow-completed ++- /cherry/github/issue-labeled ++- /cherry/github/pull-request-risk ++- /cherry/github/pull-request-forbidden ++- /cherry/runtime/degradation ++- /cherry/simulation/result ++- /cherry/release/summary ++- /cherry/github/pull-request-docs-drift ++ ++## Automation Endpoints ++ ++- /api/automation/classify/pr ++- /api/automation/events ++- /api/automation/simulation-snapshots/compare ++- /api/automation/statuses/github ++ ++## Coverage Status 1-110 ++ ++Passed: all use cases 1-110 are covered. ++ ++## Credential Objects ++ ++Detected credential objects: none ++ ++## Connection Reference Check ++ ++Passed ++ ++## HTTP Failure Handling ++ ++Every HTTP Request node has `continueOnFail: true`. ++ ++## Webhook Response Mode ++ ++All Webhook nodes set `responseMode` to `responseNode`. ++ ++## Code Node Language ++ ++All Code nodes use JavaScript. ++ ++## V2 Notes ++ ++- Archive nodes call `/api/automation/events`. ++- PR risk workflow calls `/api/automation/classify/pr` and `/api/automation/statuses/github`. ++- Forbidden-change and docs-drift workflows call `/api/automation/statuses/github`. ++- Simulation drift workflow calls `/api/automation/simulation-snapshots/compare` instead of n8n static data. ++ ++## Errors ++ ++None. +diff --git a/docs/automation/branch-protection.md b/docs/automation/branch-protection.md +new file mode 100644 +index 0000000000000000000000000000000000000000..65269c0026e1a64fb9d9d051042339eb245df9c9 +--- /dev/null ++++ b/docs/automation/branch-protection.md +@@ -0,0 +1,17 @@ ++Status: Active ++Last updated: 2026-04-28 ++ ++# Cherry Automation Branch Protection ++ ++Cherry automation V2 posts allowlisted GitHub commit statuses through Cherry-owned API endpoints. These statuses become enforcement only when the repository branch protection rules require them before merge. ++ ++Required Cherry status contexts: ++ ++- `cherry/forbidden-change` ++- `cherry/docs-drift` ++- `cherry/risk-gate` ++- `cherry/openclaw-policy` ++ ++Without branch protection, Cherry statuses are advisory only. ++ ++Configure branch protection for protected branches to require the contexts above, keep administrator bypasses limited, and keep n8n routed through Cherry `/api/automation/*` endpoints rather than posting arbitrary status contexts directly. +diff --git a/docs/ci-and-guardrails.md b/docs/ci-and-guardrails.md +index a1e1bf51ad0ccd98768888f8ba5b59589ea5e05a..94042be63dc068b5fc65ade352d87e852f1b8aaf 100644 +--- a/docs/ci-and-guardrails.md ++++ b/docs/ci-and-guardrails.md +@@ -88,8 +88,9 @@ Last updated: 2026-04-28 + + Use the narrowest proof that fully covers the changed surface: + - `npm run check:static` for guardrails, lint, and typecheck. ++- `npm run check:fast` for local guardrails + script typecheck. + - `npm run check:runtime` or `npm test` for the partitioned runtime suite. +-- `npm run check:fast` for local guardrails + script typecheck + runtime suite. ++- `npm run check:local` for `check:fast` plus the partitioned runtime suite. + - `CHERRY_TMP_ROOT="$HOME/.cherry-tmp" CHERRY_VINE_SIGNATURE_MODE=enforce npm run verify:repo-closure` for canonical full proof. + + Agents must not blindly stack `npm run check`, `npm test`, `npm run build`, and `verify:repo-closure`; do not run both `npm test` and `verify:repo-closure` unless explicitly required. +diff --git a/docs/config-snapshot.md b/docs/config-snapshot.md +index 0241263742aaa0ea554c42f74e20893038dc6195..de7d97d2836e5d48d1e87c204d6b812755f24173 100644 +--- a/docs/config-snapshot.md ++++ b/docs/config-snapshot.md +@@ -45,16 +45,45 @@ jobs: + - name: Guardrails + run: npm run check:guardrails + +- - name: Node runtime tests +- run: npm run check:tests:node +- +- - name: Next runtime tests +- run: npm run check:tests:next +- + - name: Verify CI truth + run: npm run ci:verify + ``` + ++```yaml ++// .github/workflows/n8n-notify.yml ++name: Notify n8n ++ ++on: ++ workflow_run: ++ workflows: ++ - CI ++ types: ++ - completed ++ ++jobs: ++ notify-n8n: ++ runs-on: ubuntu-latest ++ ++ steps: ++ - name: Send workflow result to n8n ++ env: ++ N8N_WEBHOOK_URL: ${{ secrets.N8N_WEBHOOK_URL }} ++ N8N_WEBHOOK_TOKEN: ${{ secrets.N8N_WEBHOOK_TOKEN }} ++ run: | ++ curl -X POST "$N8N_WEBHOOK_URL" \ ++ -H "Authorization: Bearer $N8N_WEBHOOK_TOKEN" \ ++ -H "Content-Type: application/json" \ ++ -d '{ ++ "event": "github.workflow.completed", ++ "repo": "${{ github.repository }}", ++ "workflow": "${{ github.event.workflow_run.name }}", ++ "status": "${{ github.event.workflow_run.conclusion }}", ++ "branch": "${{ github.event.workflow_run.head_branch }}", ++ "sha": "${{ github.event.workflow_run.head_sha }}", ++ "url": "${{ github.event.workflow_run.html_url }}" ++ }' ++``` ++ + ```yaml + // .github/workflows/env-checks.yml + # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +@@ -9588,12 +9617,16 @@ export default nextConfig; + "engineStrict": true, + "packageManager": "npm@11.12.1", + "scripts": { +- "predev": "npm run check:db-ready", ++ "predev": "CHERRY_TMP_ROOT=${CHERRY_TMP_ROOT:-$HOME/.cherry-tmp} npm run check:db-ready", + "dev": "next dev --webpack", + "build": "next build --webpack", + "build:strict": "npm run check:guardrails && next build --webpack", + "start": "next start", + "ci:verify": "npm run check && npm run test && npm run build", ++ "check:static": "npm run check:guardrails && npm run lint:scripts && npm run typecheck:scripts && npm run lint && npm run typecheck", ++ "check:runtime": "npm test", ++ "check:fast": "npm run check:guardrails && npm run typecheck:scripts", ++ "check:local": "npm run check:fast && npm run check:runtime", + "check:clean": "npm run ts:esm -- scripts/execution/run.mts check:clean", + "check:db-ready": "npm run ts:esm -- scripts/execution/run-db.mts check:db-ready", + "check:dev-login": "npm run ts:esm -- scripts/execution/run.mts check:dev-login", +@@ -10353,6 +10386,75 @@ model DecisionEvent { + @@index([userId, createdAt]) + } + ++model AutomationEvent { ++ id String @id @default(cuid()) ++ repo String ++ sha String? ++ event String ++ source String ++ workflow String ++ status String ++ idempotencyKey String @unique(map: "automation_event__idempotency_key__unique") ++ classifierVersion String ++ outputHash String ++ rawPayload Json ++ normalizedEvent Json ++ classifierOutput Json ++ prNumber Int? ++ issueNumber Int? ++ createdAt DateTime @default(now()) ++ updatedAt DateTime @updatedAt ++ ++ statusChecks AutomationStatusCheck[] ++ ++ @@index([repo, sha]) ++ @@index([repo, prNumber]) ++ @@index([repo, issueNumber]) ++ @@index([workflow, createdAt]) ++ @@index([classifierVersion]) ++} ++ ++model SimulationAutomationSnapshot { ++ id String @id @default(cuid()) ++ repo String ++ scopeKey String ++ runId String ++ classifierVersion String ++ snapshot Json ++ comparisonOutput Json ++ outputHash String ++ previousSnapshotId String? ++ createdAt DateTime @default(now()) ++ ++ @@unique([scopeKey, runId, classifierVersion], map: "simulation_automation_snapshot__scope_run_version__unique") ++ @@index([repo, scopeKey]) ++ @@index([scopeKey, createdAt]) ++ @@index([classifierVersion]) ++} ++ ++model AutomationStatusCheck { ++ id String @id @default(cuid()) ++ repo String ++ sha String ++ context String ++ state String ++ description String ++ targetUrl String? ++ sourceWorkflow String ++ automationEvent AutomationEvent? @relation(fields: [automationEventId], references: [id], onDelete: SetNull, map: "automation_status_check__automation_event_id__fk") ++ automationEventId String? ++ classifierVersion String ++ outputHash String ++ statusIdempotencyKey String @unique(map: "automation_status_check__status_idempotency_key__unique") ++ githubResponse Json? ++ createdAt DateTime @default(now()) ++ ++ @@index([repo, sha]) ++ @@index([repo, sha, context]) ++ @@index([automationEventId]) ++ @@index([classifierVersion]) ++} ++ + model IdempotencyKey { + userId String + key String +diff --git a/docs/schema-evolution.md b/docs/schema-evolution.md +index 0e8cde12739aa13d02704abda6fb5f19199cb8ee..96fde10bb85c4ffb656f6cd18ad9fb7f98347ccb 100644 +--- a/docs/schema-evolution.md ++++ b/docs/schema-evolution.md @@ -1,5 +1,5 @@ Status: Active --Last updated: 2026-01-03 +-Last updated: 2026-04-26 +Last updated: 2026-04-27 - ## What Cherry Is - Cherry is a **real-time spending copilot**. It observes context (merchant, amount, user budgets/cards), runs an engine, recommends the right card and budget impact, and offers Cherry Points for following advice. Cherry: -@@ -43,7 +43,7 @@ npm install - npm run dev - ``` + # Schema Evolution Rules + +@@ -9,10 +9,10 @@ Last updated: 2026-04-26 + - DB truth scripts/tests under scripts/db-check-* or tests/db/** + + ## Current schema manifest +-- `schemaVersion`: `schema_v2` +-- `lastMigration`: `20260426090000_add_scheduled_paydowns` ++- `schemaVersion`: `schema_v3` ++- `lastMigration`: `20260427153000_automation_backend` + - `invariantsVersion`: `db_invariants_v1` +-- `schema_v2` adds persisted raw scheduled paydown rows for engine loading. The runtime loader treats these rows as source data only; temporal classification remains in engine evaluation. ++- `schema_v3` adds advisory automation audit tables for n8n V2: `AutomationEvent`, `SimulationAutomationSnapshot`, and `AutomationStatusCheck`. These records support replay, classifier output hashes, and GitHub status auditability; they do not mutate finance truth. + + ## Required steps per schema change + 1. Create or update a migration under prisma/migrations/**. +diff --git a/lib/adapters/runtime/automation-events.prisma.ts b/lib/adapters/runtime/automation-events.prisma.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..4d28295daec388e0690248825365e58e890d06bb +--- /dev/null ++++ b/lib/adapters/runtime/automation-events.prisma.ts +@@ -0,0 +1,112 @@ ++import type { AutomationEvent, Prisma, SimulationAutomationSnapshot } from '@prisma/client'; ++import { prisma } from '../../prisma.js'; ++ ++export type CreateAutomationEventRecordInput = { ++ repo: string; ++ sha?: string | undefined; ++ event: string; ++ source: string; ++ workflow: string; ++ status: string; ++ idempotencyKey: string; ++ classifierVersion: string; ++ outputHash: string; ++ rawPayload: unknown; ++ normalizedEvent: unknown; ++ classifierOutput: unknown; ++ prNumber?: number | undefined; ++ issueNumber?: number | undefined; ++}; ++ ++export type CreateSimulationAutomationSnapshotRecordInput = { ++ repo: string; ++ scopeKey: string; ++ runId: string; ++ classifierVersion: string; ++ snapshot: unknown; ++ comparisonOutput: unknown; ++ outputHash: string; ++ previousSnapshotId?: string | undefined; ++}; ++ ++function asJson(value: unknown): Prisma.InputJsonValue { ++ return value as Prisma.InputJsonValue; ++} ++ ++export async function findAutomationEventByIdempotencyKey( ++ idempotencyKey: string ++): Promise { ++ return prisma.automationEvent.findUnique({ where: { idempotencyKey } }); ++} ++ ++export async function findAutomationEventById(id: string): Promise { ++ return prisma.automationEvent.findUnique({ where: { id } }); ++} ++ ++export async function createAutomationEventRecord( ++ input: CreateAutomationEventRecordInput ++): Promise { ++ const data: Prisma.AutomationEventUncheckedCreateInput = { ++ repo: input.repo, ++ event: input.event, ++ source: input.source, ++ workflow: input.workflow, ++ status: input.status, ++ idempotencyKey: input.idempotencyKey, ++ classifierVersion: input.classifierVersion, ++ outputHash: input.outputHash, ++ rawPayload: asJson(input.rawPayload), ++ normalizedEvent: asJson(input.normalizedEvent), ++ classifierOutput: asJson(input.classifierOutput), ++ }; ++ if (input.sha !== undefined) data.sha = input.sha; ++ if (input.prNumber !== undefined) data.prNumber = input.prNumber; ++ if (input.issueNumber !== undefined) data.issueNumber = input.issueNumber; ++ ++ return prisma.automationEvent.create({ data }); ++} ++ ++export async function findLatestSimulationSnapshot( ++ scopeKey: string, ++ classifierVersion: string ++): Promise { ++ return prisma.simulationAutomationSnapshot.findFirst({ ++ where: { scopeKey, classifierVersion }, ++ orderBy: { createdAt: 'desc' }, ++ }); ++} ++ ++export async function findSimulationSnapshotByRun(input: { ++ scopeKey: string; ++ runId: string; ++ classifierVersion: string; ++}): Promise { ++ return prisma.simulationAutomationSnapshot.findUnique({ ++ where: { ++ scopeKey_runId_classifierVersion: { ++ scopeKey: input.scopeKey, ++ runId: input.runId, ++ classifierVersion: input.classifierVersion, ++ }, ++ }, ++ }); ++} ++ ++export async function createSimulationAutomationSnapshotRecord( ++ input: CreateSimulationAutomationSnapshotRecordInput ++): Promise { ++ const data: Prisma.SimulationAutomationSnapshotUncheckedCreateInput = { ++ repo: input.repo, ++ scopeKey: input.scopeKey, ++ runId: input.runId, ++ classifierVersion: input.classifierVersion, ++ snapshot: asJson(input.snapshot), ++ comparisonOutput: asJson(input.comparisonOutput), ++ outputHash: input.outputHash, ++ }; ++ if (input.previousSnapshotId !== undefined) { ++ data.previousSnapshotId = input.previousSnapshotId; ++ } ++ ++ return prisma.simulationAutomationSnapshot.create({ data }); ++} +diff --git a/lib/adapters/runtime/automation-github-status.prisma.ts b/lib/adapters/runtime/automation-github-status.prisma.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..6b85870217b1e69f3a5194e83348af6fc6e53712 +--- /dev/null ++++ b/lib/adapters/runtime/automation-github-status.prisma.ts +@@ -0,0 +1,118 @@ ++import type { AutomationStatusCheck, Prisma } from '@prisma/client'; ++import { prisma } from '../../prisma.js'; ++ ++export type CreateGithubStatusCheckRecordInput = { ++ repo: string; ++ sha: string; ++ context: string; ++ state: string; ++ description: string; ++ targetUrl?: string | undefined; ++ sourceWorkflow: string; ++ automationEventId?: string | undefined; ++ classifierVersion: string; ++ outputHash: string; ++ statusIdempotencyKey: string; ++ githubResponse?: unknown; ++}; ++ ++export type GithubCommitStatusPostInput = { ++ apiBaseUrl: string; ++ githubToken: string; ++ repo: string; ++ sha: string; ++ state: string; ++ description: string; ++ context: string; ++ targetUrl?: string | undefined; ++}; ++ ++export type GithubCommitStatusPostResult = { ++ ok: boolean; ++ status: number; ++ body: string; ++}; ++ ++function asJson(value: unknown): Prisma.InputJsonValue { ++ return value as Prisma.InputJsonValue; ++} ++ ++export async function findStatusCheckByIdempotencyKey( ++ statusIdempotencyKey: string ++): Promise { ++ return prisma.automationStatusCheck.findUnique({ where: { statusIdempotencyKey } }); ++} ++ ++export async function findStatusCheckById( ++ id: string ++): Promise { ++ return prisma.automationStatusCheck.findUnique({ where: { id } }); ++} ++ ++export async function createGithubStatusCheckRecord( ++ input: CreateGithubStatusCheckRecordInput ++): Promise { ++ const data: Prisma.AutomationStatusCheckUncheckedCreateInput = { ++ repo: input.repo, ++ sha: input.sha, ++ context: input.context, ++ state: input.state, ++ description: input.description, ++ sourceWorkflow: input.sourceWorkflow, ++ classifierVersion: input.classifierVersion, ++ outputHash: input.outputHash, ++ statusIdempotencyKey: input.statusIdempotencyKey, ++ githubResponse: asJson(input.githubResponse ?? { status: 'created_not_posted' }), ++ }; ++ if (input.targetUrl !== undefined) data.targetUrl = input.targetUrl; ++ if (input.automationEventId !== undefined) data.automationEventId = input.automationEventId; ++ ++ return prisma.automationStatusCheck.create({ data }); ++} ++ ++export async function updateGithubStatusCheckResponse( ++ id: string, ++ githubResponse: unknown ++): Promise { ++ return prisma.automationStatusCheck.update({ ++ where: { id }, ++ data: { githubResponse: asJson(githubResponse) }, ++ }); ++} ++ ++export async function postGithubCommitStatus( ++ input: GithubCommitStatusPostInput ++): Promise { ++ const response = await fetch(`${input.apiBaseUrl}/repos/${input.repo}/statuses/${input.sha}`, { ++ method: 'POST', ++ headers: { ++ Authorization: `Bearer ${input.githubToken}`, ++ Accept: 'application/vnd.github+json', ++ 'Content-Type': 'application/json', ++ 'X-GitHub-Api-Version': '2022-11-28', ++ }, ++ body: JSON.stringify({ ++ state: input.state, ++ description: input.description, ++ context: input.context, ++ target_url: input.targetUrl, ++ }), ++ }); ++ const body = await response.text(); ++ return { ++ ok: response.ok, ++ status: response.status, ++ body: body.slice(0, 10_000), ++ }; ++} ++ ++export async function listGithubStatusChecks(where: { ++ repo?: string; ++ sha?: string; ++ context?: string; ++}): Promise { ++ return prisma.automationStatusCheck.findMany({ ++ where, ++ orderBy: [{ createdAt: 'desc' }, { id: 'asc' }], ++ }); ++} +diff --git a/lib/automation/classifiers/docs-drift.ts b/lib/automation/classifiers/docs-drift.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..88249ae75094c71cfbc0ee7972c986be7839963e +--- /dev/null ++++ b/lib/automation/classifiers/docs-drift.ts +@@ -0,0 +1,79 @@ ++import type { AutomationStatusRequest, PrClassifierInput } from './types.js'; ++import { DOCS_DRIFT_CLASSIFIER_VERSION } from './types.js'; ++ ++export type DocsDriftClassification = { ++ classifierVersion: typeof DOCS_DRIFT_CLASSIFIER_VERSION; ++ drift: boolean; ++ domains: string[]; ++ labels: string[]; ++ statusRequest: AutomationStatusRequest; ++}; ++ ++export function classifyDocsDrift( ++ input: Pick ++): DocsDriftClassification { ++ const names = input.files.map((file) => file.filename); ++ const domains: string[] = []; ++ ++ if (names.some((name) => name.startsWith('lib/engine') || name === 'lib/engine.ts' || name.startsWith('lib/authority'))) { ++ domains.push('engine'); ++ } ++ if (names.some((name) => name.startsWith('app/api/'))) { ++ domains.push('api'); ++ } ++ if (names.some((name) => name === 'prisma/schema.prisma' || name.startsWith('prisma/migrations/'))) { ++ domains.push('schema'); ++ } ++ if (names.some((name) => name.includes('env') || name === '.env.example')) { ++ domains.push('env'); ++ } ++ if (names.some((name) => /(^|\/)(tests?|__tests__)(\/|$)|\.(test|spec)\./.test(name))) { ++ domains.push('tests'); ++ } ++ if ( ++ names.some( ++ (name) => ++ name.startsWith('lib/automation/') || ++ name.startsWith('app/api/automation/') || ++ name.startsWith('cherry-n8n-workflows/') ++ ) ++ ) { ++ domains.push('automation'); ++ } ++ ++ const uniqueDomains = [...new Set(domains)]; ++ const docsByDomain: Record boolean> = { ++ engine: (name) => ++ name.startsWith('docs/architecture/') || ++ name.startsWith('docs/engine/') || ++ name === 'README.md', ++ api: (name) => name.startsWith('docs/api/') || name === 'README.md', ++ schema: (name) => ++ name.startsWith('docs/schema/') || ++ name === 'prisma/README.md' || ++ name.startsWith('docs/database/'), ++ env: (name) => ++ name === '.env.example' || name.startsWith('docs/env/') || name === 'README.md', ++ tests: (name) => name.startsWith('docs/testing/') || name === 'README.md', ++ automation: (name) => name.startsWith('docs/automation/') || name === 'README.md', ++ }; ++ const missingDocs = uniqueDomains.filter((domain) => { ++ const matches = docsByDomain[domain]; ++ return matches === undefined || names.some(matches) === false; ++ }); ++ const drift = missingDocs.length > 0; ++ ++ return { ++ classifierVersion: DOCS_DRIFT_CLASSIFIER_VERSION, ++ drift, ++ domains: uniqueDomains, ++ labels: drift ? ['docs-drift', 'needs-human-review'] : [], ++ statusRequest: { ++ context: 'cherry/docs-drift', ++ state: drift ? 'failure' : 'success', ++ description: drift ++ ? `Docs update required for ${domains.join(', ')} changes.` ++ : 'No docs drift detected.', ++ }, ++ }; ++} +diff --git a/lib/automation/classifiers/forbidden-change.ts b/lib/automation/classifiers/forbidden-change.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..f02045b5efd2666ba824aa52c8b14bf34d77c39f +--- /dev/null ++++ b/lib/automation/classifiers/forbidden-change.ts +@@ -0,0 +1,65 @@ ++import type { AutomationStatusRequest, PrClassifierInput } from './types.js'; ++import { FORBIDDEN_CHANGE_CLASSIFIER_VERSION } from './types.js'; ++ ++export type ForbiddenChangeClassification = { ++ classifierVersion: typeof FORBIDDEN_CHANGE_CLASSIFIER_VERSION; ++ forbidden: boolean; ++ violations: string[]; ++ labels: string[]; ++ statusRequest: AutomationStatusRequest; ++}; ++ ++export function classifyForbiddenChange( ++ input: Pick ++): ForbiddenChangeClassification { ++ const violations: string[] = []; ++ ++ for (const file of input.files) { ++ const name = file.filename; ++ const patch = file.patch ?? ''; ++ if ( ++ name === '.env' || ++ name === '.env.local' || ++ name.endsWith('/.env') || ++ name.endsWith('/.env.local') ++ ) { ++ violations.push(`env_diff:${name}`); ++ } ++ if (/secret|credentials|production.*db/i.test(name)) { ++ violations.push(`sensitive_path:${name}`); ++ } ++ if (file.status === 'removed' && /(^|\/)(tests?|__tests__)(\/|$)|\.(test|spec)\./.test(name)) { ++ violations.push(`deleted_test:${name}`); ++ } ++ if (/^[+].*\b(test|describe|it)\.skip\b/m.test(patch)) { ++ violations.push(`skipped_test_added:${name}`); ++ } ++ if (/^[+].*console\.log\(/m.test(patch)) { ++ violations.push(`console_log_added:${name}`); ++ } ++ if (/^[+].*TODO(?!.*#\d+)/im.test(patch)) { ++ violations.push(`todo_without_issue:${name}`); ++ } ++ if (/^[+].*from ['"]@prisma\/client['"]/m.test(patch) && /lib\/engine|lib\/authority/.test(name)) { ++ violations.push(`forbidden_prisma_import:${name}`); ++ } ++ if (/^[+].*(DATABASE_URL|PRODUCTION_DATABASE_URL|direct prod mutation)/im.test(patch)) { ++ violations.push(`production_truth_mutation_hint:${name}`); ++ } ++ } ++ ++ const forbidden = violations.length > 0; ++ return { ++ classifierVersion: FORBIDDEN_CHANGE_CLASSIFIER_VERSION, ++ forbidden, ++ violations, ++ labels: forbidden ? ['blocked-forbidden-change', 'needs-human-review'] : [], ++ statusRequest: { ++ context: 'cherry/forbidden-change', ++ state: forbidden ? 'failure' : 'success', ++ description: forbidden ++ ? `${violations.length} forbidden change pattern(s) detected.` ++ : 'No forbidden change patterns detected.', ++ }, ++ }; ++} +diff --git a/lib/automation/classifiers/pr-risk.ts b/lib/automation/classifiers/pr-risk.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..1aa604f763bf404aeec209963246b3405361e571 +--- /dev/null ++++ b/lib/automation/classifiers/pr-risk.ts +@@ -0,0 +1,105 @@ ++import type { AutomationStatusRequest, PrClassifierInput } from './types.js'; ++import { PR_RISK_CLASSIFIER_VERSION } from './types.js'; ++ ++export type PrRiskClassification = { ++ classifierVersion: typeof PR_RISK_CLASSIFIER_VERSION; ++ score: number; ++ level: 'low' | 'medium' | 'high'; ++ labels: string[]; ++ reasons: string[]; ++ accepted: boolean; ++ statusRequest: AutomationStatusRequest; ++}; ++ ++function hasLinkedIssue(input: PrClassifierInput): boolean { ++ const text = `${input.title} ${input.body}`; ++ return /(close[sd]?|fix(e[sd])?|resolve[sd]?)\s+#\d+|#\d+/i.test(text); ++} ++ ++export function classifyPrRisk(input: PrClassifierInput): PrRiskClassification { ++ const names = input.files.map((file) => file.filename); ++ const additions = input.files.reduce((sum, file) => sum + (file.additions ?? 0), 0); ++ const deletions = input.files.reduce((sum, file) => sum + (file.deletions ?? 0), 0); ++ const changedLines = additions + deletions; ++ ++ const hasEngine = names.some( ++ (name) => ++ name.startsWith('lib/engine') || ++ name === 'lib/engine.ts' || ++ name.includes('/engine/') ++ ); ++ const hasPrisma = names.some( ++ (name) => name === 'prisma/schema.prisma' || name.startsWith('prisma/migrations/') ++ ); ++ const hasApi = names.some((name) => name.startsWith('app/api/')); ++ const testDeleted = input.files.some( ++ (file) => ++ file.status === 'removed' && ++ /(^|\/)(tests?|__tests__)(\/|$)|\.(test|spec)\./.test(file.filename) ++ ); ++ const docsOnly = ++ names.length > 0 && ++ names.every( ++ (name) => name.startsWith('docs/') || name === 'README.md' || name.endsWith('.md') ++ ); ++ const largeDiff = changedLines > 800 || names.length > 25; ++ const noLinkedIssue = hasLinkedIssue(input) === false; ++ ++ let score = 0; ++ const reasons: string[] = []; ++ if (hasEngine) { ++ score += 5; ++ reasons.push('engine files changed +5'); ++ } ++ if (hasPrisma) { ++ score += 4; ++ reasons.push('Prisma schema or migrations changed +4'); ++ } ++ if (hasApi) { ++ score += 3; ++ reasons.push('API route changed +3'); ++ } ++ if (testDeleted) { ++ score += 5; ++ reasons.push('test deleted +5'); ++ } ++ if (docsOnly) { ++ score -= 3; ++ reasons.push('docs only -3'); ++ } ++ if (largeDiff) { ++ score += 2; ++ reasons.push('large diff +2'); ++ } ++ if (noLinkedIssue) { ++ score += 2; ++ reasons.push('no linked issue +2'); ++ } ++ ++ const level = score >= 8 ? 'high' : score >= 4 ? 'medium' : 'low'; ++ const accepted = input.labels.includes('risk-accepted'); ++ const labels = level === 'high' ? ['risk-high'] : level === 'medium' ? ['risk-medium'] : ['risk-low']; ++ if (level === 'high') labels.push('needs-human-review'); ++ if (hasEngine) labels.push('engine-change'); ++ if (docsOnly) labels.push('docs-only'); ++ ++ const state = level === 'high' && accepted === false ? 'failure' : 'success'; ++ const description = ++ state === 'failure' ++ ? `High-risk PR score ${score}; add risk-accepted only after review.` ++ : `PR risk ${level} with score ${score}.`; ++ ++ return { ++ classifierVersion: PR_RISK_CLASSIFIER_VERSION, ++ score, ++ level, ++ labels, ++ reasons, ++ accepted, ++ statusRequest: { ++ context: 'cherry/risk-gate', ++ state, ++ description, ++ }, ++ }; ++} +diff --git a/lib/automation/classifiers/pr.ts b/lib/automation/classifiers/pr.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..fb7a820c82ea488ad2efdff03bfb47c6f18b7f36 +--- /dev/null ++++ b/lib/automation/classifiers/pr.ts +@@ -0,0 +1,30 @@ ++import { classifyDocsDrift } from './docs-drift.js'; ++import { classifyForbiddenChange } from './forbidden-change.js'; ++import { classifyPrRisk } from './pr-risk.js'; ++import type { AutomationStatusRequest, PrClassifierInput } from './types.js'; ++import { PR_AUTOMATION_CLASSIFIER_VERSION } from './types.js'; ++ ++export type PrAutomationClassification = { ++ classifierVersion: typeof PR_AUTOMATION_CLASSIFIER_VERSION; ++ risk: ReturnType; ++ forbiddenChange: ReturnType; ++ docsDrift: ReturnType; ++ statusRequests: AutomationStatusRequest[]; ++}; ++ ++export function classifyPrAutomation(input: PrClassifierInput): PrAutomationClassification { ++ const risk = classifyPrRisk(input); ++ const forbiddenChange = classifyForbiddenChange(input); ++ const docsDrift = classifyDocsDrift(input); ++ return { ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ risk, ++ forbiddenChange, ++ docsDrift, ++ statusRequests: [ ++ forbiddenChange.statusRequest, ++ docsDrift.statusRequest, ++ risk.statusRequest, ++ ], ++ }; ++} +diff --git a/lib/automation/classifiers/simulation-drift.ts b/lib/automation/classifiers/simulation-drift.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..9cd31600cddf740dc325221b42ef99a33214e820 +--- /dev/null ++++ b/lib/automation/classifiers/simulation-drift.ts +@@ -0,0 +1,90 @@ ++import { SIMULATION_DRIFT_CLASSIFIER_VERSION } from './types.js'; ++ ++export type SimulationSnapshot = { ++ score?: number; ++ allocation?: Record; ++ strategy?: string | null; ++ paydownStrategy?: string | null; ++ runwayDays?: number; ++ runway?: number; ++ viableCandidates?: unknown[]; ++ viableCandidateCount?: number; ++}; ++ ++export type SimulationDriftClassification = { ++ classifierVersion: typeof SIMULATION_DRIFT_CLASSIFIER_VERSION; ++ drift: boolean; ++ reasons: string[]; ++ scoreDelta: number; ++ allocationDelta: number; ++ strategyFlip: boolean; ++ runwayCollapse: boolean; ++ emptyViableCandidates: boolean; ++}; ++ ++function numeric(value: unknown): number { ++ return typeof value === 'number' && Number.isFinite(value) ? value : 0; ++} ++ ++function normalizeSnapshot(snapshot: SimulationSnapshot) { ++ const strategy = snapshot.strategy ?? snapshot.paydownStrategy ?? null; ++ const runwayDays = numeric(snapshot.runwayDays ?? snapshot.runway); ++ const allocation = snapshot.allocation ?? {}; ++ const viableCandidates = Array.isArray(snapshot.viableCandidates) ++ ? snapshot.viableCandidates.length ++ : numeric(snapshot.viableCandidateCount); ++ ++ return { ++ score: numeric(snapshot.score), ++ allocation, ++ strategy, ++ runwayDays, ++ viableCandidates, ++ }; ++} ++ ++export function classifySimulationDrift( ++ previousSnapshot: SimulationSnapshot | null, ++ currentSnapshot: SimulationSnapshot ++): SimulationDriftClassification { ++ const current = normalizeSnapshot(currentSnapshot); ++ const previous = previousSnapshot === null ? null : normalizeSnapshot(previousSnapshot); ++ const reasons: string[] = []; ++ ++ const scoreDelta = ++ previous === null ? 0 : Math.abs(current.score - previous.score); ++ let allocationDelta = 0; ++ if (previous !== null) { ++ const allocationKeys = new Set([ ++ ...Object.keys(previous.allocation).sort((a, b) => a.localeCompare(b)), ++ ...Object.keys(current.allocation).sort((a, b) => a.localeCompare(b)), ++ ]); ++ for (const key of allocationKeys) { ++ allocationDelta += Math.abs( ++ numeric(current.allocation[key]) - numeric(previous.allocation[key]) ++ ); ++ } ++ } ++ const strategyFlip = previous !== null && current.strategy !== previous.strategy; ++ const runwayCollapse = ++ previous !== null && ++ current.runwayDays < Math.max(7, previous.runwayDays * 0.5); ++ const emptyViableCandidates = current.viableCandidates === 0; ++ ++ if (scoreDelta >= 10) reasons.push(`score_delta:${scoreDelta}`); ++ if (allocationDelta >= 5_000) reasons.push(`allocation_delta:${allocationDelta}`); ++ if (strategyFlip) reasons.push('strategy_flip'); ++ if (runwayCollapse) reasons.push('runway_collapse'); ++ if (emptyViableCandidates) reasons.push('empty_viable_candidates'); ++ ++ return { ++ classifierVersion: SIMULATION_DRIFT_CLASSIFIER_VERSION, ++ drift: reasons.length > 0, ++ reasons, ++ scoreDelta, ++ allocationDelta, ++ strategyFlip, ++ runwayCollapse, ++ emptyViableCandidates, ++ }; ++} +diff --git a/lib/automation/classifiers/types.ts b/lib/automation/classifiers/types.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..7c18830087059bd84cb0787aa133a40553b738f7 +--- /dev/null ++++ b/lib/automation/classifiers/types.ts +@@ -0,0 +1,36 @@ ++export const PR_RISK_CLASSIFIER_VERSION = 'pr-risk@1' as const; ++export const FORBIDDEN_CHANGE_CLASSIFIER_VERSION = 'forbidden-change@1' as const; ++export const DOCS_DRIFT_CLASSIFIER_VERSION = 'docs-drift@1' as const; ++export const SIMULATION_DRIFT_CLASSIFIER_VERSION = 'simulation-drift@1' as const; ++export const PR_AUTOMATION_CLASSIFIER_VERSION = ++ 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)' as const; ++ ++export type AutomationFileChange = { ++ filename: string; ++ status?: string | undefined; ++ additions?: number | undefined; ++ deletions?: number | undefined; ++ changes?: number | undefined; ++ patch?: string | undefined; ++}; ++ ++export type AutomationStatusRequest = { ++ context: ++ | 'cherry/forbidden-change' ++ | 'cherry/docs-drift' ++ | 'cherry/risk-gate' ++ | 'cherry/openclaw-policy'; ++ state: 'error' | 'failure' | 'pending' | 'success'; ++ description: string; ++ targetUrl?: string; ++}; ++ ++export type PrClassifierInput = { ++ repo: string; ++ sha: string; ++ prNumber: number; ++ title: string; ++ body: string; ++ labels: string[]; ++ files: AutomationFileChange[]; ++}; +diff --git a/lib/automation/events.ts b/lib/automation/events.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..73cd795ceab9d26818bda580ad301d10d1eba98f +--- /dev/null ++++ b/lib/automation/events.ts +@@ -0,0 +1,346 @@ ++import type { AutomationEvent, SimulationAutomationSnapshot } from '@prisma/client'; ++import { ++ createAutomationEventRecord, ++ createSimulationAutomationSnapshotRecord, ++ findAutomationEventById, ++ findAutomationEventByIdempotencyKey, ++ findLatestSimulationSnapshot, ++ findSimulationSnapshotByRun, ++} from '../adapters/runtime/automation-events.prisma.js'; ++import { buildAutomationIdempotencyKey, hashAutomationOutput } from './hash.js'; ++import { classifyPrAutomation } from './classifiers/pr.js'; ++import { classifySimulationDrift } from './classifiers/simulation-drift.js'; ++import { ++ PR_AUTOMATION_CLASSIFIER_VERSION, ++ SIMULATION_DRIFT_CLASSIFIER_VERSION, ++} from './classifiers/types.js'; ++import type { AutomationFileChange } from './classifiers/types.js'; ++import type { PrClassifierInput } from './classifiers/types.js'; ++import type { PrAutomationClassification } from './classifiers/pr.js'; ++import type { SimulationDriftClassification } from './classifiers/simulation-drift.js'; ++ ++export type StoreAutomationEventInput = { ++ repo: string; ++ sha?: string | undefined; ++ event: string; ++ source: string; ++ workflow: string; ++ status: string; ++ idempotencyKey: string; ++ classifierVersion: string; ++ rawPayload: unknown; ++ normalizedEvent: unknown; ++ classifierOutput: unknown; ++ prNumber?: number | undefined; ++ issueNumber?: number | undefined; ++}; ++ ++export type PrAutomationInput = { ++ repo: string; ++ sha: string; ++ prNumber: number; ++ title: string; ++ body?: string | null | undefined; ++ labels: string[]; ++ files: AutomationFileChange[]; ++ sourceWorkflow: string; ++ eventId?: string | undefined; ++}; ++ ++export type SimulationCompareInput = { ++ repo: string; ++ scopeKey: string; ++ runId: string; ++ snapshot: unknown; ++ sourceWorkflow: string; ++}; ++ ++export type StoredAutomationEventResult = { ++ event: AutomationEvent; ++ created: boolean; ++}; ++ ++export type PrAutomationStoreResult = StoredAutomationEventResult & { ++ classifierOutput: PrAutomationClassification; ++}; ++ ++export type ReplayAutomationEventResult = ++ | { ++ event: AutomationEvent; ++ replayedOutput: unknown; ++ outputHash: string | null; ++ matches: boolean; ++ reason: ++ | 'matched' ++ | 'output_hash_mismatch' ++ | 'classifier_version_mismatch' ++ | 'unsupported_replay_event' ++ | 'invalid_replay_input'; ++ } ++ | null; ++ ++export type SimulationSnapshotStoreResult = { ++ snapshot: SimulationAutomationSnapshot; ++ comparisonOutput: SimulationDriftClassification; ++ created: boolean; ++}; ++ ++export function outputHashFor(value: unknown): string { ++ return hashAutomationOutput(value); ++} ++ ++export class AutomationEventIdempotencyConflictError extends Error { ++ constructor(readonly idempotencyKey: string) { ++ super('automation_event_idempotency_conflict'); ++ this.name = 'AutomationEventIdempotencyConflictError'; ++ } ++} ++ ++export class SimulationSnapshotIdempotencyConflictError extends Error { ++ constructor(readonly scopeKey: string, readonly runId: string) { ++ super('simulation_snapshot_idempotency_conflict'); ++ this.name = 'SimulationSnapshotIdempotencyConflictError'; ++ } ++} ++ ++function asRecord(value: unknown): Record | null { ++ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; ++ return value as Record; ++} ++ ++function asStringArray(value: unknown): string[] | null { ++ if (!Array.isArray(value)) return null; ++ const out: string[] = []; ++ for (const entry of value) { ++ if (typeof entry !== 'string') return null; ++ out.push(entry); ++ } ++ return out; ++} ++ ++function asAutomationFiles(value: unknown): AutomationFileChange[] | null { ++ if (!Array.isArray(value)) return null; ++ const out: AutomationFileChange[] = []; ++ for (const entry of value) { ++ const record = asRecord(entry); ++ if (record === null || typeof record['filename'] !== 'string') return null; ++ const file: AutomationFileChange = { filename: record['filename'] }; ++ if (typeof record['status'] === 'string') file.status = record['status']; ++ if (typeof record['additions'] === 'number') file.additions = record['additions']; ++ if (typeof record['deletions'] === 'number') file.deletions = record['deletions']; ++ if (typeof record['changes'] === 'number') file.changes = record['changes']; ++ if (typeof record['patch'] === 'string') file.patch = record['patch']; ++ out.push(file); ++ } ++ return out; ++} ++ ++function rebuildPrClassifierInput(event: AutomationEvent): PrClassifierInput | null { ++ const normalized = asRecord(event.normalizedEvent); ++ const payload = normalized === null ? null : asRecord(normalized['payload']); ++ if (payload === null) return null; ++ const prNumber = payload['prNumber']; ++ const title = payload['title']; ++ const body = payload['body']; ++ const labels = asStringArray(payload['labels']); ++ const files = asAutomationFiles(payload['files']); ++ if ( ++ typeof event.sha !== 'string' || ++ typeof prNumber !== 'number' || ++ typeof title !== 'string' || ++ labels === null || ++ files === null ++ ) { ++ return null; ++ } ++ return { ++ repo: event.repo, ++ sha: event.sha, ++ prNumber, ++ title, ++ body: typeof body === 'string' ? body : '', ++ labels, ++ files, ++ }; ++} ++ ++export async function storeAutomationEvent( ++ input: StoreAutomationEventInput ++): Promise { ++ const classifierOutput = input.classifierOutput; ++ const outputHash = outputHashFor(classifierOutput); ++ const existing = await findAutomationEventByIdempotencyKey(input.idempotencyKey); ++ if (existing !== null) { ++ if ( ++ existing.classifierVersion !== input.classifierVersion || ++ existing.outputHash !== outputHash ++ ) { ++ throw new AutomationEventIdempotencyConflictError(input.idempotencyKey); ++ } ++ return { event: existing, created: false }; ++ } ++ ++ const event = await createAutomationEventRecord({ ++ repo: input.repo, ++ sha: input.sha, ++ event: input.event, ++ source: input.source, ++ workflow: input.workflow, ++ status: input.status, ++ idempotencyKey: input.idempotencyKey, ++ classifierVersion: input.classifierVersion, ++ outputHash, ++ rawPayload: input.rawPayload, ++ normalizedEvent: input.normalizedEvent, ++ classifierOutput, ++ prNumber: input.prNumber, ++ issueNumber: input.issueNumber, ++ }); ++ ++ return { event, created: true }; ++} ++ ++export async function classifyAndStorePrAutomation( ++ input: PrAutomationInput ++): Promise { ++ const classifierOutput = classifyPrAutomation({ ++ repo: input.repo, ++ sha: input.sha, ++ prNumber: input.prNumber, ++ title: input.title, ++ body: input.body ?? '', ++ labels: input.labels, ++ files: input.files, ++ }); ++ const outputHash = outputHashFor(classifierOutput); ++ const idempotencyKey = buildAutomationIdempotencyKey([ ++ 'pr-classification', ++ input.repo, ++ input.sha, ++ String(input.prNumber), ++ PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash, ++ ]); ++ const normalizedEvent = { ++ event: 'github.pull_request', ++ source: 'github', ++ repo: input.repo, ++ timestamp: '1970-01-01T00:00:00.000Z', ++ payload: { ++ prNumber: input.prNumber, ++ title: input.title, ++ body: input.body ?? '', ++ labels: input.labels, ++ files: input.files, ++ }, ++ }; ++ const stored = await storeAutomationEvent({ ++ repo: input.repo, ++ sha: input.sha, ++ event: normalizedEvent.event, ++ source: normalizedEvent.source, ++ workflow: input.sourceWorkflow, ++ status: 'accepted', ++ idempotencyKey, ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ rawPayload: normalizedEvent.payload, ++ normalizedEvent, ++ classifierOutput, ++ prNumber: input.prNumber, ++ }); ++ ++ return { ...stored, classifierOutput }; ++} ++ ++export async function replayAutomationEvent( ++ id: string, ++ classifierVersion: string ++): Promise { ++ const event = await findAutomationEventById(id); ++ if (event === null) { ++ return null; ++ } ++ if (event.classifierVersion !== classifierVersion) { ++ return { ++ event, ++ replayedOutput: null, ++ outputHash: null, ++ matches: false, ++ reason: 'classifier_version_mismatch', ++ }; ++ } ++ ++ if (event.event !== 'github.pull_request') { ++ return { ++ event, ++ replayedOutput: null, ++ outputHash: null, ++ matches: false, ++ reason: 'unsupported_replay_event', ++ }; ++ } ++ ++ const replayInput = rebuildPrClassifierInput(event); ++ if (replayInput === null) { ++ return { ++ event, ++ replayedOutput: null, ++ outputHash: null, ++ matches: false, ++ reason: 'invalid_replay_input', ++ }; ++ } ++ ++ const replayedOutput = classifyPrAutomation(replayInput); ++ const outputHash = outputHashFor(replayedOutput); ++ return { ++ event, ++ replayedOutput, ++ outputHash, ++ matches: outputHash === event.outputHash, ++ reason: outputHash === event.outputHash ? 'matched' : 'output_hash_mismatch', ++ }; ++} ++ ++export async function compareAndStoreSimulationSnapshot( ++ input: SimulationCompareInput ++): Promise { ++ const previous = await findLatestSimulationSnapshot( ++ input.scopeKey, ++ SIMULATION_DRIFT_CLASSIFIER_VERSION ++ ); ++ const previousSnapshot = previous === null ? null : previous.snapshot; ++ const comparisonOutput = classifySimulationDrift( ++ previousSnapshot as Parameters[0], ++ input.snapshot as Parameters[1] ++ ); ++ const outputHash = outputHashFor(comparisonOutput); ++ const existing = await findSimulationSnapshotByRun({ ++ scopeKey: input.scopeKey, ++ runId: input.runId, ++ classifierVersion: SIMULATION_DRIFT_CLASSIFIER_VERSION, ++ }); ++ if (existing !== null) { ++ if (outputHashFor(existing.snapshot) !== outputHashFor(input.snapshot)) { ++ throw new SimulationSnapshotIdempotencyConflictError(input.scopeKey, input.runId); ++ } ++ return { ++ snapshot: existing, ++ comparisonOutput: existing.comparisonOutput as unknown as SimulationDriftClassification, ++ created: false, ++ }; ++ } ++ ++ const snapshot = await createSimulationAutomationSnapshotRecord({ ++ repo: input.repo, ++ scopeKey: input.scopeKey, ++ runId: input.runId, ++ classifierVersion: SIMULATION_DRIFT_CLASSIFIER_VERSION, ++ snapshot: input.snapshot, ++ comparisonOutput, ++ outputHash, ++ previousSnapshotId: previous?.id, ++ }); ++ ++ return { snapshot, comparisonOutput, created: true }; ++} +diff --git a/lib/automation/github-status.ts b/lib/automation/github-status.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..5ba4bf956a3407a1aae69e5fa166164cb9fab6ff +--- /dev/null ++++ b/lib/automation/github-status.ts +@@ -0,0 +1,238 @@ ++import type { AutomationStatusCheck } from '@prisma/client'; ++import { ++ createGithubStatusCheckRecord, ++ findStatusCheckById, ++ findStatusCheckByIdempotencyKey, ++ listGithubStatusChecks, ++ postGithubCommitStatus, ++ updateGithubStatusCheckResponse, ++} from '../adapters/runtime/automation-github-status.prisma.js'; ++import { buildAutomationIdempotencyKey } from './hash.js'; ++ ++export const ALLOWED_GITHUB_STATUS_CONTEXTS = [ ++ 'cherry/forbidden-change', ++ 'cherry/docs-drift', ++ 'cherry/risk-gate', ++ 'cherry/openclaw-policy', ++] as const; ++ ++export type AllowedGithubStatusContext = (typeof ALLOWED_GITHUB_STATUS_CONTEXTS)[number]; ++ ++export type GithubStatusInput = { ++ repo: string; ++ sha: string; ++ context: AllowedGithubStatusContext; ++ state: 'error' | 'failure' | 'pending' | 'success'; ++ description: string; ++ targetUrl?: string | undefined; ++ sourceWorkflow: string; ++ automationEventId?: string | undefined; ++ classifierVersion: string; ++ outputHash: string; ++}; ++ ++export type GithubStatusPostOptions = { ++ githubToken: string; ++ apiBaseUrl?: string; ++}; ++ ++export type GithubStatusPostResult = { ++ statusCheck: AutomationStatusCheck; ++ posted: boolean; ++ idempotent: boolean; ++}; ++ ++export type GithubStatusRetryInput = { ++ id?: string | undefined; ++ statusIdempotencyKey?: string | undefined; ++}; ++ ++export type GithubStatusRetryResult = { ++ statusCheck: AutomationStatusCheck; ++ retried: boolean; ++}; ++ ++export class GithubStatusRetryNotFoundError extends Error { ++ constructor() { ++ super('github_status_not_found'); ++ this.name = 'GithubStatusRetryNotFoundError'; ++ } ++} ++ ++export function isAllowedGithubStatusContext( ++ context: string ++): context is AllowedGithubStatusContext { ++ return ALLOWED_GITHUB_STATUS_CONTEXTS.includes(context as AllowedGithubStatusContext); ++} ++ ++export function buildStatusIdempotencyKey(input: GithubStatusInput): string { ++ return buildAutomationIdempotencyKey([ ++ 'github-status', ++ input.repo, ++ input.sha, ++ input.context, ++ input.classifierVersion, ++ input.outputHash, ++ ]); ++} ++ ++export function targetUrlTouchesForbiddenCherryTruth(targetUrl: string | undefined): boolean { ++ if (targetUrl === undefined) return false; ++ return /\/api\/(sessions?|ledgers?|buckets?|payments?|cards?)(\/|$)|\/api\/debts?(\/.*)?\/mutate\b/i.test( ++ targetUrl ++ ); ++} ++ ++export async function postGithubStatus( ++ input: GithubStatusInput, ++ options: GithubStatusPostOptions ++): Promise { ++ if (isAllowedGithubStatusContext(input.context) === false) { ++ throw new Error(`Unsupported GitHub status context: ${input.context}`); ++ } ++ ++ const statusIdempotencyKey = buildStatusIdempotencyKey(input); ++ const existing = await findStatusCheckByIdempotencyKey(statusIdempotencyKey); ++ if (existing !== null) { ++ return { statusCheck: existing, posted: false, idempotent: true }; ++ } ++ ++ if (targetUrlTouchesForbiddenCherryTruth(input.targetUrl)) { ++ throw new Error('GitHub status targetUrl points at a forbidden Cherry finance endpoint'); ++ } ++ ++ const statusCheck = await createGithubStatusCheckRecord({ ++ repo: input.repo, ++ sha: input.sha, ++ context: input.context, ++ state: input.state, ++ description: input.description, ++ targetUrl: input.targetUrl, ++ sourceWorkflow: input.sourceWorkflow, ++ automationEventId: input.automationEventId, ++ classifierVersion: input.classifierVersion, ++ outputHash: input.outputHash, ++ statusIdempotencyKey, ++ githubResponse: { status: 'created_not_posted' }, ++ }); ++ ++ if (options.githubToken.trim().length === 0) { ++ const updated = await updateGithubStatusCheckResponse(statusCheck.id, { ++ ok: false, ++ error: 'missing_github_token', ++ }); ++ throw Object.assign(new Error('Missing GitHub token for status posting'), { ++ statusCheck: updated, ++ }); ++ } ++ ++ const apiBaseUrl = options.apiBaseUrl ?? 'https://api.github.com'; ++ const response = await postGithubCommitStatus({ ++ apiBaseUrl, ++ githubToken: options.githubToken, ++ repo: input.repo, ++ sha: input.sha, ++ state: input.state, ++ description: input.description, ++ context: input.context, ++ targetUrl: input.targetUrl, ++ }); ++ const githubResponse = { ++ ok: response.ok, ++ status: response.status, ++ body: response.body, ++ }; ++ const updated = await updateGithubStatusCheckResponse(statusCheck.id, githubResponse); ++ if (response.ok === false) { ++ throw Object.assign(new Error(`GitHub status post failed with ${response.status}`), { ++ statusCheck: updated, ++ }); ++ } ++ ++ return { statusCheck: updated, posted: true, idempotent: false }; ++} ++ ++async function repostExistingGithubStatus( ++ statusCheck: AutomationStatusCheck, ++ options: GithubStatusPostOptions ++): Promise { ++ if (isAllowedGithubStatusContext(statusCheck.context) === false) { ++ throw new Error(`Unsupported GitHub status context: ${statusCheck.context}`); ++ } ++ if (targetUrlTouchesForbiddenCherryTruth(statusCheck.targetUrl ?? undefined)) { ++ throw new Error('GitHub status targetUrl points at a forbidden Cherry finance endpoint'); ++ } ++ if (options.githubToken.trim().length === 0) { ++ const updated = await updateGithubStatusCheckResponse(statusCheck.id, { ++ ok: false, ++ retry: true, ++ error: 'missing_github_token', ++ }); ++ throw Object.assign(new Error('Missing GitHub token for status retry'), { ++ statusCheck: updated, ++ }); ++ } ++ ++ const response = await postGithubCommitStatus({ ++ apiBaseUrl: options.apiBaseUrl ?? 'https://api.github.com', ++ githubToken: options.githubToken, ++ repo: statusCheck.repo, ++ sha: statusCheck.sha, ++ state: statusCheck.state as GithubStatusInput['state'], ++ description: statusCheck.description, ++ context: statusCheck.context, ++ targetUrl: statusCheck.targetUrl ?? undefined, ++ }); ++ const updated = await updateGithubStatusCheckResponse(statusCheck.id, { ++ ok: response.ok, ++ retry: true, ++ status: response.status, ++ body: response.body, ++ }); ++ if (response.ok === false) { ++ throw Object.assign(new Error(`GitHub status retry failed with ${response.status}`), { ++ statusCheck: updated, ++ }); ++ } ++ return updated; ++} ++ ++export async function retryGithubStatus( ++ input: GithubStatusRetryInput, ++ options: GithubStatusPostOptions ++): Promise { ++ const statusCheck = ++ input.id !== undefined ++ ? await findStatusCheckById(input.id) ++ : input.statusIdempotencyKey !== undefined ++ ? await findStatusCheckByIdempotencyKey(input.statusIdempotencyKey) ++ : null; ++ if (statusCheck === null) { ++ throw new GithubStatusRetryNotFoundError(); ++ } ++ const updated = await repostExistingGithubStatus(statusCheck, options); ++ return { statusCheck: updated, retried: true }; ++} ++ ++export async function listLatestGithubStatuses(params: { ++ repo?: string; ++ sha?: string; ++ context?: AllowedGithubStatusContext; ++}): Promise { ++ const rows = await listGithubStatusChecks(params); ++ const latest = new Map(); ++ for (const row of rows) { ++ const key = `${row.repo}:${row.sha}:${row.context}`; ++ const existing = latest.get(key); ++ const existingTime = existing?.createdAt instanceof Date ? existing.createdAt.getTime() : 0; ++ const rowTime = row.createdAt instanceof Date ? row.createdAt.getTime() : 0; ++ if (existing === undefined || rowTime >= existingTime) { ++ latest.set(key, row); ++ } ++ } ++ return Array.from(latest.values()).sort((a, b) => { ++ const aTime = a.createdAt instanceof Date ? a.createdAt.getTime() : 0; ++ const bTime = b.createdAt instanceof Date ? b.createdAt.getTime() : 0; ++ return bTime - aTime; ++ }); ++} +diff --git a/lib/automation/hash.ts b/lib/automation/hash.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..d34bd70e3e08a85f71f7a5a2ddafef0649a9cd84 +--- /dev/null ++++ b/lib/automation/hash.ts +@@ -0,0 +1,34 @@ ++import { createHash } from 'node:crypto'; ++ ++export function canonicalize(value: unknown): unknown { ++ if (Array.isArray(value)) { ++ return value.map((entry) => canonicalize(entry)); ++ } ++ ++ if (value !== null && typeof value === 'object') { ++ const record = value as Record; ++ const output: Record = {}; ++ const keys = Object.keys(record).sort((a, b) => a.localeCompare(b)); ++ for (const key of keys) { ++ const entry = record[key]; ++ if (entry !== undefined) { ++ output[key] = canonicalize(entry); ++ } ++ } ++ return output; ++ } ++ ++ return value; ++} ++ ++export function canonicalJson(value: unknown): string { ++ return JSON.stringify(canonicalize(value)); ++} ++ ++export function hashAutomationOutput(value: unknown): string { ++ return createHash('sha256').update(canonicalJson(value)).digest('hex'); ++} ++ ++export function buildAutomationIdempotencyKey(parts: readonly string[]): string { ++ return hashAutomationOutput(parts); ++} +diff --git a/lib/config/route.ts b/lib/config/route.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..0f520c3f804d04da7e098df7457c3611d4a86db5 +--- /dev/null ++++ b/lib/config/route.ts +@@ -0,0 +1,18 @@ ++import { initConfigFromEnv } from './init.js'; ++import { getServerConfig } from './store.js'; ++ ++export function ensureRouteConfigFromEnv(env: NodeJS.ProcessEnv): void { ++ try { ++ getServerConfig(); ++ return; ++ } catch (error: unknown) { ++ if ( ++ error instanceof Error && ++ error.message.includes('ServerConfig not initialized') ++ ) { ++ initConfigFromEnv(env); ++ return; ++ } ++ throw error; ++ } ++} +diff --git a/lib/http/bearer-token.ts b/lib/http/bearer-token.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..1414a76ea4872e6b2bf2cec97139c92db6f88194 +--- /dev/null ++++ b/lib/http/bearer-token.ts +@@ -0,0 +1,3 @@ ++export function getStandardBearerHeader(headers: Headers): string | null { ++ return headers.get('authorization'); ++} +diff --git a/lib/schemas/automation.ts b/lib/schemas/automation.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..d249e0c7576cb8134614744952aff72c9d83d786 +--- /dev/null ++++ b/lib/schemas/automation.ts +@@ -0,0 +1,113 @@ ++import { z } from 'zod'; ++ ++export const AutomationSourceSchema = z.enum(['github', 'openclaw', 'cherry', 'manual']); ++ ++export const AutomationNormalizedEventSchema = z ++ .object({ ++ event: z.string().min(1), ++ source: AutomationSourceSchema, ++ repo: z.string().min(1), ++ timestamp: z.string().min(1), ++ payload: z.unknown(), ++ }) ++ .strict(); ++ ++export const AutomationFileChangeSchema = z ++ .object({ ++ filename: z.string().min(1), ++ status: z.string().min(1).optional(), ++ additions: z.number().int().nonnegative().optional(), ++ deletions: z.number().int().nonnegative().optional(), ++ changes: z.number().int().nonnegative().optional(), ++ patch: z.string().optional(), ++ }) ++ .strict(); ++ ++export const AutomationEventIngestSchema = z ++ .object({ ++ repo: z.string().min(1), ++ sha: z.string().min(1).optional(), ++ event: z.string().min(1), ++ source: AutomationSourceSchema, ++ workflow: z.string().min(1), ++ status: z.string().min(1).default('accepted'), ++ idempotencyKey: z.string().min(1), ++ classifierVersion: z.string().min(1), ++ rawPayload: z.unknown(), ++ normalizedEvent: AutomationNormalizedEventSchema, ++ classifierOutput: z.unknown(), ++ prNumber: z.number().int().positive().optional(), ++ issueNumber: z.number().int().positive().optional(), ++ }) ++ .strict(); ++ ++export const PrAutomationClassifySchema = z ++ .object({ ++ repo: z.string().min(1), ++ sha: z.string().min(1), ++ prNumber: z.number().int().positive(), ++ title: z.string(), ++ body: z.string().nullable().optional(), ++ labels: z.array(z.string()).default([]), ++ files: z.array(AutomationFileChangeSchema).default([]), ++ sourceWorkflow: z.string().min(1).default('unknown'), ++ eventId: z.string().min(1).optional(), ++ }) ++ .strict(); ++ ++export const SimulationSnapshotCompareSchema = z ++ .object({ ++ repo: z.string().min(1), ++ scopeKey: z.string().min(1), ++ runId: z.string().min(1), ++ snapshot: z.unknown(), ++ sourceWorkflow: z.string().min(1).default('unknown'), ++ }) ++ .strict(); ++ ++export const GithubStatusContextSchema = z.enum([ ++ 'cherry/forbidden-change', ++ 'cherry/docs-drift', ++ 'cherry/risk-gate', ++ 'cherry/openclaw-policy', ++]); ++ ++export const GithubStatusStateSchema = z.enum(['error', 'failure', 'pending', 'success']); ++ ++export const GithubStatusPostSchema = z ++ .object({ ++ repo: z.string().min(1), ++ sha: z.string().min(1), ++ context: GithubStatusContextSchema, ++ state: GithubStatusStateSchema, ++ description: z.string().min(1).max(140), ++ targetUrl: z.string().url().optional(), ++ sourceWorkflow: z.string().min(1), ++ automationEventId: z.string().min(1).optional(), ++ classifierVersion: z.string().min(1), ++ outputHash: z.string().min(1), ++ }) ++ .strict(); ++ ++export const GithubStatusRetrySchema = z ++ .union([ ++ z ++ .object({ ++ id: z.string().min(1), ++ statusIdempotencyKey: z.never().optional(), ++ }) ++ .strict(), ++ z ++ .object({ ++ id: z.never().optional(), ++ statusIdempotencyKey: z.string().min(1), ++ }) ++ .strict(), ++ ]); ++ ++export const AutomationReplaySchema = z ++ .object({ ++ automationEventId: z.string().min(1), ++ classifierVersion: z.string().min(1), ++ }) ++ .strict(); +diff --git a/package.json b/package.json +index 19435bd4ab453f24ddeabdf801ede578041e36f6..c4680fc50741578229d4ccdbd20e59fc51c91f0b 100644 +--- a/package.json ++++ b/package.json +@@ -9,7 +9,7 @@ + "engineStrict": true, + "packageManager": "npm@11.12.1", + "scripts": { +- "predev": "npm run check:db-ready", ++ "predev": "CHERRY_TMP_ROOT=${CHERRY_TMP_ROOT:-$HOME/.cherry-tmp} npm run check:db-ready", + "dev": "next dev --webpack", + "build": "next build --webpack", + "build:strict": "npm run check:guardrails && next build --webpack", +@@ -17,7 +17,8 @@ + "ci:verify": "npm run check && npm run test && npm run build", + "check:static": "npm run check:guardrails && npm run lint:scripts && npm run typecheck:scripts && npm run lint && npm run typecheck", + "check:runtime": "npm test", +- "check:fast": "npm run check:guardrails && npm run typecheck:scripts && npm test", ++ "check:fast": "npm run check:guardrails && npm run typecheck:scripts", ++ "check:local": "npm run check:fast && npm run check:runtime", + "check:clean": "npm run ts:esm -- scripts/execution/run.mts check:clean", + "check:db-ready": "npm run ts:esm -- scripts/execution/run-db.mts check:db-ready", + "check:dev-login": "npm run ts:esm -- scripts/execution/run.mts check:dev-login", +diff --git a/prisma/migrations/20260427153000_automation_backend/migration.sql b/prisma/migrations/20260427153000_automation_backend/migration.sql +new file mode 100644 +index 0000000000000000000000000000000000000000..f7caf2c5a1b9ed268c24c40c26d94efee0b7f7c0 +--- /dev/null ++++ b/prisma/migrations/20260427153000_automation_backend/migration.sql +@@ -0,0 +1,80 @@ ++-- Add durable development-automation storage for n8n V2 enforcement. ++ ++CREATE TABLE "AutomationEvent" ( ++ "id" TEXT NOT NULL, ++ "repo" TEXT NOT NULL, ++ "sha" TEXT, ++ "event" TEXT NOT NULL, ++ "source" TEXT NOT NULL, ++ "workflow" TEXT NOT NULL, ++ "status" TEXT NOT NULL, ++ "idempotencyKey" TEXT NOT NULL, ++ "classifierVersion" TEXT NOT NULL, ++ "outputHash" TEXT NOT NULL, ++ "rawPayload" JSONB NOT NULL, ++ "normalizedEvent" JSONB NOT NULL, ++ "classifierOutput" JSONB NOT NULL, ++ "prNumber" INTEGER, ++ "issueNumber" INTEGER, ++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ++ "updatedAt" TIMESTAMP(3) NOT NULL, ++ ++ CONSTRAINT "AutomationEvent_pkey" PRIMARY KEY ("id") ++); ++ ++CREATE TABLE "SimulationAutomationSnapshot" ( ++ "id" TEXT NOT NULL, ++ "repo" TEXT NOT NULL, ++ "scopeKey" TEXT NOT NULL, ++ "runId" TEXT NOT NULL, ++ "classifierVersion" TEXT NOT NULL, ++ "snapshot" JSONB NOT NULL, ++ "comparisonOutput" JSONB NOT NULL, ++ "outputHash" TEXT NOT NULL, ++ "previousSnapshotId" TEXT, ++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ++ ++ CONSTRAINT "SimulationAutomationSnapshot_pkey" PRIMARY KEY ("id") ++); ++ ++CREATE TABLE "AutomationStatusCheck" ( ++ "id" TEXT NOT NULL, ++ "repo" TEXT NOT NULL, ++ "sha" TEXT NOT NULL, ++ "context" TEXT NOT NULL, ++ "state" TEXT NOT NULL, ++ "description" TEXT NOT NULL, ++ "targetUrl" TEXT, ++ "sourceWorkflow" TEXT NOT NULL, ++ "automationEventId" TEXT, ++ "classifierVersion" TEXT NOT NULL, ++ "outputHash" TEXT NOT NULL, ++ "statusIdempotencyKey" TEXT NOT NULL, ++ "githubResponse" JSONB, ++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ++ ++ CONSTRAINT "AutomationStatusCheck_pkey" PRIMARY KEY ("id") ++); ++ ++CREATE UNIQUE INDEX "automation_event__idempotency_key__unique" ON "AutomationEvent"("idempotencyKey"); ++CREATE INDEX "AutomationEvent_repo_sha_idx" ON "AutomationEvent"("repo", "sha"); ++CREATE INDEX "AutomationEvent_repo_prNumber_idx" ON "AutomationEvent"("repo", "prNumber"); ++CREATE INDEX "AutomationEvent_repo_issueNumber_idx" ON "AutomationEvent"("repo", "issueNumber"); ++CREATE INDEX "AutomationEvent_workflow_createdAt_idx" ON "AutomationEvent"("workflow", "createdAt"); ++CREATE INDEX "AutomationEvent_classifierVersion_idx" ON "AutomationEvent"("classifierVersion"); ++ ++CREATE UNIQUE INDEX "simulation_automation_snapshot__scope_run_version__unique" ON "SimulationAutomationSnapshot"("scopeKey", "runId", "classifierVersion"); ++CREATE INDEX "SimulationAutomationSnapshot_repo_scopeKey_idx" ON "SimulationAutomationSnapshot"("repo", "scopeKey"); ++CREATE INDEX "SimulationAutomationSnapshot_scopeKey_createdAt_idx" ON "SimulationAutomationSnapshot"("scopeKey", "createdAt"); ++CREATE INDEX "SimulationAutomationSnapshot_classifierVersion_idx" ON "SimulationAutomationSnapshot"("classifierVersion"); ++ ++CREATE UNIQUE INDEX "automation_status_check__status_idempotency_key__unique" ON "AutomationStatusCheck"("statusIdempotencyKey"); ++CREATE INDEX "AutomationStatusCheck_repo_sha_idx" ON "AutomationStatusCheck"("repo", "sha"); ++CREATE INDEX "AutomationStatusCheck_repo_sha_context_idx" ON "AutomationStatusCheck"("repo", "sha", "context"); ++CREATE INDEX "AutomationStatusCheck_automationEventId_idx" ON "AutomationStatusCheck"("automationEventId"); ++CREATE INDEX "AutomationStatusCheck_classifierVersion_idx" ON "AutomationStatusCheck"("classifierVersion"); ++ ++ALTER TABLE "AutomationStatusCheck" ++ ADD CONSTRAINT "automation_status_check__automation_event_id__fk" ++ FOREIGN KEY ("automationEventId") REFERENCES "AutomationEvent"("id") ++ ON DELETE SET NULL ON UPDATE CASCADE; +diff --git a/prisma/migrations/20260428155022_automation_v2/migration.sql b/prisma/migrations/20260428155022_automation_v2/migration.sql +new file mode 100644 +index 0000000000000000000000000000000000000000..af1f53340fe26cd71c9b87870766cbe15ccf7849 +--- /dev/null ++++ b/prisma/migrations/20260428155022_automation_v2/migration.sql +@@ -0,0 +1,5 @@ ++-- RenameForeignKey ++ALTER TABLE "AccountingPosting" RENAME CONSTRAINT "accounting_posting__transaction_id__fk" TO "AccountingPosting_transactionId_fkey"; ++ ++-- RenameForeignKey ++ALTER TABLE "AccountingTransaction" RENAME CONSTRAINT "accounting_transaction__user_id__fk" TO "AccountingTransaction_userId_fkey"; +diff --git a/prisma/schema.prisma b/prisma/schema.prisma +index 157c4d2c58d87738f6d098b1a502f8c610c27b7f..4cdc91a684c196c081ce37d1b57cd70a82aca206 100644 +--- a/prisma/schema.prisma ++++ b/prisma/schema.prisma +@@ -528,6 +528,75 @@ model DecisionEvent { + @@index([userId, createdAt]) + } + ++model AutomationEvent { ++ id String @id @default(cuid()) ++ repo String ++ sha String? ++ event String ++ source String ++ workflow String ++ status String ++ idempotencyKey String @unique(map: "automation_event__idempotency_key__unique") ++ classifierVersion String ++ outputHash String ++ rawPayload Json ++ normalizedEvent Json ++ classifierOutput Json ++ prNumber Int? ++ issueNumber Int? ++ createdAt DateTime @default(now()) ++ updatedAt DateTime @updatedAt ++ ++ statusChecks AutomationStatusCheck[] ++ ++ @@index([repo, sha]) ++ @@index([repo, prNumber]) ++ @@index([repo, issueNumber]) ++ @@index([workflow, createdAt]) ++ @@index([classifierVersion]) ++} ++ ++model SimulationAutomationSnapshot { ++ id String @id @default(cuid()) ++ repo String ++ scopeKey String ++ runId String ++ classifierVersion String ++ snapshot Json ++ comparisonOutput Json ++ outputHash String ++ previousSnapshotId String? ++ createdAt DateTime @default(now()) ++ ++ @@unique([scopeKey, runId, classifierVersion], map: "simulation_automation_snapshot__scope_run_version__unique") ++ @@index([repo, scopeKey]) ++ @@index([scopeKey, createdAt]) ++ @@index([classifierVersion]) ++} ++ ++model AutomationStatusCheck { ++ id String @id @default(cuid()) ++ repo String ++ sha String ++ context String ++ state String ++ description String ++ targetUrl String? ++ sourceWorkflow String ++ automationEvent AutomationEvent? @relation(fields: [automationEventId], references: [id], onDelete: SetNull, map: "automation_status_check__automation_event_id__fk") ++ automationEventId String? ++ classifierVersion String ++ outputHash String ++ statusIdempotencyKey String @unique(map: "automation_status_check__status_idempotency_key__unique") ++ githubResponse Json? ++ createdAt DateTime @default(now()) ++ ++ @@index([repo, sha]) ++ @@index([repo, sha, context]) ++ @@index([automationEventId]) ++ @@index([classifierVersion]) ++} ++ + model IdempotencyKey { + userId String + key String +diff --git a/scripts/check-ci-guardrail-coverage.mts b/scripts/check-ci-guardrail-coverage.mts +index d58631255235b8f20df392157b638837a7e3f93d..544096b963caa2c487b8d15577c7d857e394e435 100644 +--- a/scripts/check-ci-guardrail-coverage.mts ++++ b/scripts/check-ci-guardrail-coverage.mts +@@ -28,7 +28,6 @@ const CI_ENTRYPOINT = 'ci:verify'; + const GUARDRAIL_ENTRYPOINT_NAME = GUARDRAIL_ENTRYPOINT; + const DIRECT_RUNTIME_SCRIPTS = new Set([ + 'check', +- 'check:fast', + 'check:runtime', + 'check:node', + 'check:next', +diff --git a/scripts/check-ci-must-run-check.mts b/scripts/check-ci-must-run-check.mts +index b92e20dc0081be6724cbd2bbd4cff918721c0eba..9eb309208a92ac1d9cf4970ca9ca9aed5c6559e6 100644 +--- a/scripts/check-ci-must-run-check.mts ++++ b/scripts/check-ci-must-run-check.mts +@@ -21,7 +21,6 @@ const FIX = + const REQUIRED_GUARDRAILS = ['check:guardrails']; + const DIRECT_RUNTIME_SCRIPTS = new Set([ + 'check', +- 'check:fast', + 'check:runtime', + 'check:node', + 'check:next', +diff --git a/scripts/lib/prisma-mock.cjs b/scripts/lib/prisma-mock.cjs +index 603102ae4db2a34b9a7612104e3d811d7f432584..509ebe2d25e13a7d4e56ccb84aad5f9e4d3e4808 100644 +--- a/scripts/lib/prisma-mock.cjs ++++ b/scripts/lib/prisma-mock.cjs +@@ -37,6 +37,19 @@ function matchesWhere(record, where) { + ) { + return record.userId === val.userId && record.externalId === val.externalId; + } ++ if ( ++ val !== null && ++ typeof val === 'object' && ++ 'scopeKey' in val && ++ 'runId' in val && ++ 'classifierVersion' in val ++ ) { ++ return ( ++ record.scopeKey === val.scopeKey && ++ record.runId === val.runId && ++ record.classifierVersion === val.classifierVersion ++ ); ++ } + return record[key] === val; + }); + } +@@ -55,6 +68,10 @@ function createCollection(name) { + const composite = where.userId_externalId; + return `${composite.userId}:${composite.externalId}`; + } ++ if ('scopeKey_runId_classifierVersion' in where) { ++ const composite = where.scopeKey_runId_classifierVersion; ++ return `${composite.scopeKey}:${composite.runId}:${composite.classifierVersion}`; ++ } + if ( + 'userId' in where && + 'key' in where && +@@ -80,7 +97,13 @@ function createCollection(name) { + typeof data.userId === 'string' && typeof data.key === 'string' + ? `${data.userId}:${data.key}` + : null; +- const key = data.id ?? compositeKey ?? `${name}-${counter++}`; ++ const simulationKey = ++ typeof data.scopeKey === 'string' && ++ typeof data.runId === 'string' && ++ typeof data.classifierVersion === 'string' ++ ? `${data.scopeKey}:${data.runId}:${data.classifierVersion}` ++ : null; ++ const key = data.id ?? compositeKey ?? simulationKey ?? `${name}-${counter++}`; + if (store.has(key)) { + const err = new Error(`Unique constraint failed on the fields: (${name}.id)`); + err.code = 'P2002'; +@@ -194,6 +217,9 @@ class MockPrismaClient { + simulation = createCollection('simulation'); + vineDevice = createCollection('vineDevice'); + decisionEvent = createCollection('decisionEvent'); ++ automationEvent = createCollection('automationEvent'); ++ simulationAutomationSnapshot = createCollection('simulationAutomationSnapshot'); ++ automationStatusCheck = createCollection('automationStatusCheck'); + idempotencyKey = createCollection('idempotencyKey'); --Guardrail tooling requires Node 22.x and a stable PATH (e.g. `/usr/bin:/bin:/usr/local/bin`) so `rg`, `git`, and `node` resolve deterministically. -+The repo runtime is Node 24.15.0. Use `.nvmrc` / `engines.node` as the source of truth, and keep PATH stable (e.g. `/usr/bin:/bin:/usr/local/bin`) so `rg`, `git`, and `node` resolve deterministically. + async $disconnect() { +diff --git a/scripts/lib/prisma-mock.mts b/scripts/lib/prisma-mock.mts +index 928b3e63d122fcd12de2a7f11a561868ecbd03d9..bf21bd85c6ed6824697f6910eb4c004c61bda6d3 100644 +--- a/scripts/lib/prisma-mock.mts ++++ b/scripts/lib/prisma-mock.mts +@@ -72,6 +72,24 @@ function matchesWhere(record: RecordShape, where?: Where): boolean { + record['externalId'] === composite.externalId + ); + } ++ if ( ++ val !== null && ++ typeof val === 'object' && ++ 'scopeKey' in (val as Record) && ++ 'runId' in (val as Record) && ++ 'classifierVersion' in (val as Record) ++ ) { ++ const composite = val as { ++ scopeKey: string; ++ runId: string; ++ classifierVersion: string; ++ }; ++ return ( ++ record['scopeKey'] === composite.scopeKey && ++ record['runId'] === composite.runId && ++ record['classifierVersion'] === composite.classifierVersion ++ ); ++ } + return record[key] === val; + }); + } +@@ -90,6 +108,14 @@ function createCollection(name: string) { + const composite = where['userId_externalId'] as { userId: string; externalId: string }; + return `${composite.userId}:${composite.externalId}`; + } ++ if ('scopeKey_runId_classifierVersion' in where) { ++ const composite = where['scopeKey_runId_classifierVersion'] as { ++ scopeKey: string; ++ runId: string; ++ classifierVersion: string; ++ }; ++ return `${composite.scopeKey}:${composite.runId}:${composite.classifierVersion}`; ++ } + if ( + 'userId' in where && + 'key' in where && +@@ -115,8 +141,17 @@ function createCollection(name: string) { + typeof data['userId'] === 'string' && typeof data['key'] === 'string' + ? `${data['userId']}:${data['key']}` + : null; ++ const simulationKey = ++ typeof data['scopeKey'] === 'string' && ++ typeof data['runId'] === 'string' && ++ typeof data['classifierVersion'] === 'string' ++ ? `${data['scopeKey']}:${data['runId']}:${data['classifierVersion']}` ++ : null; + const key = +- (data['id'] as string | undefined) ?? compositeKey ?? `${name}-${counter++}`; ++ (data['id'] as string | undefined) ?? ++ compositeKey ?? ++ simulationKey ?? ++ `${name}-${counter++}`; + if (store.has(key)) { + const err = Error( + `Unique constraint failed on the fields: (${name}.id)` +@@ -236,6 +271,9 @@ class MockPrismaClient { + simulation = createCollection('simulation'); + vineDevice = createCollection('vineDevice'); + decisionEvent = createCollection('decisionEvent'); ++ automationEvent = createCollection('automationEvent'); ++ simulationAutomationSnapshot = createCollection('simulationAutomationSnapshot'); ++ automationStatusCheck = createCollection('automationStatusCheck'); + idempotencyKey = createCollection('idempotencyKey'); - ## Health Gates (must pass before pushing) - ```bash + async $disconnect(): Promise { +diff --git a/scripts/schema/manifest.json b/scripts/schema/manifest.json +index ecbeaabb77b680ec077a2cd24d37c70ccee53d31..805f89fde3b521d07a47bcbc4230184f1bf406b9 100644 +--- a/scripts/schema/manifest.json ++++ b/scripts/schema/manifest.json +@@ -1,6 +1,6 @@ + { +- "schemaVersion": "schema_v2", +- "lastMigration": "20260426090000_add_scheduled_paydowns", ++ "schemaVersion": "schema_v3", ++ "lastMigration": "20260427153000_automation_backend", + "invariantsVersion": "db_invariants_v1", + "allowlistedDestructiveMigrations": [] + } +diff --git a/tests/db/constraints/automation-constraints.test.ts b/tests/db/constraints/automation-constraints.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..eb1302f7f2f0e06d066951f54029901fb405d26d +--- /dev/null ++++ b/tests/db/constraints/automation-constraints.test.ts +@@ -0,0 +1,353 @@ ++import * as assert from 'node:assert/strict'; ++import { Prisma, PrismaClient } from '@prisma/client'; ++import { assertPrismaError, getPrismaMetaString } from '../_helpers/assert-prisma-error.js'; ++ ++const prisma = new PrismaClient(); ++ ++const NOT_NULL_CONSTRAINTS = [ ++ 'NOT_NULL:071a671b50fb', ++ 'NOT_NULL:2856ae778f57', ++ 'NOT_NULL:2e723dd620a6', ++ 'NOT_NULL:342ac63ad189', ++ 'NOT_NULL:3758a6585230', ++ 'NOT_NULL:3ca21e9e45fa', ++ 'NOT_NULL:3dd52344230b', ++ 'NOT_NULL:4b10446b95e1', ++ 'NOT_NULL:4b7e6023b536', ++ 'NOT_NULL:568026ae010c', ++ 'NOT_NULL:68a2d5554b15', ++ 'NOT_NULL:6e030f061140', ++ 'NOT_NULL:7043c0f5255c', ++ 'NOT_NULL:776f09fbc5b2', ++ 'NOT_NULL:77c1392dba1e', ++ 'NOT_NULL:7996bef994a5', ++ 'NOT_NULL:7c4f17d2d641', ++ 'NOT_NULL:8005bfc46cf5', ++ 'NOT_NULL:8206bfbc595b', ++ 'NOT_NULL:82c4da434472', ++ 'NOT_NULL:87fa7b2f34a3', ++ 'NOT_NULL:90ad611dd8fd', ++ 'NOT_NULL:a4b229badf88', ++ 'NOT_NULL:afd6dd8e0c16', ++ 'NOT_NULL:b326784ec024', ++ 'NOT_NULL:c2c63cfc4045', ++ 'NOT_NULL:cd4cdd4ace1b', ++ 'NOT_NULL:cfb682211961', ++ 'NOT_NULL:d749fe9b04a7', ++ 'NOT_NULL:db294d632a8c', ++ 'NOT_NULL:dc9bd959f232', ++ 'NOT_NULL:df2cb8c868b2', ++ 'NOT_NULL:ef57d03df80f', ++ 'NOT_NULL:f7ff64432e48', ++] as const; ++ ++const UNIQUE_CONSTRAINTS = [ ++ 'automation_event__idempotency_key__unique', ++ 'automation_status_check__status_idempotency_key__unique', ++ 'simulation_automation_snapshot__scope_run_version__unique', ++] as const; ++ ++void NOT_NULL_CONSTRAINTS; ++void UNIQUE_CONSTRAINTS; ++ ++type TableSpec = { ++ table: string; ++ columns: string[]; ++ jsonColumns: Set; ++ baseRow: (suffix: string) => Record; ++}; ++ ++const at = new Date('2024-01-01T00:00:00Z'); ++ ++const tableSpecs: TableSpec[] = [ ++ { ++ table: 'AutomationEvent', ++ columns: [ ++ 'id', ++ 'repo', ++ 'event', ++ 'source', ++ 'workflow', ++ 'status', ++ 'idempotencyKey', ++ 'classifierVersion', ++ 'outputHash', ++ 'rawPayload', ++ 'normalizedEvent', ++ 'classifierOutput', ++ 'createdAt', ++ 'updatedAt', ++ ], ++ jsonColumns: new Set(['rawPayload', 'normalizedEvent', 'classifierOutput']), ++ baseRow: (suffix) => ({ ++ id: `automation-event-required-${suffix}`, ++ repo: 'div0rce/cherry', ++ event: 'db.constraint', ++ source: 'manual', ++ workflow: 'db-test', ++ status: 'accepted', ++ idempotencyKey: `automation-event-required-${suffix}`, ++ classifierVersion: 'automation_v2', ++ outputHash: `hash-${suffix}`, ++ rawPayload: JSON.stringify({ suffix }), ++ normalizedEvent: JSON.stringify({ suffix }), ++ classifierOutput: JSON.stringify({ suffix }), ++ createdAt: at, ++ updatedAt: at, ++ }), ++ }, ++ { ++ table: 'SimulationAutomationSnapshot', ++ columns: [ ++ 'id', ++ 'repo', ++ 'scopeKey', ++ 'runId', ++ 'classifierVersion', ++ 'snapshot', ++ 'comparisonOutput', ++ 'outputHash', ++ 'createdAt', ++ ], ++ jsonColumns: new Set(['snapshot', 'comparisonOutput']), ++ baseRow: (suffix) => ({ ++ id: `simulation-automation-snapshot-required-${suffix}`, ++ repo: 'div0rce/cherry', ++ scopeKey: `scope-${suffix}`, ++ runId: `run-${suffix}`, ++ classifierVersion: 'automation_v2', ++ snapshot: JSON.stringify({ suffix }), ++ comparisonOutput: JSON.stringify({ suffix }), ++ outputHash: `hash-${suffix}`, ++ createdAt: at, ++ }), ++ }, ++ { ++ table: 'AutomationStatusCheck', ++ columns: [ ++ 'id', ++ 'repo', ++ 'sha', ++ 'context', ++ 'state', ++ 'description', ++ 'sourceWorkflow', ++ 'classifierVersion', ++ 'outputHash', ++ 'statusIdempotencyKey', ++ 'createdAt', ++ ], ++ jsonColumns: new Set(), ++ baseRow: (suffix) => ({ ++ id: `automation-status-check-required-${suffix}`, ++ repo: 'div0rce/cherry', ++ sha: `sha-${suffix}`, ++ context: 'cherry/risk-gate', ++ state: 'success', ++ description: 'DB constraint check', ++ sourceWorkflow: 'db-test', ++ classifierVersion: 'automation_v2', ++ outputHash: `hash-${suffix}`, ++ statusIdempotencyKey: `automation-status-check-required-${suffix}`, ++ createdAt: at, ++ }), ++ }, ++]; ++ ++async function insertRaw(spec: TableSpec, row: Record): Promise { ++ const columns = spec.columns.map((column) => `"${column}"`).join(', '); ++ const placeholders = spec.columns ++ .map((column, index) => `$${index + 1}${spec.jsonColumns.has(column) ? '::jsonb' : ''}`) ++ .join(', '); ++ const values = spec.columns.map((column) => row[column]); ++ await prisma.$executeRawUnsafe( ++ `INSERT INTO "${spec.table}" (${columns}) VALUES (${placeholders})`, ++ ...values ++ ); ++} ++ ++function assertRawSqlCode(error: unknown, expected: '23502'): void { ++ assertPrismaError(error); ++ if (error instanceof Prisma.PrismaClientKnownRequestError) { ++ assert.equal(error.code, 'P2010', 'expected raw query failure'); ++ const code = getPrismaMetaString(error, 'code'); ++ if (code !== undefined) { ++ assert.equal(code, expected); ++ return; ++ } ++ } ++ ++ assert.ok(String(error).includes(expected), `expected SQLSTATE ${expected}`); ++} ++ ++function assertUniqueError(error: unknown): void { ++ assertPrismaError(error); ++ if (error instanceof Prisma.PrismaClientKnownRequestError) { ++ assert.equal(error.code, 'P2002', 'expected unique constraint violation'); ++ return; ++ } ++ throw new Error(`Expected PrismaClientKnownRequestError, got ${String(error)}`); ++} ++ ++async function expectNotNullViolation(spec: TableSpec, column: string): Promise { ++ let error: unknown = null; ++ try { ++ await insertRaw(spec, { ++ ...spec.baseRow(column), ++ [column]: null, ++ }); ++ } catch (err) { ++ error = err; ++ } ++ ++ if (error === null) { ++ throw new Error(`Expected NOT NULL violation on ${spec.table}.${column}`); ++ } ++ assertRawSqlCode(error, '23502'); ++} ++ ++async function expectUniqueViolations(): Promise { ++ await prisma.automationEvent.create({ ++ data: { ++ repo: 'div0rce/cherry', ++ event: 'db.constraint', ++ source: 'manual', ++ workflow: 'db-test', ++ status: 'accepted', ++ idempotencyKey: 'automation-event-unique-key', ++ classifierVersion: 'automation_v2', ++ outputHash: 'hash-event', ++ rawPayload: {}, ++ normalizedEvent: {}, ++ classifierOutput: {}, ++ }, ++ }); ++ ++ let eventError: unknown = null; ++ try { ++ await prisma.automationEvent.create({ ++ data: { ++ repo: 'div0rce/cherry', ++ event: 'db.constraint', ++ source: 'manual', ++ workflow: 'db-test', ++ status: 'accepted', ++ idempotencyKey: 'automation-event-unique-key', ++ classifierVersion: 'automation_v2', ++ outputHash: 'hash-event-duplicate', ++ rawPayload: {}, ++ normalizedEvent: {}, ++ classifierOutput: {}, ++ }, ++ }); ++ } catch (err) { ++ eventError = err; ++ } ++ if (eventError === null) { ++ throw new Error('Expected unique violation on AutomationEvent.idempotencyKey'); ++ } ++ assertUniqueError(eventError); ++ ++ await prisma.simulationAutomationSnapshot.create({ ++ data: { ++ repo: 'div0rce/cherry', ++ scopeKey: 'scope-unique', ++ runId: 'run-unique', ++ classifierVersion: 'automation_v2', ++ snapshot: {}, ++ comparisonOutput: {}, ++ outputHash: 'hash-snapshot', ++ }, ++ }); ++ ++ let snapshotError: unknown = null; ++ try { ++ await prisma.simulationAutomationSnapshot.create({ ++ data: { ++ repo: 'div0rce/cherry', ++ scopeKey: 'scope-unique', ++ runId: 'run-unique', ++ classifierVersion: 'automation_v2', ++ snapshot: {}, ++ comparisonOutput: {}, ++ outputHash: 'hash-snapshot-duplicate', ++ }, ++ }); ++ } catch (err) { ++ snapshotError = err; ++ } ++ if (snapshotError === null) { ++ throw new Error('Expected unique violation on SimulationAutomationSnapshot scope/run/version'); ++ } ++ assertUniqueError(snapshotError); ++ ++ await prisma.automationStatusCheck.create({ ++ data: { ++ repo: 'div0rce/cherry', ++ sha: 'sha-unique', ++ context: 'cherry/risk-gate', ++ state: 'success', ++ description: 'DB constraint check', ++ sourceWorkflow: 'db-test', ++ classifierVersion: 'automation_v2', ++ outputHash: 'hash-status', ++ statusIdempotencyKey: 'automation-status-unique-key', ++ }, ++ }); ++ ++ let statusError: unknown = null; ++ try { ++ await prisma.automationStatusCheck.create({ ++ data: { ++ repo: 'div0rce/cherry', ++ sha: 'sha-unique-duplicate', ++ context: 'cherry/risk-gate', ++ state: 'success', ++ description: 'DB constraint check', ++ sourceWorkflow: 'db-test', ++ classifierVersion: 'automation_v2', ++ outputHash: 'hash-status-duplicate', ++ statusIdempotencyKey: 'automation-status-unique-key', ++ }, ++ }); ++ } catch (err) { ++ statusError = err; ++ } ++ if (statusError === null) { ++ throw new Error('Expected unique violation on AutomationStatusCheck.statusIdempotencyKey'); ++ } ++ assertUniqueError(statusError); ++} ++ ++async function cleanup(): Promise { ++ await prisma.automationStatusCheck.deleteMany({ ++ where: { sourceWorkflow: 'db-test' }, ++ }); ++ await prisma.simulationAutomationSnapshot.deleteMany({ ++ where: { repo: 'div0rce/cherry', classifierVersion: 'automation_v2' }, ++ }); ++ await prisma.automationEvent.deleteMany({ ++ where: { workflow: 'db-test' }, ++ }); ++} ++ ++async function run(): Promise { ++ try { ++ await cleanup(); ++ for (const spec of tableSpecs) { ++ for (const column of spec.columns) { ++ await expectNotNullViolation(spec, column); ++ } ++ } ++ await expectUniqueViolations(); ++ console.warn('db-constraints-automation: ok'); ++ } finally { ++ await cleanup(); ++ await prisma.$disconnect(); ++ } ++} ++ ++run().catch((error: unknown) => { ++ console.error(error); ++ process.exit(1); ++}); +diff --git a/tests/next/automation-api-routes.test.ts b/tests/next/automation-api-routes.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..1f4ab308192b19c79b76b4ca74ec462a69267d7f +--- /dev/null ++++ b/tests/next/automation-api-routes.test.ts +@@ -0,0 +1,316 @@ ++import * as assert from 'node:assert/strict'; ++import { prisma } from '../../lib/prisma.js'; ++import { PR_AUTOMATION_CLASSIFIER_VERSION } from '../../lib/automation/classifiers/types.js'; ++ ++type MockRequest = { ++ headers: Headers; ++ url: string; ++ json: () => Promise; ++}; ++ ++function buildRequest(body: unknown, token: string): MockRequest { ++ return { ++ headers: new Headers({ authorization: `Bearer ${token}` }), ++ url: 'https://cherry.test/api/automation', ++ json: async () => body, ++ }; ++} ++ ++function asRecord(value: unknown): Record { ++ assert.equal(typeof value, 'object'); ++ assert.notEqual(value, null); ++ return value as Record; ++} ++ ++async function run(): Promise { ++ const token = 'automation-test-token'; ++ process.env['CHERRY_AUTOMATION_TOKEN'] = token; ++ process.env['GITHUB_TOKEN'] = 'github-test-token'; ++ ++ const originalFetch = globalThis.fetch; ++ let fetchCalls = 0; ++ globalThis.fetch = async () => { ++ fetchCalls += 1; ++ return new Response(JSON.stringify({ id: `status-${fetchCalls}` }), { status: 201 }); ++ }; ++ ++ try { ++ const classifyRoute = await import('../../app/api/automation/classify/pr/route.js'); ++ const eventsRoute = await import('../../app/api/automation/events/route.js'); ++ const replayRoute = await import('../../app/api/automation/replay/route.js'); ++ const simulationRoute = await import( ++ '../../app/api/automation/simulation-snapshots/compare/route.js' ++ ); ++ const githubStatusRoute = await import('../../app/api/automation/statuses/github/route.js'); ++ const githubStatusRetryRoute = await import( ++ '../../app/api/automation/statuses/github/retry/route.js' ++ ); ++ const statusesRoute = await import('../../app/api/automation/statuses/route.js'); ++ ++ const classifyResponse = await classifyRoute.POST( ++ buildRequest( ++ { ++ repo: 'div0rce/cherry', ++ sha: 'route-sha', ++ prNumber: 333, ++ title: 'API change without docs', ++ body: '', ++ labels: [], ++ files: [{ filename: 'app/api/scan/route.ts', status: 'modified' }], ++ sourceWorkflow: 'route-test', ++ }, ++ token ++ ) as never ++ ); ++ assert.equal(classifyResponse.status, 200); ++ const classifyBody = asRecord(await classifyResponse.json()); ++ assert.equal(classifyBody['ok'], true); ++ const automationEventId = classifyBody['automationEventId']; ++ const outputHash = classifyBody['outputHash']; ++ assert.equal(typeof automationEventId, 'string'); ++ assert.equal(typeof outputHash, 'string'); ++ const classifierOutput = asRecord(classifyBody['classifierOutput']); ++ assert.equal(Object.prototype.hasOwnProperty.call(classifierOutput, 'outputHash'), false); ++ ++ const replayResponse = await replayRoute.POST( ++ buildRequest( ++ { ++ automationEventId, ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ }, ++ token ++ ) as never ++ ); ++ assert.equal(replayResponse.status, 200); ++ const replayBody = asRecord(await replayResponse.json()); ++ assert.equal(replayBody['matches'], true); ++ assert.equal(replayBody['outputHash'], outputHash); ++ ++ const eventIngestBody = { ++ repo: 'div0rce/cherry', ++ sha: 'route-event-sha', ++ event: 'manual.test', ++ source: 'manual', ++ workflow: 'route-test', ++ status: 'accepted', ++ idempotencyKey: 'route-event-conflict', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ rawPayload: {}, ++ normalizedEvent: { ++ event: 'manual.test', ++ source: 'manual', ++ repo: 'div0rce/cherry', ++ timestamp: '1970-01-01T00:00:00.000Z', ++ payload: {}, ++ }, ++ classifierOutput: { value: 1 }, ++ }; ++ const eventIngestResponse = await eventsRoute.POST( ++ buildRequest(eventIngestBody, token) as never ++ ); ++ assert.equal(eventIngestResponse.status, 200); ++ const eventConflictResponse = await eventsRoute.POST( ++ buildRequest( ++ { ++ ...eventIngestBody, ++ classifierOutput: { value: 2 }, ++ }, ++ token ++ ) as never ++ ); ++ assert.equal(eventConflictResponse.status, 409); ++ assert.deepEqual(await eventConflictResponse.json(), { ++ error: 'automation_event_idempotency_conflict', ++ }); ++ ++ const invalidStatusResponse = await githubStatusRoute.POST( ++ buildRequest( ++ { ++ repo: 'div0rce/cherry', ++ sha: 'route-sha', ++ context: 'cherry/not-allowed', ++ state: 'failure', ++ description: 'Invalid context', ++ sourceWorkflow: 'route-test', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash, ++ }, ++ token ++ ) as never ++ ); ++ assert.equal(invalidStatusResponse.status, 400); ++ ++ const statusResponse = await githubStatusRoute.POST( ++ buildRequest( ++ { ++ repo: 'div0rce/cherry', ++ sha: 'route-sha', ++ context: 'cherry/docs-drift', ++ state: 'failure', ++ description: 'Docs drift detected.', ++ targetUrl: 'https://example.com/automation/status/route', ++ sourceWorkflow: 'route-test', ++ automationEventId, ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash, ++ }, ++ token ++ ) as never ++ ); ++ assert.equal(statusResponse.status, 200); ++ const statusBody = asRecord(await statusResponse.json()); ++ assert.equal(statusBody['posted'], true); ++ ++ const duplicateStatusResponse = await githubStatusRoute.POST( ++ buildRequest( ++ { ++ repo: 'div0rce/cherry', ++ sha: 'route-sha', ++ context: 'cherry/docs-drift', ++ state: 'success', ++ description: 'Changed fields should not create another status.', ++ targetUrl: 'https://example.com/api/debts/123/mutate', ++ sourceWorkflow: 'route-test', ++ automationEventId, ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash, ++ }, ++ token ++ ) as never ++ ); ++ assert.equal(duplicateStatusResponse.status, 200); ++ const duplicateStatusBody = asRecord(await duplicateStatusResponse.json()); ++ assert.equal(duplicateStatusBody['posted'], false); ++ assert.equal(duplicateStatusBody['idempotent'], true); ++ assert.equal(duplicateStatusBody['statusCheckId'], statusBody['statusCheckId']); ++ assert.equal(fetchCalls, 1); ++ ++ const retryResponse = await githubStatusRetryRoute.POST( ++ buildRequest({ id: statusBody['statusCheckId'] }, token) as never ++ ); ++ assert.equal(retryResponse.status, 200); ++ const retryBody = asRecord(await retryResponse.json()); ++ assert.equal(retryBody['retried'], true); ++ const retriedStatus = asRecord(retryBody['statusCheck']); ++ assert.equal(retriedStatus['id'], statusBody['statusCheckId']); ++ assert.equal(fetchCalls, 2); ++ ++ const retryByKeyResponse = await githubStatusRetryRoute.POST( ++ buildRequest( ++ { statusIdempotencyKey: String(retriedStatus['statusIdempotencyKey']) }, ++ token ++ ) as never ++ ); ++ assert.equal(retryByKeyResponse.status, 200); ++ assert.equal(fetchCalls, 3); ++ ++ const retryMissingResponse = await githubStatusRetryRoute.POST( ++ buildRequest({ id: 'missing-status-check' }, token) as never ++ ); ++ assert.equal(retryMissingResponse.status, 404); ++ ++ const auditResponse = await statusesRoute.GET({ ++ headers: new Headers({ authorization: `Bearer ${token}` }), ++ url: 'https://cherry.test/api/automation/statuses?repo=div0rce/cherry&sha=route-sha&context=cherry/docs-drift', ++ json: async () => ({}), ++ } as never); ++ assert.equal(auditResponse.status, 200); ++ const auditBody = asRecord(await auditResponse.json()); ++ const statuses = auditBody['statuses']; ++ assert.ok(Array.isArray(statuses)); ++ assert.equal(statuses.length, 1); ++ const firstStatus = asRecord(statuses[0]); ++ assert.equal(firstStatus['context'], 'cherry/docs-drift'); ++ assert.equal(firstStatus['targetUrl'], 'https://example.com/automation/status/route'); ++ assert.equal(firstStatus['automationEventId'], automationEventId); ++ ++ const forbiddenRetryStatus = await prisma.automationStatusCheck.create({ ++ data: { ++ repo: 'div0rce/cherry', ++ sha: 'route-forbidden-retry', ++ context: 'cherry/risk-gate', ++ state: 'failure', ++ description: 'Forbidden retry target.', ++ targetUrl: 'https://example.com/api/debts/123/mutate', ++ sourceWorkflow: 'route-test', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash: 'route-forbidden-retry-hash', ++ statusIdempotencyKey: 'route-forbidden-retry-key', ++ githubResponse: {}, ++ }, ++ }); ++ const forbiddenRetryResponse = await githubStatusRetryRoute.POST( ++ buildRequest({ id: forbiddenRetryStatus.id }, token) as never ++ ); ++ assert.equal(forbiddenRetryResponse.status, 400); ++ ++ const unsupportedRetryStatus = await prisma.automationStatusCheck.create({ ++ data: { ++ repo: 'div0rce/cherry', ++ sha: 'route-unsupported-retry', ++ context: 'cherry/not-allowed', ++ state: 'failure', ++ description: 'Unsupported retry context.', ++ targetUrl: 'https://example.com/automation/status/unsupported', ++ sourceWorkflow: 'route-test', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash: 'route-unsupported-retry-hash', ++ statusIdempotencyKey: 'route-unsupported-retry-key', ++ githubResponse: {}, ++ }, ++ }); ++ const unsupportedRetryResponse = await githubStatusRetryRoute.POST( ++ buildRequest({ id: unsupportedRetryStatus.id }, token) as never ++ ); ++ assert.equal(unsupportedRetryResponse.status, 400); ++ ++ const simulationBody = { ++ repo: 'div0rce/cherry', ++ scopeKey: 'route-simulation', ++ runId: 'route-run', ++ sourceWorkflow: 'route-test', ++ snapshot: { ++ score: 80, ++ allocation: { cardA: 10_000 }, ++ strategy: 'minimum', ++ runwayDays: 30, ++ viableCandidateCount: 2, ++ }, ++ }; ++ const simulationResponse = await simulationRoute.POST( ++ buildRequest(simulationBody, token) as never ++ ); ++ assert.equal(simulationResponse.status, 200); ++ const simulationDuplicateConflictResponse = await simulationRoute.POST( ++ buildRequest( ++ { ++ ...simulationBody, ++ snapshot: { ++ score: 10, ++ allocation: { cardA: 100 }, ++ strategy: 'changed', ++ runwayDays: 1, ++ viableCandidateCount: 0, ++ }, ++ }, ++ token ++ ) as never ++ ); ++ assert.equal(simulationDuplicateConflictResponse.status, 409); ++ assert.deepEqual(await simulationDuplicateConflictResponse.json(), { ++ error: 'simulation_snapshot_idempotency_conflict', ++ }); ++ } finally { ++ globalThis.fetch = originalFetch; ++ await prisma.automationStatusCheck.deleteMany({ where: {} }); ++ await prisma.simulationAutomationSnapshot.deleteMany({ where: {} }); ++ await prisma.automationEvent.deleteMany({ where: {} }); ++ } ++ ++ console.warn('automation API routes: ok'); ++} ++ ++run().catch((error: unknown) => { ++ console.error(error); ++ process.exit(1); ++}); +diff --git a/tests/node/automation-boundary.test.ts b/tests/node/automation-boundary.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..27caddbe09136deb4b4559906f877fcc82e780e3 +--- /dev/null ++++ b/tests/node/automation-boundary.test.ts +@@ -0,0 +1,81 @@ ++import * as assert from 'node:assert/strict'; ++import * as fs from 'node:fs'; ++import * as path from 'node:path'; ++ ++const repoRoot = process.cwd(); ++const scanRoots = [ ++ path.join(repoRoot, 'lib', 'automation'), ++ path.join(repoRoot, 'app', 'api', 'automation'), ++ path.join(repoRoot, 'lib', 'adapters', 'runtime'), ++]; ++ ++function walk(dir: string): string[] { ++ const entries = fs.readdirSync(dir, { withFileTypes: true }); ++ const files: string[] = []; ++ for (const entry of entries) { ++ const full = path.join(dir, entry.name); ++ if (entry.isDirectory()) { ++ files.push(...walk(full)); ++ } else if (entry.isFile() && /\.[cm]?ts$/.test(entry.name)) { ++ files.push(full); ++ } ++ } ++ return files; ++} ++ ++const forbiddenImports = [ ++ /from ['"][^'"]*\/engine(?:\/|\.js|['"])/, ++ /from ['"][^'"]*\/authority(?:\/|\.js|['"])/, ++ /from ['"][^'"]*lib\/engine/, ++ /from ['"][^'"]*lib\/authority/, ++ /import\(['"][^'"]*\/engine/, ++ /import\(['"][^'"]*\/authority/, ++]; ++ ++const forbiddenFinanceMutationPatterns = [ ++ /\bprisma\.(sessions?|session|ledgers?|ledger|buckets?|bucket|cards?|card|payments?|payment|debts?|debt)\.(create|createMany|update|updateMany|upsert|delete|deleteMany)\b/i, ++ /\/api\/(sessions?|ledgers?|buckets?|payments?|cards?)(\/|$)/i, ++ /\/api\/debts?(\/.*)?\/mutate\b/i, ++ /\b(Session|Ledger|Bucket|Card|Payment)\.(create|createMany|update|updateMany|upsert|delete|deleteMany)\b/, ++]; ++ ++const forbiddenDependencySpecifiers = [ ++ /(?:^|\/)(sessions?|ledgers?|buckets?|cards?|payments?|debts?)(?:\/|\.js|\.ts|$)/i, ++ /buckets-runtime/i, ++]; ++ ++const importSpecifierPattern = /import(?:\s+type)?(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g; ++ ++const violations: string[] = []; ++for (const root of scanRoots) { ++ for (const file of walk(root)) { ++ if ( ++ root.endsWith(path.join('lib', 'adapters', 'runtime')) && ++ !/^automation-.*\.[cm]?ts$/.test(path.basename(file)) ++ ) { ++ continue; ++ } ++ const source = fs.readFileSync(file, 'utf8'); ++ if (forbiddenImports.some((pattern) => pattern.test(source))) { ++ violations.push(`${path.relative(repoRoot, file)} imports engine/authority`); ++ } ++ if (forbiddenFinanceMutationPatterns.some((pattern) => pattern.test(source))) { ++ violations.push(`${path.relative(repoRoot, file)} mutates or calls finance truth surface`); ++ } ++ for (const match of source.matchAll(importSpecifierPattern)) { ++ const specifier = match[1] ?? ''; ++ if (forbiddenDependencySpecifiers.some((pattern) => pattern.test(specifier))) { ++ violations.push( ++ `${path.relative(repoRoot, file)} imports forbidden finance dependency ${specifier}` ++ ); ++ } ++ } ++ } ++} ++ ++assert.deepEqual( ++ violations, ++ [], ++ 'automation code must not import engine/authority or mutate finance truth surfaces' ++); ++console.warn('automation boundary: ok'); +diff --git a/tests/node/automation-classifiers.test.ts b/tests/node/automation-classifiers.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..81a73d7ba6e6c2d96cc7d3aa2c003d8252120a30 +--- /dev/null ++++ b/tests/node/automation-classifiers.test.ts +@@ -0,0 +1,242 @@ ++import * as assert from 'node:assert/strict'; ++import * as fs from 'node:fs'; ++import * as path from 'node:path'; ++import { classifyDocsDrift } from '../../lib/automation/classifiers/docs-drift.js'; ++import { classifyForbiddenChange } from '../../lib/automation/classifiers/forbidden-change.js'; ++import { classifyPrAutomation } from '../../lib/automation/classifiers/pr.js'; ++import { classifyPrRisk } from '../../lib/automation/classifiers/pr-risk.js'; ++import { classifySimulationDrift } from '../../lib/automation/classifiers/simulation-drift.js'; ++import { ++ DOCS_DRIFT_CLASSIFIER_VERSION, ++ FORBIDDEN_CHANGE_CLASSIFIER_VERSION, ++ PR_AUTOMATION_CLASSIFIER_VERSION, ++ PR_RISK_CLASSIFIER_VERSION, ++ SIMULATION_DRIFT_CLASSIFIER_VERSION, ++ type AutomationFileChange, ++} from '../../lib/automation/classifiers/types.js'; ++ ++const repoRoot = process.cwd(); ++ ++function readFiles(dir: string): string[] { ++ const out: string[] = []; ++ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { ++ const full = path.join(dir, entry.name); ++ if (entry.isDirectory()) { ++ out.push(...readFiles(full)); ++ } else if (entry.isFile() && /\.[cm]?ts$/.test(entry.name)) { ++ out.push(full); ++ } ++ } ++ return out; ++} ++ ++function runVersionConstantTests(): void { ++ assert.equal(PR_RISK_CLASSIFIER_VERSION, 'pr-risk@1'); ++ assert.equal(FORBIDDEN_CHANGE_CLASSIFIER_VERSION, 'forbidden-change@1'); ++ assert.equal(DOCS_DRIFT_CLASSIFIER_VERSION, 'docs-drift@1'); ++ assert.equal(SIMULATION_DRIFT_CLASSIFIER_VERSION, 'simulation-drift@1'); ++ assert.equal( ++ PR_AUTOMATION_CLASSIFIER_VERSION, ++ 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)' ++ ); ++ const automationSources = readFiles(path.join(repoRoot, 'lib', 'automation')); ++ const globalVersionReferences = automationSources.filter((file) => ++ fs.readFileSync(file, 'utf8').includes('automation-classifiers-v1') ++ ); ++ assert.deepEqual(globalVersionReferences, [], 'global automation classifier version is forbidden'); ++} ++ ++function runPrClassifierTests(): void { ++ const files: AutomationFileChange[] = [ ++ { ++ filename: 'lib/engine/solver.ts', ++ status: 'modified', ++ additions: 500, ++ deletions: 400, ++ patch: '+export const changed = true;', ++ }, ++ { ++ filename: 'prisma/schema.prisma', ++ status: 'modified', ++ additions: 10, ++ deletions: 2, ++ }, ++ ]; ++ const input = { ++ repo: 'div0rce/cherry', ++ sha: 'abc123', ++ prNumber: 42, ++ title: 'change engine solver', ++ body: 'No issue link', ++ labels: [], ++ files, ++ }; ++ const first = classifyPrAutomation(input); ++ const second = classifyPrAutomation(input); ++ assert.deepEqual(second, first); ++ assert.equal(first.classifierVersion, PR_AUTOMATION_CLASSIFIER_VERSION); ++ assert.equal(first.risk.classifierVersion, PR_RISK_CLASSIFIER_VERSION); ++ assert.equal( ++ first.forbiddenChange.classifierVersion, ++ FORBIDDEN_CHANGE_CLASSIFIER_VERSION ++ ); ++ assert.equal(first.docsDrift.classifierVersion, DOCS_DRIFT_CLASSIFIER_VERSION); ++ assert.equal(Object.prototype.hasOwnProperty.call(first, 'outputHash'), false); ++ assert.equal(first.risk.level, 'high'); ++ assert.equal(first.risk.statusRequest.state, 'failure'); ++ assert.equal(first.docsDrift.drift, true); ++ ++ const accepted = classifyPrRisk({ ...input, labels: ['risk-accepted'] }); ++ assert.equal(accepted.statusRequest.state, 'success'); ++} ++ ++function runForbiddenChangeTests(): void { ++ const result = classifyForbiddenChange({ ++ files: [ ++ { ++ filename: 'tests/foo.test.ts', ++ status: 'modified', ++ patch: '+it.skip(\"temporarily skips\", () => {});', ++ }, ++ { ++ filename: '.env.local', ++ status: 'modified', ++ }, ++ ], ++ }); ++ assert.equal(result.forbidden, true); ++ assert.deepEqual(result.labels, ['blocked-forbidden-change', 'needs-human-review']); ++ assert.ok(result.violations.some((violation) => violation.startsWith('env_diff'))); ++ assert.ok( ++ result.violations.some((violation) => violation.startsWith('skipped_test_added')) ++ ); ++ assert.equal(result.statusRequest.state, 'failure'); ++ assert.equal(result.classifierVersion, FORBIDDEN_CHANGE_CLASSIFIER_VERSION); ++} ++ ++function runDocsDriftTests(): void { ++ const drift = classifyDocsDrift({ ++ files: [{ filename: 'app/api/scan/route.ts', status: 'modified' }], ++ }); ++ assert.equal(drift.drift, true); ++ assert.deepEqual(drift.domains, ['api']); ++ assert.deepEqual(drift.labels, ['docs-drift', 'needs-human-review']); ++ ++ const clean = classifyDocsDrift({ ++ files: [ ++ { filename: 'app/api/scan/route.ts', status: 'modified' }, ++ { filename: 'docs/api/scan.md', status: 'modified' }, ++ ], ++ }); ++ assert.equal(clean.drift, false); ++ ++ const engineUnrelatedMd = classifyDocsDrift({ ++ files: [ ++ { filename: 'lib/engine/solver.ts', status: 'modified' }, ++ { filename: 'docs/api/update.md', status: 'modified' }, ++ ], ++ }); ++ assert.equal(engineUnrelatedMd.drift, true); ++ ++ const engineDocs = classifyDocsDrift({ ++ files: [ ++ { filename: 'lib/engine/solver.ts', status: 'modified' }, ++ { filename: 'docs/engine/update.md', status: 'modified' }, ++ ], ++ }); ++ assert.equal(engineDocs.drift, false); ++ ++ const apiWrongDocs = classifyDocsDrift({ ++ files: [ ++ { filename: 'app/api/scan/route.ts', status: 'modified' }, ++ { filename: 'docs/engine/update.md', status: 'modified' }, ++ ], ++ }); ++ assert.equal(apiWrongDocs.drift, true); ++ ++ const schemaDocs = classifyDocsDrift({ ++ files: [ ++ { filename: 'prisma/schema.prisma', status: 'modified' }, ++ { filename: 'docs/database/prisma.md', status: 'modified' }, ++ ], ++ }); ++ assert.equal(schemaDocs.drift, false); ++ ++ const schemaReadmeOnly = classifyDocsDrift({ ++ files: [ ++ { filename: 'prisma/schema.prisma', status: 'modified' }, ++ { filename: 'README.md', status: 'modified' }, ++ ], ++ }); ++ assert.equal(schemaReadmeOnly.drift, true); ++ ++ const multiDomainPartialDocs = classifyDocsDrift({ ++ files: [ ++ { filename: 'lib/engine/solver.ts', status: 'modified' }, ++ { filename: 'app/api/scan/route.ts', status: 'modified' }, ++ { filename: 'docs/engine/update.md', status: 'modified' }, ++ ], ++ }); ++ assert.equal(multiDomainPartialDocs.drift, true); ++ assert.deepEqual(multiDomainPartialDocs.domains, ['engine', 'api']); ++ ++ const multiDomainCompleteDocs = classifyDocsDrift({ ++ files: [ ++ { filename: 'lib/engine/solver.ts', status: 'modified' }, ++ { filename: 'app/api/scan/route.ts', status: 'modified' }, ++ { filename: 'docs/engine/update.md', status: 'modified' }, ++ { filename: 'docs/api/scan.md', status: 'modified' }, ++ ], ++ }); ++ assert.equal(multiDomainCompleteDocs.drift, false); ++ assert.equal(drift.classifierVersion, DOCS_DRIFT_CLASSIFIER_VERSION); ++} ++ ++function runSimulationDriftTests(): void { ++ const result = classifySimulationDrift( ++ { ++ score: 90, ++ allocation: { cardA: 10_000 }, ++ strategy: 'pay_minimum', ++ runwayDays: 30, ++ viableCandidateCount: 2, ++ }, ++ { ++ score: 70, ++ allocation: { cardA: 1_000 }, ++ strategy: 'pay_aggressive', ++ runwayDays: 5, ++ viableCandidateCount: 0, ++ } ++ ); ++ assert.equal(result.drift, true); ++ assert.ok(result.reasons.includes('strategy_flip')); ++ assert.ok(result.reasons.includes('runway_collapse')); ++ assert.ok(result.reasons.includes('empty_viable_candidates')); ++ assert.equal(result.classifierVersion, SIMULATION_DRIFT_CLASSIFIER_VERSION); ++} ++ ++function runBranchProtectionDocsTest(): void { ++ const docPath = path.join(repoRoot, 'docs', 'automation', 'branch-protection.md'); ++ const doc = fs.readFileSync(docPath, 'utf8'); ++ for (const context of [ ++ 'cherry/forbidden-change', ++ 'cherry/docs-drift', ++ 'cherry/risk-gate', ++ 'cherry/openclaw-policy', ++ ]) { ++ assert.ok(doc.includes(context), `branch protection docs must list ${context}`); ++ } ++ assert.ok( ++ doc.includes('Without branch protection, Cherry statuses are advisory only.'), ++ 'branch protection docs must state advisory-only behavior without branch protection' ++ ); ++} ++ ++runVersionConstantTests(); ++runPrClassifierTests(); ++runForbiddenChangeTests(); ++runDocsDriftTests(); ++runSimulationDriftTests(); ++runBranchProtectionDocsTest(); ++console.warn('automation classifiers: ok'); +diff --git a/tests/node/automation-services.test.ts b/tests/node/automation-services.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..3d455b809cf61eb16af73b210e4012d33b1ac667 +--- /dev/null ++++ b/tests/node/automation-services.test.ts +@@ -0,0 +1,462 @@ ++import * as assert from 'node:assert/strict'; ++import { prisma } from '../../lib/prisma.js'; ++import { ++ classifyAndStorePrAutomation, ++ compareAndStoreSimulationSnapshot, ++ replayAutomationEvent, ++ storeAutomationEvent, ++ outputHashFor, ++} from '../../lib/automation/events.js'; ++import { createAutomationEventRecord } from '../../lib/adapters/runtime/automation-events.prisma.js'; ++import { createGithubStatusCheckRecord } from '../../lib/adapters/runtime/automation-github-status.prisma.js'; ++import { classifyPrAutomation } from '../../lib/automation/classifiers/pr.js'; ++import { ++ buildStatusIdempotencyKey, ++ listLatestGithubStatuses, ++ postGithubStatus, ++ retryGithubStatus, ++} from '../../lib/automation/github-status.js'; ++import { PR_AUTOMATION_CLASSIFIER_VERSION } from '../../lib/automation/classifiers/types.js'; ++ ++async function runReplayHashTest(): Promise { ++ const result = await classifyAndStorePrAutomation({ ++ repo: 'div0rce/cherry', ++ sha: 'sha-replay', ++ prNumber: 101, ++ title: 'touch api without docs', ++ body: '', ++ labels: [], ++ files: [{ filename: 'app/api/scan/route.ts', status: 'modified' }], ++ sourceWorkflow: 'test', ++ }); ++ const replay = await replayAutomationEvent( ++ result.event.id, ++ PR_AUTOMATION_CLASSIFIER_VERSION ++ ); ++ assert.ok(replay); ++ assert.equal(replay.matches, true); ++ assert.equal(replay.outputHash, result.event.outputHash); ++ ++ const replayInput = { ++ repo: 'div0rce/cherry', ++ sha: 'sha-replay-direct', ++ prNumber: 102, ++ title: 'touch api without docs', ++ body: '', ++ labels: [], ++ files: [{ filename: 'app/api/scan/route.ts', status: 'modified' }], ++ }; ++ const recomputed = classifyPrAutomation(replayInput); ++ const directEvent = await createAutomationEventRecord({ ++ repo: replayInput.repo, ++ sha: replayInput.sha, ++ event: 'github.pull_request', ++ source: 'github', ++ workflow: 'test', ++ status: 'accepted', ++ idempotencyKey: 'direct-replay-corrupt-output', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash: outputHashFor(recomputed), ++ rawPayload: replayInput, ++ normalizedEvent: { ++ event: 'github.pull_request', ++ source: 'github', ++ repo: replayInput.repo, ++ timestamp: '1970-01-01T00:00:00.000Z', ++ payload: { ++ prNumber: replayInput.prNumber, ++ title: replayInput.title, ++ body: replayInput.body, ++ labels: replayInput.labels, ++ files: replayInput.files, ++ }, ++ }, ++ classifierOutput: { stale: true }, ++ prNumber: replayInput.prNumber, ++ }); ++ const directReplay = await replayAutomationEvent( ++ directEvent.id, ++ PR_AUTOMATION_CLASSIFIER_VERSION ++ ); ++ assert.ok(directReplay); ++ assert.equal(directReplay.matches, true); ++ assert.deepEqual(directReplay.replayedOutput, recomputed); ++ ++ const mismatchEvent = await createAutomationEventRecord({ ++ repo: replayInput.repo, ++ sha: 'sha-replay-mismatch', ++ event: 'github.pull_request', ++ source: 'github', ++ workflow: 'test', ++ status: 'accepted', ++ idempotencyKey: 'direct-replay-mismatch-output', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash: 'not-the-recomputed-hash', ++ rawPayload: replayInput, ++ normalizedEvent: directEvent.normalizedEvent, ++ classifierOutput: recomputed, ++ prNumber: replayInput.prNumber, ++ }); ++ const mismatchReplay = await replayAutomationEvent( ++ mismatchEvent.id, ++ PR_AUTOMATION_CLASSIFIER_VERSION ++ ); ++ assert.ok(mismatchReplay); ++ assert.equal(mismatchReplay.matches, false); ++ assert.equal(mismatchReplay.reason, 'output_hash_mismatch'); ++ ++ const wrongVersionReplay = await replayAutomationEvent( ++ directEvent.id, ++ 'pr-automation@0' ++ ); ++ assert.ok(wrongVersionReplay); ++ assert.equal(wrongVersionReplay.matches, false); ++ assert.equal(wrongVersionReplay.reason, 'classifier_version_mismatch'); ++ ++ const unsupportedEvent = await createAutomationEventRecord({ ++ repo: replayInput.repo, ++ sha: 'sha-replay-unsupported', ++ event: 'manual.test', ++ source: 'manual', ++ workflow: 'test', ++ status: 'accepted', ++ idempotencyKey: 'direct-replay-unsupported-event', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash: outputHashFor({ unsupported: true }), ++ rawPayload: {}, ++ normalizedEvent: { ++ event: 'manual.test', ++ source: 'manual', ++ repo: replayInput.repo, ++ timestamp: '1970-01-01T00:00:00.000Z', ++ payload: {}, ++ }, ++ classifierOutput: { unsupported: true }, ++ }); ++ const unsupportedReplay = await replayAutomationEvent( ++ unsupportedEvent.id, ++ PR_AUTOMATION_CLASSIFIER_VERSION ++ ); ++ assert.ok(unsupportedReplay); ++ assert.equal(unsupportedReplay.matches, false); ++ assert.equal(unsupportedReplay.reason, 'unsupported_replay_event'); ++ ++ const invalidEvent = await createAutomationEventRecord({ ++ repo: replayInput.repo, ++ sha: 'sha-replay-invalid', ++ event: 'github.pull_request', ++ source: 'github', ++ workflow: 'test', ++ status: 'accepted', ++ idempotencyKey: 'direct-replay-invalid-input', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash: outputHashFor({ invalid: true }), ++ rawPayload: {}, ++ normalizedEvent: { ++ event: 'github.pull_request', ++ source: 'github', ++ repo: replayInput.repo, ++ timestamp: '1970-01-01T00:00:00.000Z', ++ payload: { prNumber: 404 }, ++ }, ++ classifierOutput: { invalid: true }, ++ }); ++ const invalidReplay = await replayAutomationEvent( ++ invalidEvent.id, ++ PR_AUTOMATION_CLASSIFIER_VERSION ++ ); ++ assert.ok(invalidReplay); ++ assert.equal(invalidReplay.matches, false); ++ assert.equal(invalidReplay.reason, 'invalid_replay_input'); ++} ++ ++async function runAutomationEventIdempotencyConflictTest(): Promise { ++ const base = { ++ repo: 'div0rce/cherry', ++ event: 'manual.test', ++ source: 'manual', ++ workflow: 'test', ++ status: 'accepted', ++ idempotencyKey: 'event-conflict-key', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ rawPayload: {}, ++ normalizedEvent: { ++ event: 'manual.test', ++ source: 'manual', ++ repo: 'div0rce/cherry', ++ timestamp: '1970-01-01T00:00:00.000Z', ++ payload: {}, ++ }, ++ classifierOutput: { value: 1 }, ++ }; ++ const first = await storeAutomationEvent(base); ++ const duplicate = await storeAutomationEvent({ ...base }); ++ assert.equal(first.created, true); ++ assert.equal(duplicate.created, false); ++ await assert.rejects( ++ storeAutomationEvent({ ...base, classifierOutput: { value: 2 } }), ++ /automation_event_idempotency_conflict/ ++ ); ++} ++ ++async function runStatusIdempotencyTest(): Promise { ++ const originalFetch = globalThis.fetch; ++ let calls = 0; ++ globalThis.fetch = async () => { ++ calls += 1; ++ return new Response(JSON.stringify({ id: calls }), { status: 201 }); ++ }; ++ ++ try { ++ const linkedEvent = await storeAutomationEvent({ ++ repo: 'div0rce/cherry', ++ sha: 'sha-status', ++ event: 'manual.status', ++ source: 'manual', ++ workflow: 'test', ++ status: 'accepted', ++ idempotencyKey: 'status-linked-event', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ rawPayload: {}, ++ normalizedEvent: { ++ event: 'manual.status', ++ source: 'manual', ++ repo: 'div0rce/cherry', ++ timestamp: '1970-01-01T00:00:00.000Z', ++ payload: {}, ++ }, ++ classifierOutput: { value: 'status' }, ++ }); ++ const input = { ++ repo: 'div0rce/cherry', ++ sha: 'sha-status', ++ context: 'cherry/forbidden-change' as const, ++ state: 'failure' as const, ++ description: 'Forbidden change detected.', ++ targetUrl: 'https://example.com/status/first', ++ sourceWorkflow: 'test', ++ automationEventId: linkedEvent.event.id, ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash: 'hash-status', ++ }; ++ const first = await postGithubStatus(input, { githubToken: 'token' }); ++ assert.equal( ++ buildStatusIdempotencyKey({ ++ ...input, ++ state: 'success', ++ description: 'Changed description should not affect status identity.', ++ targetUrl: 'https://example.com/status/changed', ++ }), ++ buildStatusIdempotencyKey(input) ++ ); ++ const second = await postGithubStatus( ++ { ++ ...input, ++ state: 'success', ++ description: 'Changed description should not change status identity.', ++ targetUrl: 'https://example.com/status/second', ++ }, ++ { githubToken: 'token' } ++ ); ++ assert.equal(first.posted, true); ++ assert.equal(second.posted, false); ++ assert.equal(second.idempotent, true); ++ assert.equal(calls, 1); ++ assert.equal(first.statusCheck.statusIdempotencyKey, buildStatusIdempotencyKey(input)); ++ assert.equal(first.statusCheck.targetUrl, 'https://example.com/status/first'); ++ assert.equal(first.statusCheck.automationEventId, linkedEvent.event.id); ++ assert.equal(second.statusCheck.id, first.statusCheck.id); ++ const countBeforeRetry = await prisma.automationStatusCheck.count({ ++ where: { statusIdempotencyKey: first.statusCheck.statusIdempotencyKey }, ++ }); ++ const retry = await retryGithubStatus( ++ { id: first.statusCheck.id }, ++ { githubToken: 'token' } ++ ); ++ assert.equal(retry.retried, true); ++ assert.equal(retry.statusCheck.id, first.statusCheck.id); ++ assert.equal(calls, 2); ++ const retryByKey = await retryGithubStatus( ++ { statusIdempotencyKey: first.statusCheck.statusIdempotencyKey }, ++ { githubToken: 'token' } ++ ); ++ assert.equal(retryByKey.statusCheck.id, first.statusCheck.id); ++ assert.equal(calls, 3); ++ const countAfterRetry = await prisma.automationStatusCheck.count({ ++ where: { statusIdempotencyKey: first.statusCheck.statusIdempotencyKey }, ++ }); ++ assert.equal(countAfterRetry, countBeforeRetry); ++ await assert.rejects( ++ retryGithubStatus({ id: 'missing-status-check' }, { githubToken: 'token' }), ++ /github_status_not_found/ ++ ); ++ ++ const unsupportedStatus = await createGithubStatusCheckRecord({ ++ repo: 'div0rce/cherry', ++ sha: 'sha-retry-unsupported-context', ++ context: 'cherry/not-allowed', ++ state: 'failure', ++ description: 'Unsupported retry context.', ++ targetUrl: 'https://example.com/status/unsupported', ++ sourceWorkflow: 'test', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash: 'hash-retry-unsupported', ++ statusIdempotencyKey: 'retry-unsupported-context', ++ }); ++ await assert.rejects( ++ retryGithubStatus({ id: unsupportedStatus.id }, { githubToken: 'token' }), ++ /Unsupported GitHub status context/ ++ ); ++ ++ const latest = await listLatestGithubStatuses({ ++ repo: 'div0rce/cherry', ++ sha: 'sha-status', ++ context: 'cherry/forbidden-change', ++ }); ++ assert.equal(latest.length, 1); ++ assert.equal(latest[0]?.context, 'cherry/forbidden-change'); ++ } finally { ++ globalThis.fetch = originalFetch; ++ } ++} ++ ++async function runStatusRejectsForbiddenTargetUrl(): Promise { ++ await assert.rejects( ++ postGithubStatus( ++ { ++ repo: 'div0rce/cherry', ++ sha: 'sha-forbidden-url', ++ context: 'cherry/risk-gate', ++ state: 'failure', ++ description: 'Bad target URL.', ++ targetUrl: 'https://example.com/api/ledger/write', ++ sourceWorkflow: 'test', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash: 'hash-url', ++ }, ++ { githubToken: 'token' } ++ ), ++ /forbidden Cherry finance endpoint/ ++ ); ++ await assert.rejects( ++ postGithubStatus( ++ { ++ repo: 'div0rce/cherry', ++ sha: 'sha-forbidden-url-debt', ++ context: 'cherry/risk-gate', ++ state: 'failure', ++ description: 'Bad target URL.', ++ targetUrl: 'https://example.com/api/debts/123/mutate', ++ sourceWorkflow: 'test', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash: 'hash-url-debt', ++ }, ++ { githubToken: 'token' } ++ ), ++ /forbidden Cherry finance endpoint/ ++ ); ++} ++ ++async function runStatusRetryRejectsForbiddenTargetUrl(): Promise { ++ const statusCheck = await createGithubStatusCheckRecord({ ++ repo: 'div0rce/cherry', ++ sha: 'sha-retry-forbidden-url', ++ context: 'cherry/risk-gate', ++ state: 'failure', ++ description: 'Bad retry target.', ++ targetUrl: 'https://example.com/api/debt/123/mutate', ++ sourceWorkflow: 'test', ++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++ outputHash: 'hash-retry-forbidden', ++ statusIdempotencyKey: 'retry-forbidden-status', ++ }); ++ await assert.rejects( ++ retryGithubStatus({ id: statusCheck.id }, { githubToken: 'token' }), ++ /forbidden Cherry finance endpoint/ ++ ); ++} ++ ++async function runSimulationSnapshotTest(): Promise { ++ const first = await compareAndStoreSimulationSnapshot({ ++ repo: 'div0rce/cherry', ++ scopeKey: 'scenario-a', ++ runId: 'run-1', ++ sourceWorkflow: 'test', ++ snapshot: { ++ score: 90, ++ allocation: { cardA: 10_000 }, ++ strategy: 'minimum', ++ runwayDays: 30, ++ viableCandidateCount: 2, ++ }, ++ }); ++ assert.equal(first.created, true); ++ assert.equal(first.comparisonOutput.drift, false); ++ ++ const second = await compareAndStoreSimulationSnapshot({ ++ repo: 'div0rce/cherry', ++ scopeKey: 'scenario-a', ++ runId: 'run-2', ++ sourceWorkflow: 'test', ++ snapshot: { ++ score: 70, ++ allocation: { cardA: 1_000 }, ++ strategy: 'aggressive', ++ runwayDays: 5, ++ viableCandidateCount: 0, ++ }, ++ }); ++ assert.equal(second.created, true); ++ assert.equal(second.comparisonOutput.drift, true); ++ ++ const duplicate = await compareAndStoreSimulationSnapshot({ ++ repo: 'div0rce/cherry', ++ scopeKey: 'scenario-a', ++ runId: 'run-2', ++ sourceWorkflow: 'test', ++ snapshot: { ++ score: 70, ++ allocation: { cardA: 1_000 }, ++ strategy: 'aggressive', ++ runwayDays: 5, ++ viableCandidateCount: 0, ++ }, ++ }); ++ assert.equal(duplicate.created, false); ++ assert.deepEqual(duplicate.comparisonOutput, second.comparisonOutput); ++ ++ await assert.rejects( ++ compareAndStoreSimulationSnapshot({ ++ repo: 'div0rce/cherry', ++ scopeKey: 'scenario-a', ++ runId: 'run-2', ++ sourceWorkflow: 'test', ++ snapshot: { ++ score: 100, ++ allocation: { cardA: 2_000 }, ++ strategy: 'changed', ++ runwayDays: 50, ++ viableCandidateCount: 3, ++ }, ++ }), ++ /simulation_snapshot_idempotency_conflict/ ++ ); ++} ++ ++async function run(): Promise { ++ await runReplayHashTest(); ++ await runAutomationEventIdempotencyConflictTest(); ++ await runStatusIdempotencyTest(); ++ await runStatusRejectsForbiddenTargetUrl(); ++ await runStatusRetryRejectsForbiddenTargetUrl(); ++ await runSimulationSnapshotTest(); ++ await prisma.automationStatusCheck.deleteMany({ where: {} }); ++ await prisma.simulationAutomationSnapshot.deleteMany({ where: {} }); ++ await prisma.automationEvent.deleteMany({ where: {} }); ++ console.warn('automation services: ok'); ++} ++ ++run().catch((error: unknown) => { ++ console.error(error); ++ process.exit(1); ++}); +diff --git a/tests/node/automation-workflows.test.ts b/tests/node/automation-workflows.test.ts +new file mode 100644 +index 0000000000000000000000000000000000000000..0210dad3ef9e7cf9d6decb78ccff72d16f6566eb +--- /dev/null ++++ b/tests/node/automation-workflows.test.ts +@@ -0,0 +1,655 @@ ++import * as assert from 'node:assert/strict'; ++import { execFileSync } from 'node:child_process'; ++import * as fs from 'node:fs'; ++import * as path from 'node:path'; ++import * as vm from 'node:vm'; ++import { z } from 'zod'; ++ ++const repoRoot = process.cwd(); ++const workflowDir = path.join(repoRoot, 'cherry-n8n-workflows'); ++const workflowZip = path.join(repoRoot, 'cherry-n8n-workflows.zip'); ++const expectedWorkflowNames = new Map([ ++ ['01_ci_failure_compression.json', 'Cherry - CI Failure Compression'], ++ ['02_openclaw_issue_router.json', 'Cherry - OpenClaw Issue Router'], ++ ['03_pr_risk_classifier.json', 'Cherry - PR Risk Classifier'], ++ ['04_forbidden_change_detector.json', 'Cherry - Forbidden Change Detector'], ++ ['05_engine_degradation_alerting.json', 'Cherry - Engine Degradation Alerting'], ++ ['06_simulation_drift_detector.json', 'Cherry - Simulation Drift Detector'], ++ ['07_release_summary_generator.json', 'Cherry - Release Summary Generator'], ++ ['08_repo_intelligence_digest.json', 'Cherry - Repo Intelligence Digest'], ++ ['09_docs_drift_detector.json', 'Cherry - Docs Drift Detector'], ++ ['10_backlog_grooming.json', 'Cherry - Backlog Grooming'], ++] as const); ++const allWorkflowFiles = [...expectedWorkflowNames.keys()].sort(); ++const prWorkflowFiles = [ ++ '03_pr_risk_classifier.json', ++ '04_forbidden_change_detector.json', ++ '09_docs_drift_detector.json', ++] as const; ++const expectedWorkflowDocs = [ ++ 'README.md', ++ 'COVERAGE_MATRIX.md', ++ 'VALIDATION_REPORT.md', ++] as const; ++const requiredCherryEndpoints = [ ++ '/api/automation/classify/pr', ++ '/api/automation/events', ++ '/api/automation/statuses/github', ++] as const; ++const forbiddenAuthorityNodeNames = new Set([ ++ 'Score Risk', ++ 'Detect Forbidden Changes', ++ 'Detect Docs Drift', ++]); ++ ++const forbiddenAuthorityPayloadPatterns = [ ++ /\briskScore\b/, ++ /\$json\.riskScore\b/, ++ /\$json\.forbidden\b/, ++ /\$json\.drift\b/, ++ /String\(\$json\.riskScore/, ++ /statusRequest\?\.context/, ++ /statusRequest\?\.state/, ++ /statusRequest\?\.description/, ++ /\?\?\s*'cherry\/risk-gate'/, ++ /\?\?\s*'cherry\/forbidden-change'/, ++ /\?\?\s*'cherry\/docs-drift'/, ++ /risk\.statusRequest/, ++ /forbiddenChange\.statusRequest/, ++ /docsDrift\.statusRequest/, ++]; ++ ++const forbiddenStatusIdentityFallbackPatterns = [ ++ /\?\?\s*'div0rce\/cherry'/, ++ /\?\?\s*'unknown-sha'/, ++ /\?\?\s*'post-cherry-status'/, ++ /\?\?\s*'pr-automation@1/, ++]; ++ ++const WorkflowSchema = z ++ .object({ ++ name: z.unknown().optional(), ++ nodes: z ++ .array( ++ z ++ .object({ ++ name: z.unknown().optional(), ++ parameters: z.unknown().optional(), ++ }) ++ .passthrough() ++ ) ++ .optional(), ++ connections: z.record(z.string(), z.unknown()).optional(), ++ settings: z.unknown().optional(), ++ }) ++ .passthrough(); ++ ++type WorkflowNode = { ++ name?: unknown; ++ id?: unknown; ++ type?: unknown; ++ typeVersion?: unknown; ++ position?: unknown; ++ parameters?: unknown; ++ disabled?: unknown; ++}; ++ ++type Workflow = z.infer; ++ ++function nodeByName(nodes: WorkflowNode[], name: string): WorkflowNode { ++ const node = nodes.find((candidate) => candidate.name === name); ++ assert.notEqual(node, undefined, `workflow must contain ${name}`); ++ return node as WorkflowNode; ++} ++ ++function connectionTargets(workflow: z.infer, source: string): string[] { ++ const connections = workflow.connections; ++ if (connections === undefined) return []; ++ const sourceConnections = connections[source]; ++ if (sourceConnections === null || typeof sourceConnections !== 'object') return []; ++ const main = (sourceConnections as Record)['main']; ++ if (!Array.isArray(main)) return []; ++ return main.flatMap((group) => { ++ if (!Array.isArray(group)) return []; ++ return group ++ .map((connection) => { ++ if (connection === null || typeof connection !== 'object') return null; ++ const node = (connection as Record)['node']; ++ return typeof node === 'string' ? node : null; ++ }) ++ .filter((node): node is string => node !== null); ++ }); ++} ++ ++function isRecord(value: unknown): value is Record { ++ return value !== null && typeof value === 'object' && Array.isArray(value) === false; ++} ++ ++async function loadWorkflow(fileName: string): Promise<{ raw: string; workflow: Workflow }> { ++ const absolutePath = path.join(workflowDir, fileName); ++ const raw = fs.readFileSync(absolutePath, 'utf8'); ++ const parsed = (await new Response(raw).json()) as unknown; ++ assert.equal(Array.isArray(parsed), false, `${fileName} must contain one workflow object`); ++ return { raw, workflow: WorkflowSchema.parse(parsed) }; ++} ++ ++function allConnectionTargets(workflow: Workflow): string[] { ++ const out: string[] = []; ++ for (const source of Object.keys(workflow.connections ?? {})) { ++ out.push(...connectionTargets(workflow, source)); ++ } ++ return out; ++} ++ ++function reachableNodes(workflow: Workflow, start: string): Set { ++ const seen = new Set(); ++ const queue = [start]; ++ while (queue.length > 0) { ++ const current = queue.shift() as string; ++ if (seen.has(current)) continue; ++ seen.add(current); ++ for (const target of connectionTargets(workflow, current)) { ++ if (!seen.has(target)) queue.push(target); ++ } ++ } ++ return seen; ++} ++ ++function headerValue(node: WorkflowNode, headerName: string): string { ++ const parameters = isRecord(node.parameters) ? node.parameters : {}; ++ const headerParameters = parameters['headerParameters']; ++ if (!isRecord(headerParameters)) return ''; ++ const parametersArray = headerParameters['parameters']; ++ if (!Array.isArray(parametersArray)) return ''; ++ const entries: unknown[] = parametersArray; ++ const match: unknown = entries.find( ++ (entry) => ++ isRecord(entry) && ++ String(entry['name']).toLowerCase() === headerName.toLowerCase() ++ ); ++ return isRecord(match) ? String(match['value'] ?? '') : ''; ++} ++ ++async function assertWorkflowIntegrity(): Promise { ++ const actualJsonFiles = fs ++ .readdirSync(workflowDir) ++ .filter((file) => file.endsWith('.json')) ++ .sort(); ++ assert.deepEqual(actualJsonFiles, allWorkflowFiles, 'workflow JSON file set must be stable'); ++ ++ const seenNames = new Set(); ++ const webhookPaths: string[] = []; ++ const workflowRaw = new Map(); ++ for (const fileName of allWorkflowFiles) { ++ const { raw, workflow } = await loadWorkflow(fileName); ++ workflowRaw.set(fileName, raw); ++ assert.equal(workflow.name, expectedWorkflowNames.get(fileName), `${fileName} name drifted`); ++ assert.equal(seenNames.has(String(workflow.name)), false, `${fileName} duplicate name`); ++ seenNames.add(String(workflow.name)); ++ assert.ok(Array.isArray(workflow.nodes), `${fileName} must have nodes array`); ++ assert.ok(isRecord(workflow.connections), `${fileName} must have connections object`); ++ assert.ok(isRecord(workflow.settings), `${fileName} must have settings object`); ++ assert.equal( ++ (workflow.settings as Record)['executionOrder'], ++ 'v1', ++ `${fileName} must use executionOrder v1` ++ ); ++ ++ const nodes = (workflow.nodes ?? []) as WorkflowNode[]; ++ const nodeNames = new Set(); ++ const triggerNodes: WorkflowNode[] = []; ++ const respondNodes: WorkflowNode[] = []; ++ for (const node of nodes) { ++ assert.equal(typeof node.id, 'string', `${fileName} node must have id`); ++ assert.equal(typeof node.name, 'string', `${fileName} node must have name`); ++ assert.equal(typeof node.type, 'string', `${fileName} node ${String(node.name)} must have type`); ++ assert.equal( ++ typeof node.typeVersion, ++ 'number', ++ `${fileName} node ${String(node.name)} must have typeVersion` ++ ); ++ assert.ok( ++ Array.isArray(node.position) && node.position.length === 2, ++ `${fileName} node ${String(node.name)} must have x/y position` ++ ); ++ assert.ok( ++ isRecord(node.parameters), ++ `${fileName} node ${String(node.name)} must have parameters object` ++ ); ++ assert.equal(nodeNames.has(String(node.name)), false, `${fileName} duplicate node name`); ++ nodeNames.add(String(node.name)); ++ if ( ++ node.type === 'n8n-nodes-base.webhook' || ++ node.type === 'n8n-nodes-base.scheduleTrigger' || ++ node.type === 'n8n-nodes-base.manualTrigger' ++ ) { ++ triggerNodes.push(node); ++ } ++ if (node.type === 'n8n-nodes-base.respondToWebhook') { ++ respondNodes.push(node); ++ } ++ if (node.type === 'n8n-nodes-base.webhook') { ++ const parameters = node.parameters as Record; ++ assert.equal(parameters['responseMode'], 'responseNode', `${fileName} webhook must use responseNode`); ++ assert.equal(typeof parameters['path'], 'string', `${fileName} webhook path missing`); ++ webhookPaths.push(String(parameters['path'])); ++ } ++ if (node.type === 'n8n-nodes-base.httpRequest') { ++ assert.notEqual( ++ node.disabled, ++ true, ++ `${fileName} HTTP node ${String(node.name)} must not be disabled` ++ ); ++ } ++ } ++ ++ assert.ok(triggerNodes.length > 0, `${fileName} must have a trigger node`); ++ assert.equal( ++ triggerNodes.length === 1 && triggerNodes[0]?.type === 'n8n-nodes-base.manualTrigger', ++ false, ++ `${fileName} production workflow must not rely on Manual Trigger only` ++ ); ++ ++ for (const source of Object.keys(workflow.connections ?? {})) { ++ assert.ok(nodeNames.has(source), `${fileName} connection source missing node: ${source}`); ++ } ++ for (const target of allConnectionTargets(workflow)) { ++ assert.ok(nodeNames.has(target), `${fileName} connection target missing node: ${target}`); ++ } ++ ++ for (const webhook of nodes.filter((node) => node.type === 'n8n-nodes-base.webhook')) { ++ const reachable = reachableNodes(workflow, String(webhook.name)); ++ assert.ok( ++ respondNodes.some((node) => reachable.has(String(node.name))), ++ `${fileName} webhook must reach Respond to Webhook` ++ ); ++ } ++ } ++ assert.equal(new Set(webhookPaths).size, webhookPaths.length, 'webhook paths must be unique'); ++ ++ for (const endpoint of requiredCherryEndpoints) { ++ assert.ok( ++ [...workflowRaw.values()].some((raw) => raw.includes(endpoint)), ++ `workflow pack must call ${endpoint}` ++ ); ++ } ++} ++ ++async function assertWorkflowHttpSafety(): Promise { ++ for (const fileName of allWorkflowFiles) { ++ const { raw, workflow } = await loadWorkflow(fileName); ++ assert.equal(/api\.github\.com\/repos\/[^'"]+\/statuses\//.test(raw), false, `${fileName} must not call GitHub statuses directly`); ++ assert.equal(/\/check-runs\b|\/check-suites\b/.test(raw), false, `${fileName} must not call GitHub Checks API directly`); ++ assert.equal(/localhost|127\.0\.0\.1/.test(raw), false, `${fileName} must not hardcode local URLs`); ++ assert.equal(/"credentials"\s*:/.test(raw), false, `${fileName} must not contain credentials object`); ++ assert.equal(/credentialId|password|apiKey|webhookSecret/i.test(raw), false, `${fileName} must not contain credential identifiers or secret fields`); ++ assert.equal( ++ /ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|\bsk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}/.test(raw), ++ false, ++ `${fileName} must not contain literal secret tokens` ++ ); ++ assert.equal( ++ /\/api\/(sessions?|ledgers?|buckets?|payments?|cards?)(\/|$)|\/api\/debts?(\/.*)?\/mutate\b/i.test(raw), ++ false, ++ `${fileName} must not call forbidden Cherry finance endpoints` ++ ); ++ ++ const nodes = (workflow.nodes ?? []) as WorkflowNode[]; ++ for (const node of nodes) { ++ if (node.type !== 'n8n-nodes-base.httpRequest') continue; ++ assert.equal( ++ node.disabled === true, ++ false, ++ `${fileName} HTTP node ${String(node.name)} must not be disabled` ++ ); ++ assert.equal( ++ (node as { continueOnFail?: unknown }).continueOnFail, ++ true, ++ `${fileName} HTTP node ${String(node.name)} must continueOnFail` ++ ); ++ const parameters = isRecord(node.parameters) ? node.parameters : {}; ++ const url = String(parameters['url'] ?? ''); ++ if (url.includes('/api/automation/') || url.includes('CHERRY_API_BASE_URL')) { ++ assert.ok(url.includes('$env.CHERRY_API_BASE_URL'), `${fileName} Cherry call must use $env.CHERRY_API_BASE_URL`); ++ assert.ok( ++ headerValue(node, 'Authorization').includes('$env.CHERRY_AUTOMATION_TOKEN'), ++ `${fileName} Cherry call must use $env.CHERRY_AUTOMATION_TOKEN` ++ ); ++ } ++ } ++ } ++} ++ ++function assertZipMatchesFolder(): void { ++ assert.ok(fs.existsSync(workflowZip), 'cherry-n8n-workflows.zip must exist'); ++ const entries = execFileSync('unzip', ['-Z1', workflowZip], { ++ cwd: repoRoot, ++ encoding: 'utf8', ++ }) ++ .split('\n') ++ .map((entry) => entry.trim()) ++ .filter((entry) => entry.length > 0); ++ assert.ok( ++ entries.every((entry) => entry === 'cherry-n8n-workflows/' || entry.startsWith('cherry-n8n-workflows/')), ++ 'zip root must contain cherry-n8n-workflows/ only' ++ ); ++ const zippedFiles = entries ++ .filter((entry) => entry.endsWith('/') === false) ++ .map((entry) => entry.replace(/^cherry-n8n-workflows\//, '')) ++ .sort(); ++ const folderFiles = fs ++ .readdirSync(workflowDir, { withFileTypes: true }) ++ .filter((entry) => entry.isFile()) ++ .map((entry) => entry.name) ++ .sort(); ++ assert.deepEqual(zippedFiles, folderFiles, 'zip and workflow folder file sets must match'); ++ assert.deepEqual( ++ zippedFiles.filter((file) => file.endsWith('.json')), ++ allWorkflowFiles, ++ 'zip and folder workflow JSON filenames must match' ++ ); ++ for (const file of [...allWorkflowFiles, ...expectedWorkflowDocs]) { ++ const folderContent = fs.readFileSync(path.join(workflowDir, file), 'utf8'); ++ const zipContent = execFileSync( ++ 'unzip', ++ ['-p', workflowZip, `cherry-n8n-workflows/${file}`], ++ { cwd: repoRoot, encoding: 'utf8' } ++ ); ++ assert.equal(zipContent, folderContent, `zip entry ${file} must match folder source`); ++ } ++} ++ ++function normalizeChangedFiles(jsCode: string, items: Array<{ json: unknown }>): unknown[] { ++ const output = vm.runInNewContext(`(() => {\n${jsCode}\n})()`, { ++ $input: { all: () => items }, ++ $items: (name: string) => ++ name === 'Normalize PR' ? [{ json: { repo: 'div0rce/cherry' } }] : [], ++ }) as Array<{ json: { files?: unknown[] } }>; ++ return output[0]?.json.files ?? []; ++} ++ ++function normalizePr(jsCode: string, item: { json: unknown }): Record { ++ const output = vm.runInNewContext(`(() => {\n${jsCode}\n})()`, { ++ $input: { first: () => item }, ++ }) as Array<{ json: Record }>; ++ return output[0]?.json ?? {}; ++} ++ ++function assertPreservesReturnedFiles( ++ label: string, ++ jsCode: string, ++ items: Array<{ json: unknown }> ++): void { ++ const files = normalizeChangedFiles(jsCode, items); ++ assert.ok( ++ files.length > 0, ++ `${label}: GitHub response contained files but normalized output was empty` ++ ); ++} ++ ++await assertWorkflowIntegrity(); ++await assertWorkflowHttpSafety(); ++assertZipMatchesFolder(); ++ ++for (const fileName of prWorkflowFiles) { ++ const { raw, workflow } = await loadWorkflow(fileName); ++ const nodes = (Array.isArray(workflow.nodes) ? workflow.nodes : []) as WorkflowNode[]; ++ const nodeNames = nodes.map((node) => String(node.name ?? '')); ++ ++ for (const forbiddenName of forbiddenAuthorityNodeNames) { ++ assert.equal( ++ nodeNames.includes(forbiddenName), ++ false, ++ `${fileName} must not contain local authority node ${forbiddenName}` ++ ); ++ } ++ ++ assert.equal( ++ nodeNames.includes('Classify PR In Cherry'), ++ true, ++ `${fileName} must call Cherry PR classifier` ++ ); ++ assert.equal( ++ nodeNames.includes('Normalize Changed Files'), ++ true, ++ `${fileName} must normalize changed files before Cherry classification` ++ ); ++ assert.deepEqual( ++ connectionTargets(workflow, 'Fetch Changed Files'), ++ ['Normalize Changed Files'], ++ `${fileName} must route Fetch Changed Files to Normalize Changed Files` ++ ); ++ assert.deepEqual( ++ connectionTargets(workflow, 'Normalize Changed Files'), ++ ['Classify PR In Cherry'], ++ `${fileName} must route Normalize Changed Files to Classify PR In Cherry` ++ ); ++ assert.equal( ++ nodeNames.includes('Require Status Payload'), ++ true, ++ `${fileName} must fail closed when Cherry omits required status payload fields` ++ ); ++ assert.equal( ++ nodeNames.includes('IF: Has Status Payload?'), ++ true, ++ `${fileName} must branch before posting status` ++ ); ++ assert.deepEqual( ++ connectionTargets(workflow, 'Require Status Payload'), ++ ['IF: Has Status Payload?'], ++ `${fileName} must route status guard to status-payload IF` ++ ); ++ ++ for (const pattern of forbiddenAuthorityPayloadPatterns) { ++ assert.equal( ++ pattern.test(raw), ++ false, ++ `${fileName} must not synthesize local scoring/detection authority with ${pattern}` ++ ); ++ } ++ assert.match( ++ raw, ++ /Cherry status payload missing required fields\. Refusing to post status\./, ++ `${fileName} must include safe missing-status-payload refusal` ++ ); ++ assert.match( ++ raw, ++ /if \(!event\.sha\) missing\.push\('sha'\);/, ++ `${fileName} must fail closed when status payload sha is absent` ++ ); ++ assert.match( ++ raw, ++ /do_not_post_status/, ++ `${fileName} must refuse to post status when Cherry omits required status payload fields` ++ ); ++ ++ assert.equal( ++ /Array\.isArray\(\$json\)\s*\?\s*\$json\s*:\s*\[\]/.test(raw), ++ false, ++ `${fileName} must not drop changed files with Array.isArray($json) fallback` ++ ); ++ const classifyNode = nodeByName(nodes, 'Classify PR In Cherry'); ++ const classifyParameters = classifyNode.parameters; ++ assert.notEqual(classifyParameters, null); ++ assert.equal(typeof classifyParameters, 'object'); ++ const classifyBody = String( ++ (classifyParameters as Record)['jsonBody'] ?? '' ++ ); ++ assert.match( ++ classifyBody, ++ /files:\s*\$json\.files/, ++ `${fileName} classifier request must pass files: $json.files` ++ ); ++ assert.match( ++ classifyBody, ++ /prNumber:\s*\$json\.prNumber/, ++ `${fileName} classifier request must use normalized prNumber` ++ ); ++ assert.match( ++ classifyBody, ++ /sha:\s*\$json\.sha/, ++ `${fileName} classifier request must use normalized sha` ++ ); ++ ++ const buildRoutingNode = nodes.find((node) => ++ String(node.name ?? '').startsWith('Build Cherry') ++ ); ++ assert.notEqual(buildRoutingNode, undefined, `${fileName} must build Cherry routing output`); ++ const buildRoutingParameters = buildRoutingNode?.parameters; ++ assert.notEqual(buildRoutingParameters, null); ++ assert.equal(typeof buildRoutingParameters, 'object'); ++ const buildRoutingCode = String( ++ (buildRoutingParameters as Record)['jsCode'] ?? '' ++ ); ++ assert.match( ++ buildRoutingCode, ++ /const sha = prEvent\.sha;/, ++ `${fileName} must set sha from the normalized PR event before status posting` ++ ); ++ assert.match( ++ buildRoutingCode, ++ /\n\s*sha,\n/, ++ `${fileName} must carry sha as a direct routing output field` ++ ); ++ ++ const normalizeNode = nodeByName(nodes, 'Normalize Changed Files'); ++ const normalizePrNode = nodeByName(nodes, 'Normalize PR'); ++ const normalizePrParameters = normalizePrNode.parameters; ++ assert.notEqual(normalizePrParameters, null); ++ assert.equal(typeof normalizePrParameters, 'object'); ++ const normalizePrCode = String( ++ (normalizePrParameters as Record)['jsCode'] ?? '' ++ ); ++ const webhookNormalized = normalizePr(normalizePrCode, { ++ json: { ++ pull_request: { ++ number: 42, ++ title: 'Webhook PR', ++ body: 'Body', ++ head: { sha: 'sha-webhook' }, ++ labels: [{ name: 'bug' }], ++ }, ++ repository: { full_name: 'div0rce/cherry', name: 'cherry', owner: { login: 'div0rce' } }, ++ }, ++ }); ++ assert.equal(webhookNormalized['prNumber'], 42, `${fileName} must normalize webhook PR number`); ++ assert.equal(webhookNormalized['sha'], 'sha-webhook', `${fileName} must normalize webhook PR sha`); ++ const searchNormalized = normalizePr(normalizePrCode, { ++ json: { ++ items: [{ number: 43, title: 'Search PR', head: { sha: 'sha-search' } }], ++ repo: 'div0rce/cherry', ++ }, ++ }); ++ assert.equal(searchNormalized['prNumber'], 43, `${fileName} must normalize GitHub search PR number`); ++ assert.equal(searchNormalized['sha'], 'sha-search', `${fileName} must normalize GitHub search PR sha`); ++ const flatNormalized = normalizePr(normalizePrCode, { ++ json: { ++ pr_number: 44, ++ sha: 'sha-flat', ++ title: 'Flat PR', ++ repo: 'div0rce/cherry', ++ }, ++ }); ++ assert.equal(flatNormalized['prNumber'], 44, `${fileName} must normalize flat PR number`); ++ assert.equal(flatNormalized['sha'], 'sha-flat', `${fileName} must normalize flat PR sha`); ++ const emptySearchNormalized = normalizePr(normalizePrCode, { ++ json: { ++ items: [], ++ total_count: 0, ++ search_type: 'github_pull_request_search', ++ repo: 'div0rce/cherry', ++ }, ++ }); ++ assert.equal( ++ emptySearchNormalized['error'], ++ 'missing_pr_number', ++ `${fileName} must fail safely when GitHub search returns no PR items` ++ ); ++ assert.equal( ++ emptySearchNormalized['totalCount'], ++ 0, ++ `${fileName} missing-pr response must preserve GitHub search total_count` ++ ); ++ ++ const normalizeParameters = normalizeNode.parameters; ++ assert.notEqual(normalizeParameters, null); ++ assert.equal(typeof normalizeParameters, 'object'); ++ const normalizeCode = String( ++ (normalizeParameters as Record)['jsCode'] ?? '' ++ ); ++ assertPreservesReturnedFiles(`${fileName} array payload`, normalizeCode, [ ++ { json: [{ filename: 'lib/engine/solver.ts' }] }, ++ ]); ++ assertPreservesReturnedFiles(`${fileName} json.files`, normalizeCode, [ ++ { json: { files: [{ filename: 'app/api/scan/route.ts' }] } }, ++ ]); ++ assertPreservesReturnedFiles(`${fileName} json.data`, normalizeCode, [ ++ { json: { data: [{ filename: 'prisma/schema.prisma' }] } }, ++ ]); ++ assertPreservesReturnedFiles(`${fileName} per-item object`, normalizeCode, [ ++ { json: { filename: 'docs/engine/update.md' } }, ++ ]); ++ ++ const statusNodes = nodes.filter((node) => String(node.name ?? '').startsWith('Post ')); ++ const statusBodies = statusNodes ++ .map((node) => { ++ const parameters = node.parameters; ++ if (parameters === null || typeof parameters !== 'object') return ''; ++ return String((parameters as Record)['jsonBody'] ?? ''); ++ }) ++ .join('\n'); ++ assert.match( ++ statusBodies, ++ /repo:\s*\$json\.repo/, ++ `${fileName} status bodies must use direct Cherry repo` ++ ); ++ assert.match( ++ statusBodies, ++ /sha:\s*\$json\.sha/, ++ `${fileName} status bodies must use direct Cherry sha` ++ ); ++ assert.match( ++ statusBodies, ++ /\$json\.statusRequest\.context/, ++ `${fileName} status bodies must use direct Cherry statusRequest context` ++ ); ++ assert.match( ++ statusBodies, ++ /\$json\.statusRequest\.state/, ++ `${fileName} status bodies must use direct Cherry statusRequest state` ++ ); ++ assert.match( ++ statusBodies, ++ /\$json\.statusRequest\.description/, ++ `${fileName} status bodies must use direct Cherry statusRequest description` ++ ); ++ assert.match( ++ statusBodies, ++ /\$json\.statusRequest\.targetUrl/, ++ `${fileName} status bodies must use Cherry statusRequests` ++ ); ++ assert.match( ++ statusBodies, ++ /\$json\.outputHash/, ++ `${fileName} status bodies must use Cherry top-level outputHash` ++ ); ++ assert.match( ++ statusBodies, ++ /sourceWorkflow:\s*\$json\.workflow/, ++ `${fileName} status bodies must use direct workflow identity` ++ ); ++ assert.match( ++ statusBodies, ++ /classifierVersion:\s*\$json\.cherryClassifierOutput\.classifierVersion/, ++ `${fileName} status bodies must use direct Cherry classifier version` ++ ); ++ for (const pattern of forbiddenStatusIdentityFallbackPatterns) { ++ assert.equal( ++ pattern.test(statusBodies), ++ false, ++ `${fileName} status bodies must not synthesize status identity with ${pattern}` ++ ); ++ } ++} ++ ++console.warn('automation workflows: ok'); +diff --git a/v2-cherry-diff.patch b/v2-cherry-diff.patch +new file mode 100644 +index 0000000000000000000000000000000000000000..02e2873864b2cb08efcc8fff8e6c883acff747b4 +--- /dev/null ++++ b/v2-cherry-diff.patch +@@ -0,0 +1,19255 @@ ++diff --git a/AGENTS.md b/AGENTS.md ++index 9804f4620cc8bdfecea458c1318d688239f1b6b7..de967a454828cca83ada13b8156ed9984ffcba31 100644 ++--- a/AGENTS.md +++++ b/AGENTS.md ++@@ -102,7 +102,8 @@ Forbidden framings: “fronting card,” “proxy BIN,” “tap to pay with Che ++ - `npm run build` → Next.js build passes. ++ - `npm run ci:verify` → mirrors CI entrypoint. ++ - `npm run test:db` → DB/env tests only; not part of standard mocked runtime proof. ++-- `npm run check:fast` → local guardrails + script typecheck + partitioned runtime suite. +++- `npm run check:fast` → local guardrails + script typecheck only. +++- `npm run check:local` → `check:fast` plus partitioned runtime suite. ++ - Full repo proof → `CHERRY_TMP_ROOT="$HOME/.cherry-tmp" CHERRY_VINE_SIGNATURE_MODE=enforce npm run verify:repo-closure`. ++ - Agents must not run both `npm test` and `verify:repo-closure` unless explicitly required. ++ - If schema changed: migrations apply and Prisma client is regenerated. ++diff --git a/app/api/automation/_auth.ts b/app/api/automation/_auth.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..db01081c5c2378edd3669dea467944ec6d8d3a7d ++--- /dev/null +++++ b/app/api/automation/_auth.ts ++@@ -0,0 +1,34 @@ +++import type { NextRequest } from 'next/server'; +++import { NextResponse } from 'next/server'; +++import { getStandardBearerHeader } from '../../../lib/http/bearer-token.js'; +++ +++export type AutomationAuthResult = { ok: true } | { ok: false; response: NextResponse }; +++ +++export function requireAutomationToken(request: NextRequest): AutomationAuthResult { +++ const expected = process.env['CHERRY_AUTOMATION_TOKEN']; +++ if (typeof expected !== 'string' || expected.trim().length === 0) { +++ return { +++ ok: false, +++ response: NextResponse.json( +++ { error: 'automation_token_not_configured' }, +++ { status: 503 } +++ ), +++ }; +++ } +++ +++ const bearerHeader = getStandardBearerHeader(request.headers); +++ const headerToken = request.headers.get('x-cherry-automation-token'); +++ const bearerToken = +++ bearerHeader !== null && bearerHeader.startsWith('Bearer ') +++ ? bearerHeader.slice('Bearer '.length) +++ : null; +++ const provided = bearerToken ?? headerToken; +++ if (provided !== expected) { +++ return { +++ ok: false, +++ response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), +++ }; +++ } +++ +++ return { ok: true }; +++} ++diff --git a/app/api/automation/classify/pr/route.ts b/app/api/automation/classify/pr/route.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..e3d3f2fe77ce66efa9ea360bc09550b46764ddaa ++--- /dev/null +++++ b/app/api/automation/classify/pr/route.ts ++@@ -0,0 +1,38 @@ +++import type { NextRequest } from 'next/server'; +++import { NextResponse } from 'next/server'; +++import { +++ AutomationEventIdempotencyConflictError, +++ classifyAndStorePrAutomation, +++} from '../../../../../lib/automation/events.js'; +++import { PrAutomationClassifySchema } from '../../../../../lib/schemas/automation.js'; +++import { parseJsonBody } from '../../../../../lib/validation.js'; +++import { requireAutomationToken } from '../../_auth.js'; +++ +++export async function POST(request: NextRequest): Promise { +++ const auth = requireAutomationToken(request); +++ if (auth.ok === false) return auth.response; +++ +++ const parsed = await parseJsonBody(request, PrAutomationClassifySchema); +++ if (parsed.ok === false) return parsed.response; +++ +++ let result: Awaited>; +++ try { +++ result = await classifyAndStorePrAutomation(parsed.data); +++ } catch (error: unknown) { +++ if (error instanceof AutomationEventIdempotencyConflictError) { +++ return NextResponse.json( +++ { error: 'automation_event_idempotency_conflict' }, +++ { status: 409 } +++ ); +++ } +++ throw error; +++ } +++ +++ return NextResponse.json({ +++ ok: true, +++ created: result.created, +++ automationEventId: result.event.id, +++ outputHash: result.event.outputHash, +++ classifierOutput: result.classifierOutput, +++ }); +++} ++diff --git a/app/api/automation/events/route.ts b/app/api/automation/events/route.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..3c16a46ca8dae1384209aa0a33c6f18d0c2cd5a1 ++--- /dev/null +++++ b/app/api/automation/events/route.ts ++@@ -0,0 +1,37 @@ +++import type { NextRequest } from 'next/server'; +++import { NextResponse } from 'next/server'; +++import { AutomationEventIngestSchema } from '../../../../lib/schemas/automation.js'; +++import { +++ AutomationEventIdempotencyConflictError, +++ storeAutomationEvent, +++} from '../../../../lib/automation/events.js'; +++import { parseJsonBody } from '../../../../lib/validation.js'; +++import { requireAutomationToken } from '../_auth.js'; +++ +++export async function POST(request: NextRequest): Promise { +++ const auth = requireAutomationToken(request); +++ if (auth.ok === false) return auth.response; +++ +++ const parsed = await parseJsonBody(request, AutomationEventIngestSchema); +++ if (parsed.ok === false) return parsed.response; +++ +++ let result: Awaited>; +++ try { +++ result = await storeAutomationEvent(parsed.data); +++ } catch (error: unknown) { +++ if (error instanceof AutomationEventIdempotencyConflictError) { +++ return NextResponse.json( +++ { error: 'automation_event_idempotency_conflict' }, +++ { status: 409 } +++ ); +++ } +++ throw error; +++ } +++ +++ return NextResponse.json({ +++ ok: true, +++ created: result.created, +++ automationEventId: result.event.id, +++ outputHash: result.event.outputHash, +++ }); +++} ++diff --git a/app/api/automation/replay/route.ts b/app/api/automation/replay/route.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..251abc7030d5bec92049b5a652068aa947855104 ++--- /dev/null +++++ b/app/api/automation/replay/route.ts ++@@ -0,0 +1,31 @@ +++import type { NextRequest } from 'next/server'; +++import { NextResponse } from 'next/server'; +++import { replayAutomationEvent } from '../../../../lib/automation/events.js'; +++import { AutomationReplaySchema } from '../../../../lib/schemas/automation.js'; +++import { parseJsonBody } from '../../../../lib/validation.js'; +++import { requireAutomationToken } from '../_auth.js'; +++ +++export async function POST(request: NextRequest): Promise { +++ const auth = requireAutomationToken(request); +++ if (auth.ok === false) return auth.response; +++ +++ const parsed = await parseJsonBody(request, AutomationReplaySchema); +++ if (parsed.ok === false) return parsed.response; +++ +++ const result = await replayAutomationEvent( +++ parsed.data.automationEventId, +++ parsed.data.classifierVersion +++ ); +++ if (result === null) { +++ return NextResponse.json({ error: 'automation_event_not_found' }, { status: 404 }); +++ } +++ +++ return NextResponse.json({ +++ ok: true, +++ automationEventId: result.event.id, +++ outputHash: result.outputHash, +++ matches: result.matches, +++ reason: result.reason, +++ }); +++} +++ ++diff --git a/app/api/automation/simulation-snapshots/compare/route.ts b/app/api/automation/simulation-snapshots/compare/route.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..298d9bda365b33267dd6ee31b6c5814c55e6ed61 ++--- /dev/null +++++ b/app/api/automation/simulation-snapshots/compare/route.ts ++@@ -0,0 +1,38 @@ +++import type { NextRequest } from 'next/server'; +++import { NextResponse } from 'next/server'; +++import { +++ SimulationSnapshotIdempotencyConflictError, +++ compareAndStoreSimulationSnapshot, +++} from '../../../../../lib/automation/events.js'; +++import { SimulationSnapshotCompareSchema } from '../../../../../lib/schemas/automation.js'; +++import { parseJsonBody } from '../../../../../lib/validation.js'; +++import { requireAutomationToken } from '../../_auth.js'; +++ +++export async function POST(request: NextRequest): Promise { +++ const auth = requireAutomationToken(request); +++ if (auth.ok === false) return auth.response; +++ +++ const parsed = await parseJsonBody(request, SimulationSnapshotCompareSchema); +++ if (parsed.ok === false) return parsed.response; +++ +++ let result: Awaited>; +++ try { +++ result = await compareAndStoreSimulationSnapshot(parsed.data); +++ } catch (error: unknown) { +++ if (error instanceof SimulationSnapshotIdempotencyConflictError) { +++ return NextResponse.json( +++ { error: 'simulation_snapshot_idempotency_conflict' }, +++ { status: 409 } +++ ); +++ } +++ throw error; +++ } +++ +++ return NextResponse.json({ +++ ok: true, +++ created: result.created, +++ snapshotId: result.snapshot.id, +++ outputHash: result.snapshot.outputHash, +++ comparisonOutput: result.comparisonOutput, +++ }); +++} ++diff --git a/app/api/automation/statuses/github/retry/route.ts b/app/api/automation/statuses/github/retry/route.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..5d4d9a10cc067a567c11e7f93e27112022b28fae ++--- /dev/null +++++ b/app/api/automation/statuses/github/retry/route.ts ++@@ -0,0 +1,47 @@ +++import type { NextRequest } from 'next/server'; +++import { NextResponse } from 'next/server'; +++import { +++ GithubStatusRetryNotFoundError, +++ retryGithubStatus, +++} from '../../../../../../lib/automation/github-status.js'; +++import { GithubStatusRetrySchema } from '../../../../../../lib/schemas/automation.js'; +++import { parseJsonBody } from '../../../../../../lib/validation.js'; +++import { requireAutomationToken } from '../../../_auth.js'; +++ +++export async function POST(request: NextRequest): Promise { +++ const auth = requireAutomationToken(request); +++ if (auth.ok === false) return auth.response; +++ +++ const parsed = await parseJsonBody(request, GithubStatusRetrySchema); +++ if (parsed.ok === false) return parsed.response; +++ +++ try { +++ const result = await retryGithubStatus(parsed.data, { +++ githubToken: process.env['GITHUB_TOKEN'] ?? '', +++ }); +++ return NextResponse.json({ +++ ok: true, +++ retried: result.retried, +++ statusCheck: result.statusCheck, +++ }); +++ } catch (error: unknown) { +++ if (error instanceof GithubStatusRetryNotFoundError) { +++ return NextResponse.json({ error: 'github_status_not_found' }, { status: 404 }); +++ } +++ const statusCheck = (error as { statusCheck?: unknown }).statusCheck; +++ const message = error instanceof Error ? error.message : 'github_status_retry_failed'; +++ const status = /forbidden Cherry finance endpoint|Unsupported GitHub status context/.test( +++ message +++ ) +++ ? 400 +++ : 502; +++ return NextResponse.json( +++ { +++ ok: false, +++ error: message, +++ statusCheck, +++ }, +++ { status } +++ ); +++ } +++} ++diff --git a/app/api/automation/statuses/github/route.ts b/app/api/automation/statuses/github/route.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..f4a5d356daafb04e12e96e7e15f8095c806a5b5e ++--- /dev/null +++++ b/app/api/automation/statuses/github/route.ts ++@@ -0,0 +1,36 @@ +++import type { NextRequest } from 'next/server'; +++import { NextResponse } from 'next/server'; +++import { postGithubStatus } from '../../../../../lib/automation/github-status.js'; +++import { GithubStatusPostSchema } from '../../../../../lib/schemas/automation.js'; +++import { parseJsonBody } from '../../../../../lib/validation.js'; +++import { requireAutomationToken } from '../../_auth.js'; +++ +++export async function POST(request: NextRequest): Promise { +++ const auth = requireAutomationToken(request); +++ if (auth.ok === false) return auth.response; +++ +++ const parsed = await parseJsonBody(request, GithubStatusPostSchema); +++ if (parsed.ok === false) return parsed.response; +++ +++ try { +++ const result = await postGithubStatus(parsed.data, { +++ githubToken: process.env['GITHUB_TOKEN'] ?? '', +++ }); +++ return NextResponse.json({ +++ ok: true, +++ posted: result.posted, +++ idempotent: result.idempotent, +++ statusCheckId: result.statusCheck.id, +++ }); +++ } catch (error: unknown) { +++ const statusCheck = (error as { statusCheck?: { id?: string } }).statusCheck; +++ return NextResponse.json( +++ { +++ ok: false, +++ error: error instanceof Error ? error.message : 'github_status_failed', +++ statusCheckId: statusCheck?.id, +++ }, +++ { status: 502 } +++ ); +++ } +++} ++diff --git a/app/api/automation/statuses/route.ts b/app/api/automation/statuses/route.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..8e8fa5163abee9ba4a88f53906d0979bfd36c03f ++--- /dev/null +++++ b/app/api/automation/statuses/route.ts ++@@ -0,0 +1,30 @@ +++import type { NextRequest } from 'next/server'; +++import { NextResponse } from 'next/server'; +++import { +++ isAllowedGithubStatusContext, +++ listLatestGithubStatuses, +++} from '../../../../lib/automation/github-status.js'; +++import { requireAutomationToken } from '../_auth.js'; +++ +++export async function GET(request: NextRequest): Promise { +++ const auth = requireAutomationToken(request); +++ if (auth.ok === false) return auth.response; +++ +++ const url = new URL(request.url); +++ const repo = url.searchParams.get('repo') ?? undefined; +++ const sha = url.searchParams.get('sha') ?? undefined; +++ const contextParam = url.searchParams.get('context') ?? undefined; +++ if ( +++ contextParam !== undefined && +++ isAllowedGithubStatusContext(contextParam) === false +++ ) { +++ return NextResponse.json({ error: 'invalid_status_context' }, { status: 400 }); +++ } +++ +++ const params: Parameters[0] = {}; +++ if (repo !== undefined) params.repo = repo; +++ if (sha !== undefined) params.sha = sha; +++ if (contextParam !== undefined) params.context = contextParam; +++ const statuses = await listLatestGithubStatuses(params); +++ return NextResponse.json({ ok: true, statuses }); +++} ++diff --git a/cherry-n8n-workflows.zip b/cherry-n8n-workflows.zip ++new file mode 100644 ++index 0000000000000000000000000000000000000000..b540e98b2c789c8127b12049cd0c481dfa135455 ++GIT binary patch ++literal 38686 ++zcmb5VV~{35*CklCZQHhOv&*ih?CP>@+g6ut+g6utyK4HK*ofV4H@=;j%*ed?C-41t ++zHIOB&b?ZsfM(ytxFU7=|7+WdPGv6&a3ay2tzl$Hf ++ziPtYs)Q-*)vhXTAIVwB7r0ffKRD!NkItvJa@ri$MVb0SkQC6A(Kz*8XM2Gs{VRo~R ++zM#%mH_2roFWg@=`%hNkJNZKwR-<>jkr=}bCk|Pt!rKg7$frd(1$8r}UG$-u}!1JX+ ++zK7!8qFBU$7x1c)wp~QM5C5PSa3=fr+Q*n}&R9)?^I68>rzSj<*haAeGgnWh7PpU4D ++znexYJ^>E1Z#}$I~CmjCD#bqkssWlF~klna=x2{Xe_asfTKo%AGyNBU(@J<=Kh~a`k ++zw!WY4jgpB9{WhGP#Xo70D<$F$t?(p+ajS?l(ybJ4axdnKWpWHz*s#Oa1I9o(O;Pu9 ++z#IBi7y4nLO1x|&_#Gu0rWts5n`a2U<tCfSywc8~{BW_*bzaN3e+Dx)kda`Wy{XSElb_xcLy ++z_XeBDEC+aVSZO#g-_Yrzfk=8fERlN?&%hbI2mIAU5Al{;?INncLDED|@dWD)-f9!N ++zd;?`6NV<|BPKRMCM*5Kw^JJQN1;^oEw(wigN-Rn_WC*6wCc5*8^t?SA^LO_-zrAprQ1^SX(%>VhiT;n0rY~|@nwEta7!@|JvjYTn5k&V@TLOk0*h(7Qc2!y ++z4V>z})9T`Nzno!w{-4E-Y}5Hc8@s7;6G$L0SG|RfA~3+M14rSvYefeSQM$vaD*&mM ++zImmZTNK32+0t$c4ri3KxCek1inGHIoVg6Sp*NA=b?yA(k ++zeP=41snB-|mryUwu_^WZhHSF#WmWrf+u<_Ig6_FHEI!%sR$1_Ez?VQ2@psEkI+0G| ++zr-ulTwXCKn*8B`TFNyMjWytMB#WD<8O0$aQItoba1~|;?a@uS&dh`fPp+5~_q*N1n ++z;HDi7pVh*eEK|BVPOj;n6ooyAYE1KOh^bV;(g^+E ++z`D&)bGngjM6OAVq$(V*ByG}@?1oxmm$qi3oi*!~d>XHENhV}SYHW0h%}_T7ZLno$bRAFD-vimO(7p`6gHb0+6N;_IgNQ-Q#1Coq$(!y< ++zeWxD4=*z5wR%8gJIr>cRDN?M%-z!dOBn||bV~6gDsU=jPzz(Qu0OXH3k>V$|j?e^_ ++zQ7qcj3tM0WS>x^Nrcx!)Fp5kizM$DB#=@zBCW+>;*JoJ>qgXf-X>3ygTcPXBiph+? ++z0*ailEluJW^*+5r0jZ;sUubi!v(ktcf)pb9SGRAZ ++zMf!Wd=vOG>e4ksMt}ACM&UPJ ++zMZ1M?4?Iqtu@C`ay}+8Oa^}4ie^MP;g%5y&k_!}n_tE2xp*Dg(YO7@Jw0u|)8o=}B ++zG5*qX(alJ(yJ#SVEM}Z%O|Z~h^(gh#!@C``UV?&?bL982Uu(;$;iwo-?MP~RQfyjLy;wO|g}m+33= ++zAa(A(PVWM+q3zX4K-NB7fIN%-wkQ($K0TNBs~K?&hy4!Ju;)gZLgxrG#_g*F17-{Z?NxKuh3pT_w1wvF+ ++zh1p-**M4D?X;*tyN&2!u6Om(ZA-t;JG7|`}fO08pvcAGub2R9m^OQnxE0J2Df`bI@ ++z>ZO!lOv6^?q3uiMheV-E30`PHUKwJDg$D2XCx;bLPV@X$TAR8iLQfgbKx9 ++zwwSd)6cx{3_ay3O+txQS{fv@j(}tW-sXU%`f3|`PQP^OK7Sk%BxWW2-sDf{xz1hZM ++z@93Z!)bv`;KP=c)#j9QS!~=>MOWEri$SEn;Ti}dgE(3(=Jp8IwH8C~0=za2F1sspt ++z^W#^xy*yXLZ@>sbr3seV4(De)$;W`MC3v{~-&I8Os&~2E?U%c`b4_I^`bGl%W3Zo^ ++zM?+3gh|9&xB7Ovx;vlCsCr@MfLue%!`uBCLJ4=$qaHce99)wEXU>fE4jg@XIG~uN6 ++z&!GRlcazQ;hg9HWE8{{RlWhqHT%z8q81__tC+A4ktgZEWrWB)}C?~1oa_5$dqLWvM ++zUXIcgLbo4Bu^)vf)piH1d*>X$kB>O##?`!l7hy2WpshB5+p}~(es6aqkGSgA{=K{dgGv^d!aQR8 ++zP7Ak!x;=Sr6<Yrzj;&q{#1;{GlhT0 ++z9tW&*-*n4kE2%TKyP5bsK>CS4pV!|?{$)Kw=H3*97p-$m+`F`n{01=H87lHNc!;UD ++zt>PZW9#J@(5Gdl}_otc5DnT3;)sgVo7-p;_t#>~kDU}y2a1nF15yqs~k ++zQGRyvL?laK%9&p4r5VI0CXvI24vlno$=uC4OGB}Z&=7I#2;8(7cfLBvr=yR7xkha4 ++zUj9s^5S^7>Yg8E|9@~Y*!?cz*Es&%XK|g(QGfSlzFe{AXdLHS-qO-&$#maug-&M(o ++zPTk_+^d&o6A~{ihhg@eIfmX!7b78!Q{E)#}P$O_@v*KsJOTF)PjXA+1KagxdnC!A(} ++z2&&Vt=j;VH#Jl34dRqohn@c#E0;Ucq1U@KC5$d8!KD=h&B7O|LuM@9a0LrxDm9yoo ++zgdYOeKip&ko>0h}=}O&QAIi&Doy}{_Iv8`6-{LjRjW#Yiugl`kZ>&-k4i&bPw}@0* ++zP$=E;{C-+{vXe)u-HtXV!g7_Pl>=fuJYDpYWU5E&3nVIhLsqaK$F*ENpPsvb{}`Nu ++zWUN{aQDe2f_Dm%;L*cJS%Mb28p+NVoq@8F=&YLjvSfxjKM@~71tzm*OEH&G7fgqu( ++zkyxxanl1PldMHbs^esZr?m?I5o@@y53b@kOci8gE3r(0Ef5R6}vK-dwYV%gt-uLA* ++zFXPQ6jxIR$jj+imUz!d~Yb&k^3nhFQ4Z{3sCZI-h6^z4UJ?8;7oMoh$D9tPnjhZkE ++z%n0-d+y;^=49JI#s#q3lR6y`c0i8BHZm83+)wtMIQ7~Qnbv5GDKQhLi1DkExl65lI ++zVxK4mi*Vx)C4^+3OB8pr`F(l^zJwYeX= ++zrkv#4x(($H0i=3fX9+Wy>O2TC=1jw+DoJQ6L=cSIz9gnI>-f_Dw;Wqjo}${sDx`&> ++z`|1tp&W8g*wbE31PE6Tr9LiE@ah2QZNu?Z}Me_+g!Nn#o(1BkrQi!GmMu1YBxc4Xb ++zLLs%D$J_Gk5KgOg$ea`97kmzTIqZ)YJT!(J+PVLncu3CV(uRnjg6VIW%Cvz-a{g5u ++zWlUB>^}rapeh>f$OgCU*Kba?hIieJU)7x|K)Rr9`df-q>N=co)t+>2ajQV3ga4=6- ++zgAN7;B1cqQ4&hYgAiAlqUs;;2#)%B-dw=ZNcTEr=jNAW;J3xU!VZ&Ez=GHCDwZsqa ++zq@)ONNp?k1jz)^>e4x-m66%hRF#;7ensE>DNodY4j_bt*fF~P8HtlQGW-04b*)qvB ++z(v~wfAPYbWLWr`WsHa%!F&JIGVcT+ozs1@s_Kgz5Ls71SUoc#2jc-Z(;x`-SuAOM{ ++z7#@&jV+jGk90(5ww6eM=NMfZO+QJYRrdozxC?ie^wpa`EVb7w9qod9x1jZWy-@js? ++z-Y{~`2qE#~rQ#taeP6aKUyM)_J;ggEpFm)eouAVUl^oo{VhFbVm9bNzW3JFc3Sck` ++zLx*pfJ24I6n^l^eNWDbE#YNpNj0qM~$n2^Mb9X<4-VkL)O(j0vXBjtE&b|RH3Kmkv ++z7D-uZ-R07lGb=L3RtOgiSS#ru;+`_L(7s?PyFLqdBzFKuap+*( ++z=-E0kChmn7_dDyhCI2Jh%TESnU;Bj3;hR5tdBeZiWvSyCfcj=e&RjUlw3GI@t>=V>NZu$SY*K(PZfIwB-N_@#*vLJSJ7QX94!ESYx{4Tg}| ++zbmf%>mD%A}PxL3qYMXkO0Y23&U*1=0Z>&lk?si;dZN-r)_Tv+K&Xb^vq64wEm(7+c ++zNWz*%U5mK`Q&PL@ODM0*hvIw%;mBRy2rmYKBxw<=iG+>S@gQauChwF6TT2N^EA7P9OV9xNY0TuA_Z#X}Q4l_?MMCtL-O=oHnsD~MRJlN2ND ++zul@E@3{Vfwrm2v;8pl0@XEscZX9F_*?5Q9_LKcE~0Y6wu9e%9u2n6#IbRW+v@{7JU ++zdIaLSE33zHt;{<4T5OOi>=6Bui9QAke^G{USPoRFPL$iFH_#+XV+yX ++zdAwbBP)E3<+#2BQ83`=Pa`>OkvO%7nd-)VYobll|WJ1`ecW#d2=1CT!qToFZAFgO89aXxo!GCLMQoS4Sk)VdEo=} ++z^iYhfQ6LVyAL<}#h?9Jov3;|S*?SZrX3SW!kh77$A&!t_?pC`3S?>;X~Y ++zyngY1+3AwvUh{hEbvqqVlsF{b?4{*fG8Zg)3t ++zX6!gxqHtUyrl2OD{rh|FBwBe$cQo8UC`#fPXLLNDsVAgX*4N{i%A)HBSLu6Mp?k`9 ++zInbEGTAtd2@{`nt#!K;4DJfCbsfXnaP`dU`aQTGB!v1R|k&*t%S-u+GOcm10Yy7mq ++z9d&paiofU<+sp};l$q>t%tvrs&2+YF0lLEcWz`q9$Ku1PSw?@gY4=`bs&7|QNeSAkE!EF` ++z>kU19{+PocdbMxN)&k8$hFu3H?O$^a1IYtPF>TC`*5VZwc$@Mibj%%{Ut-XsF`^b{ ++zU&pD3vyPie&-bdcsbaG%n@acOC6E73j+;y8X_z=VDZN~js%)r^z ++z*4D_$!@$DK&dkZk#r}W&;(di{?Yzlx-{}XgFdbc7AvJkTTNHcYj7RQxphYk)e?78d ++zX^E1E^q`KEe9`fJ(+ca4mX~TPes;i;6d`>H@-cE9ICq6X48L=4StN-Q<~;ER&pa02 ++zu@p&#J)CRNKW2TdY8TqIY;laD{X*!E^xwewEOOtbeUlX~>4yb+Q!Tv^jp2GqH~T2Q>G4`+fxytAWaCp{yM ++zO#F5?AJzL1bzh{E7$-J%nm@H|rpb;at&L)AW<(N(U$OAdQ*0d2gwqK}0xuIR?)$S@ ++z2qHARc0RGd1=e>U*?*+aj?H$rfC>ka7=cplp(W6Uwo1YA7Syh{$@mx4CTu8B1GT8m ++zTZ0+O89kvJDr&gqHEi4-_wfe+SdV0^6aplu@?rEHKwB%xJYr075(D=lF&RCi9g4N% ++zva)j;n`r&>mP{zwZ;cwAq9PX~@N|;$=kXW*2J+jxIBVzMOw@I38RWd6bFhCr#cr|I ++zKxs%~4MWW~+0d4RSC-+h8MzWbJD^`v>5H)VEaim0vLn+!4nI@#l|3U? ++zu15qptcI;qEZL{cakthZ ++zY&Wdv=`i`vBL7P9y>-~<4t}&1y$0QN^P_wqc5Z90L(FO+H+3MpyRj+J8`GlPJ6qn%WI&kH{dP2Ldb?LF7+h}-&2pKCKqh2Q ++zH{Svq87Y>uwQVLXx#a#=X;OJ=qJ~)&UAhm@%q1>7%ZijZIJtgF1VQfb-05-zM~Nd3 ++zprM#lN^=RpYJFw_qbKpU*pN*=TcyEAY8iu3ghK>IQ2GT)c? ++z2H*uxN%8&3{kC&=UtL>gcFV&YGy6Mo<{TOkmYkwWbJ<>yO+3zXH0Bwib*N=b4sBuP ++zQgER`QDPSFnIw%RD`Fy ++z^P6qAIOQk`Y%zF{BYu&cDuyH;8B2*s^=q9I6)3q8ClMP^x{d83fRybu&ND}ce=tVe ++zCQ_B-7E)A4D ++zURk3;Yq=CDZhA%HSPWjN)JMQhH}HH0AegsQPrAan>-%UM>>pir0RYyCWkrLalOM8a ++zpJJz(VvlQvkUA{d+JNq6I!)?^^%j8q^EKBJ{81aI0Tt?UB|pUoKM^jQD9SaJHKLc? ++zVBj%{=UH4>dIS&JWa;oluoAUdS6dx2wXPSrGM0etB@RoHuHAGJ(RRIB7})mG5xlZTq(4l--V)rPV7zg7Y!M=V5_!o&GsbfYbg>*CEz+!X ++z5Q7cU8f7?oR9{jl`&^O@FrH<`k_tG)UyE#D3Ap_*f&e|dYN@L#7ckpwP=k>v*Lzeg ++zMb8!a#!^P#Q0}WPZLi09yh*p-qM*7)m*QAcpJcYS*9BsRhuU)IL)j?*&kd!BI>+#C ++z)3{<{!U|EkduzC*VR%!pod8Q6pn$o-#juj|@FO}pDU(=feU$)bqe=^AXhUTnQ{%Dp ++zKM3SX%>D>*w@B0nf@e{ONRmWmC`Qv1r>*?=LO$soMjS@R&^&zRQ6YPaJ79CFvg8A0 ++zgK3GsVzDL9q;t|jlz?dRaRS78_u=I8#w@m|xnBu0EnZT6^&IZVOfZKRN$(fxKQnb^YVPN(;0GU~;cvu&xMh|^Ob ++z7SmHZ^|%WW$(<78ou$8=AB}d@bNhba-|Hl^XEg~z8GFylO->K>C>qfXJV>M-MYU4i ++zSmU1tD2I#^@Esr7dLR@TaMVzgfin__4YKt`#Nh-@h7_Jus5l ++z>TtiN>b~So@Cb7r5kr&m;zA+OU5Z!p?tvyR@yMHFau6T|Ul6M-&vY|0oL)->@xI+b ++z)Xxux`^#&UnfYxPWs7V9f>ZNz>dcV2v+0t0>*j(^C8oflr|3kEe9R@25<}s~bt$|M ++z@3O<#Q%W4Pq!B@qOxoG-#djxp_ppQffG&-_^u#LLm)4oti1OZS_`mpLyE=^Ogaf%z ++z3co`cZtH^e5=wp^o2XyJpArPd^Vto~kdZl#Q+Y&Li7k)idLk6+ ++z41zK)4^gGKVVpt2e|I^_?iNUL$eqOI%WSI9`D6)Ct5I94`zl+?(4PMhWic?QS~Jiu ++ziI%d%T8rOuWCfWKb5>Rd3AdwGE ++zYgK|Ih$H(NznI*hkl^k4I@B>>qh$MHlDK{I ++z#7(eINH;6AJQIqnbo07~C~pt99jC76EXw7f9c(}I)Vuzerl8etRAJWGlg|8mMjeM^ ++zjH6JXp);SnQx+{vVfbU{0Y;&MEnXi>G+N5RJgQpqcw(C`iFQClJ5fI7FtTAnN1{W5 ++zTf%8;LdkDexkVx3XJb>AvE={j3|lf950A7n ++zyng49NN-m&Qod74&v}?oVRQ5YoDQUKZ!d~J)N*k)tS6f9F-Ag7D@!^{`4HbAx``96NxMtcUcB`; ++zy6Vb)#3+`?Tj ++z6~1)kOn66mQGMtwF|!m?+g(nS>zDB>a*Gzf^yh8ZwWf2cgYMYu*nB7azxfd;cDAk{ ++zjpOGt0uaz|o&P_6#AaY_?_>-xH8ry{FtIeUv-pqj;9_R-U-;3o?kylt@BiZny2A5w ++z-fX%4@r7Zq*W7HCa$NXRJKISotJ*rfF~ZGjyVFT_qDsc2K`NU`Mmg#9eT_&&9l69f ++z>bT{m=hi)p28;p*jN~u0%|~K7-Adt7Bf9cD!WQ`_LRcSsKI4LGiPFntQn2ihT)sR9 ++zY<`0$2oVnlbby%+s@>=(Y_tHqUkEy;j?o8Zc2pXrr)-?d!69+*?`}9eX?-HIKMSeC`)Kk=1hdSz@*N3ISODSTixAv?*%hb_t%GoS+by1SW<*(Ilh?Ev ++zncjS(Ba$wbks3?N;S420iYwVF@x^W42ZTw4qxtYs7fsLylrAs}khiP=V$hoW>!69W ++zJuhiDy>E_yN_g(%mGneh#m<8*j86N7#|L!I5RE3^+De7%43VHt+Dc#S7Lziuz^Ioo ++z8SYvd(kfyZ^g4xL1L5&l%zp9u2D9L3fTEk5e`jy@E8ZiW!yn*8&<2Rd##AuqQfR{K ++z;~~mmeHFp8c!(WuR8BuCj^6rMAV;Pc8ye!*!SGPwFnRNRMMHBWy_p%M8(Z8Js= ++zoLH~*P)Clo>N{l=caTM`BqccVO_VR1a0Ur|2X?U3>-5FBdX#gx8ZnUa$5fCuN4}Cb ++zf;|0%v^l$$DY7-fM+oV|_mgRiBATW@-F;46@|V&w;7kQyE>uS@maOpjJR+iRg@I8B ++z(uLGVQ?k{jIH0-zm=hQXm||H{1iIefLl9A>R_+I;tJF9`I^I@820=h|ZtiQ_DzbcO ++zf?}*aRHP8)_QLuZqcytcaoK}^Df*MFiZH^5bIO!Pvo|yRrswnYIo4v(=GodwHZf~W8E{!-5Um;>V07~k4%!xnwMQ-f^nH$ZRz$sUzlvqb^H*^@rz{jd$T#$5JvOT|X&ugYW8mI=;EUt&>hf_M ++z$~MOw&Wnqyahf@ByVh?Sg#7@2coVnO%Vuyx*!^~Ey!PQTcQx23QBbzSe_4-^>=zIo ++z&1wA@*dO${_?xnLo(YV7`>#zat|^CVMNGv_S3&CF>L^|1{%T$9pcBHOhjaBK61>9Z ++z@iz4>z0;5JLah=>U;1C^@9umJFxl~kgYuUaFMrtiI*W_eVt;%Tc2ynXbjU2^Hpeqw ++z;}c(z*d5!EADr6l!|BJVHF!Nz^o8@c)(stk?(CqB-c1)h{%bP~G*m{?2WIC$Su ++z9thO^2xABI5nUvEwNv_D^6aioSdBmlymG4cGQqr?A;i;s ++z;Q~N{jzi!36GEOV`fm!2)Nlm6wW ++zYxS#0$Z5J6NFNezE(RR|qgYzHDzSv-5Rp|C*UOmu+7?S$f?SZaxLR=e@Sexu&)`(Y ++zZot1vD_PA*YBq*hCOItgDo9%%3a{(1j@N`6iyP$pT}jj+dAY ++zOthnjLcv2MeZo?w?!)*-uTvGd0K4$_OiJ=x-uS10**==#0MfUIT(OUAI;lx ++zuav<*1!}?Xk?mB}Uc`lET=pP}0kk&eeW@$=P77##T_*y^#}$+6w7eyo9lJNSi@EEi ++zlN_p$-$W5A4P5BcavL*^NAg4#4SO=GlYVz$Gvk*QQUZV%X29o=$Koh3JqDSMCG)BS ++zpeajtr?I~?3QniuWj$g}619qM0~N$q-@E||?dA7AvBD7Edj>>Pe?L9m2#se8^4{F$#a#;ha*vrH|;eD@E5Tn_D80D3GiwELqCIez}mGfDL$m ++zY|Ve>(3W9Ovm|-K0J>E;)k#xm;A9Bd^YscR7Q~Xs#HT6h1v| ++z<&ah{V&nkI(w$5Uw+-KVsNV`JRANpjP6)A2xM!h0ip*IS-c_zj2aX4Ysu-l6@Sh9+ ++ztwv4;$?B!H%rhNvbvX3|-fx|HV~WyasIkYfPX|B$U4;-(GlN~2)1EqpzrBpW?^C!| ++zFl~XDT5feg_FfaC#9+5U4J}NVe0Dvd_J@DL$e?6lYjBUh!kfBh;-3`*yIm?X8}c5p ++z;?7UTim!D%y^;Fb0Z#=N#C^VfB(*#wHX!fWY(?ofESY|+s`ZFVB2$P7OGJ6x9iMV? ++z=+ICj)o;i9PY(ubvh%%w^R2-)SsmKfcsAVFIF91xY%@N|-Gd=NLG>uhiV1UpTH-W& ++zsDuzU3#E1ws~29dH(g-$)Rawgo*yvl!xL+8i5amvG@VAby1}zppL#b^B2l7gEbgZ8 ++zp^Fr#85H{4(YImksvq~~lFwb~su%C)oF61g;8?Di0hND{d#9Iul%WdE_LkeVbRCpm#m* ++zDU1?d6i0)m3a-u1ZH)iLCWDQZ)1d+tO3$s-8IS~tgn#2BA#Kkoh6(c$0 ++zi*hg(HWL=hoRNl7^z$Jgq-CdfQ)%3puY9ug#@@%OO!muR$*Xtr(Y_tj8_^&)2#=ru ++zZR;9~k-M|^-N-X(d4|Sgs`6f|ZhG20wHu6y-g0Xky|)nQQcdXGwF!#bl+kKet8eu$ ++ze#5?pIhgjug|+xF^XOsON*8Hp(Am>ipy7%!XnFM{0xLpGpHXR#q6X?RoVs5MT#F+L ++z1X23)la436oDBny4lKM-Shr~{W;n>Mm0Km45}Hy}ABgk=Xi)dUWKwbQgd>(<+y3(M ++zv_{m8H*wXrHWz&CLs&vW#^er1)?Dn?>wfF>lQ%mkpA!f#NA?&#d9Q7Y*jG> ++z>!_3}3&RG}jWeeRbo-yw6;Br5+!z{E)X=& ++zRU$&H1t^Av9D%r{P2@F;14ZTLWzm^|HV?kfS|ws0N@T|zK~N$JpW1rwAs=24sHurl ++zus0NX-CnNqbB)iOi7pipgguo}kKMn?hmK(bUU#+XPd1n-NPebnoj&|zmRO~iZb?7? ++zfE_}hS~pvn5)QV^HxmzxG~m*#De518Xi|R7=6x+X0c$OhOgJ;FeVFU-spxHxe34yU ++zhh0;e9nrt@BL;h>R3!~|NPU37Vu&KP9i?M|m66lo>9HYyy*|bkP}NGwGSX|P=Rd)I ++zy$}WptXEH13x>~M4WH&$@3Yh#=~loEuY}K_7Xt(NAOjY561UXTU!AfQ8G3Q2ID81* ++zJoevDs8rE0y_Xx=j>6+hYut36HM+Gi8gZlz(HQQ`&5Yq7Cb#Xph_&Fn(2_|^v$wO0 ++z$`oZ7{k^yOkXKQ0qP#W{p-EHHZ-&`vWwA=q&`rUpuj#{aDLgj{YYhYw&ftF`jC5cj ++zog(oPB{S(kssp{++R^fqjfaiVLZ)rYV4SFmXO^DhK!nls`qUdb6{PD>ZS9R&u%8!k ++zR#(#}XQIcf5o4U2$IcQ#wsdHo{+ge%+FBoQhn)i-|Ad@;$n3hu>Eaz=cr_D0@8e#W ++znv-JMKP|+|dSMzh$#N~$T-7456U%(3r?|9fUWkUzio^`cq7W5hG1LnVv``7KW2x>C ++z1{cR|=hwP}U%eY*9USB69nH@N0DCI>~L|rNofc1T=&~{K`N} ++z6$~IC4ma}$_6HHpA}^T@z$uJ>(I5Tmy)h#&PYQjEzsUI%^7Wwlwie>#bhF;!Cs!Ol ++z)Sl87KXEtHTn6nPmP5H$jT@ygJ8?RLTi^1g?PtklZrE&t=Yx2~dhFfH|8-2ChU^dd ++z<+0`$sdhjD;7mMhkJ|X|ENiGJDz0yC4d@f+r113&Ga#7w^#tE}76M`NUTS8*PZmH9>g1fI)w%vCR!B~1eL8xys^P{KS ++ztpoF9DjT!2HSg~eDrg)S-oWe93aQiES@l$5jXa%Zo^G>cyBA20!>LR!QnCpwF6ZjW ++zJHi(#&*sFzRBW+^7%#_Tdunm#{U$waVK>uG;^V5%jj_1wSjrt^yMLzV-qD$XL2zP+ ++z%~qtAcEcFOl)+Nf_6&sx|b4TE&(od`6$R7Lo<$MtW(l ++zVXqAI`P?j9?TXqNz1n2o?X9mX4CP#U9Vcw`mnDs+VD6npEl*ji+L>BQc{Ba-Ztir0 ++zrFH6;*PCIb>964N$W*oAaWnq7jWtVg>k6bBFD4+@)h^AZ^h!XQuKX8n$kFQkG|ze* ++znDwf0X1+1CGUHd$hU%k+SAytz^U0+A6v`CAG{M*ZgJu8c&&@)JyaVh%Q(q4Y5D@!+ ++zt3q=9Qz0Gf4FGm7W;Ql}e+s0@Kbrk7I=lRDIQo86H+fKga(YG5E_4&U>Y=+@Y9+(c ++zVB=Gpl1)+GJvsYKqv=u5N1w+zy7)4GzTC*)I`J(RyDnTe&VW({7XD~-R&A$Tw?b8L ++zk7TbrOIvN^mVcL3397tko4HC_O@D^j6nR4(z ++zQj0{FKq!n{AudKd`O9E8vsbm3Wq*}V`#S0=SHm@ht?PCHSW^>{Cbx!1%Md4iD|ke^ ++zXFM*E>))qvLiYOYUDlgl{xgd@nvL)lI5?^SpO#xVCP}P}QltX!VOA82uAlx)qp+Y2 ++zin-x}kJck!`SX{jgg&dpISB0rJzWlDOYoo(-IR~1JpBX}St9kW)UVJmz9irV_Q-@B ++z+r>bcxC)kQbAeyCw)LAFk2aJ2a|CtVaLmVlb4?}VaN|M4{w9!;=Esy0w0+$f!pR)S ++z;Z9@RMTcDBq~a`-xHI&&w`}v~&-)U$8&T`XYB}3KOZADu$U#UTy)*SO$)AHSiHr=s ++zZSBHZMkf!Gq`(eib44#2&BOn~>tWPM6tvvg ++zp)4?&w?0;?(l(dx_ycPLOKF%t8%e%hI>VM8fxS_=*oS}acNcX(!P6F5OV`YK6zNKgPjVm5) ++zhQ;XDrTW+kBk7fpBsm_7LKCf?w$BE_aKS?S%TOYenQx>>Jvh-|>ES}|e2B8_!!%Eh ++z-Tov1B4psR}8-jmXcFK*Jgz| ++z%%ga(%65vF2X?t(KNV~OXMMGH5n;t(50`?&HC16$%z{Y+NWwgpjxTRdo=dMBM5S?t ++z(BcnVxH&O+9)@p5P(vR$&e!J(|MJ-zOkCDLSB%2MPu((2`%0^!i*gN(A|?KLF#1KU ++zn~<#%N7DU!U;t^f8+2yryK6-PB$oganml$P5hi@}X>QPEG!qNB ++zv~AANDbvaySl&k_h?S!(hT<*dQ{-F0EpH(1^S9QOcnc;|(+ft7x?fnTqZ3 ++z{pO@>1Np5Fd4Kd{okO@WZ**~LiWV(72D7uUOpO-Gs0x=yKOG?r7>(_KKdDMf6%t#g ++zoUKFWuOl9$W>c#WSI2ufhd0!8=nL`+3~mxYa$!0ZvSDZcG49LLYl11Mh546o@IHnXy81l1Jx}{&Vvjre!ue3t4U= ++zo0{%vfLQE`!;%avoP>(+JPr}EsAxRwZ~xtYlrUcxka}W-7WfAWaJ)Ph#2pMH7le8- ++zm=&+pt2{KLa@%rc=4%&+6~b(c0M*r;4Hv#5HLh}~Z%?M*J_}jVabDexj*iLjqapn} ++zOm@X1*^i1|+w+I=eoOQPqyr1Y?t&_;veFXcgY&!cH+9jeEG0|vg9m>tA8(}H*YfaO ++zM)|>q#Y}ncayE@W=CCqMSYW?L4luEQE;9)4jk5*;wq ++zp}N}p;vF#>fpvWE*lEiL``c)Ys?oS8{Eoa{{Bpj>)qBaT|K$T8b ++zZ+Rl&e#mq8cLEi4@+peu9c|ChYtvz(v)llIUo12V&AkfQAvt=i90_x9fKIj-LwQO2 ++zZ&_lBsOR(fAu)n_%KPx;4J9Bu^DG&ElC_Id1+HF@hY{bglfxQ!&;@*A$VCqCJU!Nl ++zl${V(3X*(mn!2BpSTr2Y>(F^}k5COc-M)Qmiz>=yajlt-jt8l$H#k)Cvoer>COMjT ++zj5M6x8hwGlRPJ_!q4|!qM8yeqH5)&xNwVm?NDRQkyr#`-9>45H;ba@NRqQF|G(bTG ++zUfi63HNyCCK)X~S&HJ=qpGuxel==L0!fs*G&A!^RW%K*tPclKI%tX-r58(8!JNsFy ++z1M0A|_#yda1`nx{Cwa(kVbdV7sZ(XR3=mB`T$RyWdwT%lRuy ++z^(&9lH{d~@eD$?KmSXW5{%!JGpq;}ePor!@dXMr!f&$lse#UUU*RXXaO(PD;R33HQ%9!Pfe_znM1s*X>V^^3ta2JYwIs>S ++zQ2T}a@5G8mZ|Eew!Bp;rNvybEt(id%MeZd_Clhx|^nRvwx%W_00Xo`g&A8t%Y684t ++zUeWW@@7e82`&=LK+p6mPeHo{ssIhrnXZ#ZMBlx?#f@X>xS}RN3{hr`|x2fXBIEQ>w ++z*EYU;*$Ow1F1W3%@AaLu{A_z#(Qc5;A%APf^F*B8EWzEm5ID$Y4>oV&!*QqTOxFB> ++z{8xQvPeu7Y@1MT2p#cQM^xq;+ZUa+$6K4ZcCxE%j|B15mrMfwC@BST0yBKjrjS=cw ++zGNU9Dj%tZAwN~t5?|%n%5shvtt(ZuheE0M1_M)(m(^JQ+u$j!6gLz?R|8Kben{l1& ++zBP)8&1FM@hK7La{yQ%E!wDwpHngRTgYqSJ+Mu*@RxL{}6eE&=auZEYg--MYytUzeU ++zh;@v1A`RSAYw~P-bL=0Q%g;@GkGaJ6;rY48ur?(h39Bh&>To7>K5kG`|Mmax@8^|Igni+K1K(LolX ++ziM$y;7`_|8bO>E%3DX4Fi!?5FUAB}GvYd0CrI0=)4bKQ$4YQtyLm3^ ++zb7Q=KFPz}C$DeHilV3tn1(vh?DrB-Wl%_|Yy!*332UDtBN6%6OfFhmRi~nG1K0J0| ++zVGh!<6ub|NI9u~`ic+@^B9_P$U7(5IeqhCX5!jt)b67va7frAn)#2mytJg1dwc|5e ++zM7)1bMH-6rdZF1?T7Da5v->FPt(i&CpDJ@E&EnO7sw=a&P%&(>phB%F5$G`Xd+Heu7TSM<9=X&2_SJ?aT ++z7LA9$;^nnD|K)Akxn+e`F2D*GWXOpW08hdo&4PEWFJUq;mzYCgr%MUdMI^V&fl|+a ++zGv)?P9tvbkbV$1Bru3-VUQ0lg?{HYZ3m9NCt%Bc*?*fH-)(gB$vgmY8FIh1}T!d>k ++zR)S-;)#v>mjJ;ErDACq!nZ{0gr)}G|ZQHhOW2bG~wr$(Car4}I_^RDjr>cHHw0MeW ++zYpy;=AK!8CmuygXuNe0S&-WM!SUu0<$SXNtY9fr)0Nd3>p#b+BKjr@YP_2Q`B ++zNN*q1t~JrDp`_rrI}-g|?+KiI6m6w^_G??iZLu^8@HUkt=Xrz3f@?I!L3F5`}Oiq*x5uFLHD&4m$l ++z{{F2+b7Nzws_OXpVbW=Q#r<9r-vDRCZm?jll^rYa>{ulcgs3L61|jSU3h!A1oI+kS ++z=_wn&kD=-_PlM9eN2l=L*k8Kl_$wLr6>k_3S&56CeWiy$8Lw)5K^e<)ab3=m1!{Kc ++zkQhi7XChFm?gl!Xfj}FtC@HSJiXf)1Z-z!bf+SFS!(XmfjUUHBO4S< ++zStfU|Uh>S0)aDFb?|vphfn5*B==VR^4*{IYr67JW;;{4Z>JF6u-h`m+0GjfNC;)GS ++zXmnb~4HenC7a&TBZ9zU@`>e;rRAB{L4}v~>p-0*P&C&QHq}OJ|1UX4@K{BZ%>>rAd ++z<)G*fiGyHsH|Qlz4KQpO0Ppv^q260a3Vekq(a}UvhS$=de)jGZ(7546z7${5=i$Xvls#%#TI7%$pD*Lc%sheFs>6h59fQK|pQso%gZ|AZIG2W>Qtv@S ++zR3|V+oFyTyjyS@%ho;y!ebCE&I5wKTi6g9kMUvVpDc68tzWXr?y1yfJ``jCLeX!v#rP#M ++z1ypA`BjK-QN44N1v6vhs{r-r5T9Z6Q((wpb7EO+s;y)WYV;weLOB>=xvNDh0fC=>B ++zka;PnlP!kfuD5?Bt%8LVpYOQ#fMs(mcm?8*%$AuC0U;eape(>Z*U(g7^AI&CLt~|i ++z*lE+QLa_-Ia`PhQVBiC*+k$@SF#LJBZRvYCCs;MJJQMM>_I@_@jeLpmRyAexxd}!F ++z8ScX9zn%EbCFLz)BP4G*&$+K`F=6O-ScEoPA;rJoyhFflhLFd5l=VaHelkg7$UxFF ++zkT(+wpExn?tdWmOHF@}~YXeL6 ++z;;oXs&!&l1cIzn}GI`F09!*+M7d4lviSw`1KG-~7?%%MnZf~tl=6voOj~vgvjuDUS1E<3qS ++zU3Or39=H87clAcFbjba_Ts=Q*AN0I{Dph(pZeCz_j{3CVSe5)a&$F>yV(Al79Oq|Z9q?3G19z@^m4-Vxxy~=2zpZ^@=YdT=L<@o)g ++zo&HAbm-Zgl=g4D*z`eiy^v0vVPp*92=-s^9-8$6l#OJ!fS{JuSW;8mcUbSrHS@+!_ ++zdG^QO2Gb8gq{j~egpL7Qs?w#+WbFN&>{t?VCvT+}McA*DrK_wNsLbuJcjv ++zrX$Pv51<${vLmft0Xbj`QLIY<1s~iA1sEJ?Td!p;;v|jUsH{}$pK+H#AzRHj92#Km ++z%BzIJS!77Llq4J-Aj=A1vCc<%xEV%a%5VU8<|_`w!R<4359sjHNlqw@$A*)lPS+QE ++z@6x3tU@nzVvOP$`v4?bf{zO6{Q6EC|#a0-_kw*dkGkZ#n4Q=M`@Y`2z8pg;@^Z70I ++z>fVyACMF?mYb`Zfju9~`Q<2S)ttN~*h|MHbCB2gMkl!BMdp3G ++zANQ=p9B8LXWc9T_)~;$z=Jh>821?35rK^9_978hy!b~V+5UJSmQT6@yYX<*NM+r02 ++z3NIcOYCI}7NQBSFk(%HTCB!khsvrQ1_f(7bC%gna3$b3DZc#?MHuwDO?V#T5f_70c ++zx8`dy*=y?hNatk2YMizDa1^ROd8EDCXu%QNVD~Di!?r*>-5x->(ewH2`z<_xx3A?# ++zkoY_(sck4>0THOc+ ++zi&Blfn?<$+HI9YWRv>M3387QC!5+bqfiK)wv>rD3nGjMiyziRhowz0T&XXL;rOVQ(y(N+rOz`|AK ++z;F5weuj}r}hBe$4gH9nX$Pe9AzxZIn~dA3T5e1iQ3i+9yjYMg*;Z-;vva`m_BP ++z{HMUEmA{}oPArUFPTj;v#el$%ia_J4!IUZuYN!MvvYtR)4TDh?Kw1hHHO+NgS@(I{LYBOD-Bj4r_hYu-W(3K%gS ++zTsj>^F1~UUU|a-ra%2VCngrK-gc2iWh=Oj=iYck3ErA(>asnS&~%ya%dBg=a^R0msga`$E@xQTvi7DqdhBk ++z-ZW;(oNtf%A6&Ip0;!Z_f}JLEw3yErZzKY{mpwF76nt2Uab!&Fk6jqkVLlyfqBGQf ++zyzMrbk4@M^aQbM(JS_)fP#a+e3a9*|{N?Mvg0g{N^x;Yjifm$9WJlH(NJVc^fsf4Ih$33(Uz ++zv2s~SyYarq1LR;yTfO^_r*|Gu2bXK>R#!j&GYIGTWN0zGkmp%|004wS0s!Frw?SAz ++zm|sX*n8w=Ze=E+iyKnxV6lc*YlC}pd@ZA@x(6ymJP!rE!LoV@xfFtsTq%p>;5`o!7 ++z8W@^diiHviV;;WVp-H&wY#Yx+_G~wACLy?bTKUVKak@lY4(fHbx0R1Jgo~6^YTZx) ++z|BRdISL{L&nH$tV7)_{{ocCZ??3g>>v0!~+YJ8vq3J<~lRMWRaBppj=3>W#!3hpXb ++zH5yy`Z!cfWjeb#N4}?H5)ylu0%J|cgDQV|5_F_~}n6xE$i%D>WW9VV{qtI@$8A_pQ ++z*Vzp?(6rf)!wARJ3K)(ypY=gUQlH~& ++zGQGi4BRCX8y<<7e;)3QYXdXgIuA-#bcJ-`p3J|9oDmj ++zLTI&HJ>ffaW?v;KehxcOJ|pXe>Q?nATzhW&l92PKBa9qpHgr=W_Pgo;yK-0dlN9jZ0HHcVNcRkvn~0gfT-q>T2)B ++z8acm26p^qrbY;a6D>Cuu(~hMr(R@vteCN&NBkIBxo;=YrKWC7>9i=h{`b8d&E4p1G ++z2o@loK;>mJN7$Z=Y6&e69#t+;x>PA3ITWO7BUuVMQTK@A+Byrqii=@ZjaTw6rw*}b ++zeR5_vmCEhJs-~kK`DChKC+D82f4MW4$(^`FG4=bSh-?XERgp${2d9G#A{-wmtJn*aZ?1x#5Sw9LF4aQxHBP==YZr< ++zp_9hh?cA?_6tf}Tz{5}CVL$aVdcH1v292$sppgE~D?t2VE5r_i15vyKI;J^>tA7}z ++zMRkUsOhe{@7JSvTae{p9zWolMYE)(>c4gkuOillp(2kJqU@i|KGd4v}eQIg-Q(+IE ++z>sQ8&;JO$nv%f&6y`X|>kzr#lq?(lX!qz3<%-<1;xilIefd=A>mBn+-lO$X<$`TDE ++z2}3Z|^kUI#!9!;~<2@;Zyw{=|TKjBSE68@)2RlSPI$hV>^u=cOQeME&W-Ficbjm3) ++zoH%(id&!b6ES{9b-AD|R#rFjRxXiG=5@4c=`9*uYyZGil ++zca}RVrjntcu3-y8gyD*98WnKBJX`%Ji~VwK-sro1s~9j--e*q_j@**A1w1N;=mxvE ++zSc&`jf0$uXcnO|?e#MQczh)Tf|Mmx?r_(jiH?*{}HPtnBu(h@R|EGhoc3c-j{Jz$u ++zpD2QD#q-a))JaCZ7+?v{S{(+RzSdsDVonB2Rf}>W9Mjpq-KjYdz6oH%w|ahN`6n?u ++z$w<5PXtF6Bu9eb_@09-= ++zY6|C=jt5Wc@qcsUP@`XN%+R}~Pnu2q{t)L5bQpvU8c5hl ++zHc9e*d8{E)QNrdT2LU*Mu!;j3=DsW%Dqxri1MU*)G2S6et^nH-NzefexQl-JTuKh!p?SoT_f=sy8ii?QdZD(WTm#MNFC_xvdKO_!( ++zVH(mr%kn3rOZxBGf`J-KmCMqw9xY;3VE8I_r^!@AGCbEAs<&JgrCC4mAaiIB#F ++z)hX_A=IMwV@JMocb=wJN@GLMU4k5|cgZ%>B)7sY~twDpvz;oT};bnXfYTSK`V~E~g ++ze8Gr>v`Ga+5(kI8_@LHiTYx~^(WIU?3Mzrhk0yDX;b1B$*(Q&UI{hx&g9zkDAgkG2aHO?-YRTLUD3!Hb54Y82T ++zOWVd#zo@wWAYZG!b2Dd@ivY<*a2B!zm#85UGVHNTjTIckv0q+idE$FxyA7TwgM|sF ++z4H*4xg({lc&Rf!^)_ugv`d>-Fux|4{Z}bWi8?0?qwPN5}AXSnV{4^-hDxl{G+|QtWR@~ ++zyo0DV1#Rqbt$ktzB*qsU6BNieNM6gQQfw?tht(LJRwEPoS+RQ3CYjUlR`XbUylIIg ++z6Z-^6oVr_#ANaj;xswdy}32tmT=f=)Zp ++zmb6Z)UD>6YL!i)yYAwMx>1=&~oVgkMvCR_@?Kud~^4XsWJEK{6b2wL(t!`EdK-J6f ++zB%A{}1{R!3{$W^x!K4r9dLx5JrOEr*Y9d$7uw%jJeo-K^9QrgAAopjAFWI+(w_E9A ++zwp`$XJ`0`itSmf@QMjVA8F9vb(*R>h`Am%uDwRSDjf-b_2LJa ++znC8^jdhoqK768D*kXIg2NT2`*84Lnv6~UE%0-U}wYqIC$j>uP5puaIFbjXEEJB2dj ++z9AVh-^aam&%rl)zxh}OQub7xjyl{MS+c=TR<;dmmHVv^Vu ++zCypxP(II?8@snzK+&q<<@z~T6nr}*P1hN#P$-wgx6(>@{%h`xz8XwV(S(2vL(U+JJ ++zuNt8uEQHR@P9EpO=0}812h^~M%gqi!tZU%C&>@qgJ9EMWFUmEj%F7AW#d>*Arlyl{ ++z*5d{C57j9S0Y)K9BI|)pbrqGgg@qJz3(b@S@mW>$;rqRqeRc5>O|gEd_c69I8CfAX ++zRo?p1wWk5-jS)ysn8Z$K;j~JVh58nzy5n0}|BK#G@|-jTg4}SXLP`Qwcse7qJxYHr ++zcQOcig%5t7RTOnH(~wuVCKL5=cuo`{Z+!Xwa@2Nw9hG`{K7eSBuAN>;7O2vWhU1V_ ++zX$64%<(f%b-G;|E%0+bK_bn?8+cy=}x9*?F%y?lcDvi~bFkw=tE%kT;lX|lhVefuC ++z3d!lv)gI#PyHV+Js(sJ{N7%#HD;7$G|)*gQMhL6gotM_C2?wsTIq1>NhS2uR7 ++zpLtHU>zAFF9T=IOCOF&x47_a2lz!OQhXTZtw`Z$l^{1rE&Xxi ++z>rP+yd0twel4?%pQ9F?^Dwch=`I{b|tLV^?E*<=cFr^d*HlrQ67)3)F*=f(MzSpU- ++zT+R9zqE-?clcFXB$-*oOOeq+4u4@>u9}HJonPKoPeOW=_=>@6!%VMUCQ33LXQkb5h ++zqtMk?(-~xGJrEDD(a`Q&j7;F5zu(Xd$Q$rp41abF+02n0p!w{hp^l!Rf!2~&)CuzD+mR)E%v1@N#GCfj ++ze<}oCvsX;#;+lWh+Ss^?uo;>8E#B7j3p!;_>9sNU=_o>zW#w}vC ++zgtix&kGQWqD$x8k=tUvrRM0-&>lL0EUv#{)jS{Ngqtk@V=Qxmg9V^B(rauDMcC7+6PM$T; ++z#!kCP(2}p{>OUuyIh}h~+oTuCiptg*`aV;c$5L-d$J5vAvXrg-Se9n@G*Or{2r3PY|QRX~U(QLXf?F4C~*X`rsc}D6^kQ|mz(oa<$Xg2SMFjRhiT1g@zFK^iWRPbJ|B>)V8vL@** ++zLA6m|@%C(nbog#+hEb2z``p10Yx=%)5{HkYZOJ7tvs!8*78nglcNH?l?#w?oL|S`E ++zBpvH(u6x)t!Mt=|Tm7s9U7F$JNx}_w1TFSS`w**;!9MV`9Mx&JZJ@AEIGDCQ=>E@D ++z!GE?jm((;TAYgv&=;8nXjQ{PRO-HY5Xs&CbZ*Jx6V61CsYi;LX?CAKbVf_Ez)%+h} ++z%tBdV8QFwP#st~Hxsh=65?ivYLwZLY7e;yz(IE2Zy}qdP*N!b3z)s<(TvB7(q!AW^ ++z+9l01jGcRjpjhyQq=tE-ge||et1#0%W}L2*2`CLQx93^w;zQ~bB@Ccd#v<^kqZ{6# ++z%4e{?s4HNktac`psJ_?U$F$}-trZe^ChUkQkaXj??rtkS+0ArX4+M>?t<{T#$r@`q ++z;2<@h1<48kYs?h)JL=!Qro9J99@1T_5%6MAPFIUZn#^jtzG;{)t<~p$Cbd8c@oL+W ++zrEdMY>;?fnyXo+uE@hsL?usBUm|`HtD)G{gWxqKbm(|IIds<`JnVQo;kZ-i ++zj@&&eH%z>4ETmbmVlPKQlCzQJ|3wxC)EK ++z4bs=mw$BK9BJf^McFViN;wJM}p5-~-=K^Nv3 ++zfx{n$Lac#j++oQ5S{WBUC9YDFKNf8N>^r{B$$sbt;sTljBt_5ZQh(LM0BdD{BswpW7Rx+Ts#&)N_Pqr+=tBBvLo+hK^-C(ab3p?wml7OIs ++ztR1d)=h?s|sYP~;0&LCT^8@i0ZWxVXl69yw{e9`X^%J~i*LC-s@KY}iisOC23o-VLd->xIXlN&m=B1L~J6IEZ>nzJ-UjP-zZN;+|W$92>#9FpL@5 ++zMyrO^j`lY4yt?kymCqji=r8O}G?*J!J$qa^IIqA9bTc)q9pwzNe>{YWK?k4&YM ++z_(J`TV#o~FMSQBt;5=;C9{G(owPP4#+T~utO1} ++z5`ZM4fGG!NTn-R*AP?*yP;`No=k@4}gU{*VJh!j7EOK)be ++zoUf%g54D)5l%MVIuEB~Eg%GSl@4zgQB}*@q4~u5-cSg;c#~*^us5O(R=NUB26{}pl ++zL}6E&Ik$HT68b1fzYF@K(HUkNu}sYcJk+eWo;`cO7tJxL2x^ff9sxJ_Yv`T?fDXxO ++zaYpy-{^Vood8CYBjQRW_be?)EoXzcon_1ZOw0Qsr4x9MRtt0wYtL`wlLs0xdu{`Ly ++ztxPOJb-X)Cb;M%`A&8{HI3Wc1Y%!{IBO+kO7R6_$c!Z~W#QadB)_h0CWeEXNxVDl= ++z~N8=vCvYJS}p#g<(7e}WZd``%?M+gnT6%Svbrcq7{A ++zLoUKW_Z&UAdVET#s4L{=4drf08KQEn;v(~?%Iaa^3kE6;8LcC-0?JPK!hB-ig0wF2 ++z7locmaQ?RE5u@MOM=Mh+fei(&p$iRr8Q+jLMAk;p;0}0*GZ^#j2WsU(t6j&%h<9*g ++zVuv7h7`;PI$xP^WPPwxkEMEmld(Hw6g_E6&cH ++zv8X>QMgaP56$+#~zOsljdtb{lg-_J#+#Kpf%u1OJ1UE=FEmMn63lE64o(9k3-GiQd ++z?})T!E)8`)uiu5d{RTX&jORJ9LqX8`9ro==%kJClX7_sTkZ-{LO2jwHJ;c2~i6E)+ ++z4B00S7|&Q1y=UUmDubwsssk)=CqjKO(=I$?S?whMpzOIfrL=H}9uqm2#Y;U6`DFWz ++z_j^x9yJ-_v9@ZPatc`YP(W$$G{cZL2BK7gwW3|yh27?oZ&!o#fpdcIsY`fzQeCcb@ ++zRCu_qavozkZc1)GLPZDmGR`{Y?owPfa4vBPU(hLb^{W+kX??wcWpt=(>hz7oB6Vra ++zx~qaIBV&>_rJAF&){G!}yO3{}sXTk03_ifedwMC)`c-x8P2;@a;CUinY%M*4Au_Y7 ++zMf?yWVwxwW{A$#xaTV6=uL{j)T0GE9?u(3}1 ++zTB&u0lIlHPTIKA-Gf-&=XHai2a+4?Za(?MLf-7*u5|*>N%- ++zI||Nf0_9u$Vn#zts=xd(%q1AOb7Dezo;`&d7cm0{6Sn1jnci<2JvmZwTt>V>BZOyc ++zJ`*fd$!35-qA(|ec1*3r%o ++zB>8%Le3n6*;r-R(grX4*5x=!0K9Gt%t{NUr&?-zL?Q#CYRnk?;U#P}$r3<;!dGaUp ++zV!akj?1Rb5-F&_yV$w7}CsKLT2ANJ&U3zm}+-0~wHOEAbRXc!YhTMf$Nu^5;ZedD$z|RZ89rIHlF#cS!LE-Y04)*#JRkwS~$lY8HC?0 ++zjq@&CF5qc`MiWNd;Tl`Oqq%_9Ucdn80aGHPK`__SP!~oanN>7L8W|-e3ag6aezW3- ++z&JKZ*#y%|ljzFRrzSnx(N>-oj(2jXs@@QE}eMKm0EBGzRnbkcCvkpY0ec=<|*)%JlL*-oYTuQj?viMm_v||;c@nknmEOJPsT{@eb7bhpM2?K ++z2-o=|eA3L4gtEKiN_in>+gia~Q_$R)1{8sLo%b4IDS25EZmm^{q9bb+{si3w<4()| ++z_WLsECh4l~PrF*>Z%6f?n}%5Irj!U#?@73DMW`>aXaHFT!Ii>%{7(-!xItG=SI+;_ ++zd+$0N(KY@Xk@H~te-qKL=sKEPJ6ruu-K(pD|`~XvK{bvha;ij^W ++z288gR-E}wrTqV*y@*LRpW62zLvbMr65Eb-8+O~k+b>ZQ6ogCmf2x3D~;}igXLqp%v ++z-@Ty!EZa>UAVIJkmpkLQs)p$u0LGF;ZK0EM6N=IzOt3;qbM~i^a-3>fK&by3?|Y90 ++z(XT1PfJwk#`RA|e{DSK581&oY?|0;Ss$H3;RU_OnposIiEe>2KZ98mO2N(FzqAlk# ++zM%xiaH7~X?R(b9i1iE2jmZVrl>4btjg%f{3 ++z!Wn#*8AjqSVBe0%y}bEdH~W{N4N6Q8$4Tg13^4?jD0; ++zU}_c&*?WNO{h0#+mJB2YFT}PZcn8ekf1|Mlt?oLD8N`#v+SI-~-e5x0fHjtA7TyMc ++z&$JEA%+9{a6B<)5+eYTHB9w=#Pu@BErvPABy*Zj&k8g}AU545h?8gfjyVB$+{Im?d)L+=GuvIK)kx{9R*Uc}WKY33wo9T+P8FkvNUsdw;|s ++z(QHX_5`hC2Sw0L27e78;;>0Zm0ZYCj4bhnf_RujGwIz?vx5c{7@JZ~$0 ++z+qj|OXu7kbr-0WqwOG4fKUT2;46ucJUr?}iE$NVD9Mc#)eldGffOvpd28cjy`uDk{ ++zO*(x|zNk<$VVJ!17;J1w-UQ)O(ed`stEE0Rv$>K)k*0~B;hza(?ND0Am9sbKO@w4` ++zWjg}Y9W-=&1cxdF7k8@iV{!WmN#njAvSArLsr<5`z$xxNxFt*GDKR@;+VZalmU2LJCCRE4V{m%_6<`t_7vq4p^v-5g<9Bz%r{_XIcKT5t@pI7RJPOaQ ++z2%v0#>u((gMfC2H)sH4;81J<2H|F=bUepgm?#mqMdHY~dt6IVzi!7!q)6kJEl{*8# ++zfnbvuKspU`)T%R?b9e?1X)6?<-Tuxn5;^@XF^#(h{DOxIP7arTx`J#Q5zHf9vRu?+ ++z7ql64Y(Nzr3=2p0zN-_;Xt59vc`D ++zeLQbIO`G%5!8%LK5NBv?^}^@6)a;a+mZXgbAmuQa98!sIG6EvTmC1)5e1Jl`h&rTu ++z$P(6FVF#2I4WbC4M0K9;nsqVAvfx1VnxkvKNxz5AYnmq-eN6+a+i~l1cd5ViBe>sE ++zQ!f7`BQ(3dQpcgU?OhQj%m%p(2Xcz_oK#KS&baA7E76S!>9RG=;(7TB&h|O!B;^RQ ++zgeq5et^i5pC|PwNN>rDwkP;eke`C0Z($Z(XBWWU3>y-6r8J9lRVNE$l024ha_)Mqq ++zz~wac!dE}q&`tzKXiSc4<|^g0a;I~W#U}pBX}#i!-S3HdA>xV_5!oOVskDeZ!)1bn ++zjr#IF*tL$yw9%%VB5fJta;CVp+ttsAGA(Z)NKPH|MUVba`1x08u#Sq{pBh#&E*t?! ++z>@TIaSf|SxsjkSN2HrBK ++z>S6s#=e=uQz7|m~@Q1moGHfZ_4+|c*!QP7#P$G6T ++zb~agv`PyxmQINg6UeRbfR_6X|Bhthcu3L~vQ}bhj3wDNkB5Ng_vv-9z5s5GiRm#~e% ++z6l47j)*E8ozyzoq+a=2&e>N63ZwP|P09qOpiTRw(B?`_Us4`y^pYduWET7spf5npl ++zeo>Im|7=6Zl6axNPrBP_nsKHNG?$>uf|~t^UlL_R&m%cjw&gsR7jsC}%#Y7C_8DDQ ++z+FKU^Y~S7&2#=Ji?{x~wAjKYFp_+9!7$6^Jgd&+fUMcIoNN4gfozYo^r^KO*vXH9s ++zMkhHE$waD8>o0|t5JTib3>@Z@w@MSQwl!=VX5BtaD;nP{A=K3yI3HUE1euyQ0cfi` ++z7BG({m+Y6}N?gg;EcaE`ipB@?V ++zm5t~zfMVIV!QYh}UX-tNo;xSzfuTBz_;qWFr#j99(*QH_|9bZ^eU(FtO7 ++zLi&6!mH;Jq4|m75W;PSa4UB>#-eO#W+Wj*g43>7ePJ&lx62k-5UO41YOus$!kjwG| ++z30#fhMwJwGLQ(aG&GitF@||BA1s ++zhJ^p`k-Da+NmD~|uW35OlHC2UmUx<3B4gb5PWa}y^M)rlQQ7<`SxBA04@S8C5W`y1 ++zk}R*au7RqHdJXZ1-2(Go$0xO(c)4Ma<ZdK3D%zhG ++zMfZVLLDz$t(}G(LIp`BVBDr^OrxyFp_{pnun62rKfy7fltXOvko)IhvcsJ#Zb ++zz48l6#;j!dxMJs7Q&U5(1PBT)Zw}MUegcTwxYSJX;ZcNOY$U1>g@3G ++z9m)zuQU;MhyYIS|G6MVd8@=ysRnzw`x0D`wZ+y;6I^>_itMe1ryWgj5vA2{}BGsBDGnJ_CRV*;%;&)us3YSv${;*L5SgT ++zjb?o9UWG3RxX;37d9vbh;Id(rR08--_|;;H^#YcqP@&VjRPWQvn@va{tu$eV`1~c` ++z3_*OV53y3UGk|~x?!!%siQK^7f_lLGgWSx8pjb`g1d8*>-EzYO}Q)#$7q^)v(Qa ++z)$>?ehssnTC|vroZE0!I)YPoHLTkHjtbuycG&DfCu^TkB+^iqYU2EGS03^R2VudjH ++z02RSxJ7`>im(U}_`-=gB7b25zP0Q`Z ++zP_gxBVKoQB6ygH5&vG2n6%d~Qguq8nF(OWY3+c+@ ++zShLzG+X#>^3G_;9@CC_|HMn0nKN?qRyyX*~l!|a6dJqBXJ286_!Y0ucRBjv(l2bWY ++z0bF6-I0$1Bjsa%*q=0;BkY^x^3KN6eT$*|Hd-mY~|H`dxjTTwP1)s3Su>iiTK;j~e$4(Hg&pqwfZPAJvjD06O8n9>wEP#Qtr ++zbZf5W5g7OR)17z}n3b%m#Be>LxtwMLq^qKQ+eo>Yzud5V{Rn>T7>Y8V;(P)jcbbtD ++z&H-KE#G-OJk4%;*nv2Phmny6Pk-UDa`^KT+uQDZ&!ftIO1AIAb%6}%a22*8SH1_Z; ++zEskuXRuc~wicLw8b2zbj1>R6w4rmqj5+4`bMc(Ptx^>eFQRxq>uiA4!jqi7>EK)Nk ++z|GG(pbSHuytc)+gm??+Is5>qD-~vBQ_{h<53>f-@*LNL-lyp=SP7BGnod@LB#-K?Q ++z*LR%d`U0)1_2~nO&ZrWuxQ_v6fUK1)Rbh)0W_!1n+~hdGV>IqNVcE)jQq&}ot4i}2 ++zns~A^p)i1|QpIOb38NwC`_iYgW>xw{~o2^^HABaL=%#LE`DnM{i&ySl3Iv)SDWxcHz ++z&rng}ze2mP!?S-}H#xa4vC;fhyWwb_^d{Z0%x_z|wl@l{?d)c+vSA~tv>+HKZ_!AZ ++znpi|bN4C1YZ&L=ob;NlKd1VBCkXq`wA)Q_3Z@%WYXfGz9tLTO5H4?u9ZF+uo_O{3N ++zZkM)mDf7(SSr!COr8U^N)SS2`>h@mB823hB=aUeDs?-z!fbT~!UATe|sUc7WZCd1X ++zB(NZm@u$Ip^aH6(U`mi_v+vWrV?yQF_sr5KR<3ESOA_;Z*si*E3Z`1p$F@f*nBe;r ++zk^P6DUBjEc2c}NE(34d^x1$iWT=LY;$!0n()Vgs ++z!)iM6GKMHp++P`i<(rXPDHrJ@Jgp51jgfoHOL|W$Y!K^3uF`xViu3Xmu&hZE%LOO) ++zg3C8vZ{KmIHV^l2%{HlmyNVfpucP^0s3#%jPG%*^1J@tFQMap(HWX%U;-k;>poIU6 ++zHc_N<*o&)bcr3$u#to`cV*Ku0ibEgZwY84=y#0j_3}aFVWcA*Z7HfHo5x(0Nk})(uAdHQ+v1Yh>5n#2W ++zLQ0Okg_oLaSv{=>WL`b%G8dfL^TTeFjd#phay+B0L@vEVjMEv-Ycz!5cWF)**AkS+ ++zsp?~%E|ao`_ ++zBmN^h6o|BSB3&#HG3C-xWl?d>XjD_RYKx+iT&P+W)VHxKtXn2lU*cmP=%mwvle&Vd ++z(xc#guP1B;fip%bq??bLmDc09m_Rg2j`nNiA1fTGB;A@Z5u%9ANEs-N;HwQCMKpso ++zLM;azL?UGD+`s!Z--lLMQ0Kp@AuuQveM2g_5AP({*v?&0AHp1eUA~$OSOZn@r+{A2W!G ++zFUINC38IKMGl2#XE{_&(85lRD3BfP^8;t`xNjld_IGJK&!$FyXv0#o36Qbf9XEAi7 ++zAqF~xoHNVeH7%z_&S9Wf4>KZBWM~5tTFZP*bYRC6!pmF9(~aP2=T-8HRa_EPP@EY) ++zQz~&c+%-vXB~E0vPD@-tDgOicU3Eq`rH!VS$iYBZ)l`oJ7+Ntm&?!)2*^dTob@I^RwN)jZ1C ++z53XZSm__q~VF(KMu%nqn*H|;1OE})%Ks0XrJuRn%Zl;GJz$pb+{{Rw ++zVLw%Xr!7Xin+w!J1Z0rW-*|(RcCKjI<;c-*xZddy80=3}Mt`atLYdUNFqpt8i-O$P ++z{;X(Qit5w=&`9gUSB((3-h9`=81!cX<^O8zJb;>9mp&dqnsgylL6J^^fK;hcqzD2* ++zx*)woLMPO7X`x6}N}_ZS!2kl%AyT9_1u2RUDFV_vN)eD7zH{z(aFoMIW-^FV;fYF||@1<$EqVBOjvfAme#sV!}GNE3Zr-T@b^q{@IPdz092q_uERmOPgKK ++z=E~zZ6TekWQP0}FdJ%avAZK{7BQ5$sEcWTM4q%5)0COYvc6oYHr*9i{EybwsTUEh~ ++zUgpc<$fM@u&I0#4D8g6V<2`1PJT#e$_Q8(K8v4~BvY}7I+K$YcNCA`ga&O?Q!=k7t ++zCQ~{iBTe-{`~qxjab`y7Q<*78`Buf ++z^0lbHxc_$G-*=@xzNw~>M3wg_NdSN>8UTRjw7XIzZG)S-3aU5FZ!75OsvG^U@ouZ) ++zw~cpBMp$R86#akZ45{ZGzgTxwab`x~MLnDMe5WP-)|3tI5Lx95j$}@7UsR9}_`nVK ++zxR3>ft+ruD9nGJ3AAAPK)^3Qq>>(O%acCt30(;S!F|yY``|V2~ao%N2d-0LMl6CD> ++zRKr1UB0NE5$tlSurry8fDCg}Lqop}0dwalJL}T;IFO5RXH)$^Y=%|+e;07s};?={I ++zGbdpdVk!(4wyCnFc4U0}B-ZxmFHP}|PO30H4_j|vaN2$n ++zPql8wj!nEz>Uj-+X!27SI@hg0wm;PDV(v6_pt&HggaCbQaenoK)-*3mwy~v+Ko~M? ++zB~|6gUAOZAB=5j+n=~l|qYH|aS|2L$U$jEku~LnJbR`O`0oyfOerXkg{AQyPl<^(? ++z0f4rpsmt9OMcQp$4rAmJQCkmC*ntEVlTF!&!+df!mZO8ljK*$-qhxEL2*VJb;g9lp ++zx$0Ms$`a%!{T|&_C0N^~G7SlwPj{A)k7)z_SWl+HO!k}C!1Dynw4?nq%V)S0f9Rne ++zP0okFO;`13C;%eB1D0#nB ++zNr#Tcqsj6*qwq0LuA_}Gf7bOZO;kw@GB-XBt6Q;CB@3hEIIvK@n=vQSy3`*cI}6c4 ++ztQj0?UzLcXmDhCdU>jAVcR5fa)YA7S9`jT3MXd`;PA6iF>d2yrM+7MHB}50(Y&_9$ ++z-_I(Fbikqr4dPyq@i()g?#WikOC3eZ2O#h1O36-UIjfC;iUs@S4XkT-SH|Qj;>Rvp ++z{wh5_`}+fyXts#4)uF^7j=}1KkHcb(S0THGT`CnojT=wm*$AC(81yMMSdl&e!OakB ++z{o&R|sG#cC(v(9Z%blxX9Y!9d3+(L? ++z9~o|KC_sGIcwyQnnpy=)GE9?eyExLkg{2YTd{B#p>m7jv>VK(ya9 ++z?(SvBD1fH`+`=CQvS#>r1oZ1-I~X@CgPq#TRH5WC5G}v>x0AupbI6JkR?Cv72670( ++zx$+e|Jf#$s4|wMnOeP1Z4`8p%g6Q`Whty1hJ4h<;X7SElsp2&~2+5Nv1nsIlexk6Q ++z?HNbLFQOLhR4(yauY~#)wfH*P!`_wg0wecKG$|s4ev0gK7*x)&bR{5IMFoLZUB7P7 ++z-K{dmHuo^5j(_is*AgM!_==zQ>?1m*FbL)Kev^sKM;sA(i`F%tsu9Rpe#!!sO=}p# ++zoptRZd{qP3VS<9Vv}6f{-7sskb_)dfL}aXJLJ|SWwbC8Zp#G$S7Xhc_W#zrRDn_K` ++zk?>S^=#g+fD{0$f%wx40N?_sn&3O49OtqYqG<>6XD>r6kvZA)WDdD5|C%@qAD{4r- ++znT#;JtE63oQ7f+W>Y}2FU|HTU5oNf(0b;y~N#iO{mUqkGA1GVp+>P#t8}El3)RS){ ++zr3~6=eI`rH(IixuAJ<}v$W7&om6+0YdMPmk>V%kksoK ++z7(4fRwQp+Hk@ABoLYN`)78~!teXfVQaOMt! ++zjA+f~@-2{C^x?T+Mxl*@b5J`)W7Hm-^2D7G7}*$EMXf3XQ;OJPU1A+$nx~{$?(uoR ++zQfAYz9Zp)*F6XV{x=(nR^Fj`x$q~2Od;Xq0-_Gz4p`uA)Fc$JKY#mHjlr@z=Pt;<%-%*6mOa4P_ ++zyu<>}9(*~s2ze|U+d%K2#T4RhMiRg9$90nZjg6M+;fL0OQgHL@^P7>VEYE9i0x9qv ++zFEs?_VTfoFiUBDlkJr#6XsOtQ@X1`dthLcb8LrpBu+};!;y|Uq{;b0y;_5!wOyN6= ++zH;FuhA4yfTR6W680~xxX`wRKtV^LE5pA`kAU$3bgsc#L_I!f8^bW#* ++zbgr*+TKT^-%1(%d`(7jpYmkb07@fQ;`(QAy#cBNIikc|;sustvwm(Jp$NjkEt71dz ++zu0=YP=oMmHmv4kgA!s-hT11+5CyGv=0}~&Wd>|XP@y@#^-|?Od#|<0%q-|CoE)BQT ++z*o?M^@(bzVy6a4Rou%=uEHDc0OTaJ7QO%xiBhgqWe}3h+<P9|}cHa@YL&Va`Yhu`SWI@Wf ++zt(~r%7`}DTfWr2(*cRiZo{#A|AszTrfOBkyljjK``g9dqVqcl#r?cl*qz>~r48@)lPrryZ! ++zRD1*gKpimvz;{}>VW6O?uB@P^uB~ORds9bSSMPrTM{Cn>1CB7{?K@Z@>hQH6Kxpj^Fr~%aFrb#%oQ4x6605*4&OE2OKCPEO?Xf+bW)e!(-@^x ++z(idvuaaG?bp0vCeET#IMDl@%bLC7LcQGhPccA%CyS*%hO+um5(eGN1g6(6oUS4Jyi ++zQ<_2*F^u}30&Gb ++zYro^;d(&jYn(3w1YG049t^|)Guf;CHT0xWzk}wYu%}okh7CLo-@0MxQsibuk=V*yEdF95nx8;|^`k4BrMK9e ++z@_wS6JgsxheOI0V$-B(uSLF9?&zU_y3(^k=E*Wo()XHl4Z9%nDJJ$DGzyw?M4-O~6}$)x?f{QZo_XG*_E@ ++zo#2@PPhSQ3wH<$BRPjr8*W4i88`L4(BzhN>)mk=%-e>Wo=(rQ^l-qAotOzqbaF#5k ++zNTItJBF>z;YBH#cQJ&KiW=tgKF-WC(y8L}(BU`p|*z_^~=vs)Kk4BT*lr~Z$l*?NP ++z7@o4oyardh7*Z{!MK$5;6;pgI#cL=DW&9#(PQIqFJ1o&S()3YST7{KKQ`1krr)V7uswb^cF9g ++z$yp-Tx`>Y&W7ZZ}_24~L1FUTx{iIgR8~ZIsVdwp_soh%iAEMvww=+V9dN~A}-c0XE ++zdM4q|!?s$tn@y}Emsying!YMbH1P1*2yoR~xQ7!i`2RoSuNNSI@h^)Su%d8mT07Xf ++zyZZ__OF9d9ySO{r!CbsO1oia@0R&^Xz#r~I2EaS_4G#b~M~A!k&!vwo62NhDj!g60 ++zBN1E|yrvkgLFccfan}N#1#6$Sw9qwk7gwB`I?U4B9O~f#w>5WnfqVXc`U}TBt{6Lm ++z+sB1xJcjcy9M^p>`4e{)_i29Xl8~6Wt+PGU`FL2Gy}PB&pH*v?Fk5%ezuWTv9sTDq ++z)_-p3pI*x{oJK!;sP)dP1}Y$2bS@0Y3zSU)8n9L{9=ARcR2~I ++z3Oxn-yHgk(?D!PsAHGSyMqMXiT(PIXejkJ3aK|y&FE~OX+@Cst68AL3S*4GT-~AW4 ++CL9Ckq ++ ++literal 0 ++HcmV?d00001 ++ ++diff --git a/cherry-n8n-workflows/01_ci_failure_compression.json b/cherry-n8n-workflows/01_ci_failure_compression.json ++new file mode 100644 ++index 0000000000000000000000000000000000000000..f4ed582dbada241baed45d6edde23d632256ac9f ++--- /dev/null +++++ b/cherry-n8n-workflows/01_ci_failure_compression.json ++@@ -0,0 +1,570 @@ +++{ +++ "name": "Cherry - CI Failure Compression", +++ "nodes": [ +++ { +++ "parameters": { +++ "httpMethod": "POST", +++ "path": "cherry/github/workflow-completed", +++ "responseMode": "responseNode", +++ "options": {} +++ }, +++ "id": "webhook-ci-completed", +++ "name": "Webhook", +++ "type": "n8n-nodes-base.webhook", +++ "typeVersion": 2, +++ "position": [ +++ 0, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.workflow_completed',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '01_ci_failure_compression',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.workflow_completed.',\n actions: []\n};\nreturn [{ json: output }];" +++ }, +++ "id": "normalize-github-payload", +++ "name": "Normalize GitHub Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 260, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.workflow_run.conclusion\",\"payload.workflow_run.html_url\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" +++ }, +++ "id": "validate-payload", +++ "name": "Validate Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 520, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst run = event.payload?.workflow_run ?? {};\nconst conclusion = run.conclusion ?? 'unknown';\nconst text = [run.name, run.display_title, run.path, run.html_url].filter(Boolean).join(' ').toLowerCase();\nconst category = text.includes('lint') ? 'lint' : text.includes('typecheck') || text.includes('typescript') ? 'typecheck' : text.includes('test') ? 'test' : text.includes('build') ? 'build' : text.includes('closure') || text.includes('guardrail') ? 'repo-closure' : text.includes('migration') || text.includes('prisma') ? 'migration' : 'unknown';\nconst shouldProcess = conclusion === 'failure' || conclusion === 'timed_out' || conclusion === 'cancelled';\nconst workflowName = run.name ?? 'unknown workflow';\nconst branch = run.head_branch ?? 'unknown branch';\nconst sha = run.head_sha ?? 'unknown sha';\nconst url = run.html_url ?? '';\nconst title = '[ci:' + category + '] ' + workflowName + ' failed on ' + branch;\nconst output = { ...event, shouldProcess, failureCategory: category, workflowName, branch, sha, url, searchQuery: encodeURIComponent('repo:' + event.repo + ' is:issue is:open in:title \"[ci:' + category + ']\" \"' + workflowName + '\"'), issueBody: { title, labels: ['ci-failure', 'automation', 'needs-triage', category], body: 'Cherry CI failure compression.\\n\\nWorkflow: ' + workflowName + '\\nBranch: ' + branch + '\\nSHA: ' + sha + '\\nCategory: ' + category + '\\nRun: ' + url + '\\n\\nSuggested verification: npm run ci:verify\\n\\nAdvisory automation only.' }, commentBody: { body: 'Repeated CI failure.\\n\\nWorkflow: ' + workflowName + '\\nBranch: ' + branch + '\\nSHA: ' + sha + '\\nCategory: ' + category + '\\nRun: ' + url }, openclawTask: { source: 'ci_failure', repo: event.repo, title, category, branch, sha, url, guardrails: ['npm run ci:verify', 'no Cherry finance truth mutation'] }, status: shouldProcess ? 'accepted' : 'ignored', summary: shouldProcess ? title : 'Workflow conclusion was ' + conclusion + '; ignoring.', actions: shouldProcess ? ['search_existing_issue', 'comment_or_create_issue', 'optional_openclaw_task', 'archive_event'] : [] };\nreturn [{ json: output }];" +++ }, +++ "id": "classify-failure", +++ "name": "Classify Failure", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 780, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "conditions": { +++ "options": { +++ "caseSensitive": true, +++ "leftValue": "", +++ "typeValidation": "strict" +++ }, +++ "conditions": [ +++ { +++ "id": "if-failure-condition", +++ "leftValue": "={{ $json.shouldProcess === true }}", +++ "rightValue": true, +++ "operator": { +++ "type": "boolean", +++ "operation": "true", +++ "singleValue": true +++ } +++ } +++ ], +++ "combinator": "and" +++ }, +++ "options": {} +++ }, +++ "id": "if-failure", +++ "name": "IF: Failure?", +++ "type": "n8n-nodes-base.if", +++ "typeVersion": 2, +++ "position": [ +++ 1040, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "GET", +++ "url": "={{ 'https://api.github.com/search/issues?q=' + $json.searchQuery }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {} +++ }, +++ "id": "search-existing-issues", +++ "name": "Search Existing GitHub Issues", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1300, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "conditions": { +++ "options": { +++ "caseSensitive": true, +++ "leftValue": "", +++ "typeValidation": "strict" +++ }, +++ "conditions": [ +++ { +++ "id": "if-existing-issue-condition", +++ "leftValue": "={{ Array.isArray($json.items) && $json.items.length > 0 }}", +++ "rightValue": true, +++ "operator": { +++ "type": "boolean", +++ "operation": "true", +++ "singleValue": true +++ } +++ } +++ ], +++ "combinator": "and" +++ }, +++ "options": {} +++ }, +++ "id": "if-existing-issue", +++ "name": "IF: Existing Issue?", +++ "type": "n8n-nodes-base.if", +++ "typeVersion": 2, +++ "position": [ +++ 1560, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.items[0].number + '/comments' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $node[\"Classify Failure\"].json.commentBody }}" +++ }, +++ "id": "comment-existing-issue", +++ "name": "Comment Existing Issue", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1820, +++ -320 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $node[\"Classify Failure\"].json.issueBody }}" +++ }, +++ "id": "create-new-issue", +++ "name": "Create New Issue", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1820, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.OPENCLAW_WEBHOOK_URL }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $node[\"Classify Failure\"].json.openclawTask }}" +++ }, +++ "id": "send-ci-failure-to-openclaw", +++ "name": "Send CI Failure To OpenClaw", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2080, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '01_ci_failure_compression', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" +++ }, +++ "id": "route-shared-sinks", +++ "name": "Route Shared Sinks", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 2340, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '01_ci_failure_compression.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '01_ci_failure_compression',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '01_ci_failure_compression') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '01_ci_failure_compression.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" +++ }, +++ "id": "archive-event", +++ "name": "Archive Event", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2600, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" +++ }, +++ "id": "notify-discord", +++ "name": "Notify Discord", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2860, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'CI failure compressed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-response", +++ "name": "Build Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 3120, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: 'ignored', summary: event.summary ?? 'CI workflow did not fail; no action taken.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-ignored-response", +++ "name": "Build Ignored Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1300, +++ 160 +++ ] +++ }, +++ { +++ "parameters": { +++ "respondWith": "firstIncomingItem", +++ "options": { +++ "responseCode": 200 +++ } +++ }, +++ "id": "respond-to-webhook", +++ "name": "Respond to Webhook", +++ "type": "n8n-nodes-base.respondToWebhook", +++ "typeVersion": 1, +++ "position": [ +++ 3380, +++ 0 +++ ] +++ } +++ ], +++ "connections": { +++ "Webhook": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize GitHub Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Normalize GitHub Payload": { +++ "main": [ +++ [ +++ { +++ "node": "Validate Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Validate Payload": { +++ "main": [ +++ [ +++ { +++ "node": "Classify Failure", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Classify Failure": { +++ "main": [ +++ [ +++ { +++ "node": "IF: Failure?", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "IF: Failure?": { +++ "main": [ +++ [ +++ { +++ "node": "Search Existing GitHub Issues", +++ "type": "main", +++ "index": 0 +++ } +++ ], +++ [ +++ { +++ "node": "Build Ignored Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Search Existing GitHub Issues": { +++ "main": [ +++ [ +++ { +++ "node": "IF: Existing Issue?", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "IF: Existing Issue?": { +++ "main": [ +++ [ +++ { +++ "node": "Comment Existing Issue", +++ "type": "main", +++ "index": 0 +++ } +++ ], +++ [ +++ { +++ "node": "Create New Issue", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Comment Existing Issue": { +++ "main": [ +++ [ +++ { +++ "node": "Send CI Failure To OpenClaw", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Create New Issue": { +++ "main": [ +++ [ +++ { +++ "node": "Send CI Failure To OpenClaw", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Send CI Failure To OpenClaw": { +++ "main": [ +++ [ +++ { +++ "node": "Route Shared Sinks", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Route Shared Sinks": { +++ "main": [ +++ [ +++ { +++ "node": "Archive Event", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Archive Event": { +++ "main": [ +++ [ +++ { +++ "node": "Notify Discord", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Notify Discord": { +++ "main": [ +++ [ +++ { +++ "node": "Build Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Ignored Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ } +++ }, +++ "settings": { +++ "executionOrder": "v1" +++ } +++} ++diff --git a/cherry-n8n-workflows/02_openclaw_issue_router.json b/cherry-n8n-workflows/02_openclaw_issue_router.json ++new file mode 100644 ++index 0000000000000000000000000000000000000000..45c76c26fc731b6275b4e8121694063e91d88c16 ++--- /dev/null +++++ b/cherry-n8n-workflows/02_openclaw_issue_router.json ++@@ -0,0 +1,389 @@ +++{ +++ "name": "Cherry - OpenClaw Issue Router", +++ "nodes": [ +++ { +++ "parameters": { +++ "httpMethod": "POST", +++ "path": "cherry/github/issue-labeled", +++ "responseMode": "responseNode", +++ "options": {} +++ }, +++ "id": "webhook-issue-labeled", +++ "name": "Webhook", +++ "type": "n8n-nodes-base.webhook", +++ "typeVersion": 2, +++ "position": [ +++ 0, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.issue_labeled',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '02_openclaw_issue_router',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.issue_labeled.',\n actions: []\n};\nreturn [{ json: output }];" +++ }, +++ "id": "normalize-issue-event", +++ "name": "Normalize Issue Event", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 260, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.issue.number\",\"payload.issue.title\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" +++ }, +++ "id": "validate-payload", +++ "name": "Validate Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 520, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "conditions": { +++ "options": { +++ "caseSensitive": true, +++ "leftValue": "", +++ "typeValidation": "strict" +++ }, +++ "conditions": [ +++ { +++ "id": "if-openclaw-label-condition", +++ "leftValue": "={{ (($json.payload.label?.name ?? '') === 'openclaw') || (($json.payload.issue?.labels ?? []).some((label) => label.name === 'openclaw')) }}", +++ "rightValue": true, +++ "operator": { +++ "type": "boolean", +++ "operation": "true", +++ "singleValue": true +++ } +++ } +++ ], +++ "combinator": "and" +++ }, +++ "options": {} +++ }, +++ "id": "if-openclaw-label", +++ "name": "IF: Has openclaw Label?", +++ "type": "n8n-nodes-base.if", +++ "typeVersion": 2, +++ "position": [ +++ 780, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst issue = event.payload?.issue ?? {};\nconst body = String(issue.body ?? '');\nconst forbiddenPatterns = ['.env', '.env.local', 'secrets', 'production db config', '/api/session', '/api/ledger', '/api/bucket', '/api/payment', '/api/card'];\nconst forbiddenMatches = forbiddenPatterns.filter((pattern) => body.toLowerCase().includes(pattern.toLowerCase()));\nconst task = { source: 'github_issue_openclaw', repo: event.repo, issueNumber: issue.number, title: issue.title, url: issue.html_url, body, constraints: { advisoryOnly: true, forbiddenFiles: ['.env', '.env.local'], forbiddenEndpointPatterns: ['/api/session*', '/api/ledger*', '/api/bucket*', '/api/payment*', '/api/card*', '/api/debt*/mutate'], requiredReviewLabels: ['needs-human-review'] }, forbiddenMatches };\nconst output = { ...event, openclawTask: task, commentBody: { body: 'OpenClaw task prepared.\\n\\nIssue: #' + issue.number + '\\nForbidden hints: ' + (forbiddenMatches.join(', ') || 'none') + '\\nHuman review remains required before merge.' }, status: forbiddenMatches.length > 0 ? 'failed' : 'accepted', summary: forbiddenMatches.length > 0 ? 'OpenClaw issue contains forbidden-change hints.' : 'OpenClaw task routed for issue #' + issue.number + '.', actions: forbiddenMatches.length > 0 ? ['block_openclaw_task', 'comment_issue', 'archive_event'] : ['send_openclaw_task', 'comment_issue', 'archive_event'] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-openclaw-task", +++ "name": "Build OpenClaw Task", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1040, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.OPENCLAW_WEBHOOK_URL }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $json.openclawTask }}" +++ }, +++ "id": "send-to-openclaw", +++ "name": "Send To OpenClaw", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1300, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build OpenClaw Task\"].json.openclawTask.issueNumber + '/comments' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $node[\"Build OpenClaw Task\"].json.commentBody }}" +++ }, +++ "id": "comment-on-issue", +++ "name": "Comment On Issue", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1560, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '02_openclaw_issue_router', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" +++ }, +++ "id": "route-shared-sinks", +++ "name": "Route Shared Sinks", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1820, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '02_openclaw_issue_router.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '02_openclaw_issue_router',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '02_openclaw_issue_router') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '02_openclaw_issue_router.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" +++ }, +++ "id": "archive-event", +++ "name": "Archive Event", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2080, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'OpenClaw issue routed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-response", +++ "name": "Build Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 2340, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: 'ignored', summary: event.summary ?? 'Issue was not labeled openclaw; no action taken.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-ignored-response", +++ "name": "Build Ignored Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1040, +++ 160 +++ ] +++ }, +++ { +++ "parameters": { +++ "respondWith": "firstIncomingItem", +++ "options": { +++ "responseCode": 200 +++ } +++ }, +++ "id": "respond-to-webhook", +++ "name": "Respond to Webhook", +++ "type": "n8n-nodes-base.respondToWebhook", +++ "typeVersion": 1, +++ "position": [ +++ 2600, +++ 0 +++ ] +++ } +++ ], +++ "connections": { +++ "Webhook": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize Issue Event", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Normalize Issue Event": { +++ "main": [ +++ [ +++ { +++ "node": "Validate Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Validate Payload": { +++ "main": [ +++ [ +++ { +++ "node": "IF: Has openclaw Label?", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "IF: Has openclaw Label?": { +++ "main": [ +++ [ +++ { +++ "node": "Build OpenClaw Task", +++ "type": "main", +++ "index": 0 +++ } +++ ], +++ [ +++ { +++ "node": "Build Ignored Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build OpenClaw Task": { +++ "main": [ +++ [ +++ { +++ "node": "Send To OpenClaw", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Send To OpenClaw": { +++ "main": [ +++ [ +++ { +++ "node": "Comment On Issue", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Comment On Issue": { +++ "main": [ +++ [ +++ { +++ "node": "Route Shared Sinks", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Route Shared Sinks": { +++ "main": [ +++ [ +++ { +++ "node": "Archive Event", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Archive Event": { +++ "main": [ +++ [ +++ { +++ "node": "Build Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Ignored Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ } +++ }, +++ "settings": { +++ "executionOrder": "v1" +++ } +++} ++diff --git a/cherry-n8n-workflows/03_pr_risk_classifier.json b/cherry-n8n-workflows/03_pr_risk_classifier.json ++new file mode 100644 ++index 0000000000000000000000000000000000000000..daf0e7c54fa8d1f5988f05061499f1c107903272 ++--- /dev/null +++++ b/cherry-n8n-workflows/03_pr_risk_classifier.json ++@@ -0,0 +1,551 @@ +++{ +++ "name": "Cherry - PR Risk Classifier", +++ "nodes": [ +++ { +++ "parameters": { +++ "httpMethod": "POST", +++ "path": "cherry/github/pull-request-risk", +++ "responseMode": "responseNode", +++ "options": {} +++ }, +++ "id": "webhook-pr-risk", +++ "name": "Webhook", +++ "type": "n8n-nodes-base.webhook", +++ "typeVersion": 2, +++ "position": [ +++ 0, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.pull_request',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '03_pr_risk_classifier',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.pull_request.',\n actions: []\n};\nreturn [{ json: output }];" +++ }, +++ "id": "normalize-pr", +++ "name": "Normalize PR", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 260, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.pull_request.number\",\"payload.pull_request.title\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" +++ }, +++ "id": "validate-payload", +++ "name": "Validate Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 520, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "GET", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls/' + $json.payload.pull_request.number + '/files?per_page=100' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {} +++ }, +++ "id": "fetch-changed-files", +++ "name": "Fetch Changed Files", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 780, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const prEvent = $node['Normalize PR'].json;\nconst classification = $input.first()?.json ?? {};\nconst classifierOutput = classification.classifierOutput ?? {};\nconst risk = classifierOutput.risk ?? {};\nconst statusRequests = Array.isArray(classifierOutput.statusRequests) ? classifierOutput.statusRequests : [];\nconst statusRequest = statusRequests.find((request) => request.context === 'cherry/risk-gate');\nconst labels = Array.isArray(risk.labels) ? risk.labels : [];\nconst reasons = Array.isArray(risk.reasons) ? risk.reasons : [];\nconst score = typeof risk.score === 'number' ? risk.score : 'unknown';\nconst level = typeof risk.level === 'string' ? risk.level : 'unknown';\nconst prNumber = prEvent.payload.pull_request.number;\nconst sha = prEvent.payload.pull_request.head.sha;\nconst output = {\n ...prEvent,\n sha,\n automationEventId: classification.automationEventId,\n outputHash: classification.outputHash,\n cherryClassifierOutput: classifierOutput,\n statusRequest,\n labels,\n labelBody: { labels },\n commentBody: { body: 'Cherry PR risk classifier.\\n\\nLevel: ' + level + '\\nScore: ' + String(score) + '\\nLabels: ' + labels.join(', ') + '\\nReasons:\\n' + reasons.map((reason) => '- ' + reason).join('\\n') },\n status: 'accepted',\n summary: 'PR #' + prNumber + ' risk ' + level + ' from Cherry classifier.',\n actions: ['fetch_changed_files', 'classify_pr_in_cherry', 'post_risk_status', 'apply_labels', 'comment_risk_summary']\n};\nreturn [{ json: output }];" +++ }, +++ "id": "build-cherry-pr-routing", +++ "name": "Build Cherry PR Routing", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1040, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\n\nif (!event.statusRequest) missing.push('statusRequest');\nif (!event.repo) missing.push('repo');\nif (!event.sha) missing.push('sha');\nif (!event.outputHash) missing.push('outputHash');\nif (!event.cherryClassifierOutput?.classifierVersion) missing.push('classifierVersion');\n\nif (missing.length > 0) {\n return [{\n json: {\n ok: false,\n workflow: event.workflow,\n status: 'failed',\n summary: 'Cherry status payload missing required fields. Refusing to post status.',\n actions: ['do_not_post_status'],\n missing\n }\n }];\n}\n\nreturn [{ json: event }];" +++ }, +++ "id": "require-risk-status-request", +++ "name": "Require Status Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1300, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "conditions": { +++ "options": { +++ "caseSensitive": true, +++ "leftValue": "", +++ "typeValidation": "strict" +++ }, +++ "conditions": [ +++ { +++ "id": "if-risk-status-request", +++ "leftValue": "={{ $json.statusRequest !== undefined && $json.statusRequest !== null && $json.repo !== undefined && $json.repo !== null && $json.sha !== undefined && $json.sha !== null && $json.outputHash !== undefined && $json.outputHash !== null && $json.cherryClassifierOutput?.classifierVersion !== undefined && $json.cherryClassifierOutput?.classifierVersion !== null }}", +++ "rightValue": true, +++ "operator": { +++ "type": "boolean", +++ "operation": "true", +++ "singleValue": true +++ } +++ } +++ ], +++ "combinator": "and" +++ }, +++ "options": {} +++ }, +++ "id": "if-risk-status-request", +++ "name": "IF: Has Status Payload?", +++ "type": "n8n-nodes-base.if", +++ "typeVersion": 2, +++ "position": [ +++ 1170, +++ 160 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.payload.pull_request.number + '/labels' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $json.labelBody }}" +++ }, +++ "id": "apply-labels", +++ "name": "Apply Labels", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1300, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build Cherry PR Routing\"].json.payload.pull_request.number + '/comments' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $node[\"Build Cherry PR Routing\"].json.commentBody }}" +++ }, +++ "id": "comment-risk-summary", +++ "name": "Comment Risk Summary", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1560, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '03_pr_risk_classifier', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" +++ }, +++ "id": "route-shared-sinks", +++ "name": "Route Shared Sinks", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1820, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '03_pr_risk_classifier.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '03_pr_risk_classifier',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '03_pr_risk_classifier') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? 'no-sha') + ':' + ($json.outputHash ?? $now.toISO())),\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '03_pr_risk_classifier.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json.cherryClassifierOutput ?? $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" +++ }, +++ "id": "archive-event", +++ "name": "Archive Event", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2080, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'PR risk classified.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-response", +++ "name": "Build Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 2340, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "respondWith": "firstIncomingItem", +++ "options": { +++ "responseCode": 200 +++ } +++ }, +++ "id": "respond-to-webhook", +++ "name": "Respond to Webhook", +++ "type": "n8n-nodes-base.respondToWebhook", +++ "typeVersion": 1, +++ "position": [ +++ 2600, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/statuses/github' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n context: $json.statusRequest.context,\n state: $json.statusRequest.state,\n description: $json.statusRequest.description,\n targetUrl: $json.statusRequest.targetUrl,\n sourceWorkflow: $json.workflow,\n automationEventId: $json.automationEventId,\n classifierVersion: $json.cherryClassifierOutput.classifierVersion,\n outputHash: $json.outputHash\n} }}" +++ }, +++ "id": "post-risk-status", +++ "name": "Post Risk Status", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1300, +++ 160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "jsCode": "const items = $input.all();\n\nlet files = [];\n\nfor (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n files.push(...json);\n } else if (Array.isArray(json.files)) {\n files.push(...json.files);\n } else if (Array.isArray(json.data)) {\n files.push(...json.data);\n } else if (json && json.filename) {\n files.push(json);\n }\n}\n\nfiles = files.map((file) => ({\n filename: file.filename ?? file.path ?? file.name ?? '',\n status: file.status ?? 'modified',\n additions: Number(file.additions ?? 0),\n deletions: Number(file.deletions ?? 0),\n changes: Number(file.changes ?? 0),\n patch: String(file.patch ?? '')\n})).filter((file) => file.filename.length > 0);\n\nreturn [{\n json: {\n ...($items('Normalize PR')[0]?.json ?? {}),\n files\n }\n}];" +++ }, +++ "id": "normalize-changed-files", +++ "name": "Normalize Changed Files", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 910, +++ 160 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/classify/pr' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n sha: $node['Normalize PR'].json.payload.pull_request.head.sha,\n prNumber: $node['Normalize PR'].json.payload.pull_request.number,\n title: $node['Normalize PR'].json.payload.pull_request.title,\n body: $node['Normalize PR'].json.payload.pull_request.body ?? '',\n labels: ($node['Normalize PR'].json.payload.pull_request.labels ?? []).map((label) => label.name),\n files: $json.files,\n sourceWorkflow: $node['Normalize PR'].json.workflow ?? '03_pr_risk_classifier'\n} }}" +++ }, +++ "id": "classify-pr-in-cherry", +++ "name": "Classify PR In Cherry", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1040, +++ 160 +++ ], +++ "continueOnFail": true +++ } +++ ], +++ "connections": { +++ "Webhook": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize PR", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Normalize PR": { +++ "main": [ +++ [ +++ { +++ "node": "Validate Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Validate Payload": { +++ "main": [ +++ [ +++ { +++ "node": "Fetch Changed Files", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Fetch Changed Files": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize Changed Files", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Apply Labels": { +++ "main": [ +++ [ +++ { +++ "node": "Comment Risk Summary", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Comment Risk Summary": { +++ "main": [ +++ [ +++ { +++ "node": "Route Shared Sinks", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Route Shared Sinks": { +++ "main": [ +++ [ +++ { +++ "node": "Archive Event", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Archive Event": { +++ "main": [ +++ [ +++ { +++ "node": "Build Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Post Risk Status": { +++ "main": [ +++ [ +++ { +++ "node": "Apply Labels", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Classify PR In Cherry": { +++ "main": [ +++ [ +++ { +++ "node": "Build Cherry PR Routing", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Cherry PR Routing": { +++ "main": [ +++ [ +++ { +++ "node": "Require Status Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Normalize Changed Files": { +++ "main": [ +++ [ +++ { +++ "node": "Classify PR In Cherry", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Require Status Payload": { +++ "main": [ +++ [ +++ { +++ "node": "IF: Has Status Payload?", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "IF: Has Status Payload?": { +++ "main": [ +++ [ +++ { +++ "node": "Post Risk Status", +++ "type": "main", +++ "index": 0 +++ } +++ ], +++ [ +++ { +++ "node": "Build Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ } +++ }, +++ "settings": { +++ "executionOrder": "v1" +++ } +++} ++diff --git a/cherry-n8n-workflows/04_forbidden_change_detector.json b/cherry-n8n-workflows/04_forbidden_change_detector.json ++new file mode 100644 ++index 0000000000000000000000000000000000000000..624b59da7f8e5e7d80c304aa35dd7d019696d0bf ++--- /dev/null +++++ b/cherry-n8n-workflows/04_forbidden_change_detector.json ++@@ -0,0 +1,667 @@ +++{ +++ "name": "Cherry - Forbidden Change Detector", +++ "nodes": [ +++ { +++ "parameters": { +++ "httpMethod": "POST", +++ "path": "cherry/github/pull-request-forbidden", +++ "responseMode": "responseNode", +++ "options": {} +++ }, +++ "id": "webhook-pr-forbidden", +++ "name": "Webhook", +++ "type": "n8n-nodes-base.webhook", +++ "typeVersion": 2, +++ "position": [ +++ 0, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.pull_request',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '04_forbidden_change_detector',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.pull_request.',\n actions: []\n};\nreturn [{ json: output }];" +++ }, +++ "id": "normalize-pr", +++ "name": "Normalize PR", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 260, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.pull_request.number\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" +++ }, +++ "id": "validate-payload", +++ "name": "Validate Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 520, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "GET", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls/' + $json.payload.pull_request.number + '/files?per_page=100' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {} +++ }, +++ "id": "fetch-changed-files", +++ "name": "Fetch Changed Files", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 780, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const prEvent = $node['Normalize PR'].json;\nconst classification = $input.first()?.json ?? {};\nconst classifierOutput = classification.classifierOutput ?? {};\nconst forbiddenChange = classifierOutput.forbiddenChange ?? {};\nconst statusRequests = Array.isArray(classifierOutput.statusRequests) ? classifierOutput.statusRequests : [];\nconst statusRequest = statusRequests.find((request) => request.context === 'cherry/forbidden-change');\nconst violations = Array.isArray(forbiddenChange.violations) ? forbiddenChange.violations : [];\nconst labels = Array.isArray(forbiddenChange.labels) ? forbiddenChange.labels : [];\nconst blocked = forbiddenChange.forbidden === true;\nconst prNumber = prEvent.payload.pull_request.number;\nconst sha = prEvent.payload.pull_request.head.sha;\nconst output = {\n ...prEvent,\n sha,\n automationEventId: classification.automationEventId,\n outputHash: classification.outputHash,\n cherryClassifierOutput: classifierOutput,\n forbiddenChange,\n statusRequest,\n labelBody: { labels },\n commentBody: { body: 'Cherry forbidden-change detector.\\n\\n' + (blocked ? 'Blocking patterns detected by Cherry:\\n' + violations.map((violation) => '- ' + violation).join('\\n') : 'Cherry found no blocking patterns.') },\n status: blocked ? 'failed' : 'accepted',\n summary: blocked ? 'Cherry detected forbidden changes in PR #' + prNumber + '.' : 'Cherry found no forbidden changes in PR #' + prNumber + '.',\n actions: blocked ? ['classify_pr_in_cherry', 'post_forbidden_status', 'add_blocking_label', 'comment_violation'] : ['classify_pr_in_cherry', 'post_forbidden_status']\n};\nreturn [{ json: output }];" +++ }, +++ "id": "build-cherry-forbidden-routing", +++ "name": "Build Cherry Forbidden Routing", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1300, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\n\nif (!event.statusRequest) missing.push('statusRequest');\nif (!event.repo) missing.push('repo');\nif (!event.sha) missing.push('sha');\nif (!event.outputHash) missing.push('outputHash');\nif (!event.cherryClassifierOutput?.classifierVersion) missing.push('classifierVersion');\n\nif (missing.length > 0) {\n return [{\n json: {\n ok: false,\n workflow: event.workflow,\n status: 'failed',\n summary: 'Cherry status payload missing required fields. Refusing to post status.',\n actions: ['do_not_post_status'],\n missing\n }\n }];\n}\n\nreturn [{ json: event }];" +++ }, +++ "id": "require-forbidden-status-request", +++ "name": "Require Status Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1560, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "conditions": { +++ "options": { +++ "caseSensitive": true, +++ "leftValue": "", +++ "typeValidation": "strict" +++ }, +++ "conditions": [ +++ { +++ "id": "if-forbidden-status-request", +++ "leftValue": "={{ $json.statusRequest !== undefined && $json.statusRequest !== null && $json.repo !== undefined && $json.repo !== null && $json.sha !== undefined && $json.sha !== null && $json.outputHash !== undefined && $json.outputHash !== null && $json.cherryClassifierOutput?.classifierVersion !== undefined && $json.cherryClassifierOutput?.classifierVersion !== null }}", +++ "rightValue": true, +++ "operator": { +++ "type": "boolean", +++ "operation": "true", +++ "singleValue": true +++ } +++ } +++ ], +++ "combinator": "and" +++ }, +++ "options": {} +++ }, +++ "id": "if-forbidden-status-request", +++ "name": "IF: Has Status Payload?", +++ "type": "n8n-nodes-base.if", +++ "typeVersion": 2, +++ "position": [ +++ 1430, +++ 160 +++ ] +++ }, +++ { +++ "parameters": { +++ "conditions": { +++ "options": { +++ "caseSensitive": true, +++ "leftValue": "", +++ "typeValidation": "strict" +++ }, +++ "conditions": [ +++ { +++ "id": "if-forbidden-condition", +++ "leftValue": "={{ $json.forbiddenChange?.forbidden === true }}", +++ "rightValue": true, +++ "operator": { +++ "type": "boolean", +++ "operation": "true", +++ "singleValue": true +++ } +++ } +++ ], +++ "combinator": "and" +++ }, +++ "options": {} +++ }, +++ "id": "if-forbidden", +++ "name": "IF: Forbidden?", +++ "type": "n8n-nodes-base.if", +++ "typeVersion": 2, +++ "position": [ +++ 1560, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.payload.pull_request.number + '/labels' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $json.labelBody }}" +++ }, +++ "id": "add-blocking-label", +++ "name": "Add blocking label", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1820, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build Cherry Forbidden Routing\"].json.payload.pull_request.number + '/comments' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $node[\"Build Cherry Forbidden Routing\"].json.commentBody }}" +++ }, +++ "id": "comment-violation", +++ "name": "Comment Violation", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2080, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '04_forbidden_change_detector', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" +++ }, +++ "id": "route-shared-sinks", +++ "name": "Route Shared Sinks", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 2340, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '04_forbidden_change_detector.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '04_forbidden_change_detector',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '04_forbidden_change_detector') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? 'no-sha') + ':' + ($json.outputHash ?? $now.toISO())),\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '04_forbidden_change_detector.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json.cherryClassifierOutput ?? $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" +++ }, +++ "id": "archive-event", +++ "name": "Archive Event", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2600, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" +++ }, +++ "id": "notify-discord", +++ "name": "Notify Discord", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2860, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Forbidden change check completed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-response", +++ "name": "Build Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 3120, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'No forbidden changes detected.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-safe-response", +++ "name": "Build Safe Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1820, +++ 160 +++ ] +++ }, +++ { +++ "parameters": { +++ "respondWith": "firstIncomingItem", +++ "options": { +++ "responseCode": 200 +++ } +++ }, +++ "id": "respond-to-webhook", +++ "name": "Respond to Webhook", +++ "type": "n8n-nodes-base.respondToWebhook", +++ "typeVersion": 1, +++ "position": [ +++ 3380, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/statuses/github' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n context: $json.statusRequest.context,\n state: $json.statusRequest.state,\n description: $json.statusRequest.description,\n targetUrl: $json.statusRequest.targetUrl,\n sourceWorkflow: $json.workflow,\n automationEventId: $json.automationEventId,\n classifierVersion: $json.cherryClassifierOutput.classifierVersion,\n outputHash: $json.outputHash\n} }}" +++ }, +++ "id": "post-forbidden-status", +++ "name": "Post Forbidden Status", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1560, +++ 160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "jsCode": "const items = $input.all();\n\nlet files = [];\n\nfor (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n files.push(...json);\n } else if (Array.isArray(json.files)) {\n files.push(...json.files);\n } else if (Array.isArray(json.data)) {\n files.push(...json.data);\n } else if (json && json.filename) {\n files.push(json);\n }\n}\n\nfiles = files.map((file) => ({\n filename: file.filename ?? file.path ?? file.name ?? '',\n status: file.status ?? 'modified',\n additions: Number(file.additions ?? 0),\n deletions: Number(file.deletions ?? 0),\n changes: Number(file.changes ?? 0),\n patch: String(file.patch ?? '')\n})).filter((file) => file.filename.length > 0);\n\nreturn [{\n json: {\n ...($items('Normalize PR')[0]?.json ?? {}),\n files\n }\n}];" +++ }, +++ "id": "normalize-changed-files", +++ "name": "Normalize Changed Files", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 910, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/classify/pr' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $node['Normalize PR'].json.repo ?? 'div0rce/cherry',\n sha: $node['Normalize PR'].json.payload.pull_request.head.sha,\n prNumber: $node['Normalize PR'].json.payload.pull_request.number,\n title: $node['Normalize PR'].json.payload.pull_request.title,\n body: $node['Normalize PR'].json.payload.pull_request.body ?? '',\n labels: ($node['Normalize PR'].json.payload.pull_request.labels ?? []).map((label) => label.name),\n files: $json.files,\n sourceWorkflow: $node['Normalize PR'].json.workflow ?? 'pr-workflow'\n} }}" +++ }, +++ "id": "classify-pr-in-cherry-forbidden", +++ "name": "Classify PR In Cherry", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4.2, +++ "position": [ +++ 1040, +++ 0 +++ ], +++ "continueOnFail": true +++ } +++ ], +++ "connections": { +++ "Webhook": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize PR", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Normalize PR": { +++ "main": [ +++ [ +++ { +++ "node": "Validate Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Validate Payload": { +++ "main": [ +++ [ +++ { +++ "node": "Fetch Changed Files", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "IF: Forbidden?": { +++ "main": [ +++ [ +++ { +++ "node": "Add blocking label", +++ "type": "main", +++ "index": 0 +++ } +++ ], +++ [ +++ { +++ "node": "Build Safe Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Add blocking label": { +++ "main": [ +++ [ +++ { +++ "node": "Comment Violation", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Comment Violation": { +++ "main": [ +++ [ +++ { +++ "node": "Route Shared Sinks", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Route Shared Sinks": { +++ "main": [ +++ [ +++ { +++ "node": "Archive Event", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Archive Event": { +++ "main": [ +++ [ +++ { +++ "node": "Notify Discord", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Notify Discord": { +++ "main": [ +++ [ +++ { +++ "node": "Build Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Safe Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Post Forbidden Status": { +++ "main": [ +++ [ +++ { +++ "node": "IF: Forbidden?", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Cherry Forbidden Routing": { +++ "main": [ +++ [ +++ { +++ "node": "Require Status Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Fetch Changed Files": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize Changed Files", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Classify PR In Cherry": { +++ "main": [ +++ [ +++ { +++ "node": "Build Cherry Forbidden Routing", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Normalize Changed Files": { +++ "main": [ +++ [ +++ { +++ "node": "Classify PR In Cherry", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Require Status Payload": { +++ "main": [ +++ [ +++ { +++ "node": "IF: Has Status Payload?", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "IF: Has Status Payload?": { +++ "main": [ +++ [ +++ { +++ "node": "Post Forbidden Status", +++ "type": "main", +++ "index": 0 +++ } +++ ], +++ [ +++ { +++ "node": "Build Safe Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ } +++ }, +++ "settings": { +++ "executionOrder": "v1" +++ } +++} ++diff --git a/cherry-n8n-workflows/05_engine_degradation_alerting.json b/cherry-n8n-workflows/05_engine_degradation_alerting.json ++new file mode 100644 ++index 0000000000000000000000000000000000000000..636e81cec95a879b9b4a1a84ec9f76998add5f8e ++--- /dev/null +++++ b/cherry-n8n-workflows/05_engine_degradation_alerting.json ++@@ -0,0 +1,389 @@ +++{ +++ "name": "Cherry - Engine Degradation Alerting", +++ "nodes": [ +++ { +++ "parameters": { +++ "httpMethod": "POST", +++ "path": "cherry/runtime/degradation", +++ "responseMode": "responseNode", +++ "options": {} +++ }, +++ "id": "webhook-runtime-degradation", +++ "name": "Webhook", +++ "type": "n8n-nodes-base.webhook", +++ "typeVersion": 2, +++ "position": [ +++ 0, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'cherry.runtime_degradation',\n source: 'cherry',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '05_engine_degradation_alerting',\n ok: true,\n status: 'accepted',\n summary: 'Normalized cherry.runtime_degradation.',\n actions: []\n};\nreturn [{ json: output }];" +++ }, +++ "id": "normalize-degradation-event", +++ "name": "Normalize Degradation Event", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 260, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.type\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" +++ }, +++ "id": "validate-payload", +++ "name": "Validate Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 520, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst type = event.payload?.type ?? 'unknown';\nconst severityMap = { missing_debt_truth: 'high', solver_divergence: 'critical', temporal_inconsistency: 'critical', candidate_exclusion: 'medium', advisory_degraded: 'medium', impossible_state: 'critical', route_response_mismatch: 'high', score_drift: 'medium' };\nconst severity = event.payload?.severity ?? severityMap[type] ?? 'low'; const createIssue = severity === 'high' || severity === 'critical';\nconst output = { ...event, degradationType: type, severity, createIssue, issueBody: { title: '[engine:' + severity + '] ' + type, labels: ['engine-degradation', 'automation', 'needs-human-review', severity], body: 'Cherry engine degradation event.\\n\\nType: ' + type + '\\nSeverity: ' + severity + '\\nTimestamp: ' + event.timestamp + '\\n\\nPayload JSON:\\n' + JSON.stringify(event.payload, null, 2) + '\\n\\nAdvisory alert only.' }, notification: { type, severity, repo: event.repo, payload: event.payload }, status: createIssue ? 'accepted' : 'ignored', summary: createIssue ? 'High-severity engine degradation alert for ' + type + '.' : 'Archived medium/low degradation event for ' + type + '.', actions: createIssue ? ['create_github_issue', 'notify_discord', 'archive_event'] : ['notify_discord', 'archive_event'] };\nreturn [{ json: output }];" +++ }, +++ "id": "classify-severity", +++ "name": "Classify Severity", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 780, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "conditions": { +++ "options": { +++ "caseSensitive": true, +++ "leftValue": "", +++ "typeValidation": "strict" +++ }, +++ "conditions": [ +++ { +++ "id": "if-high-severity-condition", +++ "leftValue": "={{ $json.createIssue === true }}", +++ "rightValue": true, +++ "operator": { +++ "type": "boolean", +++ "operation": "true", +++ "singleValue": true +++ } +++ } +++ ], +++ "combinator": "and" +++ }, +++ "options": {} +++ }, +++ "id": "if-high-severity", +++ "name": "IF: Severity >= high?", +++ "type": "n8n-nodes-base.if", +++ "typeVersion": 2, +++ "position": [ +++ 1040, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $json.issueBody }}" +++ }, +++ "id": "create-github-issue", +++ "name": "Create GitHub Issue", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1300, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '05_engine_degradation_alerting', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" +++ }, +++ "id": "route-shared-sinks", +++ "name": "Route Shared Sinks", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1560, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" +++ }, +++ "id": "notify-discord", +++ "name": "Notify Discord", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1820, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '05_engine_degradation_alerting.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '05_engine_degradation_alerting',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '05_engine_degradation_alerting') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '05_engine_degradation_alerting.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" +++ }, +++ "id": "archive-event", +++ "name": "Archive Event", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2080, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Engine degradation handled.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-response", +++ "name": "Build Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 2340, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Engine degradation archived.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-archived-response", +++ "name": "Build Archived Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1300, +++ 160 +++ ] +++ }, +++ { +++ "parameters": { +++ "respondWith": "firstIncomingItem", +++ "options": { +++ "responseCode": 200 +++ } +++ }, +++ "id": "respond-to-webhook", +++ "name": "Respond to Webhook", +++ "type": "n8n-nodes-base.respondToWebhook", +++ "typeVersion": 1, +++ "position": [ +++ 2600, +++ 0 +++ ] +++ } +++ ], +++ "connections": { +++ "Webhook": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize Degradation Event", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Normalize Degradation Event": { +++ "main": [ +++ [ +++ { +++ "node": "Validate Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Validate Payload": { +++ "main": [ +++ [ +++ { +++ "node": "Classify Severity", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Classify Severity": { +++ "main": [ +++ [ +++ { +++ "node": "IF: Severity >= high?", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "IF: Severity >= high?": { +++ "main": [ +++ [ +++ { +++ "node": "Create GitHub Issue", +++ "type": "main", +++ "index": 0 +++ } +++ ], +++ [ +++ { +++ "node": "Build Archived Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Create GitHub Issue": { +++ "main": [ +++ [ +++ { +++ "node": "Route Shared Sinks", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Route Shared Sinks": { +++ "main": [ +++ [ +++ { +++ "node": "Notify Discord", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Notify Discord": { +++ "main": [ +++ [ +++ { +++ "node": "Archive Event", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Archive Event": { +++ "main": [ +++ [ +++ { +++ "node": "Build Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Archived Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ } +++ }, +++ "settings": { +++ "executionOrder": "v1" +++ } +++} ++diff --git a/cherry-n8n-workflows/06_simulation_drift_detector.json b/cherry-n8n-workflows/06_simulation_drift_detector.json ++new file mode 100644 ++index 0000000000000000000000000000000000000000..86fb8c1d197d0023e0fb64a5a42ac1ddef30c381 ++--- /dev/null +++++ b/cherry-n8n-workflows/06_simulation_drift_detector.json ++@@ -0,0 +1,432 @@ +++{ +++ "name": "Cherry - Simulation Drift Detector", +++ "nodes": [ +++ { +++ "parameters": { +++ "httpMethod": "POST", +++ "path": "cherry/simulation/result", +++ "responseMode": "responseNode", +++ "options": {} +++ }, +++ "id": "webhook-simulation-result", +++ "name": "Webhook", +++ "type": "n8n-nodes-base.webhook", +++ "typeVersion": 2, +++ "position": [ +++ 0, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'cherry.simulation_result',\n source: 'cherry',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '06_simulation_drift_detector',\n ok: true,\n status: 'accepted',\n summary: 'Normalized cherry.simulation_result.',\n actions: []\n};\nreturn [{ json: output }];" +++ }, +++ "id": "normalize-simulation-result", +++ "name": "Normalize Simulation Result", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 260, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.runId\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" +++ }, +++ "id": "validate-payload", +++ "name": "Validate Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 520, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const result = $input.first()?.json ?? {};\nconst event = $node['Normalize Simulation Result'].json;\nconst comparison = result.comparisonOutput ?? {};\nconst drift = comparison.drift === true;\nconst reasons = comparison.reasons ?? [];\nconst output = {\n ...event,\n automationSnapshotId: result.snapshotId,\n outputHash: result.outputHash,\n drift,\n driftReasons: reasons,\n issueBody: {\n title: '[simulation-drift] ' + (event.payload?.scenarioId ?? event.payload?.profileId ?? 'default'),\n labels: ['simulation-drift', 'automation', 'needs-human-review'],\n body: 'Cherry simulation drift detected.\\n\\n' + reasons.map((reason) => '- ' + reason).join('\\n') + '\\n\\nSnapshot comparison is stored by Cherry automation, not n8n static data.'\n },\n status: drift ? 'accepted' : 'ignored',\n summary: drift ? 'Simulation drift detected: ' + reasons.join(', ') : 'Simulation snapshot stored with no material drift.',\n actions: drift ? ['compare_snapshot_in_cherry', 'create_issue', 'archive_event'] : ['compare_snapshot_in_cherry', 'archive_event']\n};\nreturn [{ json: output }];" +++ }, +++ "id": "compare-snapshot", +++ "name": "Compare Snapshot", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 780, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "conditions": { +++ "options": { +++ "caseSensitive": true, +++ "leftValue": "", +++ "typeValidation": "strict" +++ }, +++ "conditions": [ +++ { +++ "id": "if-drift-condition", +++ "leftValue": "={{ $json.drift === true }}", +++ "rightValue": true, +++ "operator": { +++ "type": "boolean", +++ "operation": "true", +++ "singleValue": true +++ } +++ } +++ ], +++ "combinator": "and" +++ }, +++ "options": {} +++ }, +++ "id": "if-drift", +++ "name": "IF: Drift?", +++ "type": "n8n-nodes-base.if", +++ "typeVersion": 2, +++ "position": [ +++ 1040, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $json.issueBody }}" +++ }, +++ "id": "create-drift-issue", +++ "name": "Create Drift Issue", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1300, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '06_simulation_drift_detector', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" +++ }, +++ "id": "route-shared-sinks", +++ "name": "Route Shared Sinks", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1560, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" +++ }, +++ "id": "notify-discord", +++ "name": "Notify Discord", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1820, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '06_simulation_drift_detector.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '06_simulation_drift_detector',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '06_simulation_drift_detector') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'simulation-drift@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '06_simulation_drift_detector.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" +++ }, +++ "id": "archive-event", +++ "name": "Archive Event", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2080, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Simulation drift handled.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-response", +++ "name": "Build Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 2340, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Simulation snapshot stored.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-no-drift-response", +++ "name": "Build No Drift Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1300, +++ 160 +++ ] +++ }, +++ { +++ "parameters": { +++ "respondWith": "firstIncomingItem", +++ "options": { +++ "responseCode": 200 +++ } +++ }, +++ "id": "respond-to-webhook", +++ "name": "Respond to Webhook", +++ "type": "n8n-nodes-base.respondToWebhook", +++ "typeVersion": 1, +++ "position": [ +++ 2600, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/simulation-snapshots/compare' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n scopeKey: $json.payload.scenarioId ?? $json.payload.profileId ?? 'default',\n runId: $json.payload.runId,\n snapshot: $json.payload,\n sourceWorkflow: $json.workflow ?? '06_simulation_drift_detector'\n} }}" +++ }, +++ "id": "compare-simulation-in-cherry", +++ "name": "Compare Simulation In Cherry", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 780, +++ 160 +++ ], +++ "continueOnFail": true +++ } +++ ], +++ "connections": { +++ "Webhook": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize Simulation Result", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Normalize Simulation Result": { +++ "main": [ +++ [ +++ { +++ "node": "Validate Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Validate Payload": { +++ "main": [ +++ [ +++ { +++ "node": "Compare Simulation In Cherry", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Compare Snapshot": { +++ "main": [ +++ [ +++ { +++ "node": "IF: Drift?", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "IF: Drift?": { +++ "main": [ +++ [ +++ { +++ "node": "Create Drift Issue", +++ "type": "main", +++ "index": 0 +++ } +++ ], +++ [ +++ { +++ "node": "Build No Drift Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Create Drift Issue": { +++ "main": [ +++ [ +++ { +++ "node": "Route Shared Sinks", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Route Shared Sinks": { +++ "main": [ +++ [ +++ { +++ "node": "Notify Discord", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Notify Discord": { +++ "main": [ +++ [ +++ { +++ "node": "Archive Event", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Archive Event": { +++ "main": [ +++ [ +++ { +++ "node": "Build Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build No Drift Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Compare Simulation In Cherry": { +++ "main": [ +++ [ +++ { +++ "node": "Compare Snapshot", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ } +++ }, +++ "settings": { +++ "executionOrder": "v1" +++ } +++} ++diff --git a/cherry-n8n-workflows/07_release_summary_generator.json b/cherry-n8n-workflows/07_release_summary_generator.json ++new file mode 100644 ++index 0000000000000000000000000000000000000000..01234741099126e95a9d7ba6c40443f1a58ad0b5 ++--- /dev/null +++++ b/cherry-n8n-workflows/07_release_summary_generator.json ++@@ -0,0 +1,488 @@ +++{ +++ "name": "Cherry - Release Summary Generator", +++ "nodes": [ +++ { +++ "parameters": { +++ "httpMethod": "POST", +++ "path": "cherry/release/summary", +++ "responseMode": "responseNode", +++ "options": {} +++ }, +++ "id": "webhook-release-summary", +++ "name": "Webhook", +++ "type": "n8n-nodes-base.webhook", +++ "typeVersion": 2, +++ "position": [ +++ 0, +++ -160 +++ ] +++ }, +++ { +++ "parameters": {}, +++ "id": "manual-release-summary", +++ "name": "Manual Trigger", +++ "type": "n8n-nodes-base.manualTrigger", +++ "typeVersion": 1, +++ "position": [ +++ 0, +++ 160 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const input = $input.first()?.json ?? {}; const body = input.body ?? input; const source = input.body ? 'cherry' : 'manual'; const output = { event: 'cherry.release_summary', source, repo: body.repo ?? 'div0rce/cherry', timestamp: body.timestamp ?? new Date().toISOString(), payload: body, workflow: '07_release_summary_generator', ok: true, status: 'accepted', summary: 'Release summary generation started.', actions: [] }; return [{ json: output }];" +++ }, +++ "id": "normalize-release-request", +++ "name": "Normalize Release Request", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 260, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"repo\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" +++ }, +++ "id": "validate-payload", +++ "name": "Validate Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 520, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "GET", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/releases/latest' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {} +++ }, +++ "id": "fetch-latest-release", +++ "name": "Fetch Latest Release", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 780, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "GET", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/commits?per_page=100' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {} +++ }, +++ "id": "fetch-commits", +++ "name": "Fetch Commits Since Last Tag", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1040, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const commits = Array.isArray($json) ? $json : []; const groups = { engine: [], api: [], prisma: [], tests: [], docs: [], infra: [], other: [] };\nfor (const commit of commits) { const message = commit.commit?.message ?? commit.message ?? ''; const lower = message.toLowerCase(); const bucket = lower.includes('engine') ? 'engine' : lower.includes('api') || lower.includes('route') ? 'api' : lower.includes('prisma') || lower.includes('migration') ? 'prisma' : lower.includes('test') ? 'tests' : lower.includes('doc') || lower.includes('readme') ? 'docs' : lower.includes('ci') || lower.includes('guardrail') ? 'infra' : 'other'; groups[bucket].push(message.split('\\n')[0]); }\nconst risk = []; if (groups.engine.length > 0) risk.push('engine changes require deterministic review'); if (groups.prisma.length > 0) risk.push('Prisma changes require migration verification'); if (groups.api.length > 0) risk.push('API changes require route tests');\nconst changelog = Object.entries(groups).filter(([, items]) => items.length > 0).map(([name, items]) => '## ' + name + '\\n' + items.map((item) => '- ' + item).join('\\n')).join('\\n\\n'); const releaseBody = '# Cherry Release Draft\\n\\n' + (changelog || 'No commits returned by GitHub API.') + '\\n\\n## Risk Summary\\n' + (risk.length ? risk.map((r) => '- ' + r).join('\\n') : '- Low automation-detected release risk.') + '\\n\\n## Verification\\n- npm run check\\n- npm test\\n- npm run build\\n- npm run ci:verify';\nconst output = { ...$node['Normalize Release Request'].json, groups, changelog, riskSummary: risk, linkedInDraft: 'Cherry release update: guardrails, repo quality, and development automation advanced. Details remain advisory until verified in CI.', releaseBody, releaseDraftBody: { tag_name: $node['Normalize Release Request'].json.payload.tagName ?? 'v-next', name: 'Cherry v-next', body: releaseBody, draft: true, prerelease: true }, status: 'accepted', summary: 'Generated release summary from ' + commits.length + ' commits.', actions: ['fetch_commits', 'group_changes', 'generate_changelog', 'archive_event'] };\nreturn [{ json: output }];" +++ }, +++ "id": "generate-release-summary", +++ "name": "Generate Release Summary", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1300, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/releases' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $json.releaseDraftBody }}" +++ }, +++ "id": "create-github-release-draft", +++ "name": "Create GitHub Release Draft", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1560, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '07_release_summary_generator', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" +++ }, +++ "id": "route-shared-sinks", +++ "name": "Route Shared Sinks", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1820, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '07_release_summary_generator.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '07_release_summary_generator',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '07_release_summary_generator') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '07_release_summary_generator.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" +++ }, +++ "id": "archive-event", +++ "name": "Archive Event", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2080, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" +++ }, +++ "id": "notify-discord", +++ "name": "Notify Discord", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2340, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "conditions": { +++ "options": { +++ "caseSensitive": true, +++ "leftValue": "", +++ "typeValidation": "strict" +++ }, +++ "conditions": [ +++ { +++ "id": "if-webhook-source-condition", +++ "leftValue": "={{ $node['Normalize Release Request'].json.source !== 'manual' }}", +++ "rightValue": true, +++ "operator": { +++ "type": "boolean", +++ "operation": "true", +++ "singleValue": true +++ } +++ } +++ ], +++ "combinator": "and" +++ }, +++ "options": {} +++ }, +++ "id": "if-webhook-source", +++ "name": "IF: Webhook Source?", +++ "type": "n8n-nodes-base.if", +++ "typeVersion": 2, +++ "position": [ +++ 2600, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Release summary generated.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-response", +++ "name": "Build Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 2860, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Manual release summary generated.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "manual-result-log", +++ "name": "Manual Result Log", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 2860, +++ 160 +++ ] +++ }, +++ { +++ "parameters": { +++ "respondWith": "firstIncomingItem", +++ "options": { +++ "responseCode": 200 +++ } +++ }, +++ "id": "respond-to-webhook", +++ "name": "Respond to Webhook", +++ "type": "n8n-nodes-base.respondToWebhook", +++ "typeVersion": 1, +++ "position": [ +++ 3120, +++ -160 +++ ] +++ } +++ ], +++ "connections": { +++ "Webhook": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize Release Request", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Manual Trigger": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize Release Request", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Normalize Release Request": { +++ "main": [ +++ [ +++ { +++ "node": "Validate Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Validate Payload": { +++ "main": [ +++ [ +++ { +++ "node": "Fetch Latest Release", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Fetch Latest Release": { +++ "main": [ +++ [ +++ { +++ "node": "Fetch Commits Since Last Tag", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Fetch Commits Since Last Tag": { +++ "main": [ +++ [ +++ { +++ "node": "Generate Release Summary", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Generate Release Summary": { +++ "main": [ +++ [ +++ { +++ "node": "Create GitHub Release Draft", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Create GitHub Release Draft": { +++ "main": [ +++ [ +++ { +++ "node": "Route Shared Sinks", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Route Shared Sinks": { +++ "main": [ +++ [ +++ { +++ "node": "Archive Event", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Archive Event": { +++ "main": [ +++ [ +++ { +++ "node": "Notify Discord", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Notify Discord": { +++ "main": [ +++ [ +++ { +++ "node": "IF: Webhook Source?", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "IF: Webhook Source?": { +++ "main": [ +++ [ +++ { +++ "node": "Build Response", +++ "type": "main", +++ "index": 0 +++ } +++ ], +++ [ +++ { +++ "node": "Manual Result Log", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ } +++ }, +++ "settings": { +++ "executionOrder": "v1" +++ } +++} ++diff --git a/cherry-n8n-workflows/08_repo_intelligence_digest.json b/cherry-n8n-workflows/08_repo_intelligence_digest.json ++new file mode 100644 ++index 0000000000000000000000000000000000000000..7a69895b6b9bf041036a48551af8c422a7df5a5f ++--- /dev/null +++++ b/cherry-n8n-workflows/08_repo_intelligence_digest.json ++@@ -0,0 +1,378 @@ +++{ +++ "name": "Cherry - Repo Intelligence Digest", +++ "nodes": [ +++ { +++ "parameters": { +++ "rule": { +++ "interval": [ +++ { +++ "field": "weeks", +++ "triggerAtDay": [ +++ 1 +++ ], +++ "triggerAtHour": 9, +++ "triggerAtMinute": 0 +++ } +++ ] +++ } +++ }, +++ "id": "weekly-repo-digest", +++ "name": "Schedule Trigger", +++ "type": "n8n-nodes-base.scheduleTrigger", +++ "typeVersion": 1, +++ "position": [ +++ 0, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const input = $input.first()?.json ?? {};\nconst output = { event: 'cherry.weekly_repo_digest', source: 'manual', repo: 'div0rce/cherry', timestamp: input.timestamp ?? new Date().toISOString(), payload: input, workflow: '08_repo_intelligence_digest', ok: true, status: 'accepted', summary: 'Scheduled cherry.weekly_repo_digest started.', actions: [] };\nreturn [{ json: output }];" +++ }, +++ "id": "normalize-schedule", +++ "name": "Normalize Schedule", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 260, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"repo\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" +++ }, +++ "id": "validate-payload", +++ "name": "Validate Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 520, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "GET", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls?state=open&per_page=100' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {} +++ }, +++ "id": "fetch-open-prs", +++ "name": "Fetch Open PRs", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 780, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "GET", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues?state=open&per_page=100' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {} +++ }, +++ "id": "fetch-open-issues", +++ "name": "Fetch Open Issues", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1040, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "GET", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/commits?per_page=50' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {} +++ }, +++ "id": "fetch-recent-commits", +++ "name": "Fetch Recent Commits", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1300, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const prs = Array.isArray($items('Fetch Open PRs')[0]?.json) ? $items('Fetch Open PRs')[0].json : []; const issues = Array.isArray($items('Fetch Open Issues')[0]?.json) ? $items('Fetch Open Issues')[0].json : []; const commits = Array.isArray($json) ? $json : []; const now = Date.now(); const stalePrs = prs.filter((pr) => now - new Date(pr.updated_at ?? pr.created_at ?? now).getTime() > 7 * 24 * 60 * 60 * 1000); const dependabot = prs.filter((pr) => /dependabot/i.test(pr.user?.login ?? '')); const highRisk = prs.filter((pr) => /engine|prisma|migration|api/i.test((pr.title ?? '') + ' ' + (pr.body ?? ''))); const digest = '# Cherry Weekly Repo Intelligence Digest\\n\\n- Open PRs: ' + prs.length + '\\n- Stale PRs: ' + stalePrs.length + '\\n- Open issues: ' + issues.length + '\\n- Recent commits: ' + commits.length + '\\n- Dependabot PRs: ' + dependabot.length + '\\n- High-risk hints: ' + highRisk.length + '\\n\\nAdvisory automation only.'; const output = { ...$node['Normalize Schedule'].json, digest, metrics: { openPrs: prs.length, stalePrs: stalePrs.length, openIssues: issues.length, recentCommits: commits.length, dependabotPrs: dependabot.length, highRiskHints: highRisk.length }, status: 'accepted', summary: 'Weekly repo digest built: ' + prs.length + ' PRs, ' + issues.length + ' issues.', actions: ['fetch_open_prs', 'fetch_open_issues', 'fetch_recent_commits', 'archive_digest', 'notify_discord'] }; return [{ json: output }];" +++ }, +++ "id": "build-digest", +++ "name": "Build Digest", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1560, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '08_repo_intelligence_digest', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" +++ }, +++ "id": "route-shared-sinks", +++ "name": "Route Shared Sinks", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1820, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '08_repo_intelligence_digest.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '08_repo_intelligence_digest',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '08_repo_intelligence_digest') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '08_repo_intelligence_digest.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" +++ }, +++ "id": "archive-event", +++ "name": "Archive Event", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2080, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" +++ }, +++ "id": "notify-discord", +++ "name": "Notify Discord", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2340, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Weekly repo digest completed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "log-digest", +++ "name": "Log Digest", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 2600, +++ 0 +++ ] +++ } +++ ], +++ "connections": { +++ "Schedule Trigger": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize Schedule", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Normalize Schedule": { +++ "main": [ +++ [ +++ { +++ "node": "Validate Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Validate Payload": { +++ "main": [ +++ [ +++ { +++ "node": "Fetch Open PRs", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Fetch Open PRs": { +++ "main": [ +++ [ +++ { +++ "node": "Fetch Open Issues", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Fetch Open Issues": { +++ "main": [ +++ [ +++ { +++ "node": "Fetch Recent Commits", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Fetch Recent Commits": { +++ "main": [ +++ [ +++ { +++ "node": "Build Digest", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Digest": { +++ "main": [ +++ [ +++ { +++ "node": "Route Shared Sinks", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Route Shared Sinks": { +++ "main": [ +++ [ +++ { +++ "node": "Archive Event", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Archive Event": { +++ "main": [ +++ [ +++ { +++ "node": "Notify Discord", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Notify Discord": { +++ "main": [ +++ [ +++ { +++ "node": "Log Digest", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ } +++ }, +++ "settings": { +++ "executionOrder": "v1" +++ } +++} ++diff --git a/cherry-n8n-workflows/09_docs_drift_detector.json b/cherry-n8n-workflows/09_docs_drift_detector.json ++new file mode 100644 ++index 0000000000000000000000000000000000000000..5fb38e41af646c69a3c8c07b163480e359a89290 ++--- /dev/null +++++ b/cherry-n8n-workflows/09_docs_drift_detector.json ++@@ -0,0 +1,628 @@ +++{ +++ "name": "Cherry - Docs Drift Detector", +++ "nodes": [ +++ { +++ "parameters": { +++ "httpMethod": "POST", +++ "path": "cherry/github/pull-request-docs-drift", +++ "responseMode": "responseNode", +++ "options": {} +++ }, +++ "id": "webhook-docs-drift", +++ "name": "Webhook", +++ "type": "n8n-nodes-base.webhook", +++ "typeVersion": 2, +++ "position": [ +++ 0, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.pull_request_docs_drift',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '09_docs_drift_detector',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.pull_request_docs_drift.',\n actions: []\n};\nreturn [{ json: output }];" +++ }, +++ "id": "normalize-pr", +++ "name": "Normalize PR", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 260, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.pull_request.number\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" +++ }, +++ "id": "validate-payload", +++ "name": "Validate Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 520, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "GET", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls/' + $json.payload.pull_request.number + '/files?per_page=100' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {} +++ }, +++ "id": "fetch-changed-files", +++ "name": "Fetch Changed Files", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 780, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const prEvent = $node['Normalize PR'].json;\nconst classification = $input.first()?.json ?? {};\nconst classifierOutput = classification.classifierOutput ?? {};\nconst docsDrift = classifierOutput.docsDrift ?? {};\nconst statusRequests = Array.isArray(classifierOutput.statusRequests) ? classifierOutput.statusRequests : [];\nconst statusRequest = statusRequests.find((request) => request.context === 'cherry/docs-drift');\nconst sha = prEvent.payload.pull_request.head.sha;\nconst domains = Array.isArray(docsDrift.domains) ? docsDrift.domains : [];\nconst labels = Array.isArray(docsDrift.labels) ? docsDrift.labels : [];\nconst drift = docsDrift.drift === true;\nconst output = {\n ...prEvent,\n sha,\n automationEventId: classification.automationEventId,\n outputHash: classification.outputHash,\n cherryClassifierOutput: classifierOutput,\n docsDrift,\n statusRequest,\n labelBody: { labels },\n commentBody: { body: 'Cherry docs drift detector.\\n\\n' + (drift ? 'Docs update required for changed domains: ' + domains.join(', ') : 'Cherry found no docs drift.') + '\\n\\nDocs must match code reality unless legal constraints require a code fix.' },\n status: drift ? 'accepted' : 'ignored',\n summary: drift ? 'Cherry detected docs drift for ' + domains.join(', ') + '.' : 'Cherry found no docs drift.',\n actions: drift ? ['classify_pr_in_cherry', 'post_docs_status', 'label_docs_drift', 'comment_required_docs_update'] : ['classify_pr_in_cherry', 'post_docs_status']\n};\nreturn [{ json: output }];" +++ }, +++ "id": "build-cherry-docs-routing", +++ "name": "Build Cherry Docs Routing", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1300, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\n\nif (!event.statusRequest) missing.push('statusRequest');\nif (!event.repo) missing.push('repo');\nif (!event.sha) missing.push('sha');\nif (!event.outputHash) missing.push('outputHash');\nif (!event.cherryClassifierOutput?.classifierVersion) missing.push('classifierVersion');\n\nif (missing.length > 0) {\n return [{\n json: {\n ok: false,\n workflow: event.workflow,\n status: 'failed',\n summary: 'Cherry status payload missing required fields. Refusing to post status.',\n actions: ['do_not_post_status'],\n missing\n }\n }];\n}\n\nreturn [{ json: event }];" +++ }, +++ "id": "require-docs-status-request", +++ "name": "Require Status Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1560, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "conditions": { +++ "options": { +++ "caseSensitive": true, +++ "leftValue": "", +++ "typeValidation": "strict" +++ }, +++ "conditions": [ +++ { +++ "id": "if-docs-status-request", +++ "leftValue": "={{ $json.statusRequest !== undefined && $json.statusRequest !== null && $json.repo !== undefined && $json.repo !== null && $json.sha !== undefined && $json.sha !== null && $json.outputHash !== undefined && $json.outputHash !== null && $json.cherryClassifierOutput?.classifierVersion !== undefined && $json.cherryClassifierOutput?.classifierVersion !== null }}", +++ "rightValue": true, +++ "operator": { +++ "type": "boolean", +++ "operation": "true", +++ "singleValue": true +++ } +++ } +++ ], +++ "combinator": "and" +++ }, +++ "options": {} +++ }, +++ "id": "if-docs-status-request", +++ "name": "IF: Has Status Payload?", +++ "type": "n8n-nodes-base.if", +++ "typeVersion": 2, +++ "position": [ +++ 1170, +++ 160 +++ ] +++ }, +++ { +++ "parameters": { +++ "conditions": { +++ "options": { +++ "caseSensitive": true, +++ "leftValue": "", +++ "typeValidation": "strict" +++ }, +++ "conditions": [ +++ { +++ "id": "if-docs-drift-condition", +++ "leftValue": "={{ $json.docsDrift?.drift === true }}", +++ "rightValue": true, +++ "operator": { +++ "type": "boolean", +++ "operation": "true", +++ "singleValue": true +++ } +++ } +++ ], +++ "combinator": "and" +++ }, +++ "options": {} +++ }, +++ "id": "if-docs-drift", +++ "name": "IF: Docs Drift?", +++ "type": "n8n-nodes-base.if", +++ "typeVersion": 2, +++ "position": [ +++ 1300, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.payload.pull_request.number + '/labels' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $json.labelBody }}" +++ }, +++ "id": "label-docs-drift", +++ "name": "Label Docs Drift", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1560, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build Cherry Docs Routing\"].json.payload.pull_request.number + '/comments' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $node[\"Build Cherry Docs Routing\"].json.commentBody }}" +++ }, +++ "id": "comment-docs-drift", +++ "name": "Comment Required Docs Update", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1820, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '09_docs_drift_detector', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" +++ }, +++ "id": "route-shared-sinks", +++ "name": "Route Shared Sinks", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 2080, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '09_docs_drift_detector.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '09_docs_drift_detector',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '09_docs_drift_detector') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? 'no-sha') + ':' + ($json.outputHash ?? $now.toISO())),\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '09_docs_drift_detector.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json.cherryClassifierOutput ?? $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" +++ }, +++ "id": "archive-event", +++ "name": "Archive Event", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2340, +++ -160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Docs drift handled.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-response", +++ "name": "Build Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 2600, +++ -160 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: 'ignored', summary: event.summary ?? 'No docs drift detected.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "build-no-drift-response", +++ "name": "Build No Drift Response", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1560, +++ 160 +++ ] +++ }, +++ { +++ "parameters": { +++ "respondWith": "firstIncomingItem", +++ "options": { +++ "responseCode": 200 +++ } +++ }, +++ "id": "respond-to-webhook", +++ "name": "Respond to Webhook", +++ "type": "n8n-nodes-base.respondToWebhook", +++ "typeVersion": 1, +++ "position": [ +++ 2860, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/statuses/github' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n context: $json.statusRequest.context,\n state: $json.statusRequest.state,\n description: $json.statusRequest.description,\n targetUrl: $json.statusRequest.targetUrl,\n sourceWorkflow: $json.workflow,\n automationEventId: $json.automationEventId,\n classifierVersion: $json.cherryClassifierOutput.classifierVersion,\n outputHash: $json.outputHash\n} }}" +++ }, +++ "id": "post-docs-status", +++ "name": "Post Docs Status", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1300, +++ 160 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "jsCode": "const items = $input.all();\n\nlet files = [];\n\nfor (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n files.push(...json);\n } else if (Array.isArray(json.files)) {\n files.push(...json.files);\n } else if (Array.isArray(json.data)) {\n files.push(...json.data);\n } else if (json && json.filename) {\n files.push(json);\n }\n}\n\nfiles = files.map((file) => ({\n filename: file.filename ?? file.path ?? file.name ?? '',\n status: file.status ?? 'modified',\n additions: Number(file.additions ?? 0),\n deletions: Number(file.deletions ?? 0),\n changes: Number(file.changes ?? 0),\n patch: String(file.patch ?? '')\n})).filter((file) => file.filename.length > 0);\n\nreturn [{\n json: {\n ...($items('Normalize PR')[0]?.json ?? {}),\n files\n }\n}];" +++ }, +++ "id": "normalize-changed-files", +++ "name": "Normalize Changed Files", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 910, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/classify/pr' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $node['Normalize PR'].json.repo ?? 'div0rce/cherry',\n sha: $node['Normalize PR'].json.payload.pull_request.head.sha,\n prNumber: $node['Normalize PR'].json.payload.pull_request.number,\n title: $node['Normalize PR'].json.payload.pull_request.title,\n body: $node['Normalize PR'].json.payload.pull_request.body ?? '',\n labels: ($node['Normalize PR'].json.payload.pull_request.labels ?? []).map((label) => label.name),\n files: $json.files,\n sourceWorkflow: $node['Normalize PR'].json.workflow ?? 'pr-workflow'\n} }}" +++ }, +++ "id": "classify-pr-in-cherry-docs", +++ "name": "Classify PR In Cherry", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4.2, +++ "position": [ +++ 1040, +++ 0 +++ ], +++ "continueOnFail": true +++ } +++ ], +++ "connections": { +++ "Webhook": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize PR", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Normalize PR": { +++ "main": [ +++ [ +++ { +++ "node": "Validate Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Validate Payload": { +++ "main": [ +++ [ +++ { +++ "node": "Fetch Changed Files", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Fetch Changed Files": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize Changed Files", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "IF: Docs Drift?": { +++ "main": [ +++ [ +++ { +++ "node": "Label Docs Drift", +++ "type": "main", +++ "index": 0 +++ } +++ ], +++ [ +++ { +++ "node": "Build No Drift Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Label Docs Drift": { +++ "main": [ +++ [ +++ { +++ "node": "Comment Required Docs Update", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Comment Required Docs Update": { +++ "main": [ +++ [ +++ { +++ "node": "Route Shared Sinks", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Route Shared Sinks": { +++ "main": [ +++ [ +++ { +++ "node": "Archive Event", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Archive Event": { +++ "main": [ +++ [ +++ { +++ "node": "Build Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build No Drift Response": { +++ "main": [ +++ [ +++ { +++ "node": "Respond to Webhook", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Post Docs Status": { +++ "main": [ +++ [ +++ { +++ "node": "IF: Docs Drift?", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Build Cherry Docs Routing": { +++ "main": [ +++ [ +++ { +++ "node": "Require Status Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Classify PR In Cherry": { +++ "main": [ +++ [ +++ { +++ "node": "Build Cherry Docs Routing", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Normalize Changed Files": { +++ "main": [ +++ [ +++ { +++ "node": "Classify PR In Cherry", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Require Status Payload": { +++ "main": [ +++ [ +++ { +++ "node": "IF: Has Status Payload?", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "IF: Has Status Payload?": { +++ "main": [ +++ [ +++ { +++ "node": "Post Docs Status", +++ "type": "main", +++ "index": 0 +++ } +++ ], +++ [ +++ { +++ "node": "Build Response", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ } +++ }, +++ "settings": { +++ "executionOrder": "v1" +++ } +++} ++diff --git a/cherry-n8n-workflows/10_backlog_grooming.json b/cherry-n8n-workflows/10_backlog_grooming.json ++new file mode 100644 ++index 0000000000000000000000000000000000000000..4807a3816e12088afc2a1b77c9a68f5ef85fc79f ++--- /dev/null +++++ b/cherry-n8n-workflows/10_backlog_grooming.json ++@@ -0,0 +1,376 @@ +++{ +++ "name": "Cherry - Backlog Grooming", +++ "nodes": [ +++ { +++ "parameters": { +++ "rule": { +++ "interval": [ +++ { +++ "field": "weeks", +++ "triggerAtDay": [ +++ 1 +++ ], +++ "triggerAtHour": 9, +++ "triggerAtMinute": 0 +++ } +++ ] +++ } +++ }, +++ "id": "weekly-backlog-grooming", +++ "name": "Schedule Trigger", +++ "type": "n8n-nodes-base.scheduleTrigger", +++ "typeVersion": 1, +++ "position": [ +++ 0, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const input = $input.first()?.json ?? {};\nconst output = { event: 'cherry.backlog_grooming', source: 'manual', repo: 'div0rce/cherry', timestamp: input.timestamp ?? new Date().toISOString(), payload: input, workflow: '10_backlog_grooming', ok: true, status: 'accepted', summary: 'Scheduled cherry.backlog_grooming started.', actions: [] };\nreturn [{ json: output }];" +++ }, +++ "id": "normalize-schedule", +++ "name": "Normalize Schedule", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 260, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"repo\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" +++ }, +++ "id": "validate-payload", +++ "name": "Validate Payload", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 520, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "GET", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues?state=open&per_page=100' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {} +++ }, +++ "id": "fetch-open-issues", +++ "name": "Fetch Open Issues", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 780, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const issues = Array.isArray($json) ? $json.filter((issue) => !issue.pull_request) : []; const now = Date.now(); const stale = issues.filter((issue) => now - new Date(issue.updated_at ?? issue.created_at ?? now).getTime() > 30 * 24 * 60 * 60 * 1000); const unlabeled = issues.filter((issue) => (issue.labels ?? []).length === 0); const blocked = issues.filter((issue) => /blocked|waiting|depends on/i.test((issue.title ?? '') + ' ' + (issue.body ?? ''))); const noAcceptance = issues.filter((issue) => !/acceptance criteria|done when|definition of done/i.test(issue.body ?? '')); const seen = new Map(); const duplicateHints = []; for (const issue of issues) { const key = String(issue.title ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); if (seen.has(key)) duplicateHints.push([seen.get(key), issue.number]); else seen.set(key, issue.number); } const body = '# Cherry Backlog Grooming Summary\\n\\n- Open issues: ' + issues.length + '\\n- Stale issues: ' + stale.length + '\\n- Unlabeled issues: ' + unlabeled.length + '\\n- Blocked hints: ' + blocked.length + '\\n- Missing acceptance criteria: ' + noAcceptance.length + '\\n- Duplicate title hints: ' + duplicateHints.length + '\\n\\nSuggested actions are advisory.'; const metrics = { openIssues: issues.length, stale: stale.length, unlabeled: unlabeled.length, blocked: blocked.length, missingAcceptanceCriteria: noAcceptance.length, duplicateHints: duplicateHints.length }; const output = { ...$node['Normalize Schedule'].json, backlogMetrics: metrics, summaryIssueBody: { title: 'Cherry weekly backlog grooming summary', labels: ['backlog-grooming', 'automation'], body }, projectUpdatePayload: { source: 'cherry_backlog_grooming', metrics }, status: 'accepted', summary: 'Backlog grooming summary built for ' + issues.length + ' open issues.', actions: ['find_stale_issues', 'find_duplicates', 'find_unlabeled', 'find_missing_acceptance_criteria', 'archive_event'] }; return [{ json: output }];" +++ }, +++ "id": "analyze-backlog", +++ "name": "Analyze Backlog", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1040, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" +++ }, +++ { +++ "name": "Accept", +++ "value": "application/vnd.github+json" +++ }, +++ { +++ "name": "X-GitHub-Api-Version", +++ "value": "2022-11-28" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $json.summaryIssueBody }}" +++ }, +++ "id": "create-grooming-summary-issue", +++ "name": "Create Grooming Summary Issue", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1300, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ $node[\"Analyze Backlog\"].json.projectUpdatePayload }}" +++ }, +++ "id": "update-github-project", +++ "name": "Update GitHub Project", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 1560, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '10_backlog_grooming', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" +++ }, +++ "id": "route-shared-sinks", +++ "name": "Route Shared Sinks", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 1820, +++ 0 +++ ] +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Authorization", +++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" +++ }, +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '10_backlog_grooming.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '10_backlog_grooming',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '10_backlog_grooming') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '10_backlog_grooming.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" +++ }, +++ "id": "archive-event", +++ "name": "Archive Event", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2080, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "method": "POST", +++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", +++ "sendHeaders": true, +++ "headerParameters": { +++ "parameters": [ +++ { +++ "name": "Content-Type", +++ "value": "application/json" +++ } +++ ] +++ }, +++ "options": {}, +++ "sendBody": true, +++ "specifyBody": "json", +++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" +++ }, +++ "id": "notify-discord", +++ "name": "Notify Discord", +++ "type": "n8n-nodes-base.httpRequest", +++ "typeVersion": 4, +++ "position": [ +++ 2340, +++ 0 +++ ], +++ "continueOnFail": true +++ }, +++ { +++ "parameters": { +++ "mode": "runOnceForAllItems", +++ "language": "javaScript", +++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Backlog grooming completed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" +++ }, +++ "id": "log-grooming-result", +++ "name": "Log Grooming Result", +++ "type": "n8n-nodes-base.code", +++ "typeVersion": 2, +++ "position": [ +++ 2600, +++ 0 +++ ] +++ } +++ ], +++ "connections": { +++ "Schedule Trigger": { +++ "main": [ +++ [ +++ { +++ "node": "Normalize Schedule", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Normalize Schedule": { +++ "main": [ +++ [ +++ { +++ "node": "Validate Payload", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Validate Payload": { +++ "main": [ +++ [ +++ { +++ "node": "Fetch Open Issues", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Fetch Open Issues": { +++ "main": [ +++ [ +++ { +++ "node": "Analyze Backlog", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Analyze Backlog": { +++ "main": [ +++ [ +++ { +++ "node": "Create Grooming Summary Issue", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Create Grooming Summary Issue": { +++ "main": [ +++ [ +++ { +++ "node": "Update GitHub Project", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Update GitHub Project": { +++ "main": [ +++ [ +++ { +++ "node": "Route Shared Sinks", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Route Shared Sinks": { +++ "main": [ +++ [ +++ { +++ "node": "Archive Event", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Archive Event": { +++ "main": [ +++ [ +++ { +++ "node": "Notify Discord", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ }, +++ "Notify Discord": { +++ "main": [ +++ [ +++ { +++ "node": "Log Grooming Result", +++ "type": "main", +++ "index": 0 +++ } +++ ] +++ ] +++ } +++ }, +++ "settings": { +++ "executionOrder": "v1" +++ } +++} ++diff --git a/cherry-n8n-workflows/COVERAGE_MATRIX.md b/cherry-n8n-workflows/COVERAGE_MATRIX.md ++new file mode 100644 ++index 0000000000000000000000000000000000000000..6463046d9e44091c490367f9303971521380a493 ++--- /dev/null +++++ b/cherry-n8n-workflows/COVERAGE_MATRIX.md ++@@ -0,0 +1,163 @@ +++# Cherry n8n Coverage Matrix +++ +++Status: Generated +++Last updated: 2026-04-27 +++ +++## Workflow Coverage +++ +++| Workflow | Covers | +++| --- | --- | +++| `01_ci_failure_compression` | 1, 2, 3, 4, 21-29 | +++| `02_openclaw_issue_router` | 41-50 | +++| `03_pr_risk_classifier` | 11-17, 30-32, 46-49 | +++| `04_forbidden_change_detector` | 32-39, 48 | +++| `05_engine_degradation_alerting` | 51-60 | +++| `06_simulation_drift_detector` | 61-70 | +++| `07_release_summary_generator` | 71-80 | +++| `08_repo_intelligence_digest` | 5-10, 20, 91-100 | +++| `09_docs_drift_detector` | 81-90 | +++| `10_backlog_grooming` | 18-20, 40, 93-100, 106-107 | +++| `Shared sink pattern` | 101-110 | +++ +++## Use Case Map +++ +++### Repo Automation +++ +++1. CI failure -> structured issue +++2. CI failure -> existing issue comment +++3. CI failure -> OpenClaw task +++4. flaky test detector +++5. dependency update triage +++6. Dependabot PR classifier +++7. CodeQL alert router +++8. secret scan alert router +++9. stale branch detector +++10. stale PR detector +++11. PR size classifier +++12. PR risk score +++13. PR domain classifier +++14. PR checklist generator +++15. PR summary generator +++16. PR merge-block reminder +++17. issue deduplication +++18. issue severity labeling +++19. issue owner/domain labeling +++20. backlog grooming automation +++ +++### Verification Automation +++ +++21. run full verification on demand +++22. rerun failed workflow +++23. collect failed logs +++24. summarize failure cause +++25. compare failure to last passing run +++26. detect changed files causing failure +++27. enforce required scripts exist +++28. verify migrations apply cleanly +++29. verify Prisma schema drift +++30. verify test coverage changed +++31. verify route tests are in correct folder +++32. verify no forbidden imports +++33. verify no production secrets touched +++34. verify no .env diff +++35. verify no snapshot fraud +++36. verify no deleted tests +++37. verify no skipped tests added +++38. verify no console.log leaks +++39. verify no TODO introduced without issue +++40. verify issue acceptance criteria updated +++ +++### OpenClaw Automation +++ +++41. issue labeled openclaw -> create OpenClaw task +++42. OpenClaw result -> validate schema +++43. OpenClaw patch -> attach summary +++44. OpenClaw failure -> request retry +++45. OpenClaw command log -> archive +++46. OpenClaw changed engine -> require tests +++47. OpenClaw changed docs only -> lighter checks +++48. OpenClaw touched forbidden files -> block +++49. OpenClaw PR -> mark needs-human-review +++50. OpenClaw output -> generate commit message +++ +++### Cherry Engine Observability +++ +++51. degradation event -> issue +++52. missing truth event -> issue +++53. solver divergence event -> issue +++54. impossible state event -> issue +++55. temporal inconsistency event -> issue +++56. candidate exclusion spike -> alert +++57. simulation instability -> alert +++58. score drift -> alert +++59. route response mismatch -> alert +++60. advisory output degradation -> alert +++ +++### Simulation Automation +++ +++61. scheduled simulation run +++62. compare simulation to previous snapshot +++63. detect major allocation delta +++64. detect paydown strategy flip +++65. detect runway collapse +++66. detect debt relief regression +++67. detect reward-over-safety bias +++68. detect malformed candidate set +++69. detect empty viable candidates +++70. store simulation audit artifact +++ +++### Release Automation +++ +++71. changelog generation +++72. release notes generation +++73. LinkedIn draft generation +++74. GitHub release draft +++75. semantic version suggestion +++76. breaking-change detector +++77. migration warning generator +++78. issue closure report +++79. release risk summary +++80. deployment summary +++ +++### Documentation Automation +++ +++81. docs drift detector +++82. README update reminder +++83. architecture doc update reminder +++84. API contract doc generator +++85. endpoint inventory generator +++86. env var inventory generator +++87. Prisma model change summary +++88. test inventory summary +++89. issue-to-doc linkage +++90. glossary update automation +++ +++### Project Management +++ +++91. weekly progress digest +++92. daily issue digest +++93. blocked issue detector +++94. orphaned issue detector +++95. milestone progress report +++96. PR-to-issue linkage checker +++97. acceptance criteria completeness checker +++98. roadmap update generator +++99. duplicate backlog detector +++100. priority decay detector +++ +++### External Integrations +++ +++101. Discord notifications +++102. Slack notifications +++103. email summaries +++104. Notion sync +++105. Google Sheets metrics export +++106. Linear/Jira sync +++107. GitHub Projects update +++108. calendar reminder for releases +++109. webhook archive to database +++110. incident timeline export +++ +++## Coverage Status +++ +++All use cases 1-110 are mapped to at least one workflow or to the shared sink pattern. ++diff --git a/cherry-n8n-workflows/README.md b/cherry-n8n-workflows/README.md ++new file mode 100644 ++index 0000000000000000000000000000000000000000..4c5c937e962d5240e0adb912c448a9eabbe45191 ++--- /dev/null +++++ b/cherry-n8n-workflows/README.md ++@@ -0,0 +1,71 @@ +++# Cherry n8n Minmax Workflow Pack +++ +++Status: Generated +++Last updated: 2026-04-27 +++ +++This directory contains 10 importable n8n workflow JSON files for Cherry development automation. The workflows are advisory and development-facing only. They do not touch Cherry payment rails and must not mutate Sessions, Ledger, Buckets, cards, payments, or other financial truth. +++ +++## Import +++ +++Import each JSON file as a single workflow in n8n. Each file contains exactly one workflow object, not an array of workflows. +++ +++The zip is expected to preserve this root folder: +++ +++```bash +++cd /Users/nasr/repos/cherry +++zip -r cherry-n8n-workflows.zip cherry-n8n-workflows +++``` +++ +++## Required Environment Variables +++ +++- `GITHUB_OWNER` +++- `GITHUB_REPO` +++- `GITHUB_TOKEN` +++- `OPENCLAW_WEBHOOK_URL` +++- `DISCORD_WEBHOOK_URL` +++- `SLACK_WEBHOOK_URL` +++- `EMAIL_WEBHOOK_URL` +++- `NOTION_WEBHOOK_URL` +++- `GOOGLE_SHEETS_WEBHOOK_URL` +++- `LINEAR_JIRA_WEBHOOK_URL` +++- `GITHUB_PROJECTS_WEBHOOK_URL` +++- `CHERRY_API_BASE_URL` +++- `CHERRY_AUTOMATION_TOKEN` +++ +++HTTP Request nodes use header parameters with placeholder expressions only. No credentials are required at import time. +++ +++## Webhook Paths +++ +++- `POST /cherry/github/workflow-completed` -> CI failure compression +++- `POST /cherry/github/issue-labeled` -> OpenClaw issue router +++- `POST /cherry/github/pull-request-risk` -> PR risk classifier +++- `POST /cherry/github/pull-request-forbidden` -> forbidden-change detector +++- `POST /cherry/runtime/degradation` -> engine degradation alerting +++- `POST /cherry/simulation/result` -> simulation drift detector +++- `POST /cherry/release/summary` -> release summary generator +++- `POST /cherry/github/pull-request-docs-drift` -> docs drift detector +++ +++## GitHub Webhook Event Mapping +++ +++- `workflow_run.completed` -> `/cherry/github/workflow-completed` +++- `issues.labeled` -> `/cherry/github/issue-labeled` +++- `pull_request` -> PR risk, forbidden-change, and docs-drift workflows +++ +++## Scheduled Workflows +++ +++- `08_repo_intelligence_digest.json` runs weekly. +++- `10_backlog_grooming.json` runs weekly. +++ +++## Safety Boundary +++ +++Forbidden Cherry endpoint patterns: +++ +++- `/api/session*` +++- `/api/ledger*` +++- `/api/bucket*` +++- `/api/payment*` +++- `/api/card*` +++- `/api/debt*/mutate` +++- any `POST`, `PATCH`, or `DELETE` endpoint that changes financial truth +++ +++Workflow `06_simulation_drift_detector` calls Cherry's `/api/automation/simulation-snapshots/compare` endpoint so snapshot history is durable in Cherry automation storage, not n8n static data. ++diff --git a/cherry-n8n-workflows/VALIDATION_REPORT.md b/cherry-n8n-workflows/VALIDATION_REPORT.md ++new file mode 100644 ++index 0000000000000000000000000000000000000000..a7d0ddccbbce216e6c0f3ad975ceeb80a918c027 ++--- /dev/null +++++ b/cherry-n8n-workflows/VALIDATION_REPORT.md ++@@ -0,0 +1,83 @@ +++# Cherry n8n Validation Report +++ +++Status: Passed +++Last updated: 2026-04-27 +++ +++## Parsed Files +++ +++- 01_ci_failure_compression.json +++- 02_openclaw_issue_router.json +++- 03_pr_risk_classifier.json +++- 04_forbidden_change_detector.json +++- 05_engine_degradation_alerting.json +++- 06_simulation_drift_detector.json +++- 07_release_summary_generator.json +++- 08_repo_intelligence_digest.json +++- 09_docs_drift_detector.json +++- 10_backlog_grooming.json +++ +++## Workflow Names +++ +++- Cherry - CI Failure Compression +++- Cherry - OpenClaw Issue Router +++- Cherry - PR Risk Classifier +++- Cherry - Forbidden Change Detector +++- Cherry - Engine Degradation Alerting +++- Cherry - Simulation Drift Detector +++- Cherry - Release Summary Generator +++- Cherry - Repo Intelligence Digest +++- Cherry - Docs Drift Detector +++- Cherry - Backlog Grooming +++ +++## Webhook Paths +++ +++- /cherry/github/workflow-completed +++- /cherry/github/issue-labeled +++- /cherry/github/pull-request-risk +++- /cherry/github/pull-request-forbidden +++- /cherry/runtime/degradation +++- /cherry/simulation/result +++- /cherry/release/summary +++- /cherry/github/pull-request-docs-drift +++ +++## Automation Endpoints +++ +++- /api/automation/classify/pr +++- /api/automation/events +++- /api/automation/simulation-snapshots/compare +++- /api/automation/statuses/github +++ +++## Coverage Status 1-110 +++ +++Passed: all use cases 1-110 are covered. +++ +++## Credential Objects +++ +++Detected credential objects: none +++ +++## Connection Reference Check +++ +++Passed +++ +++## HTTP Failure Handling +++ +++Every HTTP Request node has `continueOnFail: true`. +++ +++## Webhook Response Mode +++ +++All Webhook nodes set `responseMode` to `responseNode`. +++ +++## Code Node Language +++ +++All Code nodes use JavaScript. +++ +++## V2 Notes +++ +++- Archive nodes call `/api/automation/events`. +++- PR risk workflow calls `/api/automation/classify/pr` and `/api/automation/statuses/github`. +++- Forbidden-change and docs-drift workflows call `/api/automation/statuses/github`. +++- Simulation drift workflow calls `/api/automation/simulation-snapshots/compare` instead of n8n static data. +++ +++## Errors +++ +++None. ++diff --git a/docs/automation/branch-protection.md b/docs/automation/branch-protection.md ++new file mode 100644 ++index 0000000000000000000000000000000000000000..65269c0026e1a64fb9d9d051042339eb245df9c9 ++--- /dev/null +++++ b/docs/automation/branch-protection.md ++@@ -0,0 +1,17 @@ +++Status: Active +++Last updated: 2026-04-28 +++ +++# Cherry Automation Branch Protection +++ +++Cherry automation V2 posts allowlisted GitHub commit statuses through Cherry-owned API endpoints. These statuses become enforcement only when the repository branch protection rules require them before merge. +++ +++Required Cherry status contexts: +++ +++- `cherry/forbidden-change` +++- `cherry/docs-drift` +++- `cherry/risk-gate` +++- `cherry/openclaw-policy` +++ +++Without branch protection, Cherry statuses are advisory only. +++ +++Configure branch protection for protected branches to require the contexts above, keep administrator bypasses limited, and keep n8n routed through Cherry `/api/automation/*` endpoints rather than posting arbitrary status contexts directly. ++diff --git a/docs/ci-and-guardrails.md b/docs/ci-and-guardrails.md ++index a1e1bf51ad0ccd98768888f8ba5b59589ea5e05a..94042be63dc068b5fc65ade352d87e852f1b8aaf 100644 ++--- a/docs/ci-and-guardrails.md +++++ b/docs/ci-and-guardrails.md ++@@ -88,8 +88,9 @@ Last updated: 2026-04-28 ++ ++ Use the narrowest proof that fully covers the changed surface: ++ - `npm run check:static` for guardrails, lint, and typecheck. +++- `npm run check:fast` for local guardrails + script typecheck. ++ - `npm run check:runtime` or `npm test` for the partitioned runtime suite. ++-- `npm run check:fast` for local guardrails + script typecheck + runtime suite. +++- `npm run check:local` for `check:fast` plus the partitioned runtime suite. ++ - `CHERRY_TMP_ROOT="$HOME/.cherry-tmp" CHERRY_VINE_SIGNATURE_MODE=enforce npm run verify:repo-closure` for canonical full proof. ++ ++ Agents must not blindly stack `npm run check`, `npm test`, `npm run build`, and `verify:repo-closure`; do not run both `npm test` and `verify:repo-closure` unless explicitly required. ++diff --git a/docs/config-snapshot.md b/docs/config-snapshot.md ++index 0241263742aaa0ea554c42f74e20893038dc6195..45887ee1519365d63492963aac0f96f3c232df7f 100644 ++--- a/docs/config-snapshot.md +++++ b/docs/config-snapshot.md ++@@ -45,16 +45,45 @@ jobs: ++ - name: Guardrails ++ run: npm run check:guardrails ++ ++- - name: Node runtime tests ++- run: npm run check:tests:node ++- ++- - name: Next runtime tests ++- run: npm run check:tests:next ++- ++ - name: Verify CI truth ++ run: npm run ci:verify ++ ``` ++ +++```yaml +++// .github/workflows/n8n-notify.yml +++name: Notify n8n +++ +++on: +++ workflow_run: +++ workflows: +++ - CI +++ types: +++ - completed +++ +++jobs: +++ notify-n8n: +++ runs-on: ubuntu-latest +++ +++ steps: +++ - name: Send workflow result to n8n +++ env: +++ N8N_WEBHOOK_URL: ${{ secrets.N8N_WEBHOOK_URL }} +++ N8N_WEBHOOK_TOKEN: ${{ secrets.N8N_WEBHOOK_TOKEN }} +++ run: | +++ curl -X POST "$N8N_WEBHOOK_URL" \ +++ -H "Authorization: Bearer $N8N_WEBHOOK_TOKEN" \ +++ -H "Content-Type: application/json" \ +++ -d '{ +++ "event": "github.workflow.completed", +++ "repo": "${{ github.repository }}", +++ "workflow": "${{ github.event.workflow_run.name }}", +++ "status": "${{ github.event.workflow_run.conclusion }}", +++ "branch": "${{ github.event.workflow_run.head_branch }}", +++ "sha": "${{ github.event.workflow_run.head_sha }}", +++ "url": "${{ github.event.workflow_run.html_url }}" +++ }' +++``` +++ ++ ```yaml ++ // .github/workflows/env-checks.yml ++ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json ++@@ -9594,6 +9623,10 @@ export default nextConfig; ++ "build:strict": "npm run check:guardrails && next build --webpack", ++ "start": "next start", ++ "ci:verify": "npm run check && npm run test && npm run build", +++ "check:static": "npm run check:guardrails && npm run lint:scripts && npm run typecheck:scripts && npm run lint && npm run typecheck", +++ "check:runtime": "npm test", +++ "check:fast": "npm run check:guardrails && npm run typecheck:scripts", +++ "check:local": "npm run check:fast && npm run check:runtime", ++ "check:clean": "npm run ts:esm -- scripts/execution/run.mts check:clean", ++ "check:db-ready": "npm run ts:esm -- scripts/execution/run-db.mts check:db-ready", ++ "check:dev-login": "npm run ts:esm -- scripts/execution/run.mts check:dev-login", ++@@ -10353,6 +10386,75 @@ model DecisionEvent { ++ @@index([userId, createdAt]) ++ } ++ +++model AutomationEvent { +++ id String @id @default(cuid()) +++ repo String +++ sha String? +++ event String +++ source String +++ workflow String +++ status String +++ idempotencyKey String @unique(map: "automation_event__idempotency_key__unique") +++ classifierVersion String +++ outputHash String +++ rawPayload Json +++ normalizedEvent Json +++ classifierOutput Json +++ prNumber Int? +++ issueNumber Int? +++ createdAt DateTime @default(now()) +++ updatedAt DateTime @updatedAt +++ +++ statusChecks AutomationStatusCheck[] +++ +++ @@index([repo, sha]) +++ @@index([repo, prNumber]) +++ @@index([repo, issueNumber]) +++ @@index([workflow, createdAt]) +++ @@index([classifierVersion]) +++} +++ +++model SimulationAutomationSnapshot { +++ id String @id @default(cuid()) +++ repo String +++ scopeKey String +++ runId String +++ classifierVersion String +++ snapshot Json +++ comparisonOutput Json +++ outputHash String +++ previousSnapshotId String? +++ createdAt DateTime @default(now()) +++ +++ @@unique([scopeKey, runId, classifierVersion], map: "simulation_automation_snapshot__scope_run_version__unique") +++ @@index([repo, scopeKey]) +++ @@index([scopeKey, createdAt]) +++ @@index([classifierVersion]) +++} +++ +++model AutomationStatusCheck { +++ id String @id @default(cuid()) +++ repo String +++ sha String +++ context String +++ state String +++ description String +++ targetUrl String? +++ sourceWorkflow String +++ automationEvent AutomationEvent? @relation(fields: [automationEventId], references: [id], onDelete: SetNull, map: "automation_status_check__automation_event_id__fk") +++ automationEventId String? +++ classifierVersion String +++ outputHash String +++ statusIdempotencyKey String @unique(map: "automation_status_check__status_idempotency_key__unique") +++ githubResponse Json? +++ createdAt DateTime @default(now()) +++ +++ @@index([repo, sha]) +++ @@index([repo, sha, context]) +++ @@index([automationEventId]) +++ @@index([classifierVersion]) +++} +++ ++ model IdempotencyKey { ++ userId String ++ key String ++diff --git a/docs/schema-evolution.md b/docs/schema-evolution.md ++index 0e8cde12739aa13d02704abda6fb5f19199cb8ee..96fde10bb85c4ffb656f6cd18ad9fb7f98347ccb 100644 ++--- a/docs/schema-evolution.md +++++ b/docs/schema-evolution.md ++@@ -1,5 +1,5 @@ ++ Status: Active ++-Last updated: 2026-04-26 +++Last updated: 2026-04-27 ++ ++ # Schema Evolution Rules ++ ++@@ -9,10 +9,10 @@ Last updated: 2026-04-26 ++ - DB truth scripts/tests under scripts/db-check-* or tests/db/** ++ ++ ## Current schema manifest ++-- `schemaVersion`: `schema_v2` ++-- `lastMigration`: `20260426090000_add_scheduled_paydowns` +++- `schemaVersion`: `schema_v3` +++- `lastMigration`: `20260427153000_automation_backend` ++ - `invariantsVersion`: `db_invariants_v1` ++-- `schema_v2` adds persisted raw scheduled paydown rows for engine loading. The runtime loader treats these rows as source data only; temporal classification remains in engine evaluation. +++- `schema_v3` adds advisory automation audit tables for n8n V2: `AutomationEvent`, `SimulationAutomationSnapshot`, and `AutomationStatusCheck`. These records support replay, classifier output hashes, and GitHub status auditability; they do not mutate finance truth. ++ ++ ## Required steps per schema change ++ 1. Create or update a migration under prisma/migrations/**. ++diff --git a/final-cherry-diff.patch b/final-cherry-diff.patch ++new file mode 100644 ++index 0000000000000000000000000000000000000000..ab67286eb77fb41e53f5631e2e7453cdc1b3bcd1 ++--- /dev/null +++++ b/final-cherry-diff.patch ++@@ -0,0 +1,8840 @@ +++diff --git a/AGENTS.md b/AGENTS.md +++index 9804f4620cc8bdfecea458c1318d688239f1b6b7..de967a454828cca83ada13b8156ed9984ffcba31 100644 +++--- a/AGENTS.md ++++++ b/AGENTS.md +++@@ -102,7 +102,8 @@ Forbidden framings: “fronting card,” “proxy BIN,” “tap to pay with Che +++ - `npm run build` → Next.js build passes. +++ - `npm run ci:verify` → mirrors CI entrypoint. +++ - `npm run test:db` → DB/env tests only; not part of standard mocked runtime proof. +++-- `npm run check:fast` → local guardrails + script typecheck + partitioned runtime suite. ++++- `npm run check:fast` → local guardrails + script typecheck only. ++++- `npm run check:local` → `check:fast` plus partitioned runtime suite. +++ - Full repo proof → `CHERRY_TMP_ROOT="$HOME/.cherry-tmp" CHERRY_VINE_SIGNATURE_MODE=enforce npm run verify:repo-closure`. +++ - Agents must not run both `npm test` and `verify:repo-closure` unless explicitly required. +++ - If schema changed: migrations apply and Prisma client is regenerated. +++diff --git a/app/api/automation/_auth.ts b/app/api/automation/_auth.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..db01081c5c2378edd3669dea467944ec6d8d3a7d +++--- /dev/null ++++++ b/app/api/automation/_auth.ts +++@@ -0,0 +1,34 @@ ++++import type { NextRequest } from 'next/server'; ++++import { NextResponse } from 'next/server'; ++++import { getStandardBearerHeader } from '../../../lib/http/bearer-token.js'; ++++ ++++export type AutomationAuthResult = { ok: true } | { ok: false; response: NextResponse }; ++++ ++++export function requireAutomationToken(request: NextRequest): AutomationAuthResult { ++++ const expected = process.env['CHERRY_AUTOMATION_TOKEN']; ++++ if (typeof expected !== 'string' || expected.trim().length === 0) { ++++ return { ++++ ok: false, ++++ response: NextResponse.json( ++++ { error: 'automation_token_not_configured' }, ++++ { status: 503 } ++++ ), ++++ }; ++++ } ++++ ++++ const bearerHeader = getStandardBearerHeader(request.headers); ++++ const headerToken = request.headers.get('x-cherry-automation-token'); ++++ const bearerToken = ++++ bearerHeader !== null && bearerHeader.startsWith('Bearer ') ++++ ? bearerHeader.slice('Bearer '.length) ++++ : null; ++++ const provided = bearerToken ?? headerToken; ++++ if (provided !== expected) { ++++ return { ++++ ok: false, ++++ response: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), ++++ }; ++++ } ++++ ++++ return { ok: true }; ++++} +++diff --git a/app/api/automation/classify/pr/route.ts b/app/api/automation/classify/pr/route.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..e3d3f2fe77ce66efa9ea360bc09550b46764ddaa +++--- /dev/null ++++++ b/app/api/automation/classify/pr/route.ts +++@@ -0,0 +1,38 @@ ++++import type { NextRequest } from 'next/server'; ++++import { NextResponse } from 'next/server'; ++++import { ++++ AutomationEventIdempotencyConflictError, ++++ classifyAndStorePrAutomation, ++++} from '../../../../../lib/automation/events.js'; ++++import { PrAutomationClassifySchema } from '../../../../../lib/schemas/automation.js'; ++++import { parseJsonBody } from '../../../../../lib/validation.js'; ++++import { requireAutomationToken } from '../../_auth.js'; ++++ ++++export async function POST(request: NextRequest): Promise { ++++ const auth = requireAutomationToken(request); ++++ if (auth.ok === false) return auth.response; ++++ ++++ const parsed = await parseJsonBody(request, PrAutomationClassifySchema); ++++ if (parsed.ok === false) return parsed.response; ++++ ++++ let result: Awaited>; ++++ try { ++++ result = await classifyAndStorePrAutomation(parsed.data); ++++ } catch (error: unknown) { ++++ if (error instanceof AutomationEventIdempotencyConflictError) { ++++ return NextResponse.json( ++++ { error: 'automation_event_idempotency_conflict' }, ++++ { status: 409 } ++++ ); ++++ } ++++ throw error; ++++ } ++++ ++++ return NextResponse.json({ ++++ ok: true, ++++ created: result.created, ++++ automationEventId: result.event.id, ++++ outputHash: result.event.outputHash, ++++ classifierOutput: result.classifierOutput, ++++ }); ++++} +++diff --git a/app/api/automation/events/route.ts b/app/api/automation/events/route.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..3c16a46ca8dae1384209aa0a33c6f18d0c2cd5a1 +++--- /dev/null ++++++ b/app/api/automation/events/route.ts +++@@ -0,0 +1,37 @@ ++++import type { NextRequest } from 'next/server'; ++++import { NextResponse } from 'next/server'; ++++import { AutomationEventIngestSchema } from '../../../../lib/schemas/automation.js'; ++++import { ++++ AutomationEventIdempotencyConflictError, ++++ storeAutomationEvent, ++++} from '../../../../lib/automation/events.js'; ++++import { parseJsonBody } from '../../../../lib/validation.js'; ++++import { requireAutomationToken } from '../_auth.js'; ++++ ++++export async function POST(request: NextRequest): Promise { ++++ const auth = requireAutomationToken(request); ++++ if (auth.ok === false) return auth.response; ++++ ++++ const parsed = await parseJsonBody(request, AutomationEventIngestSchema); ++++ if (parsed.ok === false) return parsed.response; ++++ ++++ let result: Awaited>; ++++ try { ++++ result = await storeAutomationEvent(parsed.data); ++++ } catch (error: unknown) { ++++ if (error instanceof AutomationEventIdempotencyConflictError) { ++++ return NextResponse.json( ++++ { error: 'automation_event_idempotency_conflict' }, ++++ { status: 409 } ++++ ); ++++ } ++++ throw error; ++++ } ++++ ++++ return NextResponse.json({ ++++ ok: true, ++++ created: result.created, ++++ automationEventId: result.event.id, ++++ outputHash: result.event.outputHash, ++++ }); ++++} +++diff --git a/app/api/automation/replay/route.ts b/app/api/automation/replay/route.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..251abc7030d5bec92049b5a652068aa947855104 +++--- /dev/null ++++++ b/app/api/automation/replay/route.ts +++@@ -0,0 +1,31 @@ ++++import type { NextRequest } from 'next/server'; ++++import { NextResponse } from 'next/server'; ++++import { replayAutomationEvent } from '../../../../lib/automation/events.js'; ++++import { AutomationReplaySchema } from '../../../../lib/schemas/automation.js'; ++++import { parseJsonBody } from '../../../../lib/validation.js'; ++++import { requireAutomationToken } from '../_auth.js'; ++++ ++++export async function POST(request: NextRequest): Promise { ++++ const auth = requireAutomationToken(request); ++++ if (auth.ok === false) return auth.response; ++++ ++++ const parsed = await parseJsonBody(request, AutomationReplaySchema); ++++ if (parsed.ok === false) return parsed.response; ++++ ++++ const result = await replayAutomationEvent( ++++ parsed.data.automationEventId, ++++ parsed.data.classifierVersion ++++ ); ++++ if (result === null) { ++++ return NextResponse.json({ error: 'automation_event_not_found' }, { status: 404 }); ++++ } ++++ ++++ return NextResponse.json({ ++++ ok: true, ++++ automationEventId: result.event.id, ++++ outputHash: result.outputHash, ++++ matches: result.matches, ++++ reason: result.reason, ++++ }); ++++} ++++ +++diff --git a/app/api/automation/simulation-snapshots/compare/route.ts b/app/api/automation/simulation-snapshots/compare/route.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..298d9bda365b33267dd6ee31b6c5814c55e6ed61 +++--- /dev/null ++++++ b/app/api/automation/simulation-snapshots/compare/route.ts +++@@ -0,0 +1,38 @@ ++++import type { NextRequest } from 'next/server'; ++++import { NextResponse } from 'next/server'; ++++import { ++++ SimulationSnapshotIdempotencyConflictError, ++++ compareAndStoreSimulationSnapshot, ++++} from '../../../../../lib/automation/events.js'; ++++import { SimulationSnapshotCompareSchema } from '../../../../../lib/schemas/automation.js'; ++++import { parseJsonBody } from '../../../../../lib/validation.js'; ++++import { requireAutomationToken } from '../../_auth.js'; ++++ ++++export async function POST(request: NextRequest): Promise { ++++ const auth = requireAutomationToken(request); ++++ if (auth.ok === false) return auth.response; ++++ ++++ const parsed = await parseJsonBody(request, SimulationSnapshotCompareSchema); ++++ if (parsed.ok === false) return parsed.response; ++++ ++++ let result: Awaited>; ++++ try { ++++ result = await compareAndStoreSimulationSnapshot(parsed.data); ++++ } catch (error: unknown) { ++++ if (error instanceof SimulationSnapshotIdempotencyConflictError) { ++++ return NextResponse.json( ++++ { error: 'simulation_snapshot_idempotency_conflict' }, ++++ { status: 409 } ++++ ); ++++ } ++++ throw error; ++++ } ++++ ++++ return NextResponse.json({ ++++ ok: true, ++++ created: result.created, ++++ snapshotId: result.snapshot.id, ++++ outputHash: result.snapshot.outputHash, ++++ comparisonOutput: result.comparisonOutput, ++++ }); ++++} +++diff --git a/app/api/automation/statuses/github/retry/route.ts b/app/api/automation/statuses/github/retry/route.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..5d4d9a10cc067a567c11e7f93e27112022b28fae +++--- /dev/null ++++++ b/app/api/automation/statuses/github/retry/route.ts +++@@ -0,0 +1,47 @@ ++++import type { NextRequest } from 'next/server'; ++++import { NextResponse } from 'next/server'; ++++import { ++++ GithubStatusRetryNotFoundError, ++++ retryGithubStatus, ++++} from '../../../../../../lib/automation/github-status.js'; ++++import { GithubStatusRetrySchema } from '../../../../../../lib/schemas/automation.js'; ++++import { parseJsonBody } from '../../../../../../lib/validation.js'; ++++import { requireAutomationToken } from '../../../_auth.js'; ++++ ++++export async function POST(request: NextRequest): Promise { ++++ const auth = requireAutomationToken(request); ++++ if (auth.ok === false) return auth.response; ++++ ++++ const parsed = await parseJsonBody(request, GithubStatusRetrySchema); ++++ if (parsed.ok === false) return parsed.response; ++++ ++++ try { ++++ const result = await retryGithubStatus(parsed.data, { ++++ githubToken: process.env['GITHUB_TOKEN'] ?? '', ++++ }); ++++ return NextResponse.json({ ++++ ok: true, ++++ retried: result.retried, ++++ statusCheck: result.statusCheck, ++++ }); ++++ } catch (error: unknown) { ++++ if (error instanceof GithubStatusRetryNotFoundError) { ++++ return NextResponse.json({ error: 'github_status_not_found' }, { status: 404 }); ++++ } ++++ const statusCheck = (error as { statusCheck?: unknown }).statusCheck; ++++ const message = error instanceof Error ? error.message : 'github_status_retry_failed'; ++++ const status = /forbidden Cherry finance endpoint|Unsupported GitHub status context/.test( ++++ message ++++ ) ++++ ? 400 ++++ : 502; ++++ return NextResponse.json( ++++ { ++++ ok: false, ++++ error: message, ++++ statusCheck, ++++ }, ++++ { status } ++++ ); ++++ } ++++} +++diff --git a/app/api/automation/statuses/github/route.ts b/app/api/automation/statuses/github/route.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..f4a5d356daafb04e12e96e7e15f8095c806a5b5e +++--- /dev/null ++++++ b/app/api/automation/statuses/github/route.ts +++@@ -0,0 +1,36 @@ ++++import type { NextRequest } from 'next/server'; ++++import { NextResponse } from 'next/server'; ++++import { postGithubStatus } from '../../../../../lib/automation/github-status.js'; ++++import { GithubStatusPostSchema } from '../../../../../lib/schemas/automation.js'; ++++import { parseJsonBody } from '../../../../../lib/validation.js'; ++++import { requireAutomationToken } from '../../_auth.js'; ++++ ++++export async function POST(request: NextRequest): Promise { ++++ const auth = requireAutomationToken(request); ++++ if (auth.ok === false) return auth.response; ++++ ++++ const parsed = await parseJsonBody(request, GithubStatusPostSchema); ++++ if (parsed.ok === false) return parsed.response; ++++ ++++ try { ++++ const result = await postGithubStatus(parsed.data, { ++++ githubToken: process.env['GITHUB_TOKEN'] ?? '', ++++ }); ++++ return NextResponse.json({ ++++ ok: true, ++++ posted: result.posted, ++++ idempotent: result.idempotent, ++++ statusCheckId: result.statusCheck.id, ++++ }); ++++ } catch (error: unknown) { ++++ const statusCheck = (error as { statusCheck?: { id?: string } }).statusCheck; ++++ return NextResponse.json( ++++ { ++++ ok: false, ++++ error: error instanceof Error ? error.message : 'github_status_failed', ++++ statusCheckId: statusCheck?.id, ++++ }, ++++ { status: 502 } ++++ ); ++++ } ++++} +++diff --git a/app/api/automation/statuses/route.ts b/app/api/automation/statuses/route.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..8e8fa5163abee9ba4a88f53906d0979bfd36c03f +++--- /dev/null ++++++ b/app/api/automation/statuses/route.ts +++@@ -0,0 +1,30 @@ ++++import type { NextRequest } from 'next/server'; ++++import { NextResponse } from 'next/server'; ++++import { ++++ isAllowedGithubStatusContext, ++++ listLatestGithubStatuses, ++++} from '../../../../lib/automation/github-status.js'; ++++import { requireAutomationToken } from '../_auth.js'; ++++ ++++export async function GET(request: NextRequest): Promise { ++++ const auth = requireAutomationToken(request); ++++ if (auth.ok === false) return auth.response; ++++ ++++ const url = new URL(request.url); ++++ const repo = url.searchParams.get('repo') ?? undefined; ++++ const sha = url.searchParams.get('sha') ?? undefined; ++++ const contextParam = url.searchParams.get('context') ?? undefined; ++++ if ( ++++ contextParam !== undefined && ++++ isAllowedGithubStatusContext(contextParam) === false ++++ ) { ++++ return NextResponse.json({ error: 'invalid_status_context' }, { status: 400 }); ++++ } ++++ ++++ const params: Parameters[0] = {}; ++++ if (repo !== undefined) params.repo = repo; ++++ if (sha !== undefined) params.sha = sha; ++++ if (contextParam !== undefined) params.context = contextParam; ++++ const statuses = await listLatestGithubStatuses(params); ++++ return NextResponse.json({ ok: true, statuses }); ++++} +++diff --git a/cherry-n8n-workflows/01_ci_failure_compression.json b/cherry-n8n-workflows/01_ci_failure_compression.json +++new file mode 100644 +++index 0000000000000000000000000000000000000000..f4ed582dbada241baed45d6edde23d632256ac9f +++--- /dev/null ++++++ b/cherry-n8n-workflows/01_ci_failure_compression.json +++@@ -0,0 +1,570 @@ ++++{ ++++ "name": "Cherry - CI Failure Compression", ++++ "nodes": [ ++++ { ++++ "parameters": { ++++ "httpMethod": "POST", ++++ "path": "cherry/github/workflow-completed", ++++ "responseMode": "responseNode", ++++ "options": {} ++++ }, ++++ "id": "webhook-ci-completed", ++++ "name": "Webhook", ++++ "type": "n8n-nodes-base.webhook", ++++ "typeVersion": 2, ++++ "position": [ ++++ 0, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.workflow_completed',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '01_ci_failure_compression',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.workflow_completed.',\n actions: []\n};\nreturn [{ json: output }];" ++++ }, ++++ "id": "normalize-github-payload", ++++ "name": "Normalize GitHub Payload", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 260, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.workflow_run.conclusion\",\"payload.workflow_run.html_url\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++++ }, ++++ "id": "validate-payload", ++++ "name": "Validate Payload", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 520, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst run = event.payload?.workflow_run ?? {};\nconst conclusion = run.conclusion ?? 'unknown';\nconst text = [run.name, run.display_title, run.path, run.html_url].filter(Boolean).join(' ').toLowerCase();\nconst category = text.includes('lint') ? 'lint' : text.includes('typecheck') || text.includes('typescript') ? 'typecheck' : text.includes('test') ? 'test' : text.includes('build') ? 'build' : text.includes('closure') || text.includes('guardrail') ? 'repo-closure' : text.includes('migration') || text.includes('prisma') ? 'migration' : 'unknown';\nconst shouldProcess = conclusion === 'failure' || conclusion === 'timed_out' || conclusion === 'cancelled';\nconst workflowName = run.name ?? 'unknown workflow';\nconst branch = run.head_branch ?? 'unknown branch';\nconst sha = run.head_sha ?? 'unknown sha';\nconst url = run.html_url ?? '';\nconst title = '[ci:' + category + '] ' + workflowName + ' failed on ' + branch;\nconst output = { ...event, shouldProcess, failureCategory: category, workflowName, branch, sha, url, searchQuery: encodeURIComponent('repo:' + event.repo + ' is:issue is:open in:title \"[ci:' + category + ']\" \"' + workflowName + '\"'), issueBody: { title, labels: ['ci-failure', 'automation', 'needs-triage', category], body: 'Cherry CI failure compression.\\n\\nWorkflow: ' + workflowName + '\\nBranch: ' + branch + '\\nSHA: ' + sha + '\\nCategory: ' + category + '\\nRun: ' + url + '\\n\\nSuggested verification: npm run ci:verify\\n\\nAdvisory automation only.' }, commentBody: { body: 'Repeated CI failure.\\n\\nWorkflow: ' + workflowName + '\\nBranch: ' + branch + '\\nSHA: ' + sha + '\\nCategory: ' + category + '\\nRun: ' + url }, openclawTask: { source: 'ci_failure', repo: event.repo, title, category, branch, sha, url, guardrails: ['npm run ci:verify', 'no Cherry finance truth mutation'] }, status: shouldProcess ? 'accepted' : 'ignored', summary: shouldProcess ? title : 'Workflow conclusion was ' + conclusion + '; ignoring.', actions: shouldProcess ? ['search_existing_issue', 'comment_or_create_issue', 'optional_openclaw_task', 'archive_event'] : [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "classify-failure", ++++ "name": "Classify Failure", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 780, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "conditions": { ++++ "options": { ++++ "caseSensitive": true, ++++ "leftValue": "", ++++ "typeValidation": "strict" ++++ }, ++++ "conditions": [ ++++ { ++++ "id": "if-failure-condition", ++++ "leftValue": "={{ $json.shouldProcess === true }}", ++++ "rightValue": true, ++++ "operator": { ++++ "type": "boolean", ++++ "operation": "true", ++++ "singleValue": true ++++ } ++++ } ++++ ], ++++ "combinator": "and" ++++ }, ++++ "options": {} ++++ }, ++++ "id": "if-failure", ++++ "name": "IF: Failure?", ++++ "type": "n8n-nodes-base.if", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1040, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "GET", ++++ "url": "={{ 'https://api.github.com/search/issues?q=' + $json.searchQuery }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {} ++++ }, ++++ "id": "search-existing-issues", ++++ "name": "Search Existing GitHub Issues", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1300, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "conditions": { ++++ "options": { ++++ "caseSensitive": true, ++++ "leftValue": "", ++++ "typeValidation": "strict" ++++ }, ++++ "conditions": [ ++++ { ++++ "id": "if-existing-issue-condition", ++++ "leftValue": "={{ Array.isArray($json.items) && $json.items.length > 0 }}", ++++ "rightValue": true, ++++ "operator": { ++++ "type": "boolean", ++++ "operation": "true", ++++ "singleValue": true ++++ } ++++ } ++++ ], ++++ "combinator": "and" ++++ }, ++++ "options": {} ++++ }, ++++ "id": "if-existing-issue", ++++ "name": "IF: Existing Issue?", ++++ "type": "n8n-nodes-base.if", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1560, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.items[0].number + '/comments' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $node[\"Classify Failure\"].json.commentBody }}" ++++ }, ++++ "id": "comment-existing-issue", ++++ "name": "Comment Existing Issue", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1820, ++++ -320 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $node[\"Classify Failure\"].json.issueBody }}" ++++ }, ++++ "id": "create-new-issue", ++++ "name": "Create New Issue", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1820, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.OPENCLAW_WEBHOOK_URL }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $node[\"Classify Failure\"].json.openclawTask }}" ++++ }, ++++ "id": "send-ci-failure-to-openclaw", ++++ "name": "Send CI Failure To OpenClaw", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2080, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '01_ci_failure_compression', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++++ }, ++++ "id": "route-shared-sinks", ++++ "name": "Route Shared Sinks", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 2340, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '01_ci_failure_compression.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '01_ci_failure_compression',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '01_ci_failure_compression') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '01_ci_failure_compression.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++++ }, ++++ "id": "archive-event", ++++ "name": "Archive Event", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2600, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++++ }, ++++ "id": "notify-discord", ++++ "name": "Notify Discord", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2860, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'CI failure compressed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-response", ++++ "name": "Build Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 3120, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: 'ignored', summary: event.summary ?? 'CI workflow did not fail; no action taken.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-ignored-response", ++++ "name": "Build Ignored Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1300, ++++ 160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "respondWith": "firstIncomingItem", ++++ "options": { ++++ "responseCode": 200 ++++ } ++++ }, ++++ "id": "respond-to-webhook", ++++ "name": "Respond to Webhook", ++++ "type": "n8n-nodes-base.respondToWebhook", ++++ "typeVersion": 1, ++++ "position": [ ++++ 3380, ++++ 0 ++++ ] ++++ } ++++ ], ++++ "connections": { ++++ "Webhook": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize GitHub Payload", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Normalize GitHub Payload": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Validate Payload", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Validate Payload": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Classify Failure", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Classify Failure": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "IF: Failure?", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "IF: Failure?": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Search Existing GitHub Issues", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ], ++++ [ ++++ { ++++ "node": "Build Ignored Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Search Existing GitHub Issues": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "IF: Existing Issue?", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "IF: Existing Issue?": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Comment Existing Issue", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ], ++++ [ ++++ { ++++ "node": "Create New Issue", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Comment Existing Issue": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Send CI Failure To OpenClaw", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Create New Issue": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Send CI Failure To OpenClaw", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Send CI Failure To OpenClaw": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Route Shared Sinks", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Route Shared Sinks": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Archive Event", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Archive Event": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Notify Discord", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Notify Discord": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Build Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Ignored Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ } ++++ }, ++++ "settings": { ++++ "executionOrder": "v1" ++++ } ++++} +++diff --git a/cherry-n8n-workflows/02_openclaw_issue_router.json b/cherry-n8n-workflows/02_openclaw_issue_router.json +++new file mode 100644 +++index 0000000000000000000000000000000000000000..45c76c26fc731b6275b4e8121694063e91d88c16 +++--- /dev/null ++++++ b/cherry-n8n-workflows/02_openclaw_issue_router.json +++@@ -0,0 +1,389 @@ ++++{ ++++ "name": "Cherry - OpenClaw Issue Router", ++++ "nodes": [ ++++ { ++++ "parameters": { ++++ "httpMethod": "POST", ++++ "path": "cherry/github/issue-labeled", ++++ "responseMode": "responseNode", ++++ "options": {} ++++ }, ++++ "id": "webhook-issue-labeled", ++++ "name": "Webhook", ++++ "type": "n8n-nodes-base.webhook", ++++ "typeVersion": 2, ++++ "position": [ ++++ 0, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.issue_labeled',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '02_openclaw_issue_router',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.issue_labeled.',\n actions: []\n};\nreturn [{ json: output }];" ++++ }, ++++ "id": "normalize-issue-event", ++++ "name": "Normalize Issue Event", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 260, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.issue.number\",\"payload.issue.title\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++++ }, ++++ "id": "validate-payload", ++++ "name": "Validate Payload", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 520, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "conditions": { ++++ "options": { ++++ "caseSensitive": true, ++++ "leftValue": "", ++++ "typeValidation": "strict" ++++ }, ++++ "conditions": [ ++++ { ++++ "id": "if-openclaw-label-condition", ++++ "leftValue": "={{ (($json.payload.label?.name ?? '') === 'openclaw') || (($json.payload.issue?.labels ?? []).some((label) => label.name === 'openclaw')) }}", ++++ "rightValue": true, ++++ "operator": { ++++ "type": "boolean", ++++ "operation": "true", ++++ "singleValue": true ++++ } ++++ } ++++ ], ++++ "combinator": "and" ++++ }, ++++ "options": {} ++++ }, ++++ "id": "if-openclaw-label", ++++ "name": "IF: Has openclaw Label?", ++++ "type": "n8n-nodes-base.if", ++++ "typeVersion": 2, ++++ "position": [ ++++ 780, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst issue = event.payload?.issue ?? {};\nconst body = String(issue.body ?? '');\nconst forbiddenPatterns = ['.env', '.env.local', 'secrets', 'production db config', '/api/session', '/api/ledger', '/api/bucket', '/api/payment', '/api/card'];\nconst forbiddenMatches = forbiddenPatterns.filter((pattern) => body.toLowerCase().includes(pattern.toLowerCase()));\nconst task = { source: 'github_issue_openclaw', repo: event.repo, issueNumber: issue.number, title: issue.title, url: issue.html_url, body, constraints: { advisoryOnly: true, forbiddenFiles: ['.env', '.env.local'], forbiddenEndpointPatterns: ['/api/session*', '/api/ledger*', '/api/bucket*', '/api/payment*', '/api/card*', '/api/debt*/mutate'], requiredReviewLabels: ['needs-human-review'] }, forbiddenMatches };\nconst output = { ...event, openclawTask: task, commentBody: { body: 'OpenClaw task prepared.\\n\\nIssue: #' + issue.number + '\\nForbidden hints: ' + (forbiddenMatches.join(', ') || 'none') + '\\nHuman review remains required before merge.' }, status: forbiddenMatches.length > 0 ? 'failed' : 'accepted', summary: forbiddenMatches.length > 0 ? 'OpenClaw issue contains forbidden-change hints.' : 'OpenClaw task routed for issue #' + issue.number + '.', actions: forbiddenMatches.length > 0 ? ['block_openclaw_task', 'comment_issue', 'archive_event'] : ['send_openclaw_task', 'comment_issue', 'archive_event'] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-openclaw-task", ++++ "name": "Build OpenClaw Task", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1040, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.OPENCLAW_WEBHOOK_URL }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $json.openclawTask }}" ++++ }, ++++ "id": "send-to-openclaw", ++++ "name": "Send To OpenClaw", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1300, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build OpenClaw Task\"].json.openclawTask.issueNumber + '/comments' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $node[\"Build OpenClaw Task\"].json.commentBody }}" ++++ }, ++++ "id": "comment-on-issue", ++++ "name": "Comment On Issue", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1560, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '02_openclaw_issue_router', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++++ }, ++++ "id": "route-shared-sinks", ++++ "name": "Route Shared Sinks", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1820, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '02_openclaw_issue_router.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '02_openclaw_issue_router',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '02_openclaw_issue_router') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '02_openclaw_issue_router.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++++ }, ++++ "id": "archive-event", ++++ "name": "Archive Event", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2080, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'OpenClaw issue routed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-response", ++++ "name": "Build Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 2340, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: 'ignored', summary: event.summary ?? 'Issue was not labeled openclaw; no action taken.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-ignored-response", ++++ "name": "Build Ignored Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1040, ++++ 160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "respondWith": "firstIncomingItem", ++++ "options": { ++++ "responseCode": 200 ++++ } ++++ }, ++++ "id": "respond-to-webhook", ++++ "name": "Respond to Webhook", ++++ "type": "n8n-nodes-base.respondToWebhook", ++++ "typeVersion": 1, ++++ "position": [ ++++ 2600, ++++ 0 ++++ ] ++++ } ++++ ], ++++ "connections": { ++++ "Webhook": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize Issue Event", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Normalize Issue Event": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Validate Payload", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Validate Payload": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "IF: Has openclaw Label?", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "IF: Has openclaw Label?": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Build OpenClaw Task", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ], ++++ [ ++++ { ++++ "node": "Build Ignored Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build OpenClaw Task": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Send To OpenClaw", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Send To OpenClaw": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Comment On Issue", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Comment On Issue": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Route Shared Sinks", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Route Shared Sinks": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Archive Event", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Archive Event": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Build Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Ignored Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ } ++++ }, ++++ "settings": { ++++ "executionOrder": "v1" ++++ } ++++} +++diff --git a/cherry-n8n-workflows/03_pr_risk_classifier.json b/cherry-n8n-workflows/03_pr_risk_classifier.json +++new file mode 100644 +++index 0000000000000000000000000000000000000000..52fa57e3b551128b47844128cf44463008e372be +++--- /dev/null ++++++ b/cherry-n8n-workflows/03_pr_risk_classifier.json +++@@ -0,0 +1,474 @@ ++++{ ++++ "name": "Cherry - PR Risk Classifier", ++++ "nodes": [ ++++ { ++++ "parameters": { ++++ "httpMethod": "POST", ++++ "path": "cherry/github/pull-request-risk", ++++ "responseMode": "responseNode", ++++ "options": {} ++++ }, ++++ "id": "webhook-pr-risk", ++++ "name": "Webhook", ++++ "type": "n8n-nodes-base.webhook", ++++ "typeVersion": 2, ++++ "position": [ ++++ 0, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.pull_request',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '03_pr_risk_classifier',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.pull_request.',\n actions: []\n};\nreturn [{ json: output }];" ++++ }, ++++ "id": "normalize-pr", ++++ "name": "Normalize PR", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 260, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.pull_request.number\",\"payload.pull_request.title\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++++ }, ++++ "id": "validate-payload", ++++ "name": "Validate Payload", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 520, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "GET", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls/' + $json.payload.pull_request.number + '/files?per_page=100' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {} ++++ }, ++++ "id": "fetch-changed-files", ++++ "name": "Fetch Changed Files", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 780, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const prEvent = $node['Normalize PR'].json;\nconst classification = $input.first()?.json ?? {};\nconst classifierOutput = classification.classifierOutput ?? {};\nconst risk = classifierOutput.risk ?? {};\nconst statusRequests = Array.isArray(classifierOutput.statusRequests) ? classifierOutput.statusRequests : [];\nconst statusRequest = statusRequests.find((request) => request.context === 'cherry/risk-gate') ?? risk.statusRequest ?? { context: 'cherry/risk-gate', state: 'error', description: 'Cherry risk classifier did not return a status request.' };\nconst labels = Array.isArray(risk.labels) ? risk.labels : [];\nconst reasons = Array.isArray(risk.reasons) ? risk.reasons : [];\nconst score = typeof risk.score === 'number' ? risk.score : 'unknown';\nconst level = typeof risk.level === 'string' ? risk.level : 'unknown';\nconst prNumber = prEvent.payload.pull_request.number;\nconst output = {\n ...prEvent,\n automationEventId: classification.automationEventId,\n outputHash: classification.outputHash,\n cherryClassifierOutput: classifierOutput,\n statusRequest,\n labels,\n labelBody: { labels },\n commentBody: { body: 'Cherry PR risk classifier.\\n\\nLevel: ' + level + '\\nScore: ' + String(score) + '\\nLabels: ' + labels.join(', ') + '\\nReasons:\\n' + reasons.map((reason) => '- ' + reason).join('\\n') },\n status: 'accepted',\n summary: 'PR #' + prNumber + ' risk ' + level + ' from Cherry classifier.',\n actions: ['fetch_changed_files', 'classify_pr_in_cherry', 'post_risk_status', 'apply_labels', 'comment_risk_summary']\n};\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-cherry-pr-routing", ++++ "name": "Build Cherry PR Routing", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1040, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.payload.pull_request.number + '/labels' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $json.labelBody }}" ++++ }, ++++ "id": "apply-labels", ++++ "name": "Apply Labels", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1300, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build Cherry PR Routing\"].json.payload.pull_request.number + '/comments' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $node[\"Build Cherry PR Routing\"].json.commentBody }}" ++++ }, ++++ "id": "comment-risk-summary", ++++ "name": "Comment Risk Summary", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1560, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '03_pr_risk_classifier', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++++ }, ++++ "id": "route-shared-sinks", ++++ "name": "Route Shared Sinks", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1820, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '03_pr_risk_classifier.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '03_pr_risk_classifier',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '03_pr_risk_classifier') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? 'no-sha') + ':' + ($json.outputHash ?? $now.toISO())),\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '03_pr_risk_classifier.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json.cherryClassifierOutput ?? $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++++ }, ++++ "id": "archive-event", ++++ "name": "Archive Event", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2080, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'PR risk classified.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-response", ++++ "name": "Build Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 2340, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "respondWith": "firstIncomingItem", ++++ "options": { ++++ "responseCode": 200 ++++ } ++++ }, ++++ "id": "respond-to-webhook", ++++ "name": "Respond to Webhook", ++++ "type": "n8n-nodes-base.respondToWebhook", ++++ "typeVersion": 1, ++++ "position": [ ++++ 2600, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/statuses/github' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n sha: $json.sha ?? $json.payload?.pull_request?.head?.sha ?? 'unknown-sha',\n context: $json.statusRequest?.context ?? 'cherry/risk-gate',\n state: $json.statusRequest?.state ?? 'error',\n description: $json.statusRequest?.description ?? 'Cherry classifier status request missing.',\n targetUrl: $json.statusRequest?.targetUrl,\n sourceWorkflow: $json.workflow ?? 'post-cherry-status',\n automationEventId: $json.automationEventId,\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n outputHash: $json.outputHash\n} }}" ++++ }, ++++ "id": "post-risk-status", ++++ "name": "Post Risk Status", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1300, ++++ 160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "jsCode": "const items = $input.all();\n\nlet files = [];\n\nfor (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n files.push(...json);\n } else if (Array.isArray(json.files)) {\n files.push(...json.files);\n } else if (Array.isArray(json.data)) {\n files.push(...json.data);\n } else if (json && json.filename) {\n files.push(json);\n }\n}\n\nfiles = files.map((file) => ({\n filename: file.filename ?? file.path ?? file.name ?? '',\n status: file.status ?? 'modified',\n additions: Number(file.additions ?? 0),\n deletions: Number(file.deletions ?? 0),\n changes: Number(file.changes ?? 0),\n patch: String(file.patch ?? '')\n})).filter((file) => file.filename.length > 0);\n\nreturn [{\n json: {\n ...($items('Normalize PR')[0]?.json ?? {}),\n files\n }\n}];" ++++ }, ++++ "id": "normalize-changed-files", ++++ "name": "Normalize Changed Files", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 910, ++++ 160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/classify/pr' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n sha: $node['Normalize PR'].json.payload.pull_request.head.sha,\n prNumber: $node['Normalize PR'].json.payload.pull_request.number,\n title: $node['Normalize PR'].json.payload.pull_request.title,\n body: $node['Normalize PR'].json.payload.pull_request.body ?? '',\n labels: ($node['Normalize PR'].json.payload.pull_request.labels ?? []).map((label) => label.name),\n files: $json.files,\n sourceWorkflow: $node['Normalize PR'].json.workflow ?? '03_pr_risk_classifier'\n} }}" ++++ }, ++++ "id": "classify-pr-in-cherry", ++++ "name": "Classify PR In Cherry", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1040, ++++ 160 ++++ ], ++++ "continueOnFail": true ++++ } ++++ ], ++++ "connections": { ++++ "Webhook": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize PR", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Normalize PR": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Validate Payload", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Validate Payload": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Fetch Changed Files", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Fetch Changed Files": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize Changed Files", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Apply Labels": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Comment Risk Summary", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Comment Risk Summary": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Route Shared Sinks", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Route Shared Sinks": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Archive Event", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Archive Event": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Build Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Post Risk Status": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Apply Labels", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Classify PR In Cherry": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Build Cherry PR Routing", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Cherry PR Routing": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Post Risk Status", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Normalize Changed Files": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Classify PR In Cherry", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ } ++++ }, ++++ "settings": { ++++ "executionOrder": "v1" ++++ } ++++} +++diff --git a/cherry-n8n-workflows/04_forbidden_change_detector.json b/cherry-n8n-workflows/04_forbidden_change_detector.json +++new file mode 100644 +++index 0000000000000000000000000000000000000000..45eab731eb77805ebbd12c8215a17834d953409a +++--- /dev/null ++++++ b/cherry-n8n-workflows/04_forbidden_change_detector.json +++@@ -0,0 +1,590 @@ ++++{ ++++ "name": "Cherry - Forbidden Change Detector", ++++ "nodes": [ ++++ { ++++ "parameters": { ++++ "httpMethod": "POST", ++++ "path": "cherry/github/pull-request-forbidden", ++++ "responseMode": "responseNode", ++++ "options": {} ++++ }, ++++ "id": "webhook-pr-forbidden", ++++ "name": "Webhook", ++++ "type": "n8n-nodes-base.webhook", ++++ "typeVersion": 2, ++++ "position": [ ++++ 0, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.pull_request',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '04_forbidden_change_detector',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.pull_request.',\n actions: []\n};\nreturn [{ json: output }];" ++++ }, ++++ "id": "normalize-pr", ++++ "name": "Normalize PR", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 260, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.pull_request.number\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++++ }, ++++ "id": "validate-payload", ++++ "name": "Validate Payload", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 520, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "GET", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls/' + $json.payload.pull_request.number + '/files?per_page=100' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {} ++++ }, ++++ "id": "fetch-changed-files", ++++ "name": "Fetch Changed Files", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 780, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const prEvent = $node['Normalize PR'].json;\nconst classification = $input.first()?.json ?? {};\nconst classifierOutput = classification.classifierOutput ?? {};\nconst forbiddenChange = classifierOutput.forbiddenChange ?? {};\nconst statusRequests = Array.isArray(classifierOutput.statusRequests) ? classifierOutput.statusRequests : [];\nconst statusRequest = statusRequests.find((request) => request.context === 'cherry/forbidden-change') ?? forbiddenChange.statusRequest ?? { context: 'cherry/forbidden-change', state: 'error', description: 'Cherry forbidden-change classifier did not return a status request.' };\nconst violations = Array.isArray(forbiddenChange.violations) ? forbiddenChange.violations : [];\nconst labels = Array.isArray(forbiddenChange.labels) ? forbiddenChange.labels : [];\nconst blocked = forbiddenChange.forbidden === true;\nconst prNumber = prEvent.payload.pull_request.number;\nconst output = {\n ...prEvent,\n automationEventId: classification.automationEventId,\n outputHash: classification.outputHash,\n cherryClassifierOutput: classifierOutput,\n forbiddenChange,\n statusRequest,\n labelBody: { labels },\n commentBody: { body: 'Cherry forbidden-change detector.\\n\\n' + (blocked ? 'Blocking patterns detected by Cherry:\\n' + violations.map((violation) => '- ' + violation).join('\\n') : 'Cherry found no blocking patterns.') },\n status: blocked ? 'failed' : 'accepted',\n summary: blocked ? 'Cherry detected forbidden changes in PR #' + prNumber + '.' : 'Cherry found no forbidden changes in PR #' + prNumber + '.',\n actions: blocked ? ['classify_pr_in_cherry', 'post_forbidden_status', 'add_blocking_label', 'comment_violation'] : ['classify_pr_in_cherry', 'post_forbidden_status']\n};\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-cherry-forbidden-routing", ++++ "name": "Build Cherry Forbidden Routing", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1300, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "conditions": { ++++ "options": { ++++ "caseSensitive": true, ++++ "leftValue": "", ++++ "typeValidation": "strict" ++++ }, ++++ "conditions": [ ++++ { ++++ "id": "if-forbidden-condition", ++++ "leftValue": "={{ $json.forbiddenChange?.forbidden === true }}", ++++ "rightValue": true, ++++ "operator": { ++++ "type": "boolean", ++++ "operation": "true", ++++ "singleValue": true ++++ } ++++ } ++++ ], ++++ "combinator": "and" ++++ }, ++++ "options": {} ++++ }, ++++ "id": "if-forbidden", ++++ "name": "IF: Forbidden?", ++++ "type": "n8n-nodes-base.if", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1560, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.payload.pull_request.number + '/labels' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $json.labelBody }}" ++++ }, ++++ "id": "add-blocking-label", ++++ "name": "Add blocking label", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1820, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build Cherry Forbidden Routing\"].json.payload.pull_request.number + '/comments' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $node[\"Build Cherry Forbidden Routing\"].json.commentBody }}" ++++ }, ++++ "id": "comment-violation", ++++ "name": "Comment Violation", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2080, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '04_forbidden_change_detector', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++++ }, ++++ "id": "route-shared-sinks", ++++ "name": "Route Shared Sinks", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 2340, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '04_forbidden_change_detector.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '04_forbidden_change_detector',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '04_forbidden_change_detector') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? 'no-sha') + ':' + ($json.outputHash ?? $now.toISO())),\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '04_forbidden_change_detector.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json.cherryClassifierOutput ?? $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++++ }, ++++ "id": "archive-event", ++++ "name": "Archive Event", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2600, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++++ }, ++++ "id": "notify-discord", ++++ "name": "Notify Discord", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2860, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Forbidden change check completed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-response", ++++ "name": "Build Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 3120, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'No forbidden changes detected.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-safe-response", ++++ "name": "Build Safe Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1820, ++++ 160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "respondWith": "firstIncomingItem", ++++ "options": { ++++ "responseCode": 200 ++++ } ++++ }, ++++ "id": "respond-to-webhook", ++++ "name": "Respond to Webhook", ++++ "type": "n8n-nodes-base.respondToWebhook", ++++ "typeVersion": 1, ++++ "position": [ ++++ 3380, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/statuses/github' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n sha: $json.sha ?? $json.payload?.pull_request?.head?.sha ?? 'unknown-sha',\n context: $json.statusRequest?.context ?? 'cherry/forbidden-change',\n state: $json.statusRequest?.state ?? 'error',\n description: $json.statusRequest?.description ?? 'Cherry classifier status request missing.',\n targetUrl: $json.statusRequest?.targetUrl,\n sourceWorkflow: $json.workflow ?? 'post-cherry-status',\n automationEventId: $json.automationEventId,\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n outputHash: $json.outputHash\n} }}" ++++ }, ++++ "id": "post-forbidden-status", ++++ "name": "Post Forbidden Status", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1560, ++++ 160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "jsCode": "const items = $input.all();\n\nlet files = [];\n\nfor (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n files.push(...json);\n } else if (Array.isArray(json.files)) {\n files.push(...json.files);\n } else if (Array.isArray(json.data)) {\n files.push(...json.data);\n } else if (json && json.filename) {\n files.push(json);\n }\n}\n\nfiles = files.map((file) => ({\n filename: file.filename ?? file.path ?? file.name ?? '',\n status: file.status ?? 'modified',\n additions: Number(file.additions ?? 0),\n deletions: Number(file.deletions ?? 0),\n changes: Number(file.changes ?? 0),\n patch: String(file.patch ?? '')\n})).filter((file) => file.filename.length > 0);\n\nreturn [{\n json: {\n ...($items('Normalize PR')[0]?.json ?? {}),\n files\n }\n}];" ++++ }, ++++ "id": "normalize-changed-files", ++++ "name": "Normalize Changed Files", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 910, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/classify/pr' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $node['Normalize PR'].json.repo ?? 'div0rce/cherry',\n sha: $node['Normalize PR'].json.payload.pull_request.head.sha,\n prNumber: $node['Normalize PR'].json.payload.pull_request.number,\n title: $node['Normalize PR'].json.payload.pull_request.title,\n body: $node['Normalize PR'].json.payload.pull_request.body ?? '',\n labels: ($node['Normalize PR'].json.payload.pull_request.labels ?? []).map((label) => label.name),\n files: $json.files,\n sourceWorkflow: $node['Normalize PR'].json.workflow ?? 'pr-workflow'\n} }}" ++++ }, ++++ "id": "classify-pr-in-cherry-forbidden", ++++ "name": "Classify PR In Cherry", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4.2, ++++ "position": [ ++++ 1040, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ } ++++ ], ++++ "connections": { ++++ "Webhook": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize PR", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Normalize PR": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Validate Payload", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Validate Payload": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Fetch Changed Files", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "IF: Forbidden?": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Add blocking label", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ], ++++ [ ++++ { ++++ "node": "Build Safe Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Add blocking label": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Comment Violation", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Comment Violation": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Route Shared Sinks", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Route Shared Sinks": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Archive Event", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Archive Event": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Notify Discord", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Notify Discord": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Build Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Safe Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Post Forbidden Status": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "IF: Forbidden?", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Cherry Forbidden Routing": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Post Forbidden Status", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Fetch Changed Files": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize Changed Files", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Classify PR In Cherry": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Build Cherry Forbidden Routing", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Normalize Changed Files": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Classify PR In Cherry", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ } ++++ }, ++++ "settings": { ++++ "executionOrder": "v1" ++++ } ++++} +++diff --git a/cherry-n8n-workflows/05_engine_degradation_alerting.json b/cherry-n8n-workflows/05_engine_degradation_alerting.json +++new file mode 100644 +++index 0000000000000000000000000000000000000000..636e81cec95a879b9b4a1a84ec9f76998add5f8e +++--- /dev/null ++++++ b/cherry-n8n-workflows/05_engine_degradation_alerting.json +++@@ -0,0 +1,389 @@ ++++{ ++++ "name": "Cherry - Engine Degradation Alerting", ++++ "nodes": [ ++++ { ++++ "parameters": { ++++ "httpMethod": "POST", ++++ "path": "cherry/runtime/degradation", ++++ "responseMode": "responseNode", ++++ "options": {} ++++ }, ++++ "id": "webhook-runtime-degradation", ++++ "name": "Webhook", ++++ "type": "n8n-nodes-base.webhook", ++++ "typeVersion": 2, ++++ "position": [ ++++ 0, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'cherry.runtime_degradation',\n source: 'cherry',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '05_engine_degradation_alerting',\n ok: true,\n status: 'accepted',\n summary: 'Normalized cherry.runtime_degradation.',\n actions: []\n};\nreturn [{ json: output }];" ++++ }, ++++ "id": "normalize-degradation-event", ++++ "name": "Normalize Degradation Event", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 260, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.type\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++++ }, ++++ "id": "validate-payload", ++++ "name": "Validate Payload", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 520, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst type = event.payload?.type ?? 'unknown';\nconst severityMap = { missing_debt_truth: 'high', solver_divergence: 'critical', temporal_inconsistency: 'critical', candidate_exclusion: 'medium', advisory_degraded: 'medium', impossible_state: 'critical', route_response_mismatch: 'high', score_drift: 'medium' };\nconst severity = event.payload?.severity ?? severityMap[type] ?? 'low'; const createIssue = severity === 'high' || severity === 'critical';\nconst output = { ...event, degradationType: type, severity, createIssue, issueBody: { title: '[engine:' + severity + '] ' + type, labels: ['engine-degradation', 'automation', 'needs-human-review', severity], body: 'Cherry engine degradation event.\\n\\nType: ' + type + '\\nSeverity: ' + severity + '\\nTimestamp: ' + event.timestamp + '\\n\\nPayload JSON:\\n' + JSON.stringify(event.payload, null, 2) + '\\n\\nAdvisory alert only.' }, notification: { type, severity, repo: event.repo, payload: event.payload }, status: createIssue ? 'accepted' : 'ignored', summary: createIssue ? 'High-severity engine degradation alert for ' + type + '.' : 'Archived medium/low degradation event for ' + type + '.', actions: createIssue ? ['create_github_issue', 'notify_discord', 'archive_event'] : ['notify_discord', 'archive_event'] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "classify-severity", ++++ "name": "Classify Severity", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 780, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "conditions": { ++++ "options": { ++++ "caseSensitive": true, ++++ "leftValue": "", ++++ "typeValidation": "strict" ++++ }, ++++ "conditions": [ ++++ { ++++ "id": "if-high-severity-condition", ++++ "leftValue": "={{ $json.createIssue === true }}", ++++ "rightValue": true, ++++ "operator": { ++++ "type": "boolean", ++++ "operation": "true", ++++ "singleValue": true ++++ } ++++ } ++++ ], ++++ "combinator": "and" ++++ }, ++++ "options": {} ++++ }, ++++ "id": "if-high-severity", ++++ "name": "IF: Severity >= high?", ++++ "type": "n8n-nodes-base.if", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1040, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $json.issueBody }}" ++++ }, ++++ "id": "create-github-issue", ++++ "name": "Create GitHub Issue", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1300, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '05_engine_degradation_alerting', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++++ }, ++++ "id": "route-shared-sinks", ++++ "name": "Route Shared Sinks", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1560, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++++ }, ++++ "id": "notify-discord", ++++ "name": "Notify Discord", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1820, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '05_engine_degradation_alerting.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '05_engine_degradation_alerting',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '05_engine_degradation_alerting') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '05_engine_degradation_alerting.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++++ }, ++++ "id": "archive-event", ++++ "name": "Archive Event", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2080, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Engine degradation handled.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-response", ++++ "name": "Build Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 2340, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Engine degradation archived.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-archived-response", ++++ "name": "Build Archived Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1300, ++++ 160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "respondWith": "firstIncomingItem", ++++ "options": { ++++ "responseCode": 200 ++++ } ++++ }, ++++ "id": "respond-to-webhook", ++++ "name": "Respond to Webhook", ++++ "type": "n8n-nodes-base.respondToWebhook", ++++ "typeVersion": 1, ++++ "position": [ ++++ 2600, ++++ 0 ++++ ] ++++ } ++++ ], ++++ "connections": { ++++ "Webhook": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize Degradation Event", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Normalize Degradation Event": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Validate Payload", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Validate Payload": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Classify Severity", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Classify Severity": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "IF: Severity >= high?", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "IF: Severity >= high?": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Create GitHub Issue", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ], ++++ [ ++++ { ++++ "node": "Build Archived Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Create GitHub Issue": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Route Shared Sinks", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Route Shared Sinks": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Notify Discord", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Notify Discord": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Archive Event", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Archive Event": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Build Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Archived Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ } ++++ }, ++++ "settings": { ++++ "executionOrder": "v1" ++++ } ++++} +++diff --git a/cherry-n8n-workflows/06_simulation_drift_detector.json b/cherry-n8n-workflows/06_simulation_drift_detector.json +++new file mode 100644 +++index 0000000000000000000000000000000000000000..86fb8c1d197d0023e0fb64a5a42ac1ddef30c381 +++--- /dev/null ++++++ b/cherry-n8n-workflows/06_simulation_drift_detector.json +++@@ -0,0 +1,432 @@ ++++{ ++++ "name": "Cherry - Simulation Drift Detector", ++++ "nodes": [ ++++ { ++++ "parameters": { ++++ "httpMethod": "POST", ++++ "path": "cherry/simulation/result", ++++ "responseMode": "responseNode", ++++ "options": {} ++++ }, ++++ "id": "webhook-simulation-result", ++++ "name": "Webhook", ++++ "type": "n8n-nodes-base.webhook", ++++ "typeVersion": 2, ++++ "position": [ ++++ 0, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'cherry.simulation_result',\n source: 'cherry',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '06_simulation_drift_detector',\n ok: true,\n status: 'accepted',\n summary: 'Normalized cherry.simulation_result.',\n actions: []\n};\nreturn [{ json: output }];" ++++ }, ++++ "id": "normalize-simulation-result", ++++ "name": "Normalize Simulation Result", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 260, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.runId\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++++ }, ++++ "id": "validate-payload", ++++ "name": "Validate Payload", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 520, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const result = $input.first()?.json ?? {};\nconst event = $node['Normalize Simulation Result'].json;\nconst comparison = result.comparisonOutput ?? {};\nconst drift = comparison.drift === true;\nconst reasons = comparison.reasons ?? [];\nconst output = {\n ...event,\n automationSnapshotId: result.snapshotId,\n outputHash: result.outputHash,\n drift,\n driftReasons: reasons,\n issueBody: {\n title: '[simulation-drift] ' + (event.payload?.scenarioId ?? event.payload?.profileId ?? 'default'),\n labels: ['simulation-drift', 'automation', 'needs-human-review'],\n body: 'Cherry simulation drift detected.\\n\\n' + reasons.map((reason) => '- ' + reason).join('\\n') + '\\n\\nSnapshot comparison is stored by Cherry automation, not n8n static data.'\n },\n status: drift ? 'accepted' : 'ignored',\n summary: drift ? 'Simulation drift detected: ' + reasons.join(', ') : 'Simulation snapshot stored with no material drift.',\n actions: drift ? ['compare_snapshot_in_cherry', 'create_issue', 'archive_event'] : ['compare_snapshot_in_cherry', 'archive_event']\n};\nreturn [{ json: output }];" ++++ }, ++++ "id": "compare-snapshot", ++++ "name": "Compare Snapshot", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 780, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "conditions": { ++++ "options": { ++++ "caseSensitive": true, ++++ "leftValue": "", ++++ "typeValidation": "strict" ++++ }, ++++ "conditions": [ ++++ { ++++ "id": "if-drift-condition", ++++ "leftValue": "={{ $json.drift === true }}", ++++ "rightValue": true, ++++ "operator": { ++++ "type": "boolean", ++++ "operation": "true", ++++ "singleValue": true ++++ } ++++ } ++++ ], ++++ "combinator": "and" ++++ }, ++++ "options": {} ++++ }, ++++ "id": "if-drift", ++++ "name": "IF: Drift?", ++++ "type": "n8n-nodes-base.if", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1040, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $json.issueBody }}" ++++ }, ++++ "id": "create-drift-issue", ++++ "name": "Create Drift Issue", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1300, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '06_simulation_drift_detector', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++++ }, ++++ "id": "route-shared-sinks", ++++ "name": "Route Shared Sinks", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1560, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++++ }, ++++ "id": "notify-discord", ++++ "name": "Notify Discord", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1820, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '06_simulation_drift_detector.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '06_simulation_drift_detector',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '06_simulation_drift_detector') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'simulation-drift@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '06_simulation_drift_detector.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++++ }, ++++ "id": "archive-event", ++++ "name": "Archive Event", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2080, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Simulation drift handled.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-response", ++++ "name": "Build Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 2340, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Simulation snapshot stored.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-no-drift-response", ++++ "name": "Build No Drift Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1300, ++++ 160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "respondWith": "firstIncomingItem", ++++ "options": { ++++ "responseCode": 200 ++++ } ++++ }, ++++ "id": "respond-to-webhook", ++++ "name": "Respond to Webhook", ++++ "type": "n8n-nodes-base.respondToWebhook", ++++ "typeVersion": 1, ++++ "position": [ ++++ 2600, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/simulation-snapshots/compare' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n scopeKey: $json.payload.scenarioId ?? $json.payload.profileId ?? 'default',\n runId: $json.payload.runId,\n snapshot: $json.payload,\n sourceWorkflow: $json.workflow ?? '06_simulation_drift_detector'\n} }}" ++++ }, ++++ "id": "compare-simulation-in-cherry", ++++ "name": "Compare Simulation In Cherry", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 780, ++++ 160 ++++ ], ++++ "continueOnFail": true ++++ } ++++ ], ++++ "connections": { ++++ "Webhook": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize Simulation Result", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Normalize Simulation Result": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Validate Payload", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Validate Payload": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Compare Simulation In Cherry", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Compare Snapshot": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "IF: Drift?", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "IF: Drift?": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Create Drift Issue", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ], ++++ [ ++++ { ++++ "node": "Build No Drift Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Create Drift Issue": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Route Shared Sinks", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Route Shared Sinks": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Notify Discord", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Notify Discord": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Archive Event", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Archive Event": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Build Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build No Drift Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Compare Simulation In Cherry": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Compare Snapshot", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ } ++++ }, ++++ "settings": { ++++ "executionOrder": "v1" ++++ } ++++} +++diff --git a/cherry-n8n-workflows/07_release_summary_generator.json b/cherry-n8n-workflows/07_release_summary_generator.json +++new file mode 100644 +++index 0000000000000000000000000000000000000000..01234741099126e95a9d7ba6c40443f1a58ad0b5 +++--- /dev/null ++++++ b/cherry-n8n-workflows/07_release_summary_generator.json +++@@ -0,0 +1,488 @@ ++++{ ++++ "name": "Cherry - Release Summary Generator", ++++ "nodes": [ ++++ { ++++ "parameters": { ++++ "httpMethod": "POST", ++++ "path": "cherry/release/summary", ++++ "responseMode": "responseNode", ++++ "options": {} ++++ }, ++++ "id": "webhook-release-summary", ++++ "name": "Webhook", ++++ "type": "n8n-nodes-base.webhook", ++++ "typeVersion": 2, ++++ "position": [ ++++ 0, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": {}, ++++ "id": "manual-release-summary", ++++ "name": "Manual Trigger", ++++ "type": "n8n-nodes-base.manualTrigger", ++++ "typeVersion": 1, ++++ "position": [ ++++ 0, ++++ 160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const input = $input.first()?.json ?? {}; const body = input.body ?? input; const source = input.body ? 'cherry' : 'manual'; const output = { event: 'cherry.release_summary', source, repo: body.repo ?? 'div0rce/cherry', timestamp: body.timestamp ?? new Date().toISOString(), payload: body, workflow: '07_release_summary_generator', ok: true, status: 'accepted', summary: 'Release summary generation started.', actions: [] }; return [{ json: output }];" ++++ }, ++++ "id": "normalize-release-request", ++++ "name": "Normalize Release Request", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 260, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"repo\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++++ }, ++++ "id": "validate-payload", ++++ "name": "Validate Payload", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 520, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "GET", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/releases/latest' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {} ++++ }, ++++ "id": "fetch-latest-release", ++++ "name": "Fetch Latest Release", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 780, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "GET", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/commits?per_page=100' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {} ++++ }, ++++ "id": "fetch-commits", ++++ "name": "Fetch Commits Since Last Tag", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1040, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const commits = Array.isArray($json) ? $json : []; const groups = { engine: [], api: [], prisma: [], tests: [], docs: [], infra: [], other: [] };\nfor (const commit of commits) { const message = commit.commit?.message ?? commit.message ?? ''; const lower = message.toLowerCase(); const bucket = lower.includes('engine') ? 'engine' : lower.includes('api') || lower.includes('route') ? 'api' : lower.includes('prisma') || lower.includes('migration') ? 'prisma' : lower.includes('test') ? 'tests' : lower.includes('doc') || lower.includes('readme') ? 'docs' : lower.includes('ci') || lower.includes('guardrail') ? 'infra' : 'other'; groups[bucket].push(message.split('\\n')[0]); }\nconst risk = []; if (groups.engine.length > 0) risk.push('engine changes require deterministic review'); if (groups.prisma.length > 0) risk.push('Prisma changes require migration verification'); if (groups.api.length > 0) risk.push('API changes require route tests');\nconst changelog = Object.entries(groups).filter(([, items]) => items.length > 0).map(([name, items]) => '## ' + name + '\\n' + items.map((item) => '- ' + item).join('\\n')).join('\\n\\n'); const releaseBody = '# Cherry Release Draft\\n\\n' + (changelog || 'No commits returned by GitHub API.') + '\\n\\n## Risk Summary\\n' + (risk.length ? risk.map((r) => '- ' + r).join('\\n') : '- Low automation-detected release risk.') + '\\n\\n## Verification\\n- npm run check\\n- npm test\\n- npm run build\\n- npm run ci:verify';\nconst output = { ...$node['Normalize Release Request'].json, groups, changelog, riskSummary: risk, linkedInDraft: 'Cherry release update: guardrails, repo quality, and development automation advanced. Details remain advisory until verified in CI.', releaseBody, releaseDraftBody: { tag_name: $node['Normalize Release Request'].json.payload.tagName ?? 'v-next', name: 'Cherry v-next', body: releaseBody, draft: true, prerelease: true }, status: 'accepted', summary: 'Generated release summary from ' + commits.length + ' commits.', actions: ['fetch_commits', 'group_changes', 'generate_changelog', 'archive_event'] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "generate-release-summary", ++++ "name": "Generate Release Summary", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1300, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/releases' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $json.releaseDraftBody }}" ++++ }, ++++ "id": "create-github-release-draft", ++++ "name": "Create GitHub Release Draft", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1560, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '07_release_summary_generator', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++++ }, ++++ "id": "route-shared-sinks", ++++ "name": "Route Shared Sinks", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1820, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '07_release_summary_generator.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '07_release_summary_generator',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '07_release_summary_generator') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '07_release_summary_generator.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++++ }, ++++ "id": "archive-event", ++++ "name": "Archive Event", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2080, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++++ }, ++++ "id": "notify-discord", ++++ "name": "Notify Discord", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2340, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "conditions": { ++++ "options": { ++++ "caseSensitive": true, ++++ "leftValue": "", ++++ "typeValidation": "strict" ++++ }, ++++ "conditions": [ ++++ { ++++ "id": "if-webhook-source-condition", ++++ "leftValue": "={{ $node['Normalize Release Request'].json.source !== 'manual' }}", ++++ "rightValue": true, ++++ "operator": { ++++ "type": "boolean", ++++ "operation": "true", ++++ "singleValue": true ++++ } ++++ } ++++ ], ++++ "combinator": "and" ++++ }, ++++ "options": {} ++++ }, ++++ "id": "if-webhook-source", ++++ "name": "IF: Webhook Source?", ++++ "type": "n8n-nodes-base.if", ++++ "typeVersion": 2, ++++ "position": [ ++++ 2600, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Release summary generated.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-response", ++++ "name": "Build Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 2860, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Manual release summary generated.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "manual-result-log", ++++ "name": "Manual Result Log", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 2860, ++++ 160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "respondWith": "firstIncomingItem", ++++ "options": { ++++ "responseCode": 200 ++++ } ++++ }, ++++ "id": "respond-to-webhook", ++++ "name": "Respond to Webhook", ++++ "type": "n8n-nodes-base.respondToWebhook", ++++ "typeVersion": 1, ++++ "position": [ ++++ 3120, ++++ -160 ++++ ] ++++ } ++++ ], ++++ "connections": { ++++ "Webhook": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize Release Request", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Manual Trigger": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize Release Request", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Normalize Release Request": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Validate Payload", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Validate Payload": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Fetch Latest Release", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Fetch Latest Release": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Fetch Commits Since Last Tag", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Fetch Commits Since Last Tag": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Generate Release Summary", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Generate Release Summary": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Create GitHub Release Draft", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Create GitHub Release Draft": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Route Shared Sinks", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Route Shared Sinks": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Archive Event", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Archive Event": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Notify Discord", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Notify Discord": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "IF: Webhook Source?", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "IF: Webhook Source?": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Build Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ], ++++ [ ++++ { ++++ "node": "Manual Result Log", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ } ++++ }, ++++ "settings": { ++++ "executionOrder": "v1" ++++ } ++++} +++diff --git a/cherry-n8n-workflows/08_repo_intelligence_digest.json b/cherry-n8n-workflows/08_repo_intelligence_digest.json +++new file mode 100644 +++index 0000000000000000000000000000000000000000..7a69895b6b9bf041036a48551af8c422a7df5a5f +++--- /dev/null ++++++ b/cherry-n8n-workflows/08_repo_intelligence_digest.json +++@@ -0,0 +1,378 @@ ++++{ ++++ "name": "Cherry - Repo Intelligence Digest", ++++ "nodes": [ ++++ { ++++ "parameters": { ++++ "rule": { ++++ "interval": [ ++++ { ++++ "field": "weeks", ++++ "triggerAtDay": [ ++++ 1 ++++ ], ++++ "triggerAtHour": 9, ++++ "triggerAtMinute": 0 ++++ } ++++ ] ++++ } ++++ }, ++++ "id": "weekly-repo-digest", ++++ "name": "Schedule Trigger", ++++ "type": "n8n-nodes-base.scheduleTrigger", ++++ "typeVersion": 1, ++++ "position": [ ++++ 0, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const input = $input.first()?.json ?? {};\nconst output = { event: 'cherry.weekly_repo_digest', source: 'manual', repo: 'div0rce/cherry', timestamp: input.timestamp ?? new Date().toISOString(), payload: input, workflow: '08_repo_intelligence_digest', ok: true, status: 'accepted', summary: 'Scheduled cherry.weekly_repo_digest started.', actions: [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "normalize-schedule", ++++ "name": "Normalize Schedule", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 260, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"repo\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++++ }, ++++ "id": "validate-payload", ++++ "name": "Validate Payload", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 520, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "GET", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls?state=open&per_page=100' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {} ++++ }, ++++ "id": "fetch-open-prs", ++++ "name": "Fetch Open PRs", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 780, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "GET", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues?state=open&per_page=100' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {} ++++ }, ++++ "id": "fetch-open-issues", ++++ "name": "Fetch Open Issues", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1040, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "GET", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/commits?per_page=50' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {} ++++ }, ++++ "id": "fetch-recent-commits", ++++ "name": "Fetch Recent Commits", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1300, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const prs = Array.isArray($items('Fetch Open PRs')[0]?.json) ? $items('Fetch Open PRs')[0].json : []; const issues = Array.isArray($items('Fetch Open Issues')[0]?.json) ? $items('Fetch Open Issues')[0].json : []; const commits = Array.isArray($json) ? $json : []; const now = Date.now(); const stalePrs = prs.filter((pr) => now - new Date(pr.updated_at ?? pr.created_at ?? now).getTime() > 7 * 24 * 60 * 60 * 1000); const dependabot = prs.filter((pr) => /dependabot/i.test(pr.user?.login ?? '')); const highRisk = prs.filter((pr) => /engine|prisma|migration|api/i.test((pr.title ?? '') + ' ' + (pr.body ?? ''))); const digest = '# Cherry Weekly Repo Intelligence Digest\\n\\n- Open PRs: ' + prs.length + '\\n- Stale PRs: ' + stalePrs.length + '\\n- Open issues: ' + issues.length + '\\n- Recent commits: ' + commits.length + '\\n- Dependabot PRs: ' + dependabot.length + '\\n- High-risk hints: ' + highRisk.length + '\\n\\nAdvisory automation only.'; const output = { ...$node['Normalize Schedule'].json, digest, metrics: { openPrs: prs.length, stalePrs: stalePrs.length, openIssues: issues.length, recentCommits: commits.length, dependabotPrs: dependabot.length, highRiskHints: highRisk.length }, status: 'accepted', summary: 'Weekly repo digest built: ' + prs.length + ' PRs, ' + issues.length + ' issues.', actions: ['fetch_open_prs', 'fetch_open_issues', 'fetch_recent_commits', 'archive_digest', 'notify_discord'] }; return [{ json: output }];" ++++ }, ++++ "id": "build-digest", ++++ "name": "Build Digest", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1560, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '08_repo_intelligence_digest', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++++ }, ++++ "id": "route-shared-sinks", ++++ "name": "Route Shared Sinks", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1820, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '08_repo_intelligence_digest.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '08_repo_intelligence_digest',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '08_repo_intelligence_digest') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '08_repo_intelligence_digest.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++++ }, ++++ "id": "archive-event", ++++ "name": "Archive Event", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2080, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++++ }, ++++ "id": "notify-discord", ++++ "name": "Notify Discord", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2340, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Weekly repo digest completed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "log-digest", ++++ "name": "Log Digest", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 2600, ++++ 0 ++++ ] ++++ } ++++ ], ++++ "connections": { ++++ "Schedule Trigger": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize Schedule", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Normalize Schedule": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Validate Payload", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Validate Payload": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Fetch Open PRs", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Fetch Open PRs": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Fetch Open Issues", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Fetch Open Issues": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Fetch Recent Commits", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Fetch Recent Commits": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Build Digest", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Digest": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Route Shared Sinks", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Route Shared Sinks": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Archive Event", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Archive Event": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Notify Discord", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Notify Discord": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Log Digest", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ } ++++ }, ++++ "settings": { ++++ "executionOrder": "v1" ++++ } ++++} +++diff --git a/cherry-n8n-workflows/09_docs_drift_detector.json b/cherry-n8n-workflows/09_docs_drift_detector.json +++new file mode 100644 +++index 0000000000000000000000000000000000000000..af914bfbcb82aeca704e6414fcd95fa5a92243f8 +++--- /dev/null ++++++ b/cherry-n8n-workflows/09_docs_drift_detector.json +++@@ -0,0 +1,551 @@ ++++{ ++++ "name": "Cherry - Docs Drift Detector", ++++ "nodes": [ ++++ { ++++ "parameters": { ++++ "httpMethod": "POST", ++++ "path": "cherry/github/pull-request-docs-drift", ++++ "responseMode": "responseNode", ++++ "options": {} ++++ }, ++++ "id": "webhook-docs-drift", ++++ "name": "Webhook", ++++ "type": "n8n-nodes-base.webhook", ++++ "typeVersion": 2, ++++ "position": [ ++++ 0, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.pull_request_docs_drift',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '09_docs_drift_detector',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.pull_request_docs_drift.',\n actions: []\n};\nreturn [{ json: output }];" ++++ }, ++++ "id": "normalize-pr", ++++ "name": "Normalize PR", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 260, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.pull_request.number\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++++ }, ++++ "id": "validate-payload", ++++ "name": "Validate Payload", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 520, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "GET", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls/' + $json.payload.pull_request.number + '/files?per_page=100' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {} ++++ }, ++++ "id": "fetch-changed-files", ++++ "name": "Fetch Changed Files", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 780, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const prEvent = $node['Normalize PR'].json;\nconst classification = $input.first()?.json ?? {};\nconst classifierOutput = classification.classifierOutput ?? {};\nconst docsDrift = classifierOutput.docsDrift ?? {};\nconst statusRequests = Array.isArray(classifierOutput.statusRequests) ? classifierOutput.statusRequests : [];\nconst statusRequest = statusRequests.find((request) => request.context === 'cherry/docs-drift') ?? docsDrift.statusRequest ?? { context: 'cherry/docs-drift', state: 'error', description: 'Cherry docs-drift classifier did not return a status request.' };\nconst domains = Array.isArray(docsDrift.domains) ? docsDrift.domains : [];\nconst labels = Array.isArray(docsDrift.labels) ? docsDrift.labels : [];\nconst drift = docsDrift.drift === true;\nconst output = {\n ...prEvent,\n automationEventId: classification.automationEventId,\n outputHash: classification.outputHash,\n cherryClassifierOutput: classifierOutput,\n docsDrift,\n statusRequest,\n labelBody: { labels },\n commentBody: { body: 'Cherry docs drift detector.\\n\\n' + (drift ? 'Docs update required for changed domains: ' + domains.join(', ') : 'Cherry found no docs drift.') + '\\n\\nDocs must match code reality unless legal constraints require a code fix.' },\n status: drift ? 'accepted' : 'ignored',\n summary: drift ? 'Cherry detected docs drift for ' + domains.join(', ') + '.' : 'Cherry found no docs drift.',\n actions: drift ? ['classify_pr_in_cherry', 'post_docs_status', 'label_docs_drift', 'comment_required_docs_update'] : ['classify_pr_in_cherry', 'post_docs_status']\n};\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-cherry-docs-routing", ++++ "name": "Build Cherry Docs Routing", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1300, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "conditions": { ++++ "options": { ++++ "caseSensitive": true, ++++ "leftValue": "", ++++ "typeValidation": "strict" ++++ }, ++++ "conditions": [ ++++ { ++++ "id": "if-docs-drift-condition", ++++ "leftValue": "={{ $json.docsDrift?.drift === true }}", ++++ "rightValue": true, ++++ "operator": { ++++ "type": "boolean", ++++ "operation": "true", ++++ "singleValue": true ++++ } ++++ } ++++ ], ++++ "combinator": "and" ++++ }, ++++ "options": {} ++++ }, ++++ "id": "if-docs-drift", ++++ "name": "IF: Docs Drift?", ++++ "type": "n8n-nodes-base.if", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1300, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.payload.pull_request.number + '/labels' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $json.labelBody }}" ++++ }, ++++ "id": "label-docs-drift", ++++ "name": "Label Docs Drift", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1560, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build Cherry Docs Routing\"].json.payload.pull_request.number + '/comments' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $node[\"Build Cherry Docs Routing\"].json.commentBody }}" ++++ }, ++++ "id": "comment-docs-drift", ++++ "name": "Comment Required Docs Update", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1820, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '09_docs_drift_detector', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++++ }, ++++ "id": "route-shared-sinks", ++++ "name": "Route Shared Sinks", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 2080, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '09_docs_drift_detector.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '09_docs_drift_detector',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '09_docs_drift_detector') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? 'no-sha') + ':' + ($json.outputHash ?? $now.toISO())),\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '09_docs_drift_detector.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json.cherryClassifierOutput ?? $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++++ }, ++++ "id": "archive-event", ++++ "name": "Archive Event", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2340, ++++ -160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Docs drift handled.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-response", ++++ "name": "Build Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 2600, ++++ -160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: 'ignored', summary: event.summary ?? 'No docs drift detected.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "build-no-drift-response", ++++ "name": "Build No Drift Response", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1560, ++++ 160 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "respondWith": "firstIncomingItem", ++++ "options": { ++++ "responseCode": 200 ++++ } ++++ }, ++++ "id": "respond-to-webhook", ++++ "name": "Respond to Webhook", ++++ "type": "n8n-nodes-base.respondToWebhook", ++++ "typeVersion": 1, ++++ "position": [ ++++ 2860, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/statuses/github' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n sha: $json.sha ?? $json.payload?.pull_request?.head?.sha ?? 'unknown-sha',\n context: $json.statusRequest?.context ?? 'cherry/docs-drift',\n state: $json.statusRequest?.state ?? 'error',\n description: $json.statusRequest?.description ?? 'Cherry classifier status request missing.',\n targetUrl: $json.statusRequest?.targetUrl,\n sourceWorkflow: $json.workflow ?? 'post-cherry-status',\n automationEventId: $json.automationEventId,\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n outputHash: $json.outputHash\n} }}" ++++ }, ++++ "id": "post-docs-status", ++++ "name": "Post Docs Status", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1300, ++++ 160 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "jsCode": "const items = $input.all();\n\nlet files = [];\n\nfor (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n files.push(...json);\n } else if (Array.isArray(json.files)) {\n files.push(...json.files);\n } else if (Array.isArray(json.data)) {\n files.push(...json.data);\n } else if (json && json.filename) {\n files.push(json);\n }\n}\n\nfiles = files.map((file) => ({\n filename: file.filename ?? file.path ?? file.name ?? '',\n status: file.status ?? 'modified',\n additions: Number(file.additions ?? 0),\n deletions: Number(file.deletions ?? 0),\n changes: Number(file.changes ?? 0),\n patch: String(file.patch ?? '')\n})).filter((file) => file.filename.length > 0);\n\nreturn [{\n json: {\n ...($items('Normalize PR')[0]?.json ?? {}),\n files\n }\n}];" ++++ }, ++++ "id": "normalize-changed-files", ++++ "name": "Normalize Changed Files", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 910, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/classify/pr' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $node['Normalize PR'].json.repo ?? 'div0rce/cherry',\n sha: $node['Normalize PR'].json.payload.pull_request.head.sha,\n prNumber: $node['Normalize PR'].json.payload.pull_request.number,\n title: $node['Normalize PR'].json.payload.pull_request.title,\n body: $node['Normalize PR'].json.payload.pull_request.body ?? '',\n labels: ($node['Normalize PR'].json.payload.pull_request.labels ?? []).map((label) => label.name),\n files: $json.files,\n sourceWorkflow: $node['Normalize PR'].json.workflow ?? 'pr-workflow'\n} }}" ++++ }, ++++ "id": "classify-pr-in-cherry-docs", ++++ "name": "Classify PR In Cherry", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4.2, ++++ "position": [ ++++ 1040, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ } ++++ ], ++++ "connections": { ++++ "Webhook": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize PR", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Normalize PR": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Validate Payload", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Validate Payload": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Fetch Changed Files", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Fetch Changed Files": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize Changed Files", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "IF: Docs Drift?": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Label Docs Drift", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ], ++++ [ ++++ { ++++ "node": "Build No Drift Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Label Docs Drift": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Comment Required Docs Update", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Comment Required Docs Update": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Route Shared Sinks", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Route Shared Sinks": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Archive Event", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Archive Event": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Build Response", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build No Drift Response": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Respond to Webhook", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Post Docs Status": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "IF: Docs Drift?", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Build Cherry Docs Routing": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Post Docs Status", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Classify PR In Cherry": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Build Cherry Docs Routing", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Normalize Changed Files": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Classify PR In Cherry", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ } ++++ }, ++++ "settings": { ++++ "executionOrder": "v1" ++++ } ++++} +++diff --git a/cherry-n8n-workflows/10_backlog_grooming.json b/cherry-n8n-workflows/10_backlog_grooming.json +++new file mode 100644 +++index 0000000000000000000000000000000000000000..4807a3816e12088afc2a1b77c9a68f5ef85fc79f +++--- /dev/null ++++++ b/cherry-n8n-workflows/10_backlog_grooming.json +++@@ -0,0 +1,376 @@ ++++{ ++++ "name": "Cherry - Backlog Grooming", ++++ "nodes": [ ++++ { ++++ "parameters": { ++++ "rule": { ++++ "interval": [ ++++ { ++++ "field": "weeks", ++++ "triggerAtDay": [ ++++ 1 ++++ ], ++++ "triggerAtHour": 9, ++++ "triggerAtMinute": 0 ++++ } ++++ ] ++++ } ++++ }, ++++ "id": "weekly-backlog-grooming", ++++ "name": "Schedule Trigger", ++++ "type": "n8n-nodes-base.scheduleTrigger", ++++ "typeVersion": 1, ++++ "position": [ ++++ 0, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const input = $input.first()?.json ?? {};\nconst output = { event: 'cherry.backlog_grooming', source: 'manual', repo: 'div0rce/cherry', timestamp: input.timestamp ?? new Date().toISOString(), payload: input, workflow: '10_backlog_grooming', ok: true, status: 'accepted', summary: 'Scheduled cherry.backlog_grooming started.', actions: [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "normalize-schedule", ++++ "name": "Normalize Schedule", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 260, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"repo\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" ++++ }, ++++ "id": "validate-payload", ++++ "name": "Validate Payload", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 520, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "GET", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues?state=open&per_page=100' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {} ++++ }, ++++ "id": "fetch-open-issues", ++++ "name": "Fetch Open Issues", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 780, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const issues = Array.isArray($json) ? $json.filter((issue) => !issue.pull_request) : []; const now = Date.now(); const stale = issues.filter((issue) => now - new Date(issue.updated_at ?? issue.created_at ?? now).getTime() > 30 * 24 * 60 * 60 * 1000); const unlabeled = issues.filter((issue) => (issue.labels ?? []).length === 0); const blocked = issues.filter((issue) => /blocked|waiting|depends on/i.test((issue.title ?? '') + ' ' + (issue.body ?? ''))); const noAcceptance = issues.filter((issue) => !/acceptance criteria|done when|definition of done/i.test(issue.body ?? '')); const seen = new Map(); const duplicateHints = []; for (const issue of issues) { const key = String(issue.title ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); if (seen.has(key)) duplicateHints.push([seen.get(key), issue.number]); else seen.set(key, issue.number); } const body = '# Cherry Backlog Grooming Summary\\n\\n- Open issues: ' + issues.length + '\\n- Stale issues: ' + stale.length + '\\n- Unlabeled issues: ' + unlabeled.length + '\\n- Blocked hints: ' + blocked.length + '\\n- Missing acceptance criteria: ' + noAcceptance.length + '\\n- Duplicate title hints: ' + duplicateHints.length + '\\n\\nSuggested actions are advisory.'; const metrics = { openIssues: issues.length, stale: stale.length, unlabeled: unlabeled.length, blocked: blocked.length, missingAcceptanceCriteria: noAcceptance.length, duplicateHints: duplicateHints.length }; const output = { ...$node['Normalize Schedule'].json, backlogMetrics: metrics, summaryIssueBody: { title: 'Cherry weekly backlog grooming summary', labels: ['backlog-grooming', 'automation'], body }, projectUpdatePayload: { source: 'cherry_backlog_grooming', metrics }, status: 'accepted', summary: 'Backlog grooming summary built for ' + issues.length + ' open issues.', actions: ['find_stale_issues', 'find_duplicates', 'find_unlabeled', 'find_missing_acceptance_criteria', 'archive_event'] }; return [{ json: output }];" ++++ }, ++++ "id": "analyze-backlog", ++++ "name": "Analyze Backlog", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1040, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" ++++ }, ++++ { ++++ "name": "Accept", ++++ "value": "application/vnd.github+json" ++++ }, ++++ { ++++ "name": "X-GitHub-Api-Version", ++++ "value": "2022-11-28" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $json.summaryIssueBody }}" ++++ }, ++++ "id": "create-grooming-summary-issue", ++++ "name": "Create Grooming Summary Issue", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1300, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ $node[\"Analyze Backlog\"].json.projectUpdatePayload }}" ++++ }, ++++ "id": "update-github-project", ++++ "name": "Update GitHub Project", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 1560, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '10_backlog_grooming', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" ++++ }, ++++ "id": "route-shared-sinks", ++++ "name": "Route Shared Sinks", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 1820, ++++ 0 ++++ ] ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Authorization", ++++ "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" ++++ }, ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '10_backlog_grooming.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '10_backlog_grooming',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '10_backlog_grooming') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '10_backlog_grooming.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" ++++ }, ++++ "id": "archive-event", ++++ "name": "Archive Event", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2080, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "method": "POST", ++++ "url": "={{ $env.DISCORD_WEBHOOK_URL }}", ++++ "sendHeaders": true, ++++ "headerParameters": { ++++ "parameters": [ ++++ { ++++ "name": "Content-Type", ++++ "value": "application/json" ++++ } ++++ ] ++++ }, ++++ "options": {}, ++++ "sendBody": true, ++++ "specifyBody": "json", ++++ "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" ++++ }, ++++ "id": "notify-discord", ++++ "name": "Notify Discord", ++++ "type": "n8n-nodes-base.httpRequest", ++++ "typeVersion": 4, ++++ "position": [ ++++ 2340, ++++ 0 ++++ ], ++++ "continueOnFail": true ++++ }, ++++ { ++++ "parameters": { ++++ "mode": "runOnceForAllItems", ++++ "language": "javaScript", ++++ "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Backlog grooming completed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" ++++ }, ++++ "id": "log-grooming-result", ++++ "name": "Log Grooming Result", ++++ "type": "n8n-nodes-base.code", ++++ "typeVersion": 2, ++++ "position": [ ++++ 2600, ++++ 0 ++++ ] ++++ } ++++ ], ++++ "connections": { ++++ "Schedule Trigger": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Normalize Schedule", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Normalize Schedule": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Validate Payload", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Validate Payload": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Fetch Open Issues", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Fetch Open Issues": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Analyze Backlog", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Analyze Backlog": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Create Grooming Summary Issue", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Create Grooming Summary Issue": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Update GitHub Project", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Update GitHub Project": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Route Shared Sinks", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Route Shared Sinks": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Archive Event", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Archive Event": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Notify Discord", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ }, ++++ "Notify Discord": { ++++ "main": [ ++++ [ ++++ { ++++ "node": "Log Grooming Result", ++++ "type": "main", ++++ "index": 0 ++++ } ++++ ] ++++ ] ++++ } ++++ }, ++++ "settings": { ++++ "executionOrder": "v1" ++++ } ++++} +++diff --git a/cherry-n8n-workflows/COVERAGE_MATRIX.md b/cherry-n8n-workflows/COVERAGE_MATRIX.md +++new file mode 100644 +++index 0000000000000000000000000000000000000000..6463046d9e44091c490367f9303971521380a493 +++--- /dev/null ++++++ b/cherry-n8n-workflows/COVERAGE_MATRIX.md +++@@ -0,0 +1,163 @@ ++++# Cherry n8n Coverage Matrix ++++ ++++Status: Generated ++++Last updated: 2026-04-27 ++++ ++++## Workflow Coverage ++++ ++++| Workflow | Covers | ++++| --- | --- | ++++| `01_ci_failure_compression` | 1, 2, 3, 4, 21-29 | ++++| `02_openclaw_issue_router` | 41-50 | ++++| `03_pr_risk_classifier` | 11-17, 30-32, 46-49 | ++++| `04_forbidden_change_detector` | 32-39, 48 | ++++| `05_engine_degradation_alerting` | 51-60 | ++++| `06_simulation_drift_detector` | 61-70 | ++++| `07_release_summary_generator` | 71-80 | ++++| `08_repo_intelligence_digest` | 5-10, 20, 91-100 | ++++| `09_docs_drift_detector` | 81-90 | ++++| `10_backlog_grooming` | 18-20, 40, 93-100, 106-107 | ++++| `Shared sink pattern` | 101-110 | ++++ ++++## Use Case Map ++++ ++++### Repo Automation ++++ ++++1. CI failure -> structured issue ++++2. CI failure -> existing issue comment ++++3. CI failure -> OpenClaw task ++++4. flaky test detector ++++5. dependency update triage ++++6. Dependabot PR classifier ++++7. CodeQL alert router ++++8. secret scan alert router ++++9. stale branch detector ++++10. stale PR detector ++++11. PR size classifier ++++12. PR risk score ++++13. PR domain classifier ++++14. PR checklist generator ++++15. PR summary generator ++++16. PR merge-block reminder ++++17. issue deduplication ++++18. issue severity labeling ++++19. issue owner/domain labeling ++++20. backlog grooming automation ++++ ++++### Verification Automation ++++ ++++21. run full verification on demand ++++22. rerun failed workflow ++++23. collect failed logs ++++24. summarize failure cause ++++25. compare failure to last passing run ++++26. detect changed files causing failure ++++27. enforce required scripts exist ++++28. verify migrations apply cleanly ++++29. verify Prisma schema drift ++++30. verify test coverage changed ++++31. verify route tests are in correct folder ++++32. verify no forbidden imports ++++33. verify no production secrets touched ++++34. verify no .env diff ++++35. verify no snapshot fraud ++++36. verify no deleted tests ++++37. verify no skipped tests added ++++38. verify no console.log leaks ++++39. verify no TODO introduced without issue ++++40. verify issue acceptance criteria updated ++++ ++++### OpenClaw Automation ++++ ++++41. issue labeled openclaw -> create OpenClaw task ++++42. OpenClaw result -> validate schema ++++43. OpenClaw patch -> attach summary ++++44. OpenClaw failure -> request retry ++++45. OpenClaw command log -> archive ++++46. OpenClaw changed engine -> require tests ++++47. OpenClaw changed docs only -> lighter checks ++++48. OpenClaw touched forbidden files -> block ++++49. OpenClaw PR -> mark needs-human-review ++++50. OpenClaw output -> generate commit message ++++ ++++### Cherry Engine Observability ++++ ++++51. degradation event -> issue ++++52. missing truth event -> issue ++++53. solver divergence event -> issue ++++54. impossible state event -> issue ++++55. temporal inconsistency event -> issue ++++56. candidate exclusion spike -> alert ++++57. simulation instability -> alert ++++58. score drift -> alert ++++59. route response mismatch -> alert ++++60. advisory output degradation -> alert ++++ ++++### Simulation Automation ++++ ++++61. scheduled simulation run ++++62. compare simulation to previous snapshot ++++63. detect major allocation delta ++++64. detect paydown strategy flip ++++65. detect runway collapse ++++66. detect debt relief regression ++++67. detect reward-over-safety bias ++++68. detect malformed candidate set ++++69. detect empty viable candidates ++++70. store simulation audit artifact ++++ ++++### Release Automation ++++ ++++71. changelog generation ++++72. release notes generation ++++73. LinkedIn draft generation ++++74. GitHub release draft ++++75. semantic version suggestion ++++76. breaking-change detector ++++77. migration warning generator ++++78. issue closure report ++++79. release risk summary ++++80. deployment summary ++++ ++++### Documentation Automation ++++ ++++81. docs drift detector ++++82. README update reminder ++++83. architecture doc update reminder ++++84. API contract doc generator ++++85. endpoint inventory generator ++++86. env var inventory generator ++++87. Prisma model change summary ++++88. test inventory summary ++++89. issue-to-doc linkage ++++90. glossary update automation ++++ ++++### Project Management ++++ ++++91. weekly progress digest ++++92. daily issue digest ++++93. blocked issue detector ++++94. orphaned issue detector ++++95. milestone progress report ++++96. PR-to-issue linkage checker ++++97. acceptance criteria completeness checker ++++98. roadmap update generator ++++99. duplicate backlog detector ++++100. priority decay detector ++++ ++++### External Integrations ++++ ++++101. Discord notifications ++++102. Slack notifications ++++103. email summaries ++++104. Notion sync ++++105. Google Sheets metrics export ++++106. Linear/Jira sync ++++107. GitHub Projects update ++++108. calendar reminder for releases ++++109. webhook archive to database ++++110. incident timeline export ++++ ++++## Coverage Status ++++ ++++All use cases 1-110 are mapped to at least one workflow or to the shared sink pattern. +++diff --git a/cherry-n8n-workflows/README.md b/cherry-n8n-workflows/README.md +++new file mode 100644 +++index 0000000000000000000000000000000000000000..4c5c937e962d5240e0adb912c448a9eabbe45191 +++--- /dev/null ++++++ b/cherry-n8n-workflows/README.md +++@@ -0,0 +1,71 @@ ++++# Cherry n8n Minmax Workflow Pack ++++ ++++Status: Generated ++++Last updated: 2026-04-27 ++++ ++++This directory contains 10 importable n8n workflow JSON files for Cherry development automation. The workflows are advisory and development-facing only. They do not touch Cherry payment rails and must not mutate Sessions, Ledger, Buckets, cards, payments, or other financial truth. ++++ ++++## Import ++++ ++++Import each JSON file as a single workflow in n8n. Each file contains exactly one workflow object, not an array of workflows. ++++ ++++The zip is expected to preserve this root folder: ++++ ++++```bash ++++cd /Users/nasr/repos/cherry ++++zip -r cherry-n8n-workflows.zip cherry-n8n-workflows ++++``` ++++ ++++## Required Environment Variables ++++ ++++- `GITHUB_OWNER` ++++- `GITHUB_REPO` ++++- `GITHUB_TOKEN` ++++- `OPENCLAW_WEBHOOK_URL` ++++- `DISCORD_WEBHOOK_URL` ++++- `SLACK_WEBHOOK_URL` ++++- `EMAIL_WEBHOOK_URL` ++++- `NOTION_WEBHOOK_URL` ++++- `GOOGLE_SHEETS_WEBHOOK_URL` ++++- `LINEAR_JIRA_WEBHOOK_URL` ++++- `GITHUB_PROJECTS_WEBHOOK_URL` ++++- `CHERRY_API_BASE_URL` ++++- `CHERRY_AUTOMATION_TOKEN` ++++ ++++HTTP Request nodes use header parameters with placeholder expressions only. No credentials are required at import time. ++++ ++++## Webhook Paths ++++ ++++- `POST /cherry/github/workflow-completed` -> CI failure compression ++++- `POST /cherry/github/issue-labeled` -> OpenClaw issue router ++++- `POST /cherry/github/pull-request-risk` -> PR risk classifier ++++- `POST /cherry/github/pull-request-forbidden` -> forbidden-change detector ++++- `POST /cherry/runtime/degradation` -> engine degradation alerting ++++- `POST /cherry/simulation/result` -> simulation drift detector ++++- `POST /cherry/release/summary` -> release summary generator ++++- `POST /cherry/github/pull-request-docs-drift` -> docs drift detector ++++ ++++## GitHub Webhook Event Mapping ++++ ++++- `workflow_run.completed` -> `/cherry/github/workflow-completed` ++++- `issues.labeled` -> `/cherry/github/issue-labeled` ++++- `pull_request` -> PR risk, forbidden-change, and docs-drift workflows ++++ ++++## Scheduled Workflows ++++ ++++- `08_repo_intelligence_digest.json` runs weekly. ++++- `10_backlog_grooming.json` runs weekly. ++++ ++++## Safety Boundary ++++ ++++Forbidden Cherry endpoint patterns: ++++ ++++- `/api/session*` ++++- `/api/ledger*` ++++- `/api/bucket*` ++++- `/api/payment*` ++++- `/api/card*` ++++- `/api/debt*/mutate` ++++- any `POST`, `PATCH`, or `DELETE` endpoint that changes financial truth ++++ ++++Workflow `06_simulation_drift_detector` calls Cherry's `/api/automation/simulation-snapshots/compare` endpoint so snapshot history is durable in Cherry automation storage, not n8n static data. +++diff --git a/cherry-n8n-workflows/VALIDATION_REPORT.md b/cherry-n8n-workflows/VALIDATION_REPORT.md +++new file mode 100644 +++index 0000000000000000000000000000000000000000..a7d0ddccbbce216e6c0f3ad975ceeb80a918c027 +++--- /dev/null ++++++ b/cherry-n8n-workflows/VALIDATION_REPORT.md +++@@ -0,0 +1,83 @@ ++++# Cherry n8n Validation Report ++++ ++++Status: Passed ++++Last updated: 2026-04-27 ++++ ++++## Parsed Files ++++ ++++- 01_ci_failure_compression.json ++++- 02_openclaw_issue_router.json ++++- 03_pr_risk_classifier.json ++++- 04_forbidden_change_detector.json ++++- 05_engine_degradation_alerting.json ++++- 06_simulation_drift_detector.json ++++- 07_release_summary_generator.json ++++- 08_repo_intelligence_digest.json ++++- 09_docs_drift_detector.json ++++- 10_backlog_grooming.json ++++ ++++## Workflow Names ++++ ++++- Cherry - CI Failure Compression ++++- Cherry - OpenClaw Issue Router ++++- Cherry - PR Risk Classifier ++++- Cherry - Forbidden Change Detector ++++- Cherry - Engine Degradation Alerting ++++- Cherry - Simulation Drift Detector ++++- Cherry - Release Summary Generator ++++- Cherry - Repo Intelligence Digest ++++- Cherry - Docs Drift Detector ++++- Cherry - Backlog Grooming ++++ ++++## Webhook Paths ++++ ++++- /cherry/github/workflow-completed ++++- /cherry/github/issue-labeled ++++- /cherry/github/pull-request-risk ++++- /cherry/github/pull-request-forbidden ++++- /cherry/runtime/degradation ++++- /cherry/simulation/result ++++- /cherry/release/summary ++++- /cherry/github/pull-request-docs-drift ++++ ++++## Automation Endpoints ++++ ++++- /api/automation/classify/pr ++++- /api/automation/events ++++- /api/automation/simulation-snapshots/compare ++++- /api/automation/statuses/github ++++ ++++## Coverage Status 1-110 ++++ ++++Passed: all use cases 1-110 are covered. ++++ ++++## Credential Objects ++++ ++++Detected credential objects: none ++++ ++++## Connection Reference Check ++++ ++++Passed ++++ ++++## HTTP Failure Handling ++++ ++++Every HTTP Request node has `continueOnFail: true`. ++++ ++++## Webhook Response Mode ++++ ++++All Webhook nodes set `responseMode` to `responseNode`. ++++ ++++## Code Node Language ++++ ++++All Code nodes use JavaScript. ++++ ++++## V2 Notes ++++ ++++- Archive nodes call `/api/automation/events`. ++++- PR risk workflow calls `/api/automation/classify/pr` and `/api/automation/statuses/github`. ++++- Forbidden-change and docs-drift workflows call `/api/automation/statuses/github`. ++++- Simulation drift workflow calls `/api/automation/simulation-snapshots/compare` instead of n8n static data. ++++ ++++## Errors ++++ ++++None. +++diff --git a/docs/automation/branch-protection.md b/docs/automation/branch-protection.md +++new file mode 100644 +++index 0000000000000000000000000000000000000000..65269c0026e1a64fb9d9d051042339eb245df9c9 +++--- /dev/null ++++++ b/docs/automation/branch-protection.md +++@@ -0,0 +1,17 @@ ++++Status: Active ++++Last updated: 2026-04-28 ++++ ++++# Cherry Automation Branch Protection ++++ ++++Cherry automation V2 posts allowlisted GitHub commit statuses through Cherry-owned API endpoints. These statuses become enforcement only when the repository branch protection rules require them before merge. ++++ ++++Required Cherry status contexts: ++++ ++++- `cherry/forbidden-change` ++++- `cherry/docs-drift` ++++- `cherry/risk-gate` ++++- `cherry/openclaw-policy` ++++ ++++Without branch protection, Cherry statuses are advisory only. ++++ ++++Configure branch protection for protected branches to require the contexts above, keep administrator bypasses limited, and keep n8n routed through Cherry `/api/automation/*` endpoints rather than posting arbitrary status contexts directly. +++diff --git a/docs/ci-and-guardrails.md b/docs/ci-and-guardrails.md +++index a1e1bf51ad0ccd98768888f8ba5b59589ea5e05a..94042be63dc068b5fc65ade352d87e852f1b8aaf 100644 +++--- a/docs/ci-and-guardrails.md ++++++ b/docs/ci-and-guardrails.md +++@@ -88,8 +88,9 @@ Last updated: 2026-04-28 +++ +++ Use the narrowest proof that fully covers the changed surface: +++ - `npm run check:static` for guardrails, lint, and typecheck. ++++- `npm run check:fast` for local guardrails + script typecheck. +++ - `npm run check:runtime` or `npm test` for the partitioned runtime suite. +++-- `npm run check:fast` for local guardrails + script typecheck + runtime suite. ++++- `npm run check:local` for `check:fast` plus the partitioned runtime suite. +++ - `CHERRY_TMP_ROOT="$HOME/.cherry-tmp" CHERRY_VINE_SIGNATURE_MODE=enforce npm run verify:repo-closure` for canonical full proof. +++ +++ Agents must not blindly stack `npm run check`, `npm test`, `npm run build`, and `verify:repo-closure`; do not run both `npm test` and `verify:repo-closure` unless explicitly required. +++diff --git a/docs/config-snapshot.md b/docs/config-snapshot.md +++index 0241263742aaa0ea554c42f74e20893038dc6195..45887ee1519365d63492963aac0f96f3c232df7f 100644 +++--- a/docs/config-snapshot.md ++++++ b/docs/config-snapshot.md +++@@ -45,16 +45,45 @@ jobs: +++ - name: Guardrails +++ run: npm run check:guardrails +++ +++- - name: Node runtime tests +++- run: npm run check:tests:node +++- +++- - name: Next runtime tests +++- run: npm run check:tests:next +++- +++ - name: Verify CI truth +++ run: npm run ci:verify +++ ``` +++ ++++```yaml ++++// .github/workflows/n8n-notify.yml ++++name: Notify n8n ++++ ++++on: ++++ workflow_run: ++++ workflows: ++++ - CI ++++ types: ++++ - completed ++++ ++++jobs: ++++ notify-n8n: ++++ runs-on: ubuntu-latest ++++ ++++ steps: ++++ - name: Send workflow result to n8n ++++ env: ++++ N8N_WEBHOOK_URL: ${{ secrets.N8N_WEBHOOK_URL }} ++++ N8N_WEBHOOK_TOKEN: ${{ secrets.N8N_WEBHOOK_TOKEN }} ++++ run: | ++++ curl -X POST "$N8N_WEBHOOK_URL" \ ++++ -H "Authorization: Bearer $N8N_WEBHOOK_TOKEN" \ ++++ -H "Content-Type: application/json" \ ++++ -d '{ ++++ "event": "github.workflow.completed", ++++ "repo": "${{ github.repository }}", ++++ "workflow": "${{ github.event.workflow_run.name }}", ++++ "status": "${{ github.event.workflow_run.conclusion }}", ++++ "branch": "${{ github.event.workflow_run.head_branch }}", ++++ "sha": "${{ github.event.workflow_run.head_sha }}", ++++ "url": "${{ github.event.workflow_run.html_url }}" ++++ }' ++++``` ++++ +++ ```yaml +++ // .github/workflows/env-checks.yml +++ # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json +++@@ -9594,6 +9623,10 @@ export default nextConfig; +++ "build:strict": "npm run check:guardrails && next build --webpack", +++ "start": "next start", +++ "ci:verify": "npm run check && npm run test && npm run build", ++++ "check:static": "npm run check:guardrails && npm run lint:scripts && npm run typecheck:scripts && npm run lint && npm run typecheck", ++++ "check:runtime": "npm test", ++++ "check:fast": "npm run check:guardrails && npm run typecheck:scripts", ++++ "check:local": "npm run check:fast && npm run check:runtime", +++ "check:clean": "npm run ts:esm -- scripts/execution/run.mts check:clean", +++ "check:db-ready": "npm run ts:esm -- scripts/execution/run-db.mts check:db-ready", +++ "check:dev-login": "npm run ts:esm -- scripts/execution/run.mts check:dev-login", +++@@ -10353,6 +10386,75 @@ model DecisionEvent { +++ @@index([userId, createdAt]) +++ } +++ ++++model AutomationEvent { ++++ id String @id @default(cuid()) ++++ repo String ++++ sha String? ++++ event String ++++ source String ++++ workflow String ++++ status String ++++ idempotencyKey String @unique(map: "automation_event__idempotency_key__unique") ++++ classifierVersion String ++++ outputHash String ++++ rawPayload Json ++++ normalizedEvent Json ++++ classifierOutput Json ++++ prNumber Int? ++++ issueNumber Int? ++++ createdAt DateTime @default(now()) ++++ updatedAt DateTime @updatedAt ++++ ++++ statusChecks AutomationStatusCheck[] ++++ ++++ @@index([repo, sha]) ++++ @@index([repo, prNumber]) ++++ @@index([repo, issueNumber]) ++++ @@index([workflow, createdAt]) ++++ @@index([classifierVersion]) ++++} ++++ ++++model SimulationAutomationSnapshot { ++++ id String @id @default(cuid()) ++++ repo String ++++ scopeKey String ++++ runId String ++++ classifierVersion String ++++ snapshot Json ++++ comparisonOutput Json ++++ outputHash String ++++ previousSnapshotId String? ++++ createdAt DateTime @default(now()) ++++ ++++ @@unique([scopeKey, runId, classifierVersion], map: "simulation_automation_snapshot__scope_run_version__unique") ++++ @@index([repo, scopeKey]) ++++ @@index([scopeKey, createdAt]) ++++ @@index([classifierVersion]) ++++} ++++ ++++model AutomationStatusCheck { ++++ id String @id @default(cuid()) ++++ repo String ++++ sha String ++++ context String ++++ state String ++++ description String ++++ targetUrl String? ++++ sourceWorkflow String ++++ automationEvent AutomationEvent? @relation(fields: [automationEventId], references: [id], onDelete: SetNull, map: "automation_status_check__automation_event_id__fk") ++++ automationEventId String? ++++ classifierVersion String ++++ outputHash String ++++ statusIdempotencyKey String @unique(map: "automation_status_check__status_idempotency_key__unique") ++++ githubResponse Json? ++++ createdAt DateTime @default(now()) ++++ ++++ @@index([repo, sha]) ++++ @@index([repo, sha, context]) ++++ @@index([automationEventId]) ++++ @@index([classifierVersion]) ++++} ++++ +++ model IdempotencyKey { +++ userId String +++ key String +++diff --git a/docs/schema-evolution.md b/docs/schema-evolution.md +++index 0e8cde12739aa13d02704abda6fb5f19199cb8ee..96fde10bb85c4ffb656f6cd18ad9fb7f98347ccb 100644 +++--- a/docs/schema-evolution.md ++++++ b/docs/schema-evolution.md +++@@ -1,5 +1,5 @@ +++ Status: Active +++-Last updated: 2026-04-26 ++++Last updated: 2026-04-27 +++ +++ # Schema Evolution Rules +++ +++@@ -9,10 +9,10 @@ Last updated: 2026-04-26 +++ - DB truth scripts/tests under scripts/db-check-* or tests/db/** +++ +++ ## Current schema manifest +++-- `schemaVersion`: `schema_v2` +++-- `lastMigration`: `20260426090000_add_scheduled_paydowns` ++++- `schemaVersion`: `schema_v3` ++++- `lastMigration`: `20260427153000_automation_backend` +++ - `invariantsVersion`: `db_invariants_v1` +++-- `schema_v2` adds persisted raw scheduled paydown rows for engine loading. The runtime loader treats these rows as source data only; temporal classification remains in engine evaluation. ++++- `schema_v3` adds advisory automation audit tables for n8n V2: `AutomationEvent`, `SimulationAutomationSnapshot`, and `AutomationStatusCheck`. These records support replay, classifier output hashes, and GitHub status auditability; they do not mutate finance truth. +++ +++ ## Required steps per schema change +++ 1. Create or update a migration under prisma/migrations/**. +++diff --git a/lib/adapters/runtime/automation-events.prisma.ts b/lib/adapters/runtime/automation-events.prisma.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..4d28295daec388e0690248825365e58e890d06bb +++--- /dev/null ++++++ b/lib/adapters/runtime/automation-events.prisma.ts +++@@ -0,0 +1,112 @@ ++++import type { AutomationEvent, Prisma, SimulationAutomationSnapshot } from '@prisma/client'; ++++import { prisma } from '../../prisma.js'; ++++ ++++export type CreateAutomationEventRecordInput = { ++++ repo: string; ++++ sha?: string | undefined; ++++ event: string; ++++ source: string; ++++ workflow: string; ++++ status: string; ++++ idempotencyKey: string; ++++ classifierVersion: string; ++++ outputHash: string; ++++ rawPayload: unknown; ++++ normalizedEvent: unknown; ++++ classifierOutput: unknown; ++++ prNumber?: number | undefined; ++++ issueNumber?: number | undefined; ++++}; ++++ ++++export type CreateSimulationAutomationSnapshotRecordInput = { ++++ repo: string; ++++ scopeKey: string; ++++ runId: string; ++++ classifierVersion: string; ++++ snapshot: unknown; ++++ comparisonOutput: unknown; ++++ outputHash: string; ++++ previousSnapshotId?: string | undefined; ++++}; ++++ ++++function asJson(value: unknown): Prisma.InputJsonValue { ++++ return value as Prisma.InputJsonValue; ++++} ++++ ++++export async function findAutomationEventByIdempotencyKey( ++++ idempotencyKey: string ++++): Promise { ++++ return prisma.automationEvent.findUnique({ where: { idempotencyKey } }); ++++} ++++ ++++export async function findAutomationEventById(id: string): Promise { ++++ return prisma.automationEvent.findUnique({ where: { id } }); ++++} ++++ ++++export async function createAutomationEventRecord( ++++ input: CreateAutomationEventRecordInput ++++): Promise { ++++ const data: Prisma.AutomationEventUncheckedCreateInput = { ++++ repo: input.repo, ++++ event: input.event, ++++ source: input.source, ++++ workflow: input.workflow, ++++ status: input.status, ++++ idempotencyKey: input.idempotencyKey, ++++ classifierVersion: input.classifierVersion, ++++ outputHash: input.outputHash, ++++ rawPayload: asJson(input.rawPayload), ++++ normalizedEvent: asJson(input.normalizedEvent), ++++ classifierOutput: asJson(input.classifierOutput), ++++ }; ++++ if (input.sha !== undefined) data.sha = input.sha; ++++ if (input.prNumber !== undefined) data.prNumber = input.prNumber; ++++ if (input.issueNumber !== undefined) data.issueNumber = input.issueNumber; ++++ ++++ return prisma.automationEvent.create({ data }); ++++} ++++ ++++export async function findLatestSimulationSnapshot( ++++ scopeKey: string, ++++ classifierVersion: string ++++): Promise { ++++ return prisma.simulationAutomationSnapshot.findFirst({ ++++ where: { scopeKey, classifierVersion }, ++++ orderBy: { createdAt: 'desc' }, ++++ }); ++++} ++++ ++++export async function findSimulationSnapshotByRun(input: { ++++ scopeKey: string; ++++ runId: string; ++++ classifierVersion: string; ++++}): Promise { ++++ return prisma.simulationAutomationSnapshot.findUnique({ ++++ where: { ++++ scopeKey_runId_classifierVersion: { ++++ scopeKey: input.scopeKey, ++++ runId: input.runId, ++++ classifierVersion: input.classifierVersion, ++++ }, ++++ }, ++++ }); ++++} ++++ ++++export async function createSimulationAutomationSnapshotRecord( ++++ input: CreateSimulationAutomationSnapshotRecordInput ++++): Promise { ++++ const data: Prisma.SimulationAutomationSnapshotUncheckedCreateInput = { ++++ repo: input.repo, ++++ scopeKey: input.scopeKey, ++++ runId: input.runId, ++++ classifierVersion: input.classifierVersion, ++++ snapshot: asJson(input.snapshot), ++++ comparisonOutput: asJson(input.comparisonOutput), ++++ outputHash: input.outputHash, ++++ }; ++++ if (input.previousSnapshotId !== undefined) { ++++ data.previousSnapshotId = input.previousSnapshotId; ++++ } ++++ ++++ return prisma.simulationAutomationSnapshot.create({ data }); ++++} +++diff --git a/lib/adapters/runtime/automation-github-status.prisma.ts b/lib/adapters/runtime/automation-github-status.prisma.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..6b85870217b1e69f3a5194e83348af6fc6e53712 +++--- /dev/null ++++++ b/lib/adapters/runtime/automation-github-status.prisma.ts +++@@ -0,0 +1,118 @@ ++++import type { AutomationStatusCheck, Prisma } from '@prisma/client'; ++++import { prisma } from '../../prisma.js'; ++++ ++++export type CreateGithubStatusCheckRecordInput = { ++++ repo: string; ++++ sha: string; ++++ context: string; ++++ state: string; ++++ description: string; ++++ targetUrl?: string | undefined; ++++ sourceWorkflow: string; ++++ automationEventId?: string | undefined; ++++ classifierVersion: string; ++++ outputHash: string; ++++ statusIdempotencyKey: string; ++++ githubResponse?: unknown; ++++}; ++++ ++++export type GithubCommitStatusPostInput = { ++++ apiBaseUrl: string; ++++ githubToken: string; ++++ repo: string; ++++ sha: string; ++++ state: string; ++++ description: string; ++++ context: string; ++++ targetUrl?: string | undefined; ++++}; ++++ ++++export type GithubCommitStatusPostResult = { ++++ ok: boolean; ++++ status: number; ++++ body: string; ++++}; ++++ ++++function asJson(value: unknown): Prisma.InputJsonValue { ++++ return value as Prisma.InputJsonValue; ++++} ++++ ++++export async function findStatusCheckByIdempotencyKey( ++++ statusIdempotencyKey: string ++++): Promise { ++++ return prisma.automationStatusCheck.findUnique({ where: { statusIdempotencyKey } }); ++++} ++++ ++++export async function findStatusCheckById( ++++ id: string ++++): Promise { ++++ return prisma.automationStatusCheck.findUnique({ where: { id } }); ++++} ++++ ++++export async function createGithubStatusCheckRecord( ++++ input: CreateGithubStatusCheckRecordInput ++++): Promise { ++++ const data: Prisma.AutomationStatusCheckUncheckedCreateInput = { ++++ repo: input.repo, ++++ sha: input.sha, ++++ context: input.context, ++++ state: input.state, ++++ description: input.description, ++++ sourceWorkflow: input.sourceWorkflow, ++++ classifierVersion: input.classifierVersion, ++++ outputHash: input.outputHash, ++++ statusIdempotencyKey: input.statusIdempotencyKey, ++++ githubResponse: asJson(input.githubResponse ?? { status: 'created_not_posted' }), ++++ }; ++++ if (input.targetUrl !== undefined) data.targetUrl = input.targetUrl; ++++ if (input.automationEventId !== undefined) data.automationEventId = input.automationEventId; ++++ ++++ return prisma.automationStatusCheck.create({ data }); ++++} ++++ ++++export async function updateGithubStatusCheckResponse( ++++ id: string, ++++ githubResponse: unknown ++++): Promise { ++++ return prisma.automationStatusCheck.update({ ++++ where: { id }, ++++ data: { githubResponse: asJson(githubResponse) }, ++++ }); ++++} ++++ ++++export async function postGithubCommitStatus( ++++ input: GithubCommitStatusPostInput ++++): Promise { ++++ const response = await fetch(`${input.apiBaseUrl}/repos/${input.repo}/statuses/${input.sha}`, { ++++ method: 'POST', ++++ headers: { ++++ Authorization: `Bearer ${input.githubToken}`, ++++ Accept: 'application/vnd.github+json', ++++ 'Content-Type': 'application/json', ++++ 'X-GitHub-Api-Version': '2022-11-28', ++++ }, ++++ body: JSON.stringify({ ++++ state: input.state, ++++ description: input.description, ++++ context: input.context, ++++ target_url: input.targetUrl, ++++ }), ++++ }); ++++ const body = await response.text(); ++++ return { ++++ ok: response.ok, ++++ status: response.status, ++++ body: body.slice(0, 10_000), ++++ }; ++++} ++++ ++++export async function listGithubStatusChecks(where: { ++++ repo?: string; ++++ sha?: string; ++++ context?: string; ++++}): Promise { ++++ return prisma.automationStatusCheck.findMany({ ++++ where, ++++ orderBy: [{ createdAt: 'desc' }, { id: 'asc' }], ++++ }); ++++} +++diff --git a/lib/automation/classifiers/docs-drift.ts b/lib/automation/classifiers/docs-drift.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..b1b1088df0be5b69788c9ba7f58c723f81f624f6 +++--- /dev/null ++++++ b/lib/automation/classifiers/docs-drift.ts +++@@ -0,0 +1,80 @@ ++++import type { AutomationStatusRequest, PrClassifierInput } from './types.js'; ++++import { DOCS_DRIFT_CLASSIFIER_VERSION } from './types.js'; ++++ ++++export type DocsDriftClassification = { ++++ classifierVersion: typeof DOCS_DRIFT_CLASSIFIER_VERSION; ++++ drift: boolean; ++++ domains: string[]; ++++ labels: string[]; ++++ statusRequest: AutomationStatusRequest; ++++}; ++++ ++++export function classifyDocsDrift( ++++ input: Pick ++++): DocsDriftClassification { ++++ const names = input.files.map((file) => file.filename); ++++ const domains: string[] = []; ++++ ++++ if (names.some((name) => name.startsWith('lib/engine') || name === 'lib/engine.ts' || name.startsWith('lib/authority'))) { ++++ domains.push('engine'); ++++ } ++++ if (names.some((name) => name.startsWith('app/api/'))) { ++++ domains.push('api'); ++++ } ++++ if (names.some((name) => name === 'prisma/schema.prisma' || name.startsWith('prisma/migrations/'))) { ++++ domains.push('schema'); ++++ } ++++ if (names.some((name) => name.includes('env') || name === '.env.example')) { ++++ domains.push('env'); ++++ } ++++ if (names.some((name) => /(^|\/)(tests?|__tests__)(\/|$)|\.(test|spec)\./.test(name))) { ++++ domains.push('tests'); ++++ } ++++ if ( ++++ names.some( ++++ (name) => ++++ name.startsWith('lib/automation/') || ++++ name.startsWith('app/api/automation/') || ++++ name.startsWith('cherry-n8n-workflows/') ++++ ) ++++ ) { ++++ domains.push('automation'); ++++ } ++++ ++++ const uniqueDomains = [...new Set(domains)]; ++++ const docsByDomain: Record boolean> = { ++++ engine: (name) => ++++ name.startsWith('docs/architecture/') || ++++ name.startsWith('docs/engine/') || ++++ name === 'README.md', ++++ api: (name) => name.startsWith('docs/api/') || name === 'README.md', ++++ schema: (name) => ++++ name.startsWith('docs/schema/') || ++++ name === 'prisma/README.md' || ++++ name.startsWith('docs/database/') || ++++ name === 'README.md', ++++ env: (name) => ++++ name === '.env.example' || name.startsWith('docs/env/') || name === 'README.md', ++++ tests: (name) => name.startsWith('docs/testing/') || name === 'README.md', ++++ automation: (name) => name.startsWith('docs/automation/') || name === 'README.md', ++++ }; ++++ const missingDocs = uniqueDomains.filter((domain) => { ++++ const matches = docsByDomain[domain]; ++++ return matches === undefined || names.some(matches) === false; ++++ }); ++++ const drift = missingDocs.length > 0; ++++ ++++ return { ++++ classifierVersion: DOCS_DRIFT_CLASSIFIER_VERSION, ++++ drift, ++++ domains: uniqueDomains, ++++ labels: drift ? ['docs-drift', 'needs-human-review'] : [], ++++ statusRequest: { ++++ context: 'cherry/docs-drift', ++++ state: drift ? 'failure' : 'success', ++++ description: drift ++++ ? `Docs update required for ${domains.join(', ')} changes.` ++++ : 'No docs drift detected.', ++++ }, ++++ }; ++++} +++diff --git a/lib/automation/classifiers/forbidden-change.ts b/lib/automation/classifiers/forbidden-change.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..f02045b5efd2666ba824aa52c8b14bf34d77c39f +++--- /dev/null ++++++ b/lib/automation/classifiers/forbidden-change.ts +++@@ -0,0 +1,65 @@ ++++import type { AutomationStatusRequest, PrClassifierInput } from './types.js'; ++++import { FORBIDDEN_CHANGE_CLASSIFIER_VERSION } from './types.js'; ++++ ++++export type ForbiddenChangeClassification = { ++++ classifierVersion: typeof FORBIDDEN_CHANGE_CLASSIFIER_VERSION; ++++ forbidden: boolean; ++++ violations: string[]; ++++ labels: string[]; ++++ statusRequest: AutomationStatusRequest; ++++}; ++++ ++++export function classifyForbiddenChange( ++++ input: Pick ++++): ForbiddenChangeClassification { ++++ const violations: string[] = []; ++++ ++++ for (const file of input.files) { ++++ const name = file.filename; ++++ const patch = file.patch ?? ''; ++++ if ( ++++ name === '.env' || ++++ name === '.env.local' || ++++ name.endsWith('/.env') || ++++ name.endsWith('/.env.local') ++++ ) { ++++ violations.push(`env_diff:${name}`); ++++ } ++++ if (/secret|credentials|production.*db/i.test(name)) { ++++ violations.push(`sensitive_path:${name}`); ++++ } ++++ if (file.status === 'removed' && /(^|\/)(tests?|__tests__)(\/|$)|\.(test|spec)\./.test(name)) { ++++ violations.push(`deleted_test:${name}`); ++++ } ++++ if (/^[+].*\b(test|describe|it)\.skip\b/m.test(patch)) { ++++ violations.push(`skipped_test_added:${name}`); ++++ } ++++ if (/^[+].*console\.log\(/m.test(patch)) { ++++ violations.push(`console_log_added:${name}`); ++++ } ++++ if (/^[+].*TODO(?!.*#\d+)/im.test(patch)) { ++++ violations.push(`todo_without_issue:${name}`); ++++ } ++++ if (/^[+].*from ['"]@prisma\/client['"]/m.test(patch) && /lib\/engine|lib\/authority/.test(name)) { ++++ violations.push(`forbidden_prisma_import:${name}`); ++++ } ++++ if (/^[+].*(DATABASE_URL|PRODUCTION_DATABASE_URL|direct prod mutation)/im.test(patch)) { ++++ violations.push(`production_truth_mutation_hint:${name}`); ++++ } ++++ } ++++ ++++ const forbidden = violations.length > 0; ++++ return { ++++ classifierVersion: FORBIDDEN_CHANGE_CLASSIFIER_VERSION, ++++ forbidden, ++++ violations, ++++ labels: forbidden ? ['blocked-forbidden-change', 'needs-human-review'] : [], ++++ statusRequest: { ++++ context: 'cherry/forbidden-change', ++++ state: forbidden ? 'failure' : 'success', ++++ description: forbidden ++++ ? `${violations.length} forbidden change pattern(s) detected.` ++++ : 'No forbidden change patterns detected.', ++++ }, ++++ }; ++++} +++diff --git a/lib/automation/classifiers/pr-risk.ts b/lib/automation/classifiers/pr-risk.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..1aa604f763bf404aeec209963246b3405361e571 +++--- /dev/null ++++++ b/lib/automation/classifiers/pr-risk.ts +++@@ -0,0 +1,105 @@ ++++import type { AutomationStatusRequest, PrClassifierInput } from './types.js'; ++++import { PR_RISK_CLASSIFIER_VERSION } from './types.js'; ++++ ++++export type PrRiskClassification = { ++++ classifierVersion: typeof PR_RISK_CLASSIFIER_VERSION; ++++ score: number; ++++ level: 'low' | 'medium' | 'high'; ++++ labels: string[]; ++++ reasons: string[]; ++++ accepted: boolean; ++++ statusRequest: AutomationStatusRequest; ++++}; ++++ ++++function hasLinkedIssue(input: PrClassifierInput): boolean { ++++ const text = `${input.title} ${input.body}`; ++++ return /(close[sd]?|fix(e[sd])?|resolve[sd]?)\s+#\d+|#\d+/i.test(text); ++++} ++++ ++++export function classifyPrRisk(input: PrClassifierInput): PrRiskClassification { ++++ const names = input.files.map((file) => file.filename); ++++ const additions = input.files.reduce((sum, file) => sum + (file.additions ?? 0), 0); ++++ const deletions = input.files.reduce((sum, file) => sum + (file.deletions ?? 0), 0); ++++ const changedLines = additions + deletions; ++++ ++++ const hasEngine = names.some( ++++ (name) => ++++ name.startsWith('lib/engine') || ++++ name === 'lib/engine.ts' || ++++ name.includes('/engine/') ++++ ); ++++ const hasPrisma = names.some( ++++ (name) => name === 'prisma/schema.prisma' || name.startsWith('prisma/migrations/') ++++ ); ++++ const hasApi = names.some((name) => name.startsWith('app/api/')); ++++ const testDeleted = input.files.some( ++++ (file) => ++++ file.status === 'removed' && ++++ /(^|\/)(tests?|__tests__)(\/|$)|\.(test|spec)\./.test(file.filename) ++++ ); ++++ const docsOnly = ++++ names.length > 0 && ++++ names.every( ++++ (name) => name.startsWith('docs/') || name === 'README.md' || name.endsWith('.md') ++++ ); ++++ const largeDiff = changedLines > 800 || names.length > 25; ++++ const noLinkedIssue = hasLinkedIssue(input) === false; ++++ ++++ let score = 0; ++++ const reasons: string[] = []; ++++ if (hasEngine) { ++++ score += 5; ++++ reasons.push('engine files changed +5'); ++++ } ++++ if (hasPrisma) { ++++ score += 4; ++++ reasons.push('Prisma schema or migrations changed +4'); ++++ } ++++ if (hasApi) { ++++ score += 3; ++++ reasons.push('API route changed +3'); ++++ } ++++ if (testDeleted) { ++++ score += 5; ++++ reasons.push('test deleted +5'); ++++ } ++++ if (docsOnly) { ++++ score -= 3; ++++ reasons.push('docs only -3'); ++++ } ++++ if (largeDiff) { ++++ score += 2; ++++ reasons.push('large diff +2'); ++++ } ++++ if (noLinkedIssue) { ++++ score += 2; ++++ reasons.push('no linked issue +2'); ++++ } ++++ ++++ const level = score >= 8 ? 'high' : score >= 4 ? 'medium' : 'low'; ++++ const accepted = input.labels.includes('risk-accepted'); ++++ const labels = level === 'high' ? ['risk-high'] : level === 'medium' ? ['risk-medium'] : ['risk-low']; ++++ if (level === 'high') labels.push('needs-human-review'); ++++ if (hasEngine) labels.push('engine-change'); ++++ if (docsOnly) labels.push('docs-only'); ++++ ++++ const state = level === 'high' && accepted === false ? 'failure' : 'success'; ++++ const description = ++++ state === 'failure' ++++ ? `High-risk PR score ${score}; add risk-accepted only after review.` ++++ : `PR risk ${level} with score ${score}.`; ++++ ++++ return { ++++ classifierVersion: PR_RISK_CLASSIFIER_VERSION, ++++ score, ++++ level, ++++ labels, ++++ reasons, ++++ accepted, ++++ statusRequest: { ++++ context: 'cherry/risk-gate', ++++ state, ++++ description, ++++ }, ++++ }; ++++} +++diff --git a/lib/automation/classifiers/pr.ts b/lib/automation/classifiers/pr.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..fb7a820c82ea488ad2efdff03bfb47c6f18b7f36 +++--- /dev/null ++++++ b/lib/automation/classifiers/pr.ts +++@@ -0,0 +1,30 @@ ++++import { classifyDocsDrift } from './docs-drift.js'; ++++import { classifyForbiddenChange } from './forbidden-change.js'; ++++import { classifyPrRisk } from './pr-risk.js'; ++++import type { AutomationStatusRequest, PrClassifierInput } from './types.js'; ++++import { PR_AUTOMATION_CLASSIFIER_VERSION } from './types.js'; ++++ ++++export type PrAutomationClassification = { ++++ classifierVersion: typeof PR_AUTOMATION_CLASSIFIER_VERSION; ++++ risk: ReturnType; ++++ forbiddenChange: ReturnType; ++++ docsDrift: ReturnType; ++++ statusRequests: AutomationStatusRequest[]; ++++}; ++++ ++++export function classifyPrAutomation(input: PrClassifierInput): PrAutomationClassification { ++++ const risk = classifyPrRisk(input); ++++ const forbiddenChange = classifyForbiddenChange(input); ++++ const docsDrift = classifyDocsDrift(input); ++++ return { ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ risk, ++++ forbiddenChange, ++++ docsDrift, ++++ statusRequests: [ ++++ forbiddenChange.statusRequest, ++++ docsDrift.statusRequest, ++++ risk.statusRequest, ++++ ], ++++ }; ++++} +++diff --git a/lib/automation/classifiers/simulation-drift.ts b/lib/automation/classifiers/simulation-drift.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..9cd31600cddf740dc325221b42ef99a33214e820 +++--- /dev/null ++++++ b/lib/automation/classifiers/simulation-drift.ts +++@@ -0,0 +1,90 @@ ++++import { SIMULATION_DRIFT_CLASSIFIER_VERSION } from './types.js'; ++++ ++++export type SimulationSnapshot = { ++++ score?: number; ++++ allocation?: Record; ++++ strategy?: string | null; ++++ paydownStrategy?: string | null; ++++ runwayDays?: number; ++++ runway?: number; ++++ viableCandidates?: unknown[]; ++++ viableCandidateCount?: number; ++++}; ++++ ++++export type SimulationDriftClassification = { ++++ classifierVersion: typeof SIMULATION_DRIFT_CLASSIFIER_VERSION; ++++ drift: boolean; ++++ reasons: string[]; ++++ scoreDelta: number; ++++ allocationDelta: number; ++++ strategyFlip: boolean; ++++ runwayCollapse: boolean; ++++ emptyViableCandidates: boolean; ++++}; ++++ ++++function numeric(value: unknown): number { ++++ return typeof value === 'number' && Number.isFinite(value) ? value : 0; ++++} ++++ ++++function normalizeSnapshot(snapshot: SimulationSnapshot) { ++++ const strategy = snapshot.strategy ?? snapshot.paydownStrategy ?? null; ++++ const runwayDays = numeric(snapshot.runwayDays ?? snapshot.runway); ++++ const allocation = snapshot.allocation ?? {}; ++++ const viableCandidates = Array.isArray(snapshot.viableCandidates) ++++ ? snapshot.viableCandidates.length ++++ : numeric(snapshot.viableCandidateCount); ++++ ++++ return { ++++ score: numeric(snapshot.score), ++++ allocation, ++++ strategy, ++++ runwayDays, ++++ viableCandidates, ++++ }; ++++} ++++ ++++export function classifySimulationDrift( ++++ previousSnapshot: SimulationSnapshot | null, ++++ currentSnapshot: SimulationSnapshot ++++): SimulationDriftClassification { ++++ const current = normalizeSnapshot(currentSnapshot); ++++ const previous = previousSnapshot === null ? null : normalizeSnapshot(previousSnapshot); ++++ const reasons: string[] = []; ++++ ++++ const scoreDelta = ++++ previous === null ? 0 : Math.abs(current.score - previous.score); ++++ let allocationDelta = 0; ++++ if (previous !== null) { ++++ const allocationKeys = new Set([ ++++ ...Object.keys(previous.allocation).sort((a, b) => a.localeCompare(b)), ++++ ...Object.keys(current.allocation).sort((a, b) => a.localeCompare(b)), ++++ ]); ++++ for (const key of allocationKeys) { ++++ allocationDelta += Math.abs( ++++ numeric(current.allocation[key]) - numeric(previous.allocation[key]) ++++ ); ++++ } ++++ } ++++ const strategyFlip = previous !== null && current.strategy !== previous.strategy; ++++ const runwayCollapse = ++++ previous !== null && ++++ current.runwayDays < Math.max(7, previous.runwayDays * 0.5); ++++ const emptyViableCandidates = current.viableCandidates === 0; ++++ ++++ if (scoreDelta >= 10) reasons.push(`score_delta:${scoreDelta}`); ++++ if (allocationDelta >= 5_000) reasons.push(`allocation_delta:${allocationDelta}`); ++++ if (strategyFlip) reasons.push('strategy_flip'); ++++ if (runwayCollapse) reasons.push('runway_collapse'); ++++ if (emptyViableCandidates) reasons.push('empty_viable_candidates'); ++++ ++++ return { ++++ classifierVersion: SIMULATION_DRIFT_CLASSIFIER_VERSION, ++++ drift: reasons.length > 0, ++++ reasons, ++++ scoreDelta, ++++ allocationDelta, ++++ strategyFlip, ++++ runwayCollapse, ++++ emptyViableCandidates, ++++ }; ++++} +++diff --git a/lib/automation/classifiers/types.ts b/lib/automation/classifiers/types.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..7c18830087059bd84cb0787aa133a40553b738f7 +++--- /dev/null ++++++ b/lib/automation/classifiers/types.ts +++@@ -0,0 +1,36 @@ ++++export const PR_RISK_CLASSIFIER_VERSION = 'pr-risk@1' as const; ++++export const FORBIDDEN_CHANGE_CLASSIFIER_VERSION = 'forbidden-change@1' as const; ++++export const DOCS_DRIFT_CLASSIFIER_VERSION = 'docs-drift@1' as const; ++++export const SIMULATION_DRIFT_CLASSIFIER_VERSION = 'simulation-drift@1' as const; ++++export const PR_AUTOMATION_CLASSIFIER_VERSION = ++++ 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)' as const; ++++ ++++export type AutomationFileChange = { ++++ filename: string; ++++ status?: string | undefined; ++++ additions?: number | undefined; ++++ deletions?: number | undefined; ++++ changes?: number | undefined; ++++ patch?: string | undefined; ++++}; ++++ ++++export type AutomationStatusRequest = { ++++ context: ++++ | 'cherry/forbidden-change' ++++ | 'cherry/docs-drift' ++++ | 'cherry/risk-gate' ++++ | 'cherry/openclaw-policy'; ++++ state: 'error' | 'failure' | 'pending' | 'success'; ++++ description: string; ++++ targetUrl?: string; ++++}; ++++ ++++export type PrClassifierInput = { ++++ repo: string; ++++ sha: string; ++++ prNumber: number; ++++ title: string; ++++ body: string; ++++ labels: string[]; ++++ files: AutomationFileChange[]; ++++}; +++diff --git a/lib/automation/events.ts b/lib/automation/events.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..73cd795ceab9d26818bda580ad301d10d1eba98f +++--- /dev/null ++++++ b/lib/automation/events.ts +++@@ -0,0 +1,346 @@ ++++import type { AutomationEvent, SimulationAutomationSnapshot } from '@prisma/client'; ++++import { ++++ createAutomationEventRecord, ++++ createSimulationAutomationSnapshotRecord, ++++ findAutomationEventById, ++++ findAutomationEventByIdempotencyKey, ++++ findLatestSimulationSnapshot, ++++ findSimulationSnapshotByRun, ++++} from '../adapters/runtime/automation-events.prisma.js'; ++++import { buildAutomationIdempotencyKey, hashAutomationOutput } from './hash.js'; ++++import { classifyPrAutomation } from './classifiers/pr.js'; ++++import { classifySimulationDrift } from './classifiers/simulation-drift.js'; ++++import { ++++ PR_AUTOMATION_CLASSIFIER_VERSION, ++++ SIMULATION_DRIFT_CLASSIFIER_VERSION, ++++} from './classifiers/types.js'; ++++import type { AutomationFileChange } from './classifiers/types.js'; ++++import type { PrClassifierInput } from './classifiers/types.js'; ++++import type { PrAutomationClassification } from './classifiers/pr.js'; ++++import type { SimulationDriftClassification } from './classifiers/simulation-drift.js'; ++++ ++++export type StoreAutomationEventInput = { ++++ repo: string; ++++ sha?: string | undefined; ++++ event: string; ++++ source: string; ++++ workflow: string; ++++ status: string; ++++ idempotencyKey: string; ++++ classifierVersion: string; ++++ rawPayload: unknown; ++++ normalizedEvent: unknown; ++++ classifierOutput: unknown; ++++ prNumber?: number | undefined; ++++ issueNumber?: number | undefined; ++++}; ++++ ++++export type PrAutomationInput = { ++++ repo: string; ++++ sha: string; ++++ prNumber: number; ++++ title: string; ++++ body?: string | null | undefined; ++++ labels: string[]; ++++ files: AutomationFileChange[]; ++++ sourceWorkflow: string; ++++ eventId?: string | undefined; ++++}; ++++ ++++export type SimulationCompareInput = { ++++ repo: string; ++++ scopeKey: string; ++++ runId: string; ++++ snapshot: unknown; ++++ sourceWorkflow: string; ++++}; ++++ ++++export type StoredAutomationEventResult = { ++++ event: AutomationEvent; ++++ created: boolean; ++++}; ++++ ++++export type PrAutomationStoreResult = StoredAutomationEventResult & { ++++ classifierOutput: PrAutomationClassification; ++++}; ++++ ++++export type ReplayAutomationEventResult = ++++ | { ++++ event: AutomationEvent; ++++ replayedOutput: unknown; ++++ outputHash: string | null; ++++ matches: boolean; ++++ reason: ++++ | 'matched' ++++ | 'output_hash_mismatch' ++++ | 'classifier_version_mismatch' ++++ | 'unsupported_replay_event' ++++ | 'invalid_replay_input'; ++++ } ++++ | null; ++++ ++++export type SimulationSnapshotStoreResult = { ++++ snapshot: SimulationAutomationSnapshot; ++++ comparisonOutput: SimulationDriftClassification; ++++ created: boolean; ++++}; ++++ ++++export function outputHashFor(value: unknown): string { ++++ return hashAutomationOutput(value); ++++} ++++ ++++export class AutomationEventIdempotencyConflictError extends Error { ++++ constructor(readonly idempotencyKey: string) { ++++ super('automation_event_idempotency_conflict'); ++++ this.name = 'AutomationEventIdempotencyConflictError'; ++++ } ++++} ++++ ++++export class SimulationSnapshotIdempotencyConflictError extends Error { ++++ constructor(readonly scopeKey: string, readonly runId: string) { ++++ super('simulation_snapshot_idempotency_conflict'); ++++ this.name = 'SimulationSnapshotIdempotencyConflictError'; ++++ } ++++} ++++ ++++function asRecord(value: unknown): Record | null { ++++ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; ++++ return value as Record; ++++} ++++ ++++function asStringArray(value: unknown): string[] | null { ++++ if (!Array.isArray(value)) return null; ++++ const out: string[] = []; ++++ for (const entry of value) { ++++ if (typeof entry !== 'string') return null; ++++ out.push(entry); ++++ } ++++ return out; ++++} ++++ ++++function asAutomationFiles(value: unknown): AutomationFileChange[] | null { ++++ if (!Array.isArray(value)) return null; ++++ const out: AutomationFileChange[] = []; ++++ for (const entry of value) { ++++ const record = asRecord(entry); ++++ if (record === null || typeof record['filename'] !== 'string') return null; ++++ const file: AutomationFileChange = { filename: record['filename'] }; ++++ if (typeof record['status'] === 'string') file.status = record['status']; ++++ if (typeof record['additions'] === 'number') file.additions = record['additions']; ++++ if (typeof record['deletions'] === 'number') file.deletions = record['deletions']; ++++ if (typeof record['changes'] === 'number') file.changes = record['changes']; ++++ if (typeof record['patch'] === 'string') file.patch = record['patch']; ++++ out.push(file); ++++ } ++++ return out; ++++} ++++ ++++function rebuildPrClassifierInput(event: AutomationEvent): PrClassifierInput | null { ++++ const normalized = asRecord(event.normalizedEvent); ++++ const payload = normalized === null ? null : asRecord(normalized['payload']); ++++ if (payload === null) return null; ++++ const prNumber = payload['prNumber']; ++++ const title = payload['title']; ++++ const body = payload['body']; ++++ const labels = asStringArray(payload['labels']); ++++ const files = asAutomationFiles(payload['files']); ++++ if ( ++++ typeof event.sha !== 'string' || ++++ typeof prNumber !== 'number' || ++++ typeof title !== 'string' || ++++ labels === null || ++++ files === null ++++ ) { ++++ return null; ++++ } ++++ return { ++++ repo: event.repo, ++++ sha: event.sha, ++++ prNumber, ++++ title, ++++ body: typeof body === 'string' ? body : '', ++++ labels, ++++ files, ++++ }; ++++} ++++ ++++export async function storeAutomationEvent( ++++ input: StoreAutomationEventInput ++++): Promise { ++++ const classifierOutput = input.classifierOutput; ++++ const outputHash = outputHashFor(classifierOutput); ++++ const existing = await findAutomationEventByIdempotencyKey(input.idempotencyKey); ++++ if (existing !== null) { ++++ if ( ++++ existing.classifierVersion !== input.classifierVersion || ++++ existing.outputHash !== outputHash ++++ ) { ++++ throw new AutomationEventIdempotencyConflictError(input.idempotencyKey); ++++ } ++++ return { event: existing, created: false }; ++++ } ++++ ++++ const event = await createAutomationEventRecord({ ++++ repo: input.repo, ++++ sha: input.sha, ++++ event: input.event, ++++ source: input.source, ++++ workflow: input.workflow, ++++ status: input.status, ++++ idempotencyKey: input.idempotencyKey, ++++ classifierVersion: input.classifierVersion, ++++ outputHash, ++++ rawPayload: input.rawPayload, ++++ normalizedEvent: input.normalizedEvent, ++++ classifierOutput, ++++ prNumber: input.prNumber, ++++ issueNumber: input.issueNumber, ++++ }); ++++ ++++ return { event, created: true }; ++++} ++++ ++++export async function classifyAndStorePrAutomation( ++++ input: PrAutomationInput ++++): Promise { ++++ const classifierOutput = classifyPrAutomation({ ++++ repo: input.repo, ++++ sha: input.sha, ++++ prNumber: input.prNumber, ++++ title: input.title, ++++ body: input.body ?? '', ++++ labels: input.labels, ++++ files: input.files, ++++ }); ++++ const outputHash = outputHashFor(classifierOutput); ++++ const idempotencyKey = buildAutomationIdempotencyKey([ ++++ 'pr-classification', ++++ input.repo, ++++ input.sha, ++++ String(input.prNumber), ++++ PR_AUTOMATION_CLASSIFIER_VERSION, ++++ outputHash, ++++ ]); ++++ const normalizedEvent = { ++++ event: 'github.pull_request', ++++ source: 'github', ++++ repo: input.repo, ++++ timestamp: '1970-01-01T00:00:00.000Z', ++++ payload: { ++++ prNumber: input.prNumber, ++++ title: input.title, ++++ body: input.body ?? '', ++++ labels: input.labels, ++++ files: input.files, ++++ }, ++++ }; ++++ const stored = await storeAutomationEvent({ ++++ repo: input.repo, ++++ sha: input.sha, ++++ event: normalizedEvent.event, ++++ source: normalizedEvent.source, ++++ workflow: input.sourceWorkflow, ++++ status: 'accepted', ++++ idempotencyKey, ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ rawPayload: normalizedEvent.payload, ++++ normalizedEvent, ++++ classifierOutput, ++++ prNumber: input.prNumber, ++++ }); ++++ ++++ return { ...stored, classifierOutput }; ++++} ++++ ++++export async function replayAutomationEvent( ++++ id: string, ++++ classifierVersion: string ++++): Promise { ++++ const event = await findAutomationEventById(id); ++++ if (event === null) { ++++ return null; ++++ } ++++ if (event.classifierVersion !== classifierVersion) { ++++ return { ++++ event, ++++ replayedOutput: null, ++++ outputHash: null, ++++ matches: false, ++++ reason: 'classifier_version_mismatch', ++++ }; ++++ } ++++ ++++ if (event.event !== 'github.pull_request') { ++++ return { ++++ event, ++++ replayedOutput: null, ++++ outputHash: null, ++++ matches: false, ++++ reason: 'unsupported_replay_event', ++++ }; ++++ } ++++ ++++ const replayInput = rebuildPrClassifierInput(event); ++++ if (replayInput === null) { ++++ return { ++++ event, ++++ replayedOutput: null, ++++ outputHash: null, ++++ matches: false, ++++ reason: 'invalid_replay_input', ++++ }; ++++ } ++++ ++++ const replayedOutput = classifyPrAutomation(replayInput); ++++ const outputHash = outputHashFor(replayedOutput); ++++ return { ++++ event, ++++ replayedOutput, ++++ outputHash, ++++ matches: outputHash === event.outputHash, ++++ reason: outputHash === event.outputHash ? 'matched' : 'output_hash_mismatch', ++++ }; ++++} ++++ ++++export async function compareAndStoreSimulationSnapshot( ++++ input: SimulationCompareInput ++++): Promise { ++++ const previous = await findLatestSimulationSnapshot( ++++ input.scopeKey, ++++ SIMULATION_DRIFT_CLASSIFIER_VERSION ++++ ); ++++ const previousSnapshot = previous === null ? null : previous.snapshot; ++++ const comparisonOutput = classifySimulationDrift( ++++ previousSnapshot as Parameters[0], ++++ input.snapshot as Parameters[1] ++++ ); ++++ const outputHash = outputHashFor(comparisonOutput); ++++ const existing = await findSimulationSnapshotByRun({ ++++ scopeKey: input.scopeKey, ++++ runId: input.runId, ++++ classifierVersion: SIMULATION_DRIFT_CLASSIFIER_VERSION, ++++ }); ++++ if (existing !== null) { ++++ if (outputHashFor(existing.snapshot) !== outputHashFor(input.snapshot)) { ++++ throw new SimulationSnapshotIdempotencyConflictError(input.scopeKey, input.runId); ++++ } ++++ return { ++++ snapshot: existing, ++++ comparisonOutput: existing.comparisonOutput as unknown as SimulationDriftClassification, ++++ created: false, ++++ }; ++++ } ++++ ++++ const snapshot = await createSimulationAutomationSnapshotRecord({ ++++ repo: input.repo, ++++ scopeKey: input.scopeKey, ++++ runId: input.runId, ++++ classifierVersion: SIMULATION_DRIFT_CLASSIFIER_VERSION, ++++ snapshot: input.snapshot, ++++ comparisonOutput, ++++ outputHash, ++++ previousSnapshotId: previous?.id, ++++ }); ++++ ++++ return { snapshot, comparisonOutput, created: true }; ++++} +++diff --git a/lib/automation/github-status.ts b/lib/automation/github-status.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..5ba4bf956a3407a1aae69e5fa166164cb9fab6ff +++--- /dev/null ++++++ b/lib/automation/github-status.ts +++@@ -0,0 +1,238 @@ ++++import type { AutomationStatusCheck } from '@prisma/client'; ++++import { ++++ createGithubStatusCheckRecord, ++++ findStatusCheckById, ++++ findStatusCheckByIdempotencyKey, ++++ listGithubStatusChecks, ++++ postGithubCommitStatus, ++++ updateGithubStatusCheckResponse, ++++} from '../adapters/runtime/automation-github-status.prisma.js'; ++++import { buildAutomationIdempotencyKey } from './hash.js'; ++++ ++++export const ALLOWED_GITHUB_STATUS_CONTEXTS = [ ++++ 'cherry/forbidden-change', ++++ 'cherry/docs-drift', ++++ 'cherry/risk-gate', ++++ 'cherry/openclaw-policy', ++++] as const; ++++ ++++export type AllowedGithubStatusContext = (typeof ALLOWED_GITHUB_STATUS_CONTEXTS)[number]; ++++ ++++export type GithubStatusInput = { ++++ repo: string; ++++ sha: string; ++++ context: AllowedGithubStatusContext; ++++ state: 'error' | 'failure' | 'pending' | 'success'; ++++ description: string; ++++ targetUrl?: string | undefined; ++++ sourceWorkflow: string; ++++ automationEventId?: string | undefined; ++++ classifierVersion: string; ++++ outputHash: string; ++++}; ++++ ++++export type GithubStatusPostOptions = { ++++ githubToken: string; ++++ apiBaseUrl?: string; ++++}; ++++ ++++export type GithubStatusPostResult = { ++++ statusCheck: AutomationStatusCheck; ++++ posted: boolean; ++++ idempotent: boolean; ++++}; ++++ ++++export type GithubStatusRetryInput = { ++++ id?: string | undefined; ++++ statusIdempotencyKey?: string | undefined; ++++}; ++++ ++++export type GithubStatusRetryResult = { ++++ statusCheck: AutomationStatusCheck; ++++ retried: boolean; ++++}; ++++ ++++export class GithubStatusRetryNotFoundError extends Error { ++++ constructor() { ++++ super('github_status_not_found'); ++++ this.name = 'GithubStatusRetryNotFoundError'; ++++ } ++++} ++++ ++++export function isAllowedGithubStatusContext( ++++ context: string ++++): context is AllowedGithubStatusContext { ++++ return ALLOWED_GITHUB_STATUS_CONTEXTS.includes(context as AllowedGithubStatusContext); ++++} ++++ ++++export function buildStatusIdempotencyKey(input: GithubStatusInput): string { ++++ return buildAutomationIdempotencyKey([ ++++ 'github-status', ++++ input.repo, ++++ input.sha, ++++ input.context, ++++ input.classifierVersion, ++++ input.outputHash, ++++ ]); ++++} ++++ ++++export function targetUrlTouchesForbiddenCherryTruth(targetUrl: string | undefined): boolean { ++++ if (targetUrl === undefined) return false; ++++ return /\/api\/(sessions?|ledgers?|buckets?|payments?|cards?)(\/|$)|\/api\/debts?(\/.*)?\/mutate\b/i.test( ++++ targetUrl ++++ ); ++++} ++++ ++++export async function postGithubStatus( ++++ input: GithubStatusInput, ++++ options: GithubStatusPostOptions ++++): Promise { ++++ if (isAllowedGithubStatusContext(input.context) === false) { ++++ throw new Error(`Unsupported GitHub status context: ${input.context}`); ++++ } ++++ ++++ const statusIdempotencyKey = buildStatusIdempotencyKey(input); ++++ const existing = await findStatusCheckByIdempotencyKey(statusIdempotencyKey); ++++ if (existing !== null) { ++++ return { statusCheck: existing, posted: false, idempotent: true }; ++++ } ++++ ++++ if (targetUrlTouchesForbiddenCherryTruth(input.targetUrl)) { ++++ throw new Error('GitHub status targetUrl points at a forbidden Cherry finance endpoint'); ++++ } ++++ ++++ const statusCheck = await createGithubStatusCheckRecord({ ++++ repo: input.repo, ++++ sha: input.sha, ++++ context: input.context, ++++ state: input.state, ++++ description: input.description, ++++ targetUrl: input.targetUrl, ++++ sourceWorkflow: input.sourceWorkflow, ++++ automationEventId: input.automationEventId, ++++ classifierVersion: input.classifierVersion, ++++ outputHash: input.outputHash, ++++ statusIdempotencyKey, ++++ githubResponse: { status: 'created_not_posted' }, ++++ }); ++++ ++++ if (options.githubToken.trim().length === 0) { ++++ const updated = await updateGithubStatusCheckResponse(statusCheck.id, { ++++ ok: false, ++++ error: 'missing_github_token', ++++ }); ++++ throw Object.assign(new Error('Missing GitHub token for status posting'), { ++++ statusCheck: updated, ++++ }); ++++ } ++++ ++++ const apiBaseUrl = options.apiBaseUrl ?? 'https://api.github.com'; ++++ const response = await postGithubCommitStatus({ ++++ apiBaseUrl, ++++ githubToken: options.githubToken, ++++ repo: input.repo, ++++ sha: input.sha, ++++ state: input.state, ++++ description: input.description, ++++ context: input.context, ++++ targetUrl: input.targetUrl, ++++ }); ++++ const githubResponse = { ++++ ok: response.ok, ++++ status: response.status, ++++ body: response.body, ++++ }; ++++ const updated = await updateGithubStatusCheckResponse(statusCheck.id, githubResponse); ++++ if (response.ok === false) { ++++ throw Object.assign(new Error(`GitHub status post failed with ${response.status}`), { ++++ statusCheck: updated, ++++ }); ++++ } ++++ ++++ return { statusCheck: updated, posted: true, idempotent: false }; ++++} ++++ ++++async function repostExistingGithubStatus( ++++ statusCheck: AutomationStatusCheck, ++++ options: GithubStatusPostOptions ++++): Promise { ++++ if (isAllowedGithubStatusContext(statusCheck.context) === false) { ++++ throw new Error(`Unsupported GitHub status context: ${statusCheck.context}`); ++++ } ++++ if (targetUrlTouchesForbiddenCherryTruth(statusCheck.targetUrl ?? undefined)) { ++++ throw new Error('GitHub status targetUrl points at a forbidden Cherry finance endpoint'); ++++ } ++++ if (options.githubToken.trim().length === 0) { ++++ const updated = await updateGithubStatusCheckResponse(statusCheck.id, { ++++ ok: false, ++++ retry: true, ++++ error: 'missing_github_token', ++++ }); ++++ throw Object.assign(new Error('Missing GitHub token for status retry'), { ++++ statusCheck: updated, ++++ }); ++++ } ++++ ++++ const response = await postGithubCommitStatus({ ++++ apiBaseUrl: options.apiBaseUrl ?? 'https://api.github.com', ++++ githubToken: options.githubToken, ++++ repo: statusCheck.repo, ++++ sha: statusCheck.sha, ++++ state: statusCheck.state as GithubStatusInput['state'], ++++ description: statusCheck.description, ++++ context: statusCheck.context, ++++ targetUrl: statusCheck.targetUrl ?? undefined, ++++ }); ++++ const updated = await updateGithubStatusCheckResponse(statusCheck.id, { ++++ ok: response.ok, ++++ retry: true, ++++ status: response.status, ++++ body: response.body, ++++ }); ++++ if (response.ok === false) { ++++ throw Object.assign(new Error(`GitHub status retry failed with ${response.status}`), { ++++ statusCheck: updated, ++++ }); ++++ } ++++ return updated; ++++} ++++ ++++export async function retryGithubStatus( ++++ input: GithubStatusRetryInput, ++++ options: GithubStatusPostOptions ++++): Promise { ++++ const statusCheck = ++++ input.id !== undefined ++++ ? await findStatusCheckById(input.id) ++++ : input.statusIdempotencyKey !== undefined ++++ ? await findStatusCheckByIdempotencyKey(input.statusIdempotencyKey) ++++ : null; ++++ if (statusCheck === null) { ++++ throw new GithubStatusRetryNotFoundError(); ++++ } ++++ const updated = await repostExistingGithubStatus(statusCheck, options); ++++ return { statusCheck: updated, retried: true }; ++++} ++++ ++++export async function listLatestGithubStatuses(params: { ++++ repo?: string; ++++ sha?: string; ++++ context?: AllowedGithubStatusContext; ++++}): Promise { ++++ const rows = await listGithubStatusChecks(params); ++++ const latest = new Map(); ++++ for (const row of rows) { ++++ const key = `${row.repo}:${row.sha}:${row.context}`; ++++ const existing = latest.get(key); ++++ const existingTime = existing?.createdAt instanceof Date ? existing.createdAt.getTime() : 0; ++++ const rowTime = row.createdAt instanceof Date ? row.createdAt.getTime() : 0; ++++ if (existing === undefined || rowTime >= existingTime) { ++++ latest.set(key, row); ++++ } ++++ } ++++ return Array.from(latest.values()).sort((a, b) => { ++++ const aTime = a.createdAt instanceof Date ? a.createdAt.getTime() : 0; ++++ const bTime = b.createdAt instanceof Date ? b.createdAt.getTime() : 0; ++++ return bTime - aTime; ++++ }); ++++} +++diff --git a/lib/automation/hash.ts b/lib/automation/hash.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..d34bd70e3e08a85f71f7a5a2ddafef0649a9cd84 +++--- /dev/null ++++++ b/lib/automation/hash.ts +++@@ -0,0 +1,34 @@ ++++import { createHash } from 'node:crypto'; ++++ ++++export function canonicalize(value: unknown): unknown { ++++ if (Array.isArray(value)) { ++++ return value.map((entry) => canonicalize(entry)); ++++ } ++++ ++++ if (value !== null && typeof value === 'object') { ++++ const record = value as Record; ++++ const output: Record = {}; ++++ const keys = Object.keys(record).sort((a, b) => a.localeCompare(b)); ++++ for (const key of keys) { ++++ const entry = record[key]; ++++ if (entry !== undefined) { ++++ output[key] = canonicalize(entry); ++++ } ++++ } ++++ return output; ++++ } ++++ ++++ return value; ++++} ++++ ++++export function canonicalJson(value: unknown): string { ++++ return JSON.stringify(canonicalize(value)); ++++} ++++ ++++export function hashAutomationOutput(value: unknown): string { ++++ return createHash('sha256').update(canonicalJson(value)).digest('hex'); ++++} ++++ ++++export function buildAutomationIdempotencyKey(parts: readonly string[]): string { ++++ return hashAutomationOutput(parts); ++++} +++diff --git a/lib/http/bearer-token.ts b/lib/http/bearer-token.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..1414a76ea4872e6b2bf2cec97139c92db6f88194 +++--- /dev/null ++++++ b/lib/http/bearer-token.ts +++@@ -0,0 +1,3 @@ ++++export function getStandardBearerHeader(headers: Headers): string | null { ++++ return headers.get('authorization'); ++++} +++diff --git a/lib/schemas/automation.ts b/lib/schemas/automation.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..d249e0c7576cb8134614744952aff72c9d83d786 +++--- /dev/null ++++++ b/lib/schemas/automation.ts +++@@ -0,0 +1,113 @@ ++++import { z } from 'zod'; ++++ ++++export const AutomationSourceSchema = z.enum(['github', 'openclaw', 'cherry', 'manual']); ++++ ++++export const AutomationNormalizedEventSchema = z ++++ .object({ ++++ event: z.string().min(1), ++++ source: AutomationSourceSchema, ++++ repo: z.string().min(1), ++++ timestamp: z.string().min(1), ++++ payload: z.unknown(), ++++ }) ++++ .strict(); ++++ ++++export const AutomationFileChangeSchema = z ++++ .object({ ++++ filename: z.string().min(1), ++++ status: z.string().min(1).optional(), ++++ additions: z.number().int().nonnegative().optional(), ++++ deletions: z.number().int().nonnegative().optional(), ++++ changes: z.number().int().nonnegative().optional(), ++++ patch: z.string().optional(), ++++ }) ++++ .strict(); ++++ ++++export const AutomationEventIngestSchema = z ++++ .object({ ++++ repo: z.string().min(1), ++++ sha: z.string().min(1).optional(), ++++ event: z.string().min(1), ++++ source: AutomationSourceSchema, ++++ workflow: z.string().min(1), ++++ status: z.string().min(1).default('accepted'), ++++ idempotencyKey: z.string().min(1), ++++ classifierVersion: z.string().min(1), ++++ rawPayload: z.unknown(), ++++ normalizedEvent: AutomationNormalizedEventSchema, ++++ classifierOutput: z.unknown(), ++++ prNumber: z.number().int().positive().optional(), ++++ issueNumber: z.number().int().positive().optional(), ++++ }) ++++ .strict(); ++++ ++++export const PrAutomationClassifySchema = z ++++ .object({ ++++ repo: z.string().min(1), ++++ sha: z.string().min(1), ++++ prNumber: z.number().int().positive(), ++++ title: z.string(), ++++ body: z.string().nullable().optional(), ++++ labels: z.array(z.string()).default([]), ++++ files: z.array(AutomationFileChangeSchema).default([]), ++++ sourceWorkflow: z.string().min(1).default('unknown'), ++++ eventId: z.string().min(1).optional(), ++++ }) ++++ .strict(); ++++ ++++export const SimulationSnapshotCompareSchema = z ++++ .object({ ++++ repo: z.string().min(1), ++++ scopeKey: z.string().min(1), ++++ runId: z.string().min(1), ++++ snapshot: z.unknown(), ++++ sourceWorkflow: z.string().min(1).default('unknown'), ++++ }) ++++ .strict(); ++++ ++++export const GithubStatusContextSchema = z.enum([ ++++ 'cherry/forbidden-change', ++++ 'cherry/docs-drift', ++++ 'cherry/risk-gate', ++++ 'cherry/openclaw-policy', ++++]); ++++ ++++export const GithubStatusStateSchema = z.enum(['error', 'failure', 'pending', 'success']); ++++ ++++export const GithubStatusPostSchema = z ++++ .object({ ++++ repo: z.string().min(1), ++++ sha: z.string().min(1), ++++ context: GithubStatusContextSchema, ++++ state: GithubStatusStateSchema, ++++ description: z.string().min(1).max(140), ++++ targetUrl: z.string().url().optional(), ++++ sourceWorkflow: z.string().min(1), ++++ automationEventId: z.string().min(1).optional(), ++++ classifierVersion: z.string().min(1), ++++ outputHash: z.string().min(1), ++++ }) ++++ .strict(); ++++ ++++export const GithubStatusRetrySchema = z ++++ .union([ ++++ z ++++ .object({ ++++ id: z.string().min(1), ++++ statusIdempotencyKey: z.never().optional(), ++++ }) ++++ .strict(), ++++ z ++++ .object({ ++++ id: z.never().optional(), ++++ statusIdempotencyKey: z.string().min(1), ++++ }) ++++ .strict(), ++++ ]); ++++ ++++export const AutomationReplaySchema = z ++++ .object({ ++++ automationEventId: z.string().min(1), ++++ classifierVersion: z.string().min(1), ++++ }) ++++ .strict(); +++diff --git a/package.json b/package.json +++index 19435bd4ab453f24ddeabdf801ede578041e36f6..751dd3b032475909e07f817a61e5fb9b2c3ed30c 100644 +++--- a/package.json ++++++ b/package.json +++@@ -17,7 +17,8 @@ +++ "ci:verify": "npm run check && npm run test && npm run build", +++ "check:static": "npm run check:guardrails && npm run lint:scripts && npm run typecheck:scripts && npm run lint && npm run typecheck", +++ "check:runtime": "npm test", +++- "check:fast": "npm run check:guardrails && npm run typecheck:scripts && npm test", ++++ "check:fast": "npm run check:guardrails && npm run typecheck:scripts", ++++ "check:local": "npm run check:fast && npm run check:runtime", +++ "check:clean": "npm run ts:esm -- scripts/execution/run.mts check:clean", +++ "check:db-ready": "npm run ts:esm -- scripts/execution/run-db.mts check:db-ready", +++ "check:dev-login": "npm run ts:esm -- scripts/execution/run.mts check:dev-login", +++diff --git a/prisma/migrations/20260427153000_automation_backend/migration.sql b/prisma/migrations/20260427153000_automation_backend/migration.sql +++new file mode 100644 +++index 0000000000000000000000000000000000000000..f7caf2c5a1b9ed268c24c40c26d94efee0b7f7c0 +++--- /dev/null ++++++ b/prisma/migrations/20260427153000_automation_backend/migration.sql +++@@ -0,0 +1,80 @@ ++++-- Add durable development-automation storage for n8n V2 enforcement. ++++ ++++CREATE TABLE "AutomationEvent" ( ++++ "id" TEXT NOT NULL, ++++ "repo" TEXT NOT NULL, ++++ "sha" TEXT, ++++ "event" TEXT NOT NULL, ++++ "source" TEXT NOT NULL, ++++ "workflow" TEXT NOT NULL, ++++ "status" TEXT NOT NULL, ++++ "idempotencyKey" TEXT NOT NULL, ++++ "classifierVersion" TEXT NOT NULL, ++++ "outputHash" TEXT NOT NULL, ++++ "rawPayload" JSONB NOT NULL, ++++ "normalizedEvent" JSONB NOT NULL, ++++ "classifierOutput" JSONB NOT NULL, ++++ "prNumber" INTEGER, ++++ "issueNumber" INTEGER, ++++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ++++ "updatedAt" TIMESTAMP(3) NOT NULL, ++++ ++++ CONSTRAINT "AutomationEvent_pkey" PRIMARY KEY ("id") ++++); ++++ ++++CREATE TABLE "SimulationAutomationSnapshot" ( ++++ "id" TEXT NOT NULL, ++++ "repo" TEXT NOT NULL, ++++ "scopeKey" TEXT NOT NULL, ++++ "runId" TEXT NOT NULL, ++++ "classifierVersion" TEXT NOT NULL, ++++ "snapshot" JSONB NOT NULL, ++++ "comparisonOutput" JSONB NOT NULL, ++++ "outputHash" TEXT NOT NULL, ++++ "previousSnapshotId" TEXT, ++++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ++++ ++++ CONSTRAINT "SimulationAutomationSnapshot_pkey" PRIMARY KEY ("id") ++++); ++++ ++++CREATE TABLE "AutomationStatusCheck" ( ++++ "id" TEXT NOT NULL, ++++ "repo" TEXT NOT NULL, ++++ "sha" TEXT NOT NULL, ++++ "context" TEXT NOT NULL, ++++ "state" TEXT NOT NULL, ++++ "description" TEXT NOT NULL, ++++ "targetUrl" TEXT, ++++ "sourceWorkflow" TEXT NOT NULL, ++++ "automationEventId" TEXT, ++++ "classifierVersion" TEXT NOT NULL, ++++ "outputHash" TEXT NOT NULL, ++++ "statusIdempotencyKey" TEXT NOT NULL, ++++ "githubResponse" JSONB, ++++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, ++++ ++++ CONSTRAINT "AutomationStatusCheck_pkey" PRIMARY KEY ("id") ++++); ++++ ++++CREATE UNIQUE INDEX "automation_event__idempotency_key__unique" ON "AutomationEvent"("idempotencyKey"); ++++CREATE INDEX "AutomationEvent_repo_sha_idx" ON "AutomationEvent"("repo", "sha"); ++++CREATE INDEX "AutomationEvent_repo_prNumber_idx" ON "AutomationEvent"("repo", "prNumber"); ++++CREATE INDEX "AutomationEvent_repo_issueNumber_idx" ON "AutomationEvent"("repo", "issueNumber"); ++++CREATE INDEX "AutomationEvent_workflow_createdAt_idx" ON "AutomationEvent"("workflow", "createdAt"); ++++CREATE INDEX "AutomationEvent_classifierVersion_idx" ON "AutomationEvent"("classifierVersion"); ++++ ++++CREATE UNIQUE INDEX "simulation_automation_snapshot__scope_run_version__unique" ON "SimulationAutomationSnapshot"("scopeKey", "runId", "classifierVersion"); ++++CREATE INDEX "SimulationAutomationSnapshot_repo_scopeKey_idx" ON "SimulationAutomationSnapshot"("repo", "scopeKey"); ++++CREATE INDEX "SimulationAutomationSnapshot_scopeKey_createdAt_idx" ON "SimulationAutomationSnapshot"("scopeKey", "createdAt"); ++++CREATE INDEX "SimulationAutomationSnapshot_classifierVersion_idx" ON "SimulationAutomationSnapshot"("classifierVersion"); ++++ ++++CREATE UNIQUE INDEX "automation_status_check__status_idempotency_key__unique" ON "AutomationStatusCheck"("statusIdempotencyKey"); ++++CREATE INDEX "AutomationStatusCheck_repo_sha_idx" ON "AutomationStatusCheck"("repo", "sha"); ++++CREATE INDEX "AutomationStatusCheck_repo_sha_context_idx" ON "AutomationStatusCheck"("repo", "sha", "context"); ++++CREATE INDEX "AutomationStatusCheck_automationEventId_idx" ON "AutomationStatusCheck"("automationEventId"); ++++CREATE INDEX "AutomationStatusCheck_classifierVersion_idx" ON "AutomationStatusCheck"("classifierVersion"); ++++ ++++ALTER TABLE "AutomationStatusCheck" ++++ ADD CONSTRAINT "automation_status_check__automation_event_id__fk" ++++ FOREIGN KEY ("automationEventId") REFERENCES "AutomationEvent"("id") ++++ ON DELETE SET NULL ON UPDATE CASCADE; +++diff --git a/prisma/schema.prisma b/prisma/schema.prisma +++index 157c4d2c58d87738f6d098b1a502f8c610c27b7f..4cdc91a684c196c081ce37d1b57cd70a82aca206 100644 +++--- a/prisma/schema.prisma ++++++ b/prisma/schema.prisma +++@@ -528,6 +528,75 @@ model DecisionEvent { +++ @@index([userId, createdAt]) +++ } +++ ++++model AutomationEvent { ++++ id String @id @default(cuid()) ++++ repo String ++++ sha String? ++++ event String ++++ source String ++++ workflow String ++++ status String ++++ idempotencyKey String @unique(map: "automation_event__idempotency_key__unique") ++++ classifierVersion String ++++ outputHash String ++++ rawPayload Json ++++ normalizedEvent Json ++++ classifierOutput Json ++++ prNumber Int? ++++ issueNumber Int? ++++ createdAt DateTime @default(now()) ++++ updatedAt DateTime @updatedAt ++++ ++++ statusChecks AutomationStatusCheck[] ++++ ++++ @@index([repo, sha]) ++++ @@index([repo, prNumber]) ++++ @@index([repo, issueNumber]) ++++ @@index([workflow, createdAt]) ++++ @@index([classifierVersion]) ++++} ++++ ++++model SimulationAutomationSnapshot { ++++ id String @id @default(cuid()) ++++ repo String ++++ scopeKey String ++++ runId String ++++ classifierVersion String ++++ snapshot Json ++++ comparisonOutput Json ++++ outputHash String ++++ previousSnapshotId String? ++++ createdAt DateTime @default(now()) ++++ ++++ @@unique([scopeKey, runId, classifierVersion], map: "simulation_automation_snapshot__scope_run_version__unique") ++++ @@index([repo, scopeKey]) ++++ @@index([scopeKey, createdAt]) ++++ @@index([classifierVersion]) ++++} ++++ ++++model AutomationStatusCheck { ++++ id String @id @default(cuid()) ++++ repo String ++++ sha String ++++ context String ++++ state String ++++ description String ++++ targetUrl String? ++++ sourceWorkflow String ++++ automationEvent AutomationEvent? @relation(fields: [automationEventId], references: [id], onDelete: SetNull, map: "automation_status_check__automation_event_id__fk") ++++ automationEventId String? ++++ classifierVersion String ++++ outputHash String ++++ statusIdempotencyKey String @unique(map: "automation_status_check__status_idempotency_key__unique") ++++ githubResponse Json? ++++ createdAt DateTime @default(now()) ++++ ++++ @@index([repo, sha]) ++++ @@index([repo, sha, context]) ++++ @@index([automationEventId]) ++++ @@index([classifierVersion]) ++++} ++++ +++ model IdempotencyKey { +++ userId String +++ key String +++diff --git a/scripts/check-ci-guardrail-coverage.mts b/scripts/check-ci-guardrail-coverage.mts +++index d58631255235b8f20df392157b638837a7e3f93d..544096b963caa2c487b8d15577c7d857e394e435 100644 +++--- a/scripts/check-ci-guardrail-coverage.mts ++++++ b/scripts/check-ci-guardrail-coverage.mts +++@@ -28,7 +28,6 @@ const CI_ENTRYPOINT = 'ci:verify'; +++ const GUARDRAIL_ENTRYPOINT_NAME = GUARDRAIL_ENTRYPOINT; +++ const DIRECT_RUNTIME_SCRIPTS = new Set([ +++ 'check', +++- 'check:fast', +++ 'check:runtime', +++ 'check:node', +++ 'check:next', +++diff --git a/scripts/check-ci-must-run-check.mts b/scripts/check-ci-must-run-check.mts +++index b92e20dc0081be6724cbd2bbd4cff918721c0eba..9eb309208a92ac1d9cf4970ca9ca9aed5c6559e6 100644 +++--- a/scripts/check-ci-must-run-check.mts ++++++ b/scripts/check-ci-must-run-check.mts +++@@ -21,7 +21,6 @@ const FIX = +++ const REQUIRED_GUARDRAILS = ['check:guardrails']; +++ const DIRECT_RUNTIME_SCRIPTS = new Set([ +++ 'check', +++- 'check:fast', +++ 'check:runtime', +++ 'check:node', +++ 'check:next', +++diff --git a/scripts/lib/prisma-mock.cjs b/scripts/lib/prisma-mock.cjs +++index 603102ae4db2a34b9a7612104e3d811d7f432584..509ebe2d25e13a7d4e56ccb84aad5f9e4d3e4808 100644 +++--- a/scripts/lib/prisma-mock.cjs ++++++ b/scripts/lib/prisma-mock.cjs +++@@ -37,6 +37,19 @@ function matchesWhere(record, where) { +++ ) { +++ return record.userId === val.userId && record.externalId === val.externalId; +++ } ++++ if ( ++++ val !== null && ++++ typeof val === 'object' && ++++ 'scopeKey' in val && ++++ 'runId' in val && ++++ 'classifierVersion' in val ++++ ) { ++++ return ( ++++ record.scopeKey === val.scopeKey && ++++ record.runId === val.runId && ++++ record.classifierVersion === val.classifierVersion ++++ ); ++++ } +++ return record[key] === val; +++ }); +++ } +++@@ -55,6 +68,10 @@ function createCollection(name) { +++ const composite = where.userId_externalId; +++ return `${composite.userId}:${composite.externalId}`; +++ } ++++ if ('scopeKey_runId_classifierVersion' in where) { ++++ const composite = where.scopeKey_runId_classifierVersion; ++++ return `${composite.scopeKey}:${composite.runId}:${composite.classifierVersion}`; ++++ } +++ if ( +++ 'userId' in where && +++ 'key' in where && +++@@ -80,7 +97,13 @@ function createCollection(name) { +++ typeof data.userId === 'string' && typeof data.key === 'string' +++ ? `${data.userId}:${data.key}` +++ : null; +++- const key = data.id ?? compositeKey ?? `${name}-${counter++}`; ++++ const simulationKey = ++++ typeof data.scopeKey === 'string' && ++++ typeof data.runId === 'string' && ++++ typeof data.classifierVersion === 'string' ++++ ? `${data.scopeKey}:${data.runId}:${data.classifierVersion}` ++++ : null; ++++ const key = data.id ?? compositeKey ?? simulationKey ?? `${name}-${counter++}`; +++ if (store.has(key)) { +++ const err = new Error(`Unique constraint failed on the fields: (${name}.id)`); +++ err.code = 'P2002'; +++@@ -194,6 +217,9 @@ class MockPrismaClient { +++ simulation = createCollection('simulation'); +++ vineDevice = createCollection('vineDevice'); +++ decisionEvent = createCollection('decisionEvent'); ++++ automationEvent = createCollection('automationEvent'); ++++ simulationAutomationSnapshot = createCollection('simulationAutomationSnapshot'); ++++ automationStatusCheck = createCollection('automationStatusCheck'); +++ idempotencyKey = createCollection('idempotencyKey'); +++ +++ async $disconnect() { +++diff --git a/scripts/lib/prisma-mock.mts b/scripts/lib/prisma-mock.mts +++index 928b3e63d122fcd12de2a7f11a561868ecbd03d9..bf21bd85c6ed6824697f6910eb4c004c61bda6d3 100644 +++--- a/scripts/lib/prisma-mock.mts ++++++ b/scripts/lib/prisma-mock.mts +++@@ -72,6 +72,24 @@ function matchesWhere(record: RecordShape, where?: Where): boolean { +++ record['externalId'] === composite.externalId +++ ); +++ } ++++ if ( ++++ val !== null && ++++ typeof val === 'object' && ++++ 'scopeKey' in (val as Record) && ++++ 'runId' in (val as Record) && ++++ 'classifierVersion' in (val as Record) ++++ ) { ++++ const composite = val as { ++++ scopeKey: string; ++++ runId: string; ++++ classifierVersion: string; ++++ }; ++++ return ( ++++ record['scopeKey'] === composite.scopeKey && ++++ record['runId'] === composite.runId && ++++ record['classifierVersion'] === composite.classifierVersion ++++ ); ++++ } +++ return record[key] === val; +++ }); +++ } +++@@ -90,6 +108,14 @@ function createCollection(name: string) { +++ const composite = where['userId_externalId'] as { userId: string; externalId: string }; +++ return `${composite.userId}:${composite.externalId}`; +++ } ++++ if ('scopeKey_runId_classifierVersion' in where) { ++++ const composite = where['scopeKey_runId_classifierVersion'] as { ++++ scopeKey: string; ++++ runId: string; ++++ classifierVersion: string; ++++ }; ++++ return `${composite.scopeKey}:${composite.runId}:${composite.classifierVersion}`; ++++ } +++ if ( +++ 'userId' in where && +++ 'key' in where && +++@@ -115,8 +141,17 @@ function createCollection(name: string) { +++ typeof data['userId'] === 'string' && typeof data['key'] === 'string' +++ ? `${data['userId']}:${data['key']}` +++ : null; ++++ const simulationKey = ++++ typeof data['scopeKey'] === 'string' && ++++ typeof data['runId'] === 'string' && ++++ typeof data['classifierVersion'] === 'string' ++++ ? `${data['scopeKey']}:${data['runId']}:${data['classifierVersion']}` ++++ : null; +++ const key = +++- (data['id'] as string | undefined) ?? compositeKey ?? `${name}-${counter++}`; ++++ (data['id'] as string | undefined) ?? ++++ compositeKey ?? ++++ simulationKey ?? ++++ `${name}-${counter++}`; +++ if (store.has(key)) { +++ const err = Error( +++ `Unique constraint failed on the fields: (${name}.id)` +++@@ -236,6 +271,9 @@ class MockPrismaClient { +++ simulation = createCollection('simulation'); +++ vineDevice = createCollection('vineDevice'); +++ decisionEvent = createCollection('decisionEvent'); ++++ automationEvent = createCollection('automationEvent'); ++++ simulationAutomationSnapshot = createCollection('simulationAutomationSnapshot'); ++++ automationStatusCheck = createCollection('automationStatusCheck'); +++ idempotencyKey = createCollection('idempotencyKey'); +++ +++ async $disconnect(): Promise { +++diff --git a/scripts/schema/manifest.json b/scripts/schema/manifest.json +++index ecbeaabb77b680ec077a2cd24d37c70ccee53d31..805f89fde3b521d07a47bcbc4230184f1bf406b9 100644 +++--- a/scripts/schema/manifest.json ++++++ b/scripts/schema/manifest.json +++@@ -1,6 +1,6 @@ +++ { +++- "schemaVersion": "schema_v2", +++- "lastMigration": "20260426090000_add_scheduled_paydowns", ++++ "schemaVersion": "schema_v3", ++++ "lastMigration": "20260427153000_automation_backend", +++ "invariantsVersion": "db_invariants_v1", +++ "allowlistedDestructiveMigrations": [] +++ } +++diff --git a/tests/db/constraints/automation-constraints.test.ts b/tests/db/constraints/automation-constraints.test.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..eb1302f7f2f0e06d066951f54029901fb405d26d +++--- /dev/null ++++++ b/tests/db/constraints/automation-constraints.test.ts +++@@ -0,0 +1,353 @@ ++++import * as assert from 'node:assert/strict'; ++++import { Prisma, PrismaClient } from '@prisma/client'; ++++import { assertPrismaError, getPrismaMetaString } from '../_helpers/assert-prisma-error.js'; ++++ ++++const prisma = new PrismaClient(); ++++ ++++const NOT_NULL_CONSTRAINTS = [ ++++ 'NOT_NULL:071a671b50fb', ++++ 'NOT_NULL:2856ae778f57', ++++ 'NOT_NULL:2e723dd620a6', ++++ 'NOT_NULL:342ac63ad189', ++++ 'NOT_NULL:3758a6585230', ++++ 'NOT_NULL:3ca21e9e45fa', ++++ 'NOT_NULL:3dd52344230b', ++++ 'NOT_NULL:4b10446b95e1', ++++ 'NOT_NULL:4b7e6023b536', ++++ 'NOT_NULL:568026ae010c', ++++ 'NOT_NULL:68a2d5554b15', ++++ 'NOT_NULL:6e030f061140', ++++ 'NOT_NULL:7043c0f5255c', ++++ 'NOT_NULL:776f09fbc5b2', ++++ 'NOT_NULL:77c1392dba1e', ++++ 'NOT_NULL:7996bef994a5', ++++ 'NOT_NULL:7c4f17d2d641', ++++ 'NOT_NULL:8005bfc46cf5', ++++ 'NOT_NULL:8206bfbc595b', ++++ 'NOT_NULL:82c4da434472', ++++ 'NOT_NULL:87fa7b2f34a3', ++++ 'NOT_NULL:90ad611dd8fd', ++++ 'NOT_NULL:a4b229badf88', ++++ 'NOT_NULL:afd6dd8e0c16', ++++ 'NOT_NULL:b326784ec024', ++++ 'NOT_NULL:c2c63cfc4045', ++++ 'NOT_NULL:cd4cdd4ace1b', ++++ 'NOT_NULL:cfb682211961', ++++ 'NOT_NULL:d749fe9b04a7', ++++ 'NOT_NULL:db294d632a8c', ++++ 'NOT_NULL:dc9bd959f232', ++++ 'NOT_NULL:df2cb8c868b2', ++++ 'NOT_NULL:ef57d03df80f', ++++ 'NOT_NULL:f7ff64432e48', ++++] as const; ++++ ++++const UNIQUE_CONSTRAINTS = [ ++++ 'automation_event__idempotency_key__unique', ++++ 'automation_status_check__status_idempotency_key__unique', ++++ 'simulation_automation_snapshot__scope_run_version__unique', ++++] as const; ++++ ++++void NOT_NULL_CONSTRAINTS; ++++void UNIQUE_CONSTRAINTS; ++++ ++++type TableSpec = { ++++ table: string; ++++ columns: string[]; ++++ jsonColumns: Set; ++++ baseRow: (suffix: string) => Record; ++++}; ++++ ++++const at = new Date('2024-01-01T00:00:00Z'); ++++ ++++const tableSpecs: TableSpec[] = [ ++++ { ++++ table: 'AutomationEvent', ++++ columns: [ ++++ 'id', ++++ 'repo', ++++ 'event', ++++ 'source', ++++ 'workflow', ++++ 'status', ++++ 'idempotencyKey', ++++ 'classifierVersion', ++++ 'outputHash', ++++ 'rawPayload', ++++ 'normalizedEvent', ++++ 'classifierOutput', ++++ 'createdAt', ++++ 'updatedAt', ++++ ], ++++ jsonColumns: new Set(['rawPayload', 'normalizedEvent', 'classifierOutput']), ++++ baseRow: (suffix) => ({ ++++ id: `automation-event-required-${suffix}`, ++++ repo: 'div0rce/cherry', ++++ event: 'db.constraint', ++++ source: 'manual', ++++ workflow: 'db-test', ++++ status: 'accepted', ++++ idempotencyKey: `automation-event-required-${suffix}`, ++++ classifierVersion: 'automation_v2', ++++ outputHash: `hash-${suffix}`, ++++ rawPayload: JSON.stringify({ suffix }), ++++ normalizedEvent: JSON.stringify({ suffix }), ++++ classifierOutput: JSON.stringify({ suffix }), ++++ createdAt: at, ++++ updatedAt: at, ++++ }), ++++ }, ++++ { ++++ table: 'SimulationAutomationSnapshot', ++++ columns: [ ++++ 'id', ++++ 'repo', ++++ 'scopeKey', ++++ 'runId', ++++ 'classifierVersion', ++++ 'snapshot', ++++ 'comparisonOutput', ++++ 'outputHash', ++++ 'createdAt', ++++ ], ++++ jsonColumns: new Set(['snapshot', 'comparisonOutput']), ++++ baseRow: (suffix) => ({ ++++ id: `simulation-automation-snapshot-required-${suffix}`, ++++ repo: 'div0rce/cherry', ++++ scopeKey: `scope-${suffix}`, ++++ runId: `run-${suffix}`, ++++ classifierVersion: 'automation_v2', ++++ snapshot: JSON.stringify({ suffix }), ++++ comparisonOutput: JSON.stringify({ suffix }), ++++ outputHash: `hash-${suffix}`, ++++ createdAt: at, ++++ }), ++++ }, ++++ { ++++ table: 'AutomationStatusCheck', ++++ columns: [ ++++ 'id', ++++ 'repo', ++++ 'sha', ++++ 'context', ++++ 'state', ++++ 'description', ++++ 'sourceWorkflow', ++++ 'classifierVersion', ++++ 'outputHash', ++++ 'statusIdempotencyKey', ++++ 'createdAt', ++++ ], ++++ jsonColumns: new Set(), ++++ baseRow: (suffix) => ({ ++++ id: `automation-status-check-required-${suffix}`, ++++ repo: 'div0rce/cherry', ++++ sha: `sha-${suffix}`, ++++ context: 'cherry/risk-gate', ++++ state: 'success', ++++ description: 'DB constraint check', ++++ sourceWorkflow: 'db-test', ++++ classifierVersion: 'automation_v2', ++++ outputHash: `hash-${suffix}`, ++++ statusIdempotencyKey: `automation-status-check-required-${suffix}`, ++++ createdAt: at, ++++ }), ++++ }, ++++]; ++++ ++++async function insertRaw(spec: TableSpec, row: Record): Promise { ++++ const columns = spec.columns.map((column) => `"${column}"`).join(', '); ++++ const placeholders = spec.columns ++++ .map((column, index) => `$${index + 1}${spec.jsonColumns.has(column) ? '::jsonb' : ''}`) ++++ .join(', '); ++++ const values = spec.columns.map((column) => row[column]); ++++ await prisma.$executeRawUnsafe( ++++ `INSERT INTO "${spec.table}" (${columns}) VALUES (${placeholders})`, ++++ ...values ++++ ); ++++} ++++ ++++function assertRawSqlCode(error: unknown, expected: '23502'): void { ++++ assertPrismaError(error); ++++ if (error instanceof Prisma.PrismaClientKnownRequestError) { ++++ assert.equal(error.code, 'P2010', 'expected raw query failure'); ++++ const code = getPrismaMetaString(error, 'code'); ++++ if (code !== undefined) { ++++ assert.equal(code, expected); ++++ return; ++++ } ++++ } ++++ ++++ assert.ok(String(error).includes(expected), `expected SQLSTATE ${expected}`); ++++} ++++ ++++function assertUniqueError(error: unknown): void { ++++ assertPrismaError(error); ++++ if (error instanceof Prisma.PrismaClientKnownRequestError) { ++++ assert.equal(error.code, 'P2002', 'expected unique constraint violation'); ++++ return; ++++ } ++++ throw new Error(`Expected PrismaClientKnownRequestError, got ${String(error)}`); ++++} ++++ ++++async function expectNotNullViolation(spec: TableSpec, column: string): Promise { ++++ let error: unknown = null; ++++ try { ++++ await insertRaw(spec, { ++++ ...spec.baseRow(column), ++++ [column]: null, ++++ }); ++++ } catch (err) { ++++ error = err; ++++ } ++++ ++++ if (error === null) { ++++ throw new Error(`Expected NOT NULL violation on ${spec.table}.${column}`); ++++ } ++++ assertRawSqlCode(error, '23502'); ++++} ++++ ++++async function expectUniqueViolations(): Promise { ++++ await prisma.automationEvent.create({ ++++ data: { ++++ repo: 'div0rce/cherry', ++++ event: 'db.constraint', ++++ source: 'manual', ++++ workflow: 'db-test', ++++ status: 'accepted', ++++ idempotencyKey: 'automation-event-unique-key', ++++ classifierVersion: 'automation_v2', ++++ outputHash: 'hash-event', ++++ rawPayload: {}, ++++ normalizedEvent: {}, ++++ classifierOutput: {}, ++++ }, ++++ }); ++++ ++++ let eventError: unknown = null; ++++ try { ++++ await prisma.automationEvent.create({ ++++ data: { ++++ repo: 'div0rce/cherry', ++++ event: 'db.constraint', ++++ source: 'manual', ++++ workflow: 'db-test', ++++ status: 'accepted', ++++ idempotencyKey: 'automation-event-unique-key', ++++ classifierVersion: 'automation_v2', ++++ outputHash: 'hash-event-duplicate', ++++ rawPayload: {}, ++++ normalizedEvent: {}, ++++ classifierOutput: {}, ++++ }, ++++ }); ++++ } catch (err) { ++++ eventError = err; ++++ } ++++ if (eventError === null) { ++++ throw new Error('Expected unique violation on AutomationEvent.idempotencyKey'); ++++ } ++++ assertUniqueError(eventError); ++++ ++++ await prisma.simulationAutomationSnapshot.create({ ++++ data: { ++++ repo: 'div0rce/cherry', ++++ scopeKey: 'scope-unique', ++++ runId: 'run-unique', ++++ classifierVersion: 'automation_v2', ++++ snapshot: {}, ++++ comparisonOutput: {}, ++++ outputHash: 'hash-snapshot', ++++ }, ++++ }); ++++ ++++ let snapshotError: unknown = null; ++++ try { ++++ await prisma.simulationAutomationSnapshot.create({ ++++ data: { ++++ repo: 'div0rce/cherry', ++++ scopeKey: 'scope-unique', ++++ runId: 'run-unique', ++++ classifierVersion: 'automation_v2', ++++ snapshot: {}, ++++ comparisonOutput: {}, ++++ outputHash: 'hash-snapshot-duplicate', ++++ }, ++++ }); ++++ } catch (err) { ++++ snapshotError = err; ++++ } ++++ if (snapshotError === null) { ++++ throw new Error('Expected unique violation on SimulationAutomationSnapshot scope/run/version'); ++++ } ++++ assertUniqueError(snapshotError); ++++ ++++ await prisma.automationStatusCheck.create({ ++++ data: { ++++ repo: 'div0rce/cherry', ++++ sha: 'sha-unique', ++++ context: 'cherry/risk-gate', ++++ state: 'success', ++++ description: 'DB constraint check', ++++ sourceWorkflow: 'db-test', ++++ classifierVersion: 'automation_v2', ++++ outputHash: 'hash-status', ++++ statusIdempotencyKey: 'automation-status-unique-key', ++++ }, ++++ }); ++++ ++++ let statusError: unknown = null; ++++ try { ++++ await prisma.automationStatusCheck.create({ ++++ data: { ++++ repo: 'div0rce/cherry', ++++ sha: 'sha-unique-duplicate', ++++ context: 'cherry/risk-gate', ++++ state: 'success', ++++ description: 'DB constraint check', ++++ sourceWorkflow: 'db-test', ++++ classifierVersion: 'automation_v2', ++++ outputHash: 'hash-status-duplicate', ++++ statusIdempotencyKey: 'automation-status-unique-key', ++++ }, ++++ }); ++++ } catch (err) { ++++ statusError = err; ++++ } ++++ if (statusError === null) { ++++ throw new Error('Expected unique violation on AutomationStatusCheck.statusIdempotencyKey'); ++++ } ++++ assertUniqueError(statusError); ++++} ++++ ++++async function cleanup(): Promise { ++++ await prisma.automationStatusCheck.deleteMany({ ++++ where: { sourceWorkflow: 'db-test' }, ++++ }); ++++ await prisma.simulationAutomationSnapshot.deleteMany({ ++++ where: { repo: 'div0rce/cherry', classifierVersion: 'automation_v2' }, ++++ }); ++++ await prisma.automationEvent.deleteMany({ ++++ where: { workflow: 'db-test' }, ++++ }); ++++} ++++ ++++async function run(): Promise { ++++ try { ++++ await cleanup(); ++++ for (const spec of tableSpecs) { ++++ for (const column of spec.columns) { ++++ await expectNotNullViolation(spec, column); ++++ } ++++ } ++++ await expectUniqueViolations(); ++++ console.warn('db-constraints-automation: ok'); ++++ } finally { ++++ await cleanup(); ++++ await prisma.$disconnect(); ++++ } ++++} ++++ ++++run().catch((error: unknown) => { ++++ console.error(error); ++++ process.exit(1); ++++}); +++diff --git a/tests/next/automation-api-routes.test.ts b/tests/next/automation-api-routes.test.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..fff7eb4a4c4a60cf53b4bf5d6641932ec29e072d +++--- /dev/null ++++++ b/tests/next/automation-api-routes.test.ts +++@@ -0,0 +1,296 @@ ++++import * as assert from 'node:assert/strict'; ++++import { prisma } from '../../lib/prisma.js'; ++++import { PR_AUTOMATION_CLASSIFIER_VERSION } from '../../lib/automation/classifiers/types.js'; ++++ ++++type MockRequest = { ++++ headers: Headers; ++++ url: string; ++++ json: () => Promise; ++++}; ++++ ++++function buildRequest(body: unknown, token: string): MockRequest { ++++ return { ++++ headers: new Headers({ authorization: `Bearer ${token}` }), ++++ url: 'https://cherry.test/api/automation', ++++ json: async () => body, ++++ }; ++++} ++++ ++++function asRecord(value: unknown): Record { ++++ assert.equal(typeof value, 'object'); ++++ assert.notEqual(value, null); ++++ return value as Record; ++++} ++++ ++++async function run(): Promise { ++++ const token = 'automation-test-token'; ++++ process.env['CHERRY_AUTOMATION_TOKEN'] = token; ++++ process.env['GITHUB_TOKEN'] = 'github-test-token'; ++++ ++++ const originalFetch = globalThis.fetch; ++++ let fetchCalls = 0; ++++ globalThis.fetch = async () => { ++++ fetchCalls += 1; ++++ return new Response(JSON.stringify({ id: `status-${fetchCalls}` }), { status: 201 }); ++++ }; ++++ ++++ try { ++++ const classifyRoute = await import('../../app/api/automation/classify/pr/route.js'); ++++ const eventsRoute = await import('../../app/api/automation/events/route.js'); ++++ const replayRoute = await import('../../app/api/automation/replay/route.js'); ++++ const simulationRoute = await import( ++++ '../../app/api/automation/simulation-snapshots/compare/route.js' ++++ ); ++++ const githubStatusRoute = await import('../../app/api/automation/statuses/github/route.js'); ++++ const githubStatusRetryRoute = await import( ++++ '../../app/api/automation/statuses/github/retry/route.js' ++++ ); ++++ const statusesRoute = await import('../../app/api/automation/statuses/route.js'); ++++ ++++ const classifyResponse = await classifyRoute.POST( ++++ buildRequest( ++++ { ++++ repo: 'div0rce/cherry', ++++ sha: 'route-sha', ++++ prNumber: 333, ++++ title: 'API change without docs', ++++ body: '', ++++ labels: [], ++++ files: [{ filename: 'app/api/scan/route.ts', status: 'modified' }], ++++ sourceWorkflow: 'route-test', ++++ }, ++++ token ++++ ) as never ++++ ); ++++ assert.equal(classifyResponse.status, 200); ++++ const classifyBody = asRecord(await classifyResponse.json()); ++++ assert.equal(classifyBody['ok'], true); ++++ const automationEventId = classifyBody['automationEventId']; ++++ const outputHash = classifyBody['outputHash']; ++++ assert.equal(typeof automationEventId, 'string'); ++++ assert.equal(typeof outputHash, 'string'); ++++ const classifierOutput = asRecord(classifyBody['classifierOutput']); ++++ assert.equal(Object.prototype.hasOwnProperty.call(classifierOutput, 'outputHash'), false); ++++ ++++ const replayResponse = await replayRoute.POST( ++++ buildRequest( ++++ { ++++ automationEventId, ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ }, ++++ token ++++ ) as never ++++ ); ++++ assert.equal(replayResponse.status, 200); ++++ const replayBody = asRecord(await replayResponse.json()); ++++ assert.equal(replayBody['matches'], true); ++++ assert.equal(replayBody['outputHash'], outputHash); ++++ ++++ const eventIngestBody = { ++++ repo: 'div0rce/cherry', ++++ sha: 'route-event-sha', ++++ event: 'manual.test', ++++ source: 'manual', ++++ workflow: 'route-test', ++++ status: 'accepted', ++++ idempotencyKey: 'route-event-conflict', ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ rawPayload: {}, ++++ normalizedEvent: { ++++ event: 'manual.test', ++++ source: 'manual', ++++ repo: 'div0rce/cherry', ++++ timestamp: '1970-01-01T00:00:00.000Z', ++++ payload: {}, ++++ }, ++++ classifierOutput: { value: 1 }, ++++ }; ++++ const eventIngestResponse = await eventsRoute.POST( ++++ buildRequest(eventIngestBody, token) as never ++++ ); ++++ assert.equal(eventIngestResponse.status, 200); ++++ const eventConflictResponse = await eventsRoute.POST( ++++ buildRequest( ++++ { ++++ ...eventIngestBody, ++++ classifierOutput: { value: 2 }, ++++ }, ++++ token ++++ ) as never ++++ ); ++++ assert.equal(eventConflictResponse.status, 409); ++++ assert.deepEqual(await eventConflictResponse.json(), { ++++ error: 'automation_event_idempotency_conflict', ++++ }); ++++ ++++ const invalidStatusResponse = await githubStatusRoute.POST( ++++ buildRequest( ++++ { ++++ repo: 'div0rce/cherry', ++++ sha: 'route-sha', ++++ context: 'cherry/not-allowed', ++++ state: 'failure', ++++ description: 'Invalid context', ++++ sourceWorkflow: 'route-test', ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ outputHash, ++++ }, ++++ token ++++ ) as never ++++ ); ++++ assert.equal(invalidStatusResponse.status, 400); ++++ ++++ const statusResponse = await githubStatusRoute.POST( ++++ buildRequest( ++++ { ++++ repo: 'div0rce/cherry', ++++ sha: 'route-sha', ++++ context: 'cherry/docs-drift', ++++ state: 'failure', ++++ description: 'Docs drift detected.', ++++ targetUrl: 'https://example.com/automation/status/route', ++++ sourceWorkflow: 'route-test', ++++ automationEventId, ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ outputHash, ++++ }, ++++ token ++++ ) as never ++++ ); ++++ assert.equal(statusResponse.status, 200); ++++ const statusBody = asRecord(await statusResponse.json()); ++++ assert.equal(statusBody['posted'], true); ++++ ++++ const duplicateStatusResponse = await githubStatusRoute.POST( ++++ buildRequest( ++++ { ++++ repo: 'div0rce/cherry', ++++ sha: 'route-sha', ++++ context: 'cherry/docs-drift', ++++ state: 'success', ++++ description: 'Changed fields should not create another status.', ++++ targetUrl: 'https://example.com/api/debts/123/mutate', ++++ sourceWorkflow: 'route-test', ++++ automationEventId, ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ outputHash, ++++ }, ++++ token ++++ ) as never ++++ ); ++++ assert.equal(duplicateStatusResponse.status, 200); ++++ const duplicateStatusBody = asRecord(await duplicateStatusResponse.json()); ++++ assert.equal(duplicateStatusBody['posted'], false); ++++ assert.equal(duplicateStatusBody['idempotent'], true); ++++ assert.equal(duplicateStatusBody['statusCheckId'], statusBody['statusCheckId']); ++++ assert.equal(fetchCalls, 1); ++++ ++++ const retryResponse = await githubStatusRetryRoute.POST( ++++ buildRequest({ id: statusBody['statusCheckId'] }, token) as never ++++ ); ++++ assert.equal(retryResponse.status, 200); ++++ const retryBody = asRecord(await retryResponse.json()); ++++ assert.equal(retryBody['retried'], true); ++++ const retriedStatus = asRecord(retryBody['statusCheck']); ++++ assert.equal(retriedStatus['id'], statusBody['statusCheckId']); ++++ assert.equal(fetchCalls, 2); ++++ ++++ const retryByKeyResponse = await githubStatusRetryRoute.POST( ++++ buildRequest( ++++ { statusIdempotencyKey: String(retriedStatus['statusIdempotencyKey']) }, ++++ token ++++ ) as never ++++ ); ++++ assert.equal(retryByKeyResponse.status, 200); ++++ assert.equal(fetchCalls, 3); ++++ ++++ const retryMissingResponse = await githubStatusRetryRoute.POST( ++++ buildRequest({ id: 'missing-status-check' }, token) as never ++++ ); ++++ assert.equal(retryMissingResponse.status, 404); ++++ ++++ const auditResponse = await statusesRoute.GET({ ++++ headers: new Headers({ authorization: `Bearer ${token}` }), ++++ url: 'https://cherry.test/api/automation/statuses?repo=div0rce/cherry&sha=route-sha&context=cherry/docs-drift', ++++ json: async () => ({}), ++++ } as never); ++++ assert.equal(auditResponse.status, 200); ++++ const auditBody = asRecord(await auditResponse.json()); ++++ const statuses = auditBody['statuses']; ++++ assert.ok(Array.isArray(statuses)); ++++ assert.equal(statuses.length, 1); ++++ const firstStatus = asRecord(statuses[0]); ++++ assert.equal(firstStatus['context'], 'cherry/docs-drift'); ++++ assert.equal(firstStatus['targetUrl'], 'https://example.com/automation/status/route'); ++++ assert.equal(firstStatus['automationEventId'], automationEventId); ++++ ++++ const forbiddenRetryStatus = await prisma.automationStatusCheck.create({ ++++ data: { ++++ repo: 'div0rce/cherry', ++++ sha: 'route-forbidden-retry', ++++ context: 'cherry/risk-gate', ++++ state: 'failure', ++++ description: 'Forbidden retry target.', ++++ targetUrl: 'https://example.com/api/debts/123/mutate', ++++ sourceWorkflow: 'route-test', ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ outputHash: 'route-forbidden-retry-hash', ++++ statusIdempotencyKey: 'route-forbidden-retry-key', ++++ githubResponse: {}, ++++ }, ++++ }); ++++ const forbiddenRetryResponse = await githubStatusRetryRoute.POST( ++++ buildRequest({ id: forbiddenRetryStatus.id }, token) as never ++++ ); ++++ assert.equal(forbiddenRetryResponse.status, 400); ++++ ++++ const simulationBody = { ++++ repo: 'div0rce/cherry', ++++ scopeKey: 'route-simulation', ++++ runId: 'route-run', ++++ sourceWorkflow: 'route-test', ++++ snapshot: { ++++ score: 80, ++++ allocation: { cardA: 10_000 }, ++++ strategy: 'minimum', ++++ runwayDays: 30, ++++ viableCandidateCount: 2, ++++ }, ++++ }; ++++ const simulationResponse = await simulationRoute.POST( ++++ buildRequest(simulationBody, token) as never ++++ ); ++++ assert.equal(simulationResponse.status, 200); ++++ const simulationDuplicateConflictResponse = await simulationRoute.POST( ++++ buildRequest( ++++ { ++++ ...simulationBody, ++++ snapshot: { ++++ score: 10, ++++ allocation: { cardA: 100 }, ++++ strategy: 'changed', ++++ runwayDays: 1, ++++ viableCandidateCount: 0, ++++ }, ++++ }, ++++ token ++++ ) as never ++++ ); ++++ assert.equal(simulationDuplicateConflictResponse.status, 409); ++++ assert.deepEqual(await simulationDuplicateConflictResponse.json(), { ++++ error: 'simulation_snapshot_idempotency_conflict', ++++ }); ++++ } finally { ++++ globalThis.fetch = originalFetch; ++++ await prisma.automationStatusCheck.deleteMany({ where: {} }); ++++ await prisma.simulationAutomationSnapshot.deleteMany({ where: {} }); ++++ await prisma.automationEvent.deleteMany({ where: {} }); ++++ } ++++ ++++ console.warn('automation API routes: ok'); ++++} ++++ ++++run().catch((error: unknown) => { ++++ console.error(error); ++++ process.exit(1); ++++}); +++diff --git a/tests/node/automation-boundary.test.ts b/tests/node/automation-boundary.test.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..03bbaf330f7f5c8a4bd6a1722aad8a296470af7f +++--- /dev/null ++++++ b/tests/node/automation-boundary.test.ts +++@@ -0,0 +1,56 @@ ++++import * as assert from 'node:assert/strict'; ++++import * as fs from 'node:fs'; ++++import * as path from 'node:path'; ++++ ++++const repoRoot = process.cwd(); ++++const scanRoots = [ ++++ path.join(repoRoot, 'lib', 'automation'), ++++ path.join(repoRoot, 'app', 'api', 'automation'), ++++ path.join(repoRoot, 'lib', 'adapters', 'runtime'), ++++]; ++++ ++++function walk(dir: string): string[] { ++++ const entries = fs.readdirSync(dir, { withFileTypes: true }); ++++ const files: string[] = []; ++++ for (const entry of entries) { ++++ const full = path.join(dir, entry.name); ++++ if (entry.isDirectory()) { ++++ files.push(...walk(full)); ++++ } else if (entry.isFile() && /\.[cm]?ts$/.test(entry.name)) { ++++ files.push(full); ++++ } ++++ } ++++ return files; ++++} ++++ ++++const forbiddenImports = [ ++++ /from ['"][^'"]*\/engine(?:\/|\.js|['"])/, ++++ /from ['"][^'"]*\/authority(?:\/|\.js|['"])/, ++++ /from ['"][^'"]*lib\/engine/, ++++ /from ['"][^'"]*lib\/authority/, ++++ /import\(['"][^'"]*\/engine/, ++++ /import\(['"][^'"]*\/authority/, ++++]; ++++ ++++const violations: string[] = []; ++++for (const root of scanRoots) { ++++ for (const file of walk(root)) { ++++ if ( ++++ root.endsWith(path.join('lib', 'adapters', 'runtime')) && ++++ !/^automation-.*\.[cm]?ts$/.test(path.basename(file)) ++++ ) { ++++ continue; ++++ } ++++ const source = fs.readFileSync(file, 'utf8'); ++++ if (forbiddenImports.some((pattern) => pattern.test(source))) { ++++ violations.push(path.relative(repoRoot, file)); ++++ } ++++ } ++++} ++++ ++++assert.deepEqual( ++++ violations, ++++ [], ++++ 'automation code must not import from lib/engine/* or lib/authority/*' ++++); ++++console.warn('automation boundary: ok'); +++diff --git a/tests/node/automation-classifiers.test.ts b/tests/node/automation-classifiers.test.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..dafee3c242874755ac2999a98dfe735337193848 +++--- /dev/null ++++++ b/tests/node/automation-classifiers.test.ts +++@@ -0,0 +1,162 @@ ++++import * as assert from 'node:assert/strict'; ++++import { classifyDocsDrift } from '../../lib/automation/classifiers/docs-drift.js'; ++++import { classifyForbiddenChange } from '../../lib/automation/classifiers/forbidden-change.js'; ++++import { classifyPrAutomation } from '../../lib/automation/classifiers/pr.js'; ++++import { classifyPrRisk } from '../../lib/automation/classifiers/pr-risk.js'; ++++import { classifySimulationDrift } from '../../lib/automation/classifiers/simulation-drift.js'; ++++import { ++++ DOCS_DRIFT_CLASSIFIER_VERSION, ++++ FORBIDDEN_CHANGE_CLASSIFIER_VERSION, ++++ PR_AUTOMATION_CLASSIFIER_VERSION, ++++ PR_RISK_CLASSIFIER_VERSION, ++++ SIMULATION_DRIFT_CLASSIFIER_VERSION, ++++ type AutomationFileChange, ++++} from '../../lib/automation/classifiers/types.js'; ++++ ++++function runPrClassifierTests(): void { ++++ const files: AutomationFileChange[] = [ ++++ { ++++ filename: 'lib/engine/solver.ts', ++++ status: 'modified', ++++ additions: 500, ++++ deletions: 400, ++++ patch: '+export const changed = true;', ++++ }, ++++ { ++++ filename: 'prisma/schema.prisma', ++++ status: 'modified', ++++ additions: 10, ++++ deletions: 2, ++++ }, ++++ ]; ++++ const input = { ++++ repo: 'div0rce/cherry', ++++ sha: 'abc123', ++++ prNumber: 42, ++++ title: 'change engine solver', ++++ body: 'No issue link', ++++ labels: [], ++++ files, ++++ }; ++++ const first = classifyPrAutomation(input); ++++ const second = classifyPrAutomation(input); ++++ assert.deepEqual(second, first); ++++ assert.equal(first.classifierVersion, PR_AUTOMATION_CLASSIFIER_VERSION); ++++ assert.equal(first.risk.classifierVersion, PR_RISK_CLASSIFIER_VERSION); ++++ assert.equal( ++++ first.forbiddenChange.classifierVersion, ++++ FORBIDDEN_CHANGE_CLASSIFIER_VERSION ++++ ); ++++ assert.equal(first.docsDrift.classifierVersion, DOCS_DRIFT_CLASSIFIER_VERSION); ++++ assert.equal(Object.prototype.hasOwnProperty.call(first, 'outputHash'), false); ++++ assert.equal(first.risk.level, 'high'); ++++ assert.equal(first.risk.statusRequest.state, 'failure'); ++++ assert.equal(first.docsDrift.drift, true); ++++ ++++ const accepted = classifyPrRisk({ ...input, labels: ['risk-accepted'] }); ++++ assert.equal(accepted.statusRequest.state, 'success'); ++++} ++++ ++++function runForbiddenChangeTests(): void { ++++ const result = classifyForbiddenChange({ ++++ files: [ ++++ { ++++ filename: 'tests/foo.test.ts', ++++ status: 'modified', ++++ patch: '+it.skip(\"temporarily skips\", () => {});', ++++ }, ++++ { ++++ filename: '.env.local', ++++ status: 'modified', ++++ }, ++++ ], ++++ }); ++++ assert.equal(result.forbidden, true); ++++ assert.deepEqual(result.labels, ['blocked-forbidden-change', 'needs-human-review']); ++++ assert.ok(result.violations.some((violation) => violation.startsWith('env_diff'))); ++++ assert.ok( ++++ result.violations.some((violation) => violation.startsWith('skipped_test_added')) ++++ ); ++++ assert.equal(result.statusRequest.state, 'failure'); ++++ assert.equal(result.classifierVersion, FORBIDDEN_CHANGE_CLASSIFIER_VERSION); ++++} ++++ ++++function runDocsDriftTests(): void { ++++ const drift = classifyDocsDrift({ ++++ files: [{ filename: 'app/api/scan/route.ts', status: 'modified' }], ++++ }); ++++ assert.equal(drift.drift, true); ++++ assert.deepEqual(drift.domains, ['api']); ++++ assert.deepEqual(drift.labels, ['docs-drift', 'needs-human-review']); ++++ ++++ const clean = classifyDocsDrift({ ++++ files: [ ++++ { filename: 'app/api/scan/route.ts', status: 'modified' }, ++++ { filename: 'docs/api/scan.md', status: 'modified' }, ++++ ], ++++ }); ++++ assert.equal(clean.drift, false); ++++ ++++ const engineUnrelatedMd = classifyDocsDrift({ ++++ files: [ ++++ { filename: 'lib/engine/solver.ts', status: 'modified' }, ++++ { filename: 'docs/api/update.md', status: 'modified' }, ++++ ], ++++ }); ++++ assert.equal(engineUnrelatedMd.drift, true); ++++ ++++ const engineDocs = classifyDocsDrift({ ++++ files: [ ++++ { filename: 'lib/engine/solver.ts', status: 'modified' }, ++++ { filename: 'docs/engine/update.md', status: 'modified' }, ++++ ], ++++ }); ++++ assert.equal(engineDocs.drift, false); ++++ ++++ const apiWrongDocs = classifyDocsDrift({ ++++ files: [ ++++ { filename: 'app/api/scan/route.ts', status: 'modified' }, ++++ { filename: 'docs/engine/update.md', status: 'modified' }, ++++ ], ++++ }); ++++ assert.equal(apiWrongDocs.drift, true); ++++ ++++ const schemaDocs = classifyDocsDrift({ ++++ files: [ ++++ { filename: 'prisma/schema.prisma', status: 'modified' }, ++++ { filename: 'docs/database/prisma.md', status: 'modified' }, ++++ ], ++++ }); ++++ assert.equal(schemaDocs.drift, false); ++++ assert.equal(drift.classifierVersion, DOCS_DRIFT_CLASSIFIER_VERSION); ++++} ++++ ++++function runSimulationDriftTests(): void { ++++ const result = classifySimulationDrift( ++++ { ++++ score: 90, ++++ allocation: { cardA: 10_000 }, ++++ strategy: 'pay_minimum', ++++ runwayDays: 30, ++++ viableCandidateCount: 2, ++++ }, ++++ { ++++ score: 70, ++++ allocation: { cardA: 1_000 }, ++++ strategy: 'pay_aggressive', ++++ runwayDays: 5, ++++ viableCandidateCount: 0, ++++ } ++++ ); ++++ assert.equal(result.drift, true); ++++ assert.ok(result.reasons.includes('strategy_flip')); ++++ assert.ok(result.reasons.includes('runway_collapse')); ++++ assert.ok(result.reasons.includes('empty_viable_candidates')); ++++ assert.equal(result.classifierVersion, SIMULATION_DRIFT_CLASSIFIER_VERSION); ++++} ++++ ++++runPrClassifierTests(); ++++runForbiddenChangeTests(); ++++runDocsDriftTests(); ++++runSimulationDriftTests(); ++++console.warn('automation classifiers: ok'); +++diff --git a/tests/node/automation-services.test.ts b/tests/node/automation-services.test.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..56ba7d5a4cd0ad0b5b87b41c4cdd762f62d1350a +++--- /dev/null ++++++ b/tests/node/automation-services.test.ts +++@@ -0,0 +1,349 @@ ++++import * as assert from 'node:assert/strict'; ++++import { prisma } from '../../lib/prisma.js'; ++++import { ++++ classifyAndStorePrAutomation, ++++ compareAndStoreSimulationSnapshot, ++++ replayAutomationEvent, ++++ storeAutomationEvent, ++++ outputHashFor, ++++} from '../../lib/automation/events.js'; ++++import { createAutomationEventRecord } from '../../lib/adapters/runtime/automation-events.prisma.js'; ++++import { createGithubStatusCheckRecord } from '../../lib/adapters/runtime/automation-github-status.prisma.js'; ++++import { classifyPrAutomation } from '../../lib/automation/classifiers/pr.js'; ++++import { ++++ buildStatusIdempotencyKey, ++++ listLatestGithubStatuses, ++++ postGithubStatus, ++++ retryGithubStatus, ++++} from '../../lib/automation/github-status.js'; ++++import { PR_AUTOMATION_CLASSIFIER_VERSION } from '../../lib/automation/classifiers/types.js'; ++++ ++++async function runReplayHashTest(): Promise { ++++ const result = await classifyAndStorePrAutomation({ ++++ repo: 'div0rce/cherry', ++++ sha: 'sha-replay', ++++ prNumber: 101, ++++ title: 'touch api without docs', ++++ body: '', ++++ labels: [], ++++ files: [{ filename: 'app/api/scan/route.ts', status: 'modified' }], ++++ sourceWorkflow: 'test', ++++ }); ++++ const replay = await replayAutomationEvent( ++++ result.event.id, ++++ PR_AUTOMATION_CLASSIFIER_VERSION ++++ ); ++++ assert.ok(replay); ++++ assert.equal(replay.matches, true); ++++ assert.equal(replay.outputHash, result.event.outputHash); ++++ ++++ const replayInput = { ++++ repo: 'div0rce/cherry', ++++ sha: 'sha-replay-direct', ++++ prNumber: 102, ++++ title: 'touch api without docs', ++++ body: '', ++++ labels: [], ++++ files: [{ filename: 'app/api/scan/route.ts', status: 'modified' }], ++++ }; ++++ const recomputed = classifyPrAutomation(replayInput); ++++ const directEvent = await createAutomationEventRecord({ ++++ repo: replayInput.repo, ++++ sha: replayInput.sha, ++++ event: 'github.pull_request', ++++ source: 'github', ++++ workflow: 'test', ++++ status: 'accepted', ++++ idempotencyKey: 'direct-replay-corrupt-output', ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ outputHash: outputHashFor(recomputed), ++++ rawPayload: replayInput, ++++ normalizedEvent: { ++++ event: 'github.pull_request', ++++ source: 'github', ++++ repo: replayInput.repo, ++++ timestamp: '1970-01-01T00:00:00.000Z', ++++ payload: { ++++ prNumber: replayInput.prNumber, ++++ title: replayInput.title, ++++ body: replayInput.body, ++++ labels: replayInput.labels, ++++ files: replayInput.files, ++++ }, ++++ }, ++++ classifierOutput: { stale: true }, ++++ prNumber: replayInput.prNumber, ++++ }); ++++ const directReplay = await replayAutomationEvent( ++++ directEvent.id, ++++ PR_AUTOMATION_CLASSIFIER_VERSION ++++ ); ++++ assert.ok(directReplay); ++++ assert.equal(directReplay.matches, true); ++++ assert.deepEqual(directReplay.replayedOutput, recomputed); ++++} ++++ ++++async function runAutomationEventIdempotencyConflictTest(): Promise { ++++ const base = { ++++ repo: 'div0rce/cherry', ++++ event: 'manual.test', ++++ source: 'manual', ++++ workflow: 'test', ++++ status: 'accepted', ++++ idempotencyKey: 'event-conflict-key', ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ rawPayload: {}, ++++ normalizedEvent: { ++++ event: 'manual.test', ++++ source: 'manual', ++++ repo: 'div0rce/cherry', ++++ timestamp: '1970-01-01T00:00:00.000Z', ++++ payload: {}, ++++ }, ++++ classifierOutput: { value: 1 }, ++++ }; ++++ const first = await storeAutomationEvent(base); ++++ const duplicate = await storeAutomationEvent({ ...base }); ++++ assert.equal(first.created, true); ++++ assert.equal(duplicate.created, false); ++++ await assert.rejects( ++++ storeAutomationEvent({ ...base, classifierOutput: { value: 2 } }), ++++ /automation_event_idempotency_conflict/ ++++ ); ++++} ++++ ++++async function runStatusIdempotencyTest(): Promise { ++++ const originalFetch = globalThis.fetch; ++++ let calls = 0; ++++ globalThis.fetch = async () => { ++++ calls += 1; ++++ return new Response(JSON.stringify({ id: calls }), { status: 201 }); ++++ }; ++++ ++++ try { ++++ const linkedEvent = await storeAutomationEvent({ ++++ repo: 'div0rce/cherry', ++++ sha: 'sha-status', ++++ event: 'manual.status', ++++ source: 'manual', ++++ workflow: 'test', ++++ status: 'accepted', ++++ idempotencyKey: 'status-linked-event', ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ rawPayload: {}, ++++ normalizedEvent: { ++++ event: 'manual.status', ++++ source: 'manual', ++++ repo: 'div0rce/cherry', ++++ timestamp: '1970-01-01T00:00:00.000Z', ++++ payload: {}, ++++ }, ++++ classifierOutput: { value: 'status' }, ++++ }); ++++ const input = { ++++ repo: 'div0rce/cherry', ++++ sha: 'sha-status', ++++ context: 'cherry/forbidden-change' as const, ++++ state: 'failure' as const, ++++ description: 'Forbidden change detected.', ++++ targetUrl: 'https://example.com/status/first', ++++ sourceWorkflow: 'test', ++++ automationEventId: linkedEvent.event.id, ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ outputHash: 'hash-status', ++++ }; ++++ const first = await postGithubStatus(input, { githubToken: 'token' }); ++++ const second = await postGithubStatus( ++++ { ++++ ...input, ++++ state: 'success', ++++ description: 'Changed description should not change status identity.', ++++ targetUrl: 'https://example.com/status/second', ++++ }, ++++ { githubToken: 'token' } ++++ ); ++++ assert.equal(first.posted, true); ++++ assert.equal(second.posted, false); ++++ assert.equal(second.idempotent, true); ++++ assert.equal(calls, 1); ++++ assert.equal(first.statusCheck.statusIdempotencyKey, buildStatusIdempotencyKey(input)); ++++ assert.equal(first.statusCheck.targetUrl, 'https://example.com/status/first'); ++++ assert.equal(first.statusCheck.automationEventId, linkedEvent.event.id); ++++ assert.equal(second.statusCheck.id, first.statusCheck.id); ++++ const countBeforeRetry = await prisma.automationStatusCheck.count({ ++++ where: { statusIdempotencyKey: first.statusCheck.statusIdempotencyKey }, ++++ }); ++++ const retry = await retryGithubStatus( ++++ { id: first.statusCheck.id }, ++++ { githubToken: 'token' } ++++ ); ++++ assert.equal(retry.retried, true); ++++ assert.equal(retry.statusCheck.id, first.statusCheck.id); ++++ assert.equal(calls, 2); ++++ const retryByKey = await retryGithubStatus( ++++ { statusIdempotencyKey: first.statusCheck.statusIdempotencyKey }, ++++ { githubToken: 'token' } ++++ ); ++++ assert.equal(retryByKey.statusCheck.id, first.statusCheck.id); ++++ assert.equal(calls, 3); ++++ const countAfterRetry = await prisma.automationStatusCheck.count({ ++++ where: { statusIdempotencyKey: first.statusCheck.statusIdempotencyKey }, ++++ }); ++++ assert.equal(countAfterRetry, countBeforeRetry); ++++ await assert.rejects( ++++ retryGithubStatus({ id: 'missing-status-check' }, { githubToken: 'token' }), ++++ /github_status_not_found/ ++++ ); ++++ ++++ const latest = await listLatestGithubStatuses({ ++++ repo: 'div0rce/cherry', ++++ sha: 'sha-status', ++++ context: 'cherry/forbidden-change', ++++ }); ++++ assert.equal(latest.length, 1); ++++ assert.equal(latest[0]?.context, 'cherry/forbidden-change'); ++++ } finally { ++++ globalThis.fetch = originalFetch; ++++ } ++++} ++++ ++++async function runStatusRejectsForbiddenTargetUrl(): Promise { ++++ await assert.rejects( ++++ postGithubStatus( ++++ { ++++ repo: 'div0rce/cherry', ++++ sha: 'sha-forbidden-url', ++++ context: 'cherry/risk-gate', ++++ state: 'failure', ++++ description: 'Bad target URL.', ++++ targetUrl: 'https://example.com/api/ledger/write', ++++ sourceWorkflow: 'test', ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ outputHash: 'hash-url', ++++ }, ++++ { githubToken: 'token' } ++++ ), ++++ /forbidden Cherry finance endpoint/ ++++ ); ++++ await assert.rejects( ++++ postGithubStatus( ++++ { ++++ repo: 'div0rce/cherry', ++++ sha: 'sha-forbidden-url-debt', ++++ context: 'cherry/risk-gate', ++++ state: 'failure', ++++ description: 'Bad target URL.', ++++ targetUrl: 'https://example.com/api/debts/123/mutate', ++++ sourceWorkflow: 'test', ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ outputHash: 'hash-url-debt', ++++ }, ++++ { githubToken: 'token' } ++++ ), ++++ /forbidden Cherry finance endpoint/ ++++ ); ++++} ++++ ++++async function runStatusRetryRejectsForbiddenTargetUrl(): Promise { ++++ const statusCheck = await createGithubStatusCheckRecord({ ++++ repo: 'div0rce/cherry', ++++ sha: 'sha-retry-forbidden-url', ++++ context: 'cherry/risk-gate', ++++ state: 'failure', ++++ description: 'Bad retry target.', ++++ targetUrl: 'https://example.com/api/debt/123/mutate', ++++ sourceWorkflow: 'test', ++++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, ++++ outputHash: 'hash-retry-forbidden', ++++ statusIdempotencyKey: 'retry-forbidden-status', ++++ }); ++++ await assert.rejects( ++++ retryGithubStatus({ id: statusCheck.id }, { githubToken: 'token' }), ++++ /forbidden Cherry finance endpoint/ ++++ ); ++++} ++++ ++++async function runSimulationSnapshotTest(): Promise { ++++ const first = await compareAndStoreSimulationSnapshot({ ++++ repo: 'div0rce/cherry', ++++ scopeKey: 'scenario-a', ++++ runId: 'run-1', ++++ sourceWorkflow: 'test', ++++ snapshot: { ++++ score: 90, ++++ allocation: { cardA: 10_000 }, ++++ strategy: 'minimum', ++++ runwayDays: 30, ++++ viableCandidateCount: 2, ++++ }, ++++ }); ++++ assert.equal(first.created, true); ++++ assert.equal(first.comparisonOutput.drift, false); ++++ ++++ const second = await compareAndStoreSimulationSnapshot({ ++++ repo: 'div0rce/cherry', ++++ scopeKey: 'scenario-a', ++++ runId: 'run-2', ++++ sourceWorkflow: 'test', ++++ snapshot: { ++++ score: 70, ++++ allocation: { cardA: 1_000 }, ++++ strategy: 'aggressive', ++++ runwayDays: 5, ++++ viableCandidateCount: 0, ++++ }, ++++ }); ++++ assert.equal(second.created, true); ++++ assert.equal(second.comparisonOutput.drift, true); ++++ ++++ const duplicate = await compareAndStoreSimulationSnapshot({ ++++ repo: 'div0rce/cherry', ++++ scopeKey: 'scenario-a', ++++ runId: 'run-2', ++++ sourceWorkflow: 'test', ++++ snapshot: { ++++ score: 70, ++++ allocation: { cardA: 1_000 }, ++++ strategy: 'aggressive', ++++ runwayDays: 5, ++++ viableCandidateCount: 0, ++++ }, ++++ }); ++++ assert.equal(duplicate.created, false); ++++ assert.deepEqual(duplicate.comparisonOutput, second.comparisonOutput); ++++ ++++ await assert.rejects( ++++ compareAndStoreSimulationSnapshot({ ++++ repo: 'div0rce/cherry', ++++ scopeKey: 'scenario-a', ++++ runId: 'run-2', ++++ sourceWorkflow: 'test', ++++ snapshot: { ++++ score: 100, ++++ allocation: { cardA: 2_000 }, ++++ strategy: 'changed', ++++ runwayDays: 50, ++++ viableCandidateCount: 3, ++++ }, ++++ }), ++++ /simulation_snapshot_idempotency_conflict/ ++++ ); ++++} ++++ ++++async function run(): Promise { ++++ await runReplayHashTest(); ++++ await runAutomationEventIdempotencyConflictTest(); ++++ await runStatusIdempotencyTest(); ++++ await runStatusRejectsForbiddenTargetUrl(); ++++ await runStatusRetryRejectsForbiddenTargetUrl(); ++++ await runSimulationSnapshotTest(); ++++ await prisma.automationStatusCheck.deleteMany({ where: {} }); ++++ await prisma.simulationAutomationSnapshot.deleteMany({ where: {} }); ++++ await prisma.automationEvent.deleteMany({ where: {} }); ++++ console.warn('automation services: ok'); ++++} ++++ ++++run().catch((error: unknown) => { ++++ console.error(error); ++++ process.exit(1); ++++}); +++diff --git a/tests/node/automation-workflows.test.ts b/tests/node/automation-workflows.test.ts +++new file mode 100644 +++index 0000000000000000000000000000000000000000..20cf7232d485cc4da0c1f81fbfc04ca7db07719e +++--- /dev/null ++++++ b/tests/node/automation-workflows.test.ts +++@@ -0,0 +1,199 @@ ++++import * as assert from 'node:assert/strict'; ++++import * as fs from 'node:fs'; ++++import * as path from 'node:path'; ++++import * as vm from 'node:vm'; ++++import { z } from 'zod'; ++++ ++++const repoRoot = process.cwd(); ++++const workflowDir = path.join(repoRoot, 'cherry-n8n-workflows'); ++++const prWorkflowFiles = [ ++++ '03_pr_risk_classifier.json', ++++ '04_forbidden_change_detector.json', ++++ '09_docs_drift_detector.json', ++++] as const; ++++ ++++const forbiddenAuthorityNodeNames = new Set([ ++++ 'Score Risk', ++++ 'Detect Forbidden Changes', ++++ 'Detect Docs Drift', ++++]); ++++ ++++const forbiddenAuthorityPayloadPatterns = [ ++++ /\briskScore\b/, ++++ /\$json\.riskScore\b/, ++++ /\$json\.forbidden\b/, ++++ /\$json\.drift\b/, ++++ /String\(\$json\.riskScore/, ++++]; ++++ ++++const WorkflowSchema = z ++++ .object({ ++++ nodes: z ++++ .array( ++++ z ++++ .object({ ++++ name: z.unknown().optional(), ++++ parameters: z.unknown().optional(), ++++ }) ++++ .passthrough() ++++ ) ++++ .optional(), ++++ connections: z.record(z.string(), z.unknown()).optional(), ++++ }) ++++ .passthrough(); ++++ ++++type WorkflowNode = { ++++ name?: unknown; ++++ parameters?: unknown; ++++}; ++++ ++++function nodeByName(nodes: WorkflowNode[], name: string): WorkflowNode { ++++ const node = nodes.find((candidate) => candidate.name === name); ++++ assert.notEqual(node, undefined, `workflow must contain ${name}`); ++++ return node as WorkflowNode; ++++} ++++ ++++function connectionTargets(workflow: z.infer, source: string): string[] { ++++ const connections = workflow.connections; ++++ if (connections === undefined) return []; ++++ const sourceConnections = connections[source]; ++++ if (sourceConnections === null || typeof sourceConnections !== 'object') return []; ++++ const main = (sourceConnections as Record)['main']; ++++ if (!Array.isArray(main)) return []; ++++ return main.flatMap((group) => { ++++ if (!Array.isArray(group)) return []; ++++ return group ++++ .map((connection) => { ++++ if (connection === null || typeof connection !== 'object') return null; ++++ const node = (connection as Record)['node']; ++++ return typeof node === 'string' ? node : null; ++++ }) ++++ .filter((node): node is string => node !== null); ++++ }); ++++} ++++ ++++function normalizeChangedFiles(jsCode: string, items: Array<{ json: unknown }>): unknown[] { ++++ const output = vm.runInNewContext(`(() => {\n${jsCode}\n})()`, { ++++ $input: { all: () => items }, ++++ $items: (name: string) => ++++ name === 'Normalize PR' ? [{ json: { repo: 'div0rce/cherry' } }] : [], ++++ }) as Array<{ json: { files?: unknown[] } }>; ++++ return output[0]?.json.files ?? []; ++++} ++++ ++++function assertPreservesReturnedFiles( ++++ label: string, ++++ jsCode: string, ++++ items: Array<{ json: unknown }> ++++): void { ++++ const files = normalizeChangedFiles(jsCode, items); ++++ assert.ok( ++++ files.length > 0, ++++ `${label}: GitHub response contained files but normalized output was empty` ++++ ); ++++} ++++ ++++for (const fileName of prWorkflowFiles) { ++++ const absolutePath = path.join(workflowDir, fileName); ++++ const raw = fs.readFileSync(absolutePath, 'utf8'); ++++ const workflow = WorkflowSchema.parse(await new Response(raw).json()); ++++ const nodes = Array.isArray(workflow.nodes) ? workflow.nodes : []; ++++ const nodeNames = nodes.map((node) => String(node.name ?? '')); ++++ ++++ for (const forbiddenName of forbiddenAuthorityNodeNames) { ++++ assert.equal( ++++ nodeNames.includes(forbiddenName), ++++ false, ++++ `${fileName} must not contain local authority node ${forbiddenName}` ++++ ); ++++ } ++++ ++++ assert.equal( ++++ nodeNames.includes('Classify PR In Cherry'), ++++ true, ++++ `${fileName} must call Cherry PR classifier` ++++ ); ++++ assert.equal( ++++ nodeNames.includes('Normalize Changed Files'), ++++ true, ++++ `${fileName} must normalize changed files before Cherry classification` ++++ ); ++++ assert.deepEqual( ++++ connectionTargets(workflow, 'Fetch Changed Files'), ++++ ['Normalize Changed Files'], ++++ `${fileName} must route Fetch Changed Files to Normalize Changed Files` ++++ ); ++++ assert.deepEqual( ++++ connectionTargets(workflow, 'Normalize Changed Files'), ++++ ['Classify PR In Cherry'], ++++ `${fileName} must route Normalize Changed Files to Classify PR In Cherry` ++++ ); ++++ ++++ for (const pattern of forbiddenAuthorityPayloadPatterns) { ++++ assert.equal( ++++ pattern.test(raw), ++++ false, ++++ `${fileName} must not synthesize local scoring/detection authority with ${pattern}` ++++ ); ++++ } ++++ ++++ assert.equal( ++++ /Array\.isArray\(\$json\)\s*\?\s*\$json\s*:\s*\[\]/.test(raw), ++++ false, ++++ `${fileName} must not drop changed files with Array.isArray($json) fallback` ++++ ); ++++ ++++ const classifyNode = nodeByName(nodes, 'Classify PR In Cherry'); ++++ const classifyParameters = classifyNode.parameters; ++++ assert.notEqual(classifyParameters, null); ++++ assert.equal(typeof classifyParameters, 'object'); ++++ const classifyBody = String( ++++ (classifyParameters as Record)['jsonBody'] ?? '' ++++ ); ++++ assert.match( ++++ classifyBody, ++++ /files:\s*\$json\.files/, ++++ `${fileName} classifier request must pass files: $json.files` ++++ ); ++++ ++++ const normalizeNode = nodeByName(nodes, 'Normalize Changed Files'); ++++ const normalizeParameters = normalizeNode.parameters; ++++ assert.notEqual(normalizeParameters, null); ++++ assert.equal(typeof normalizeParameters, 'object'); ++++ const normalizeCode = String( ++++ (normalizeParameters as Record)['jsCode'] ?? '' ++++ ); ++++ assertPreservesReturnedFiles(`${fileName} array payload`, normalizeCode, [ ++++ { json: [{ filename: 'lib/engine/solver.ts' }] }, ++++ ]); ++++ assertPreservesReturnedFiles(`${fileName} json.files`, normalizeCode, [ ++++ { json: { files: [{ filename: 'app/api/scan/route.ts' }] } }, ++++ ]); ++++ assertPreservesReturnedFiles(`${fileName} json.data`, normalizeCode, [ ++++ { json: { data: [{ filename: 'prisma/schema.prisma' }] } }, ++++ ]); ++++ assertPreservesReturnedFiles(`${fileName} per-item object`, normalizeCode, [ ++++ { json: { filename: 'docs/engine/update.md' } }, ++++ ]); ++++ ++++ const statusNodes = nodes.filter((node) => String(node.name ?? '').startsWith('Post ')); ++++ const statusBodies = statusNodes ++++ .map((node) => { ++++ const parameters = node.parameters; ++++ if (parameters === null || typeof parameters !== 'object') return ''; ++++ return String((parameters as Record)['jsonBody'] ?? ''); ++++ }) ++++ .join('\n'); ++++ assert.match( ++++ statusBodies, ++++ /\$json\.statusRequest/, ++++ `${fileName} status bodies must use Cherry statusRequests` ++++ ); ++++ assert.match( ++++ statusBodies, ++++ /\$json\.outputHash/, ++++ `${fileName} status bodies must use Cherry top-level outputHash` ++++ ); ++++} ++++ ++++console.warn('automation workflows: ok'); ++diff --git a/lib/adapters/runtime/automation-events.prisma.ts b/lib/adapters/runtime/automation-events.prisma.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..4d28295daec388e0690248825365e58e890d06bb ++--- /dev/null +++++ b/lib/adapters/runtime/automation-events.prisma.ts ++@@ -0,0 +1,112 @@ +++import type { AutomationEvent, Prisma, SimulationAutomationSnapshot } from '@prisma/client'; +++import { prisma } from '../../prisma.js'; +++ +++export type CreateAutomationEventRecordInput = { +++ repo: string; +++ sha?: string | undefined; +++ event: string; +++ source: string; +++ workflow: string; +++ status: string; +++ idempotencyKey: string; +++ classifierVersion: string; +++ outputHash: string; +++ rawPayload: unknown; +++ normalizedEvent: unknown; +++ classifierOutput: unknown; +++ prNumber?: number | undefined; +++ issueNumber?: number | undefined; +++}; +++ +++export type CreateSimulationAutomationSnapshotRecordInput = { +++ repo: string; +++ scopeKey: string; +++ runId: string; +++ classifierVersion: string; +++ snapshot: unknown; +++ comparisonOutput: unknown; +++ outputHash: string; +++ previousSnapshotId?: string | undefined; +++}; +++ +++function asJson(value: unknown): Prisma.InputJsonValue { +++ return value as Prisma.InputJsonValue; +++} +++ +++export async function findAutomationEventByIdempotencyKey( +++ idempotencyKey: string +++): Promise { +++ return prisma.automationEvent.findUnique({ where: { idempotencyKey } }); +++} +++ +++export async function findAutomationEventById(id: string): Promise { +++ return prisma.automationEvent.findUnique({ where: { id } }); +++} +++ +++export async function createAutomationEventRecord( +++ input: CreateAutomationEventRecordInput +++): Promise { +++ const data: Prisma.AutomationEventUncheckedCreateInput = { +++ repo: input.repo, +++ event: input.event, +++ source: input.source, +++ workflow: input.workflow, +++ status: input.status, +++ idempotencyKey: input.idempotencyKey, +++ classifierVersion: input.classifierVersion, +++ outputHash: input.outputHash, +++ rawPayload: asJson(input.rawPayload), +++ normalizedEvent: asJson(input.normalizedEvent), +++ classifierOutput: asJson(input.classifierOutput), +++ }; +++ if (input.sha !== undefined) data.sha = input.sha; +++ if (input.prNumber !== undefined) data.prNumber = input.prNumber; +++ if (input.issueNumber !== undefined) data.issueNumber = input.issueNumber; +++ +++ return prisma.automationEvent.create({ data }); +++} +++ +++export async function findLatestSimulationSnapshot( +++ scopeKey: string, +++ classifierVersion: string +++): Promise { +++ return prisma.simulationAutomationSnapshot.findFirst({ +++ where: { scopeKey, classifierVersion }, +++ orderBy: { createdAt: 'desc' }, +++ }); +++} +++ +++export async function findSimulationSnapshotByRun(input: { +++ scopeKey: string; +++ runId: string; +++ classifierVersion: string; +++}): Promise { +++ return prisma.simulationAutomationSnapshot.findUnique({ +++ where: { +++ scopeKey_runId_classifierVersion: { +++ scopeKey: input.scopeKey, +++ runId: input.runId, +++ classifierVersion: input.classifierVersion, +++ }, +++ }, +++ }); +++} +++ +++export async function createSimulationAutomationSnapshotRecord( +++ input: CreateSimulationAutomationSnapshotRecordInput +++): Promise { +++ const data: Prisma.SimulationAutomationSnapshotUncheckedCreateInput = { +++ repo: input.repo, +++ scopeKey: input.scopeKey, +++ runId: input.runId, +++ classifierVersion: input.classifierVersion, +++ snapshot: asJson(input.snapshot), +++ comparisonOutput: asJson(input.comparisonOutput), +++ outputHash: input.outputHash, +++ }; +++ if (input.previousSnapshotId !== undefined) { +++ data.previousSnapshotId = input.previousSnapshotId; +++ } +++ +++ return prisma.simulationAutomationSnapshot.create({ data }); +++} ++diff --git a/lib/adapters/runtime/automation-github-status.prisma.ts b/lib/adapters/runtime/automation-github-status.prisma.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..6b85870217b1e69f3a5194e83348af6fc6e53712 ++--- /dev/null +++++ b/lib/adapters/runtime/automation-github-status.prisma.ts ++@@ -0,0 +1,118 @@ +++import type { AutomationStatusCheck, Prisma } from '@prisma/client'; +++import { prisma } from '../../prisma.js'; +++ +++export type CreateGithubStatusCheckRecordInput = { +++ repo: string; +++ sha: string; +++ context: string; +++ state: string; +++ description: string; +++ targetUrl?: string | undefined; +++ sourceWorkflow: string; +++ automationEventId?: string | undefined; +++ classifierVersion: string; +++ outputHash: string; +++ statusIdempotencyKey: string; +++ githubResponse?: unknown; +++}; +++ +++export type GithubCommitStatusPostInput = { +++ apiBaseUrl: string; +++ githubToken: string; +++ repo: string; +++ sha: string; +++ state: string; +++ description: string; +++ context: string; +++ targetUrl?: string | undefined; +++}; +++ +++export type GithubCommitStatusPostResult = { +++ ok: boolean; +++ status: number; +++ body: string; +++}; +++ +++function asJson(value: unknown): Prisma.InputJsonValue { +++ return value as Prisma.InputJsonValue; +++} +++ +++export async function findStatusCheckByIdempotencyKey( +++ statusIdempotencyKey: string +++): Promise { +++ return prisma.automationStatusCheck.findUnique({ where: { statusIdempotencyKey } }); +++} +++ +++export async function findStatusCheckById( +++ id: string +++): Promise { +++ return prisma.automationStatusCheck.findUnique({ where: { id } }); +++} +++ +++export async function createGithubStatusCheckRecord( +++ input: CreateGithubStatusCheckRecordInput +++): Promise { +++ const data: Prisma.AutomationStatusCheckUncheckedCreateInput = { +++ repo: input.repo, +++ sha: input.sha, +++ context: input.context, +++ state: input.state, +++ description: input.description, +++ sourceWorkflow: input.sourceWorkflow, +++ classifierVersion: input.classifierVersion, +++ outputHash: input.outputHash, +++ statusIdempotencyKey: input.statusIdempotencyKey, +++ githubResponse: asJson(input.githubResponse ?? { status: 'created_not_posted' }), +++ }; +++ if (input.targetUrl !== undefined) data.targetUrl = input.targetUrl; +++ if (input.automationEventId !== undefined) data.automationEventId = input.automationEventId; +++ +++ return prisma.automationStatusCheck.create({ data }); +++} +++ +++export async function updateGithubStatusCheckResponse( +++ id: string, +++ githubResponse: unknown +++): Promise { +++ return prisma.automationStatusCheck.update({ +++ where: { id }, +++ data: { githubResponse: asJson(githubResponse) }, +++ }); +++} +++ +++export async function postGithubCommitStatus( +++ input: GithubCommitStatusPostInput +++): Promise { +++ const response = await fetch(`${input.apiBaseUrl}/repos/${input.repo}/statuses/${input.sha}`, { +++ method: 'POST', +++ headers: { +++ Authorization: `Bearer ${input.githubToken}`, +++ Accept: 'application/vnd.github+json', +++ 'Content-Type': 'application/json', +++ 'X-GitHub-Api-Version': '2022-11-28', +++ }, +++ body: JSON.stringify({ +++ state: input.state, +++ description: input.description, +++ context: input.context, +++ target_url: input.targetUrl, +++ }), +++ }); +++ const body = await response.text(); +++ return { +++ ok: response.ok, +++ status: response.status, +++ body: body.slice(0, 10_000), +++ }; +++} +++ +++export async function listGithubStatusChecks(where: { +++ repo?: string; +++ sha?: string; +++ context?: string; +++}): Promise { +++ return prisma.automationStatusCheck.findMany({ +++ where, +++ orderBy: [{ createdAt: 'desc' }, { id: 'asc' }], +++ }); +++} ++diff --git a/lib/automation/classifiers/docs-drift.ts b/lib/automation/classifiers/docs-drift.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..88249ae75094c71cfbc0ee7972c986be7839963e ++--- /dev/null +++++ b/lib/automation/classifiers/docs-drift.ts ++@@ -0,0 +1,79 @@ +++import type { AutomationStatusRequest, PrClassifierInput } from './types.js'; +++import { DOCS_DRIFT_CLASSIFIER_VERSION } from './types.js'; +++ +++export type DocsDriftClassification = { +++ classifierVersion: typeof DOCS_DRIFT_CLASSIFIER_VERSION; +++ drift: boolean; +++ domains: string[]; +++ labels: string[]; +++ statusRequest: AutomationStatusRequest; +++}; +++ +++export function classifyDocsDrift( +++ input: Pick +++): DocsDriftClassification { +++ const names = input.files.map((file) => file.filename); +++ const domains: string[] = []; +++ +++ if (names.some((name) => name.startsWith('lib/engine') || name === 'lib/engine.ts' || name.startsWith('lib/authority'))) { +++ domains.push('engine'); +++ } +++ if (names.some((name) => name.startsWith('app/api/'))) { +++ domains.push('api'); +++ } +++ if (names.some((name) => name === 'prisma/schema.prisma' || name.startsWith('prisma/migrations/'))) { +++ domains.push('schema'); +++ } +++ if (names.some((name) => name.includes('env') || name === '.env.example')) { +++ domains.push('env'); +++ } +++ if (names.some((name) => /(^|\/)(tests?|__tests__)(\/|$)|\.(test|spec)\./.test(name))) { +++ domains.push('tests'); +++ } +++ if ( +++ names.some( +++ (name) => +++ name.startsWith('lib/automation/') || +++ name.startsWith('app/api/automation/') || +++ name.startsWith('cherry-n8n-workflows/') +++ ) +++ ) { +++ domains.push('automation'); +++ } +++ +++ const uniqueDomains = [...new Set(domains)]; +++ const docsByDomain: Record boolean> = { +++ engine: (name) => +++ name.startsWith('docs/architecture/') || +++ name.startsWith('docs/engine/') || +++ name === 'README.md', +++ api: (name) => name.startsWith('docs/api/') || name === 'README.md', +++ schema: (name) => +++ name.startsWith('docs/schema/') || +++ name === 'prisma/README.md' || +++ name.startsWith('docs/database/'), +++ env: (name) => +++ name === '.env.example' || name.startsWith('docs/env/') || name === 'README.md', +++ tests: (name) => name.startsWith('docs/testing/') || name === 'README.md', +++ automation: (name) => name.startsWith('docs/automation/') || name === 'README.md', +++ }; +++ const missingDocs = uniqueDomains.filter((domain) => { +++ const matches = docsByDomain[domain]; +++ return matches === undefined || names.some(matches) === false; +++ }); +++ const drift = missingDocs.length > 0; +++ +++ return { +++ classifierVersion: DOCS_DRIFT_CLASSIFIER_VERSION, +++ drift, +++ domains: uniqueDomains, +++ labels: drift ? ['docs-drift', 'needs-human-review'] : [], +++ statusRequest: { +++ context: 'cherry/docs-drift', +++ state: drift ? 'failure' : 'success', +++ description: drift +++ ? `Docs update required for ${domains.join(', ')} changes.` +++ : 'No docs drift detected.', +++ }, +++ }; +++} ++diff --git a/lib/automation/classifiers/forbidden-change.ts b/lib/automation/classifiers/forbidden-change.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..f02045b5efd2666ba824aa52c8b14bf34d77c39f ++--- /dev/null +++++ b/lib/automation/classifiers/forbidden-change.ts ++@@ -0,0 +1,65 @@ +++import type { AutomationStatusRequest, PrClassifierInput } from './types.js'; +++import { FORBIDDEN_CHANGE_CLASSIFIER_VERSION } from './types.js'; +++ +++export type ForbiddenChangeClassification = { +++ classifierVersion: typeof FORBIDDEN_CHANGE_CLASSIFIER_VERSION; +++ forbidden: boolean; +++ violations: string[]; +++ labels: string[]; +++ statusRequest: AutomationStatusRequest; +++}; +++ +++export function classifyForbiddenChange( +++ input: Pick +++): ForbiddenChangeClassification { +++ const violations: string[] = []; +++ +++ for (const file of input.files) { +++ const name = file.filename; +++ const patch = file.patch ?? ''; +++ if ( +++ name === '.env' || +++ name === '.env.local' || +++ name.endsWith('/.env') || +++ name.endsWith('/.env.local') +++ ) { +++ violations.push(`env_diff:${name}`); +++ } +++ if (/secret|credentials|production.*db/i.test(name)) { +++ violations.push(`sensitive_path:${name}`); +++ } +++ if (file.status === 'removed' && /(^|\/)(tests?|__tests__)(\/|$)|\.(test|spec)\./.test(name)) { +++ violations.push(`deleted_test:${name}`); +++ } +++ if (/^[+].*\b(test|describe|it)\.skip\b/m.test(patch)) { +++ violations.push(`skipped_test_added:${name}`); +++ } +++ if (/^[+].*console\.log\(/m.test(patch)) { +++ violations.push(`console_log_added:${name}`); +++ } +++ if (/^[+].*TODO(?!.*#\d+)/im.test(patch)) { +++ violations.push(`todo_without_issue:${name}`); +++ } +++ if (/^[+].*from ['"]@prisma\/client['"]/m.test(patch) && /lib\/engine|lib\/authority/.test(name)) { +++ violations.push(`forbidden_prisma_import:${name}`); +++ } +++ if (/^[+].*(DATABASE_URL|PRODUCTION_DATABASE_URL|direct prod mutation)/im.test(patch)) { +++ violations.push(`production_truth_mutation_hint:${name}`); +++ } +++ } +++ +++ const forbidden = violations.length > 0; +++ return { +++ classifierVersion: FORBIDDEN_CHANGE_CLASSIFIER_VERSION, +++ forbidden, +++ violations, +++ labels: forbidden ? ['blocked-forbidden-change', 'needs-human-review'] : [], +++ statusRequest: { +++ context: 'cherry/forbidden-change', +++ state: forbidden ? 'failure' : 'success', +++ description: forbidden +++ ? `${violations.length} forbidden change pattern(s) detected.` +++ : 'No forbidden change patterns detected.', +++ }, +++ }; +++} ++diff --git a/lib/automation/classifiers/pr-risk.ts b/lib/automation/classifiers/pr-risk.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..1aa604f763bf404aeec209963246b3405361e571 ++--- /dev/null +++++ b/lib/automation/classifiers/pr-risk.ts ++@@ -0,0 +1,105 @@ +++import type { AutomationStatusRequest, PrClassifierInput } from './types.js'; +++import { PR_RISK_CLASSIFIER_VERSION } from './types.js'; +++ +++export type PrRiskClassification = { +++ classifierVersion: typeof PR_RISK_CLASSIFIER_VERSION; +++ score: number; +++ level: 'low' | 'medium' | 'high'; +++ labels: string[]; +++ reasons: string[]; +++ accepted: boolean; +++ statusRequest: AutomationStatusRequest; +++}; +++ +++function hasLinkedIssue(input: PrClassifierInput): boolean { +++ const text = `${input.title} ${input.body}`; +++ return /(close[sd]?|fix(e[sd])?|resolve[sd]?)\s+#\d+|#\d+/i.test(text); +++} +++ +++export function classifyPrRisk(input: PrClassifierInput): PrRiskClassification { +++ const names = input.files.map((file) => file.filename); +++ const additions = input.files.reduce((sum, file) => sum + (file.additions ?? 0), 0); +++ const deletions = input.files.reduce((sum, file) => sum + (file.deletions ?? 0), 0); +++ const changedLines = additions + deletions; +++ +++ const hasEngine = names.some( +++ (name) => +++ name.startsWith('lib/engine') || +++ name === 'lib/engine.ts' || +++ name.includes('/engine/') +++ ); +++ const hasPrisma = names.some( +++ (name) => name === 'prisma/schema.prisma' || name.startsWith('prisma/migrations/') +++ ); +++ const hasApi = names.some((name) => name.startsWith('app/api/')); +++ const testDeleted = input.files.some( +++ (file) => +++ file.status === 'removed' && +++ /(^|\/)(tests?|__tests__)(\/|$)|\.(test|spec)\./.test(file.filename) +++ ); +++ const docsOnly = +++ names.length > 0 && +++ names.every( +++ (name) => name.startsWith('docs/') || name === 'README.md' || name.endsWith('.md') +++ ); +++ const largeDiff = changedLines > 800 || names.length > 25; +++ const noLinkedIssue = hasLinkedIssue(input) === false; +++ +++ let score = 0; +++ const reasons: string[] = []; +++ if (hasEngine) { +++ score += 5; +++ reasons.push('engine files changed +5'); +++ } +++ if (hasPrisma) { +++ score += 4; +++ reasons.push('Prisma schema or migrations changed +4'); +++ } +++ if (hasApi) { +++ score += 3; +++ reasons.push('API route changed +3'); +++ } +++ if (testDeleted) { +++ score += 5; +++ reasons.push('test deleted +5'); +++ } +++ if (docsOnly) { +++ score -= 3; +++ reasons.push('docs only -3'); +++ } +++ if (largeDiff) { +++ score += 2; +++ reasons.push('large diff +2'); +++ } +++ if (noLinkedIssue) { +++ score += 2; +++ reasons.push('no linked issue +2'); +++ } +++ +++ const level = score >= 8 ? 'high' : score >= 4 ? 'medium' : 'low'; +++ const accepted = input.labels.includes('risk-accepted'); +++ const labels = level === 'high' ? ['risk-high'] : level === 'medium' ? ['risk-medium'] : ['risk-low']; +++ if (level === 'high') labels.push('needs-human-review'); +++ if (hasEngine) labels.push('engine-change'); +++ if (docsOnly) labels.push('docs-only'); +++ +++ const state = level === 'high' && accepted === false ? 'failure' : 'success'; +++ const description = +++ state === 'failure' +++ ? `High-risk PR score ${score}; add risk-accepted only after review.` +++ : `PR risk ${level} with score ${score}.`; +++ +++ return { +++ classifierVersion: PR_RISK_CLASSIFIER_VERSION, +++ score, +++ level, +++ labels, +++ reasons, +++ accepted, +++ statusRequest: { +++ context: 'cherry/risk-gate', +++ state, +++ description, +++ }, +++ }; +++} ++diff --git a/lib/automation/classifiers/pr.ts b/lib/automation/classifiers/pr.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..fb7a820c82ea488ad2efdff03bfb47c6f18b7f36 ++--- /dev/null +++++ b/lib/automation/classifiers/pr.ts ++@@ -0,0 +1,30 @@ +++import { classifyDocsDrift } from './docs-drift.js'; +++import { classifyForbiddenChange } from './forbidden-change.js'; +++import { classifyPrRisk } from './pr-risk.js'; +++import type { AutomationStatusRequest, PrClassifierInput } from './types.js'; +++import { PR_AUTOMATION_CLASSIFIER_VERSION } from './types.js'; +++ +++export type PrAutomationClassification = { +++ classifierVersion: typeof PR_AUTOMATION_CLASSIFIER_VERSION; +++ risk: ReturnType; +++ forbiddenChange: ReturnType; +++ docsDrift: ReturnType; +++ statusRequests: AutomationStatusRequest[]; +++}; +++ +++export function classifyPrAutomation(input: PrClassifierInput): PrAutomationClassification { +++ const risk = classifyPrRisk(input); +++ const forbiddenChange = classifyForbiddenChange(input); +++ const docsDrift = classifyDocsDrift(input); +++ return { +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ risk, +++ forbiddenChange, +++ docsDrift, +++ statusRequests: [ +++ forbiddenChange.statusRequest, +++ docsDrift.statusRequest, +++ risk.statusRequest, +++ ], +++ }; +++} ++diff --git a/lib/automation/classifiers/simulation-drift.ts b/lib/automation/classifiers/simulation-drift.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..9cd31600cddf740dc325221b42ef99a33214e820 ++--- /dev/null +++++ b/lib/automation/classifiers/simulation-drift.ts ++@@ -0,0 +1,90 @@ +++import { SIMULATION_DRIFT_CLASSIFIER_VERSION } from './types.js'; +++ +++export type SimulationSnapshot = { +++ score?: number; +++ allocation?: Record; +++ strategy?: string | null; +++ paydownStrategy?: string | null; +++ runwayDays?: number; +++ runway?: number; +++ viableCandidates?: unknown[]; +++ viableCandidateCount?: number; +++}; +++ +++export type SimulationDriftClassification = { +++ classifierVersion: typeof SIMULATION_DRIFT_CLASSIFIER_VERSION; +++ drift: boolean; +++ reasons: string[]; +++ scoreDelta: number; +++ allocationDelta: number; +++ strategyFlip: boolean; +++ runwayCollapse: boolean; +++ emptyViableCandidates: boolean; +++}; +++ +++function numeric(value: unknown): number { +++ return typeof value === 'number' && Number.isFinite(value) ? value : 0; +++} +++ +++function normalizeSnapshot(snapshot: SimulationSnapshot) { +++ const strategy = snapshot.strategy ?? snapshot.paydownStrategy ?? null; +++ const runwayDays = numeric(snapshot.runwayDays ?? snapshot.runway); +++ const allocation = snapshot.allocation ?? {}; +++ const viableCandidates = Array.isArray(snapshot.viableCandidates) +++ ? snapshot.viableCandidates.length +++ : numeric(snapshot.viableCandidateCount); +++ +++ return { +++ score: numeric(snapshot.score), +++ allocation, +++ strategy, +++ runwayDays, +++ viableCandidates, +++ }; +++} +++ +++export function classifySimulationDrift( +++ previousSnapshot: SimulationSnapshot | null, +++ currentSnapshot: SimulationSnapshot +++): SimulationDriftClassification { +++ const current = normalizeSnapshot(currentSnapshot); +++ const previous = previousSnapshot === null ? null : normalizeSnapshot(previousSnapshot); +++ const reasons: string[] = []; +++ +++ const scoreDelta = +++ previous === null ? 0 : Math.abs(current.score - previous.score); +++ let allocationDelta = 0; +++ if (previous !== null) { +++ const allocationKeys = new Set([ +++ ...Object.keys(previous.allocation).sort((a, b) => a.localeCompare(b)), +++ ...Object.keys(current.allocation).sort((a, b) => a.localeCompare(b)), +++ ]); +++ for (const key of allocationKeys) { +++ allocationDelta += Math.abs( +++ numeric(current.allocation[key]) - numeric(previous.allocation[key]) +++ ); +++ } +++ } +++ const strategyFlip = previous !== null && current.strategy !== previous.strategy; +++ const runwayCollapse = +++ previous !== null && +++ current.runwayDays < Math.max(7, previous.runwayDays * 0.5); +++ const emptyViableCandidates = current.viableCandidates === 0; +++ +++ if (scoreDelta >= 10) reasons.push(`score_delta:${scoreDelta}`); +++ if (allocationDelta >= 5_000) reasons.push(`allocation_delta:${allocationDelta}`); +++ if (strategyFlip) reasons.push('strategy_flip'); +++ if (runwayCollapse) reasons.push('runway_collapse'); +++ if (emptyViableCandidates) reasons.push('empty_viable_candidates'); +++ +++ return { +++ classifierVersion: SIMULATION_DRIFT_CLASSIFIER_VERSION, +++ drift: reasons.length > 0, +++ reasons, +++ scoreDelta, +++ allocationDelta, +++ strategyFlip, +++ runwayCollapse, +++ emptyViableCandidates, +++ }; +++} ++diff --git a/lib/automation/classifiers/types.ts b/lib/automation/classifiers/types.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..7c18830087059bd84cb0787aa133a40553b738f7 ++--- /dev/null +++++ b/lib/automation/classifiers/types.ts ++@@ -0,0 +1,36 @@ +++export const PR_RISK_CLASSIFIER_VERSION = 'pr-risk@1' as const; +++export const FORBIDDEN_CHANGE_CLASSIFIER_VERSION = 'forbidden-change@1' as const; +++export const DOCS_DRIFT_CLASSIFIER_VERSION = 'docs-drift@1' as const; +++export const SIMULATION_DRIFT_CLASSIFIER_VERSION = 'simulation-drift@1' as const; +++export const PR_AUTOMATION_CLASSIFIER_VERSION = +++ 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)' as const; +++ +++export type AutomationFileChange = { +++ filename: string; +++ status?: string | undefined; +++ additions?: number | undefined; +++ deletions?: number | undefined; +++ changes?: number | undefined; +++ patch?: string | undefined; +++}; +++ +++export type AutomationStatusRequest = { +++ context: +++ | 'cherry/forbidden-change' +++ | 'cherry/docs-drift' +++ | 'cherry/risk-gate' +++ | 'cherry/openclaw-policy'; +++ state: 'error' | 'failure' | 'pending' | 'success'; +++ description: string; +++ targetUrl?: string; +++}; +++ +++export type PrClassifierInput = { +++ repo: string; +++ sha: string; +++ prNumber: number; +++ title: string; +++ body: string; +++ labels: string[]; +++ files: AutomationFileChange[]; +++}; ++diff --git a/lib/automation/events.ts b/lib/automation/events.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..73cd795ceab9d26818bda580ad301d10d1eba98f ++--- /dev/null +++++ b/lib/automation/events.ts ++@@ -0,0 +1,346 @@ +++import type { AutomationEvent, SimulationAutomationSnapshot } from '@prisma/client'; +++import { +++ createAutomationEventRecord, +++ createSimulationAutomationSnapshotRecord, +++ findAutomationEventById, +++ findAutomationEventByIdempotencyKey, +++ findLatestSimulationSnapshot, +++ findSimulationSnapshotByRun, +++} from '../adapters/runtime/automation-events.prisma.js'; +++import { buildAutomationIdempotencyKey, hashAutomationOutput } from './hash.js'; +++import { classifyPrAutomation } from './classifiers/pr.js'; +++import { classifySimulationDrift } from './classifiers/simulation-drift.js'; +++import { +++ PR_AUTOMATION_CLASSIFIER_VERSION, +++ SIMULATION_DRIFT_CLASSIFIER_VERSION, +++} from './classifiers/types.js'; +++import type { AutomationFileChange } from './classifiers/types.js'; +++import type { PrClassifierInput } from './classifiers/types.js'; +++import type { PrAutomationClassification } from './classifiers/pr.js'; +++import type { SimulationDriftClassification } from './classifiers/simulation-drift.js'; +++ +++export type StoreAutomationEventInput = { +++ repo: string; +++ sha?: string | undefined; +++ event: string; +++ source: string; +++ workflow: string; +++ status: string; +++ idempotencyKey: string; +++ classifierVersion: string; +++ rawPayload: unknown; +++ normalizedEvent: unknown; +++ classifierOutput: unknown; +++ prNumber?: number | undefined; +++ issueNumber?: number | undefined; +++}; +++ +++export type PrAutomationInput = { +++ repo: string; +++ sha: string; +++ prNumber: number; +++ title: string; +++ body?: string | null | undefined; +++ labels: string[]; +++ files: AutomationFileChange[]; +++ sourceWorkflow: string; +++ eventId?: string | undefined; +++}; +++ +++export type SimulationCompareInput = { +++ repo: string; +++ scopeKey: string; +++ runId: string; +++ snapshot: unknown; +++ sourceWorkflow: string; +++}; +++ +++export type StoredAutomationEventResult = { +++ event: AutomationEvent; +++ created: boolean; +++}; +++ +++export type PrAutomationStoreResult = StoredAutomationEventResult & { +++ classifierOutput: PrAutomationClassification; +++}; +++ +++export type ReplayAutomationEventResult = +++ | { +++ event: AutomationEvent; +++ replayedOutput: unknown; +++ outputHash: string | null; +++ matches: boolean; +++ reason: +++ | 'matched' +++ | 'output_hash_mismatch' +++ | 'classifier_version_mismatch' +++ | 'unsupported_replay_event' +++ | 'invalid_replay_input'; +++ } +++ | null; +++ +++export type SimulationSnapshotStoreResult = { +++ snapshot: SimulationAutomationSnapshot; +++ comparisonOutput: SimulationDriftClassification; +++ created: boolean; +++}; +++ +++export function outputHashFor(value: unknown): string { +++ return hashAutomationOutput(value); +++} +++ +++export class AutomationEventIdempotencyConflictError extends Error { +++ constructor(readonly idempotencyKey: string) { +++ super('automation_event_idempotency_conflict'); +++ this.name = 'AutomationEventIdempotencyConflictError'; +++ } +++} +++ +++export class SimulationSnapshotIdempotencyConflictError extends Error { +++ constructor(readonly scopeKey: string, readonly runId: string) { +++ super('simulation_snapshot_idempotency_conflict'); +++ this.name = 'SimulationSnapshotIdempotencyConflictError'; +++ } +++} +++ +++function asRecord(value: unknown): Record | null { +++ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null; +++ return value as Record; +++} +++ +++function asStringArray(value: unknown): string[] | null { +++ if (!Array.isArray(value)) return null; +++ const out: string[] = []; +++ for (const entry of value) { +++ if (typeof entry !== 'string') return null; +++ out.push(entry); +++ } +++ return out; +++} +++ +++function asAutomationFiles(value: unknown): AutomationFileChange[] | null { +++ if (!Array.isArray(value)) return null; +++ const out: AutomationFileChange[] = []; +++ for (const entry of value) { +++ const record = asRecord(entry); +++ if (record === null || typeof record['filename'] !== 'string') return null; +++ const file: AutomationFileChange = { filename: record['filename'] }; +++ if (typeof record['status'] === 'string') file.status = record['status']; +++ if (typeof record['additions'] === 'number') file.additions = record['additions']; +++ if (typeof record['deletions'] === 'number') file.deletions = record['deletions']; +++ if (typeof record['changes'] === 'number') file.changes = record['changes']; +++ if (typeof record['patch'] === 'string') file.patch = record['patch']; +++ out.push(file); +++ } +++ return out; +++} +++ +++function rebuildPrClassifierInput(event: AutomationEvent): PrClassifierInput | null { +++ const normalized = asRecord(event.normalizedEvent); +++ const payload = normalized === null ? null : asRecord(normalized['payload']); +++ if (payload === null) return null; +++ const prNumber = payload['prNumber']; +++ const title = payload['title']; +++ const body = payload['body']; +++ const labels = asStringArray(payload['labels']); +++ const files = asAutomationFiles(payload['files']); +++ if ( +++ typeof event.sha !== 'string' || +++ typeof prNumber !== 'number' || +++ typeof title !== 'string' || +++ labels === null || +++ files === null +++ ) { +++ return null; +++ } +++ return { +++ repo: event.repo, +++ sha: event.sha, +++ prNumber, +++ title, +++ body: typeof body === 'string' ? body : '', +++ labels, +++ files, +++ }; +++} +++ +++export async function storeAutomationEvent( +++ input: StoreAutomationEventInput +++): Promise { +++ const classifierOutput = input.classifierOutput; +++ const outputHash = outputHashFor(classifierOutput); +++ const existing = await findAutomationEventByIdempotencyKey(input.idempotencyKey); +++ if (existing !== null) { +++ if ( +++ existing.classifierVersion !== input.classifierVersion || +++ existing.outputHash !== outputHash +++ ) { +++ throw new AutomationEventIdempotencyConflictError(input.idempotencyKey); +++ } +++ return { event: existing, created: false }; +++ } +++ +++ const event = await createAutomationEventRecord({ +++ repo: input.repo, +++ sha: input.sha, +++ event: input.event, +++ source: input.source, +++ workflow: input.workflow, +++ status: input.status, +++ idempotencyKey: input.idempotencyKey, +++ classifierVersion: input.classifierVersion, +++ outputHash, +++ rawPayload: input.rawPayload, +++ normalizedEvent: input.normalizedEvent, +++ classifierOutput, +++ prNumber: input.prNumber, +++ issueNumber: input.issueNumber, +++ }); +++ +++ return { event, created: true }; +++} +++ +++export async function classifyAndStorePrAutomation( +++ input: PrAutomationInput +++): Promise { +++ const classifierOutput = classifyPrAutomation({ +++ repo: input.repo, +++ sha: input.sha, +++ prNumber: input.prNumber, +++ title: input.title, +++ body: input.body ?? '', +++ labels: input.labels, +++ files: input.files, +++ }); +++ const outputHash = outputHashFor(classifierOutput); +++ const idempotencyKey = buildAutomationIdempotencyKey([ +++ 'pr-classification', +++ input.repo, +++ input.sha, +++ String(input.prNumber), +++ PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash, +++ ]); +++ const normalizedEvent = { +++ event: 'github.pull_request', +++ source: 'github', +++ repo: input.repo, +++ timestamp: '1970-01-01T00:00:00.000Z', +++ payload: { +++ prNumber: input.prNumber, +++ title: input.title, +++ body: input.body ?? '', +++ labels: input.labels, +++ files: input.files, +++ }, +++ }; +++ const stored = await storeAutomationEvent({ +++ repo: input.repo, +++ sha: input.sha, +++ event: normalizedEvent.event, +++ source: normalizedEvent.source, +++ workflow: input.sourceWorkflow, +++ status: 'accepted', +++ idempotencyKey, +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ rawPayload: normalizedEvent.payload, +++ normalizedEvent, +++ classifierOutput, +++ prNumber: input.prNumber, +++ }); +++ +++ return { ...stored, classifierOutput }; +++} +++ +++export async function replayAutomationEvent( +++ id: string, +++ classifierVersion: string +++): Promise { +++ const event = await findAutomationEventById(id); +++ if (event === null) { +++ return null; +++ } +++ if (event.classifierVersion !== classifierVersion) { +++ return { +++ event, +++ replayedOutput: null, +++ outputHash: null, +++ matches: false, +++ reason: 'classifier_version_mismatch', +++ }; +++ } +++ +++ if (event.event !== 'github.pull_request') { +++ return { +++ event, +++ replayedOutput: null, +++ outputHash: null, +++ matches: false, +++ reason: 'unsupported_replay_event', +++ }; +++ } +++ +++ const replayInput = rebuildPrClassifierInput(event); +++ if (replayInput === null) { +++ return { +++ event, +++ replayedOutput: null, +++ outputHash: null, +++ matches: false, +++ reason: 'invalid_replay_input', +++ }; +++ } +++ +++ const replayedOutput = classifyPrAutomation(replayInput); +++ const outputHash = outputHashFor(replayedOutput); +++ return { +++ event, +++ replayedOutput, +++ outputHash, +++ matches: outputHash === event.outputHash, +++ reason: outputHash === event.outputHash ? 'matched' : 'output_hash_mismatch', +++ }; +++} +++ +++export async function compareAndStoreSimulationSnapshot( +++ input: SimulationCompareInput +++): Promise { +++ const previous = await findLatestSimulationSnapshot( +++ input.scopeKey, +++ SIMULATION_DRIFT_CLASSIFIER_VERSION +++ ); +++ const previousSnapshot = previous === null ? null : previous.snapshot; +++ const comparisonOutput = classifySimulationDrift( +++ previousSnapshot as Parameters[0], +++ input.snapshot as Parameters[1] +++ ); +++ const outputHash = outputHashFor(comparisonOutput); +++ const existing = await findSimulationSnapshotByRun({ +++ scopeKey: input.scopeKey, +++ runId: input.runId, +++ classifierVersion: SIMULATION_DRIFT_CLASSIFIER_VERSION, +++ }); +++ if (existing !== null) { +++ if (outputHashFor(existing.snapshot) !== outputHashFor(input.snapshot)) { +++ throw new SimulationSnapshotIdempotencyConflictError(input.scopeKey, input.runId); +++ } +++ return { +++ snapshot: existing, +++ comparisonOutput: existing.comparisonOutput as unknown as SimulationDriftClassification, +++ created: false, +++ }; +++ } +++ +++ const snapshot = await createSimulationAutomationSnapshotRecord({ +++ repo: input.repo, +++ scopeKey: input.scopeKey, +++ runId: input.runId, +++ classifierVersion: SIMULATION_DRIFT_CLASSIFIER_VERSION, +++ snapshot: input.snapshot, +++ comparisonOutput, +++ outputHash, +++ previousSnapshotId: previous?.id, +++ }); +++ +++ return { snapshot, comparisonOutput, created: true }; +++} ++diff --git a/lib/automation/github-status.ts b/lib/automation/github-status.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..5ba4bf956a3407a1aae69e5fa166164cb9fab6ff ++--- /dev/null +++++ b/lib/automation/github-status.ts ++@@ -0,0 +1,238 @@ +++import type { AutomationStatusCheck } from '@prisma/client'; +++import { +++ createGithubStatusCheckRecord, +++ findStatusCheckById, +++ findStatusCheckByIdempotencyKey, +++ listGithubStatusChecks, +++ postGithubCommitStatus, +++ updateGithubStatusCheckResponse, +++} from '../adapters/runtime/automation-github-status.prisma.js'; +++import { buildAutomationIdempotencyKey } from './hash.js'; +++ +++export const ALLOWED_GITHUB_STATUS_CONTEXTS = [ +++ 'cherry/forbidden-change', +++ 'cherry/docs-drift', +++ 'cherry/risk-gate', +++ 'cherry/openclaw-policy', +++] as const; +++ +++export type AllowedGithubStatusContext = (typeof ALLOWED_GITHUB_STATUS_CONTEXTS)[number]; +++ +++export type GithubStatusInput = { +++ repo: string; +++ sha: string; +++ context: AllowedGithubStatusContext; +++ state: 'error' | 'failure' | 'pending' | 'success'; +++ description: string; +++ targetUrl?: string | undefined; +++ sourceWorkflow: string; +++ automationEventId?: string | undefined; +++ classifierVersion: string; +++ outputHash: string; +++}; +++ +++export type GithubStatusPostOptions = { +++ githubToken: string; +++ apiBaseUrl?: string; +++}; +++ +++export type GithubStatusPostResult = { +++ statusCheck: AutomationStatusCheck; +++ posted: boolean; +++ idempotent: boolean; +++}; +++ +++export type GithubStatusRetryInput = { +++ id?: string | undefined; +++ statusIdempotencyKey?: string | undefined; +++}; +++ +++export type GithubStatusRetryResult = { +++ statusCheck: AutomationStatusCheck; +++ retried: boolean; +++}; +++ +++export class GithubStatusRetryNotFoundError extends Error { +++ constructor() { +++ super('github_status_not_found'); +++ this.name = 'GithubStatusRetryNotFoundError'; +++ } +++} +++ +++export function isAllowedGithubStatusContext( +++ context: string +++): context is AllowedGithubStatusContext { +++ return ALLOWED_GITHUB_STATUS_CONTEXTS.includes(context as AllowedGithubStatusContext); +++} +++ +++export function buildStatusIdempotencyKey(input: GithubStatusInput): string { +++ return buildAutomationIdempotencyKey([ +++ 'github-status', +++ input.repo, +++ input.sha, +++ input.context, +++ input.classifierVersion, +++ input.outputHash, +++ ]); +++} +++ +++export function targetUrlTouchesForbiddenCherryTruth(targetUrl: string | undefined): boolean { +++ if (targetUrl === undefined) return false; +++ return /\/api\/(sessions?|ledgers?|buckets?|payments?|cards?)(\/|$)|\/api\/debts?(\/.*)?\/mutate\b/i.test( +++ targetUrl +++ ); +++} +++ +++export async function postGithubStatus( +++ input: GithubStatusInput, +++ options: GithubStatusPostOptions +++): Promise { +++ if (isAllowedGithubStatusContext(input.context) === false) { +++ throw new Error(`Unsupported GitHub status context: ${input.context}`); +++ } +++ +++ const statusIdempotencyKey = buildStatusIdempotencyKey(input); +++ const existing = await findStatusCheckByIdempotencyKey(statusIdempotencyKey); +++ if (existing !== null) { +++ return { statusCheck: existing, posted: false, idempotent: true }; +++ } +++ +++ if (targetUrlTouchesForbiddenCherryTruth(input.targetUrl)) { +++ throw new Error('GitHub status targetUrl points at a forbidden Cherry finance endpoint'); +++ } +++ +++ const statusCheck = await createGithubStatusCheckRecord({ +++ repo: input.repo, +++ sha: input.sha, +++ context: input.context, +++ state: input.state, +++ description: input.description, +++ targetUrl: input.targetUrl, +++ sourceWorkflow: input.sourceWorkflow, +++ automationEventId: input.automationEventId, +++ classifierVersion: input.classifierVersion, +++ outputHash: input.outputHash, +++ statusIdempotencyKey, +++ githubResponse: { status: 'created_not_posted' }, +++ }); +++ +++ if (options.githubToken.trim().length === 0) { +++ const updated = await updateGithubStatusCheckResponse(statusCheck.id, { +++ ok: false, +++ error: 'missing_github_token', +++ }); +++ throw Object.assign(new Error('Missing GitHub token for status posting'), { +++ statusCheck: updated, +++ }); +++ } +++ +++ const apiBaseUrl = options.apiBaseUrl ?? 'https://api.github.com'; +++ const response = await postGithubCommitStatus({ +++ apiBaseUrl, +++ githubToken: options.githubToken, +++ repo: input.repo, +++ sha: input.sha, +++ state: input.state, +++ description: input.description, +++ context: input.context, +++ targetUrl: input.targetUrl, +++ }); +++ const githubResponse = { +++ ok: response.ok, +++ status: response.status, +++ body: response.body, +++ }; +++ const updated = await updateGithubStatusCheckResponse(statusCheck.id, githubResponse); +++ if (response.ok === false) { +++ throw Object.assign(new Error(`GitHub status post failed with ${response.status}`), { +++ statusCheck: updated, +++ }); +++ } +++ +++ return { statusCheck: updated, posted: true, idempotent: false }; +++} +++ +++async function repostExistingGithubStatus( +++ statusCheck: AutomationStatusCheck, +++ options: GithubStatusPostOptions +++): Promise { +++ if (isAllowedGithubStatusContext(statusCheck.context) === false) { +++ throw new Error(`Unsupported GitHub status context: ${statusCheck.context}`); +++ } +++ if (targetUrlTouchesForbiddenCherryTruth(statusCheck.targetUrl ?? undefined)) { +++ throw new Error('GitHub status targetUrl points at a forbidden Cherry finance endpoint'); +++ } +++ if (options.githubToken.trim().length === 0) { +++ const updated = await updateGithubStatusCheckResponse(statusCheck.id, { +++ ok: false, +++ retry: true, +++ error: 'missing_github_token', +++ }); +++ throw Object.assign(new Error('Missing GitHub token for status retry'), { +++ statusCheck: updated, +++ }); +++ } +++ +++ const response = await postGithubCommitStatus({ +++ apiBaseUrl: options.apiBaseUrl ?? 'https://api.github.com', +++ githubToken: options.githubToken, +++ repo: statusCheck.repo, +++ sha: statusCheck.sha, +++ state: statusCheck.state as GithubStatusInput['state'], +++ description: statusCheck.description, +++ context: statusCheck.context, +++ targetUrl: statusCheck.targetUrl ?? undefined, +++ }); +++ const updated = await updateGithubStatusCheckResponse(statusCheck.id, { +++ ok: response.ok, +++ retry: true, +++ status: response.status, +++ body: response.body, +++ }); +++ if (response.ok === false) { +++ throw Object.assign(new Error(`GitHub status retry failed with ${response.status}`), { +++ statusCheck: updated, +++ }); +++ } +++ return updated; +++} +++ +++export async function retryGithubStatus( +++ input: GithubStatusRetryInput, +++ options: GithubStatusPostOptions +++): Promise { +++ const statusCheck = +++ input.id !== undefined +++ ? await findStatusCheckById(input.id) +++ : input.statusIdempotencyKey !== undefined +++ ? await findStatusCheckByIdempotencyKey(input.statusIdempotencyKey) +++ : null; +++ if (statusCheck === null) { +++ throw new GithubStatusRetryNotFoundError(); +++ } +++ const updated = await repostExistingGithubStatus(statusCheck, options); +++ return { statusCheck: updated, retried: true }; +++} +++ +++export async function listLatestGithubStatuses(params: { +++ repo?: string; +++ sha?: string; +++ context?: AllowedGithubStatusContext; +++}): Promise { +++ const rows = await listGithubStatusChecks(params); +++ const latest = new Map(); +++ for (const row of rows) { +++ const key = `${row.repo}:${row.sha}:${row.context}`; +++ const existing = latest.get(key); +++ const existingTime = existing?.createdAt instanceof Date ? existing.createdAt.getTime() : 0; +++ const rowTime = row.createdAt instanceof Date ? row.createdAt.getTime() : 0; +++ if (existing === undefined || rowTime >= existingTime) { +++ latest.set(key, row); +++ } +++ } +++ return Array.from(latest.values()).sort((a, b) => { +++ const aTime = a.createdAt instanceof Date ? a.createdAt.getTime() : 0; +++ const bTime = b.createdAt instanceof Date ? b.createdAt.getTime() : 0; +++ return bTime - aTime; +++ }); +++} ++diff --git a/lib/automation/hash.ts b/lib/automation/hash.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..d34bd70e3e08a85f71f7a5a2ddafef0649a9cd84 ++--- /dev/null +++++ b/lib/automation/hash.ts ++@@ -0,0 +1,34 @@ +++import { createHash } from 'node:crypto'; +++ +++export function canonicalize(value: unknown): unknown { +++ if (Array.isArray(value)) { +++ return value.map((entry) => canonicalize(entry)); +++ } +++ +++ if (value !== null && typeof value === 'object') { +++ const record = value as Record; +++ const output: Record = {}; +++ const keys = Object.keys(record).sort((a, b) => a.localeCompare(b)); +++ for (const key of keys) { +++ const entry = record[key]; +++ if (entry !== undefined) { +++ output[key] = canonicalize(entry); +++ } +++ } +++ return output; +++ } +++ +++ return value; +++} +++ +++export function canonicalJson(value: unknown): string { +++ return JSON.stringify(canonicalize(value)); +++} +++ +++export function hashAutomationOutput(value: unknown): string { +++ return createHash('sha256').update(canonicalJson(value)).digest('hex'); +++} +++ +++export function buildAutomationIdempotencyKey(parts: readonly string[]): string { +++ return hashAutomationOutput(parts); +++} ++diff --git a/lib/http/bearer-token.ts b/lib/http/bearer-token.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..1414a76ea4872e6b2bf2cec97139c92db6f88194 ++--- /dev/null +++++ b/lib/http/bearer-token.ts ++@@ -0,0 +1,3 @@ +++export function getStandardBearerHeader(headers: Headers): string | null { +++ return headers.get('authorization'); +++} ++diff --git a/lib/schemas/automation.ts b/lib/schemas/automation.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..d249e0c7576cb8134614744952aff72c9d83d786 ++--- /dev/null +++++ b/lib/schemas/automation.ts ++@@ -0,0 +1,113 @@ +++import { z } from 'zod'; +++ +++export const AutomationSourceSchema = z.enum(['github', 'openclaw', 'cherry', 'manual']); +++ +++export const AutomationNormalizedEventSchema = z +++ .object({ +++ event: z.string().min(1), +++ source: AutomationSourceSchema, +++ repo: z.string().min(1), +++ timestamp: z.string().min(1), +++ payload: z.unknown(), +++ }) +++ .strict(); +++ +++export const AutomationFileChangeSchema = z +++ .object({ +++ filename: z.string().min(1), +++ status: z.string().min(1).optional(), +++ additions: z.number().int().nonnegative().optional(), +++ deletions: z.number().int().nonnegative().optional(), +++ changes: z.number().int().nonnegative().optional(), +++ patch: z.string().optional(), +++ }) +++ .strict(); +++ +++export const AutomationEventIngestSchema = z +++ .object({ +++ repo: z.string().min(1), +++ sha: z.string().min(1).optional(), +++ event: z.string().min(1), +++ source: AutomationSourceSchema, +++ workflow: z.string().min(1), +++ status: z.string().min(1).default('accepted'), +++ idempotencyKey: z.string().min(1), +++ classifierVersion: z.string().min(1), +++ rawPayload: z.unknown(), +++ normalizedEvent: AutomationNormalizedEventSchema, +++ classifierOutput: z.unknown(), +++ prNumber: z.number().int().positive().optional(), +++ issueNumber: z.number().int().positive().optional(), +++ }) +++ .strict(); +++ +++export const PrAutomationClassifySchema = z +++ .object({ +++ repo: z.string().min(1), +++ sha: z.string().min(1), +++ prNumber: z.number().int().positive(), +++ title: z.string(), +++ body: z.string().nullable().optional(), +++ labels: z.array(z.string()).default([]), +++ files: z.array(AutomationFileChangeSchema).default([]), +++ sourceWorkflow: z.string().min(1).default('unknown'), +++ eventId: z.string().min(1).optional(), +++ }) +++ .strict(); +++ +++export const SimulationSnapshotCompareSchema = z +++ .object({ +++ repo: z.string().min(1), +++ scopeKey: z.string().min(1), +++ runId: z.string().min(1), +++ snapshot: z.unknown(), +++ sourceWorkflow: z.string().min(1).default('unknown'), +++ }) +++ .strict(); +++ +++export const GithubStatusContextSchema = z.enum([ +++ 'cherry/forbidden-change', +++ 'cherry/docs-drift', +++ 'cherry/risk-gate', +++ 'cherry/openclaw-policy', +++]); +++ +++export const GithubStatusStateSchema = z.enum(['error', 'failure', 'pending', 'success']); +++ +++export const GithubStatusPostSchema = z +++ .object({ +++ repo: z.string().min(1), +++ sha: z.string().min(1), +++ context: GithubStatusContextSchema, +++ state: GithubStatusStateSchema, +++ description: z.string().min(1).max(140), +++ targetUrl: z.string().url().optional(), +++ sourceWorkflow: z.string().min(1), +++ automationEventId: z.string().min(1).optional(), +++ classifierVersion: z.string().min(1), +++ outputHash: z.string().min(1), +++ }) +++ .strict(); +++ +++export const GithubStatusRetrySchema = z +++ .union([ +++ z +++ .object({ +++ id: z.string().min(1), +++ statusIdempotencyKey: z.never().optional(), +++ }) +++ .strict(), +++ z +++ .object({ +++ id: z.never().optional(), +++ statusIdempotencyKey: z.string().min(1), +++ }) +++ .strict(), +++ ]); +++ +++export const AutomationReplaySchema = z +++ .object({ +++ automationEventId: z.string().min(1), +++ classifierVersion: z.string().min(1), +++ }) +++ .strict(); ++diff --git a/package.json b/package.json ++index 19435bd4ab453f24ddeabdf801ede578041e36f6..751dd3b032475909e07f817a61e5fb9b2c3ed30c 100644 ++--- a/package.json +++++ b/package.json ++@@ -17,7 +17,8 @@ ++ "ci:verify": "npm run check && npm run test && npm run build", ++ "check:static": "npm run check:guardrails && npm run lint:scripts && npm run typecheck:scripts && npm run lint && npm run typecheck", ++ "check:runtime": "npm test", ++- "check:fast": "npm run check:guardrails && npm run typecheck:scripts && npm test", +++ "check:fast": "npm run check:guardrails && npm run typecheck:scripts", +++ "check:local": "npm run check:fast && npm run check:runtime", ++ "check:clean": "npm run ts:esm -- scripts/execution/run.mts check:clean", ++ "check:db-ready": "npm run ts:esm -- scripts/execution/run-db.mts check:db-ready", ++ "check:dev-login": "npm run ts:esm -- scripts/execution/run.mts check:dev-login", ++diff --git a/prisma/migrations/20260427153000_automation_backend/migration.sql b/prisma/migrations/20260427153000_automation_backend/migration.sql ++new file mode 100644 ++index 0000000000000000000000000000000000000000..f7caf2c5a1b9ed268c24c40c26d94efee0b7f7c0 ++--- /dev/null +++++ b/prisma/migrations/20260427153000_automation_backend/migration.sql ++@@ -0,0 +1,80 @@ +++-- Add durable development-automation storage for n8n V2 enforcement. +++ +++CREATE TABLE "AutomationEvent" ( +++ "id" TEXT NOT NULL, +++ "repo" TEXT NOT NULL, +++ "sha" TEXT, +++ "event" TEXT NOT NULL, +++ "source" TEXT NOT NULL, +++ "workflow" TEXT NOT NULL, +++ "status" TEXT NOT NULL, +++ "idempotencyKey" TEXT NOT NULL, +++ "classifierVersion" TEXT NOT NULL, +++ "outputHash" TEXT NOT NULL, +++ "rawPayload" JSONB NOT NULL, +++ "normalizedEvent" JSONB NOT NULL, +++ "classifierOutput" JSONB NOT NULL, +++ "prNumber" INTEGER, +++ "issueNumber" INTEGER, +++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +++ "updatedAt" TIMESTAMP(3) NOT NULL, +++ +++ CONSTRAINT "AutomationEvent_pkey" PRIMARY KEY ("id") +++); +++ +++CREATE TABLE "SimulationAutomationSnapshot" ( +++ "id" TEXT NOT NULL, +++ "repo" TEXT NOT NULL, +++ "scopeKey" TEXT NOT NULL, +++ "runId" TEXT NOT NULL, +++ "classifierVersion" TEXT NOT NULL, +++ "snapshot" JSONB NOT NULL, +++ "comparisonOutput" JSONB NOT NULL, +++ "outputHash" TEXT NOT NULL, +++ "previousSnapshotId" TEXT, +++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +++ +++ CONSTRAINT "SimulationAutomationSnapshot_pkey" PRIMARY KEY ("id") +++); +++ +++CREATE TABLE "AutomationStatusCheck" ( +++ "id" TEXT NOT NULL, +++ "repo" TEXT NOT NULL, +++ "sha" TEXT NOT NULL, +++ "context" TEXT NOT NULL, +++ "state" TEXT NOT NULL, +++ "description" TEXT NOT NULL, +++ "targetUrl" TEXT, +++ "sourceWorkflow" TEXT NOT NULL, +++ "automationEventId" TEXT, +++ "classifierVersion" TEXT NOT NULL, +++ "outputHash" TEXT NOT NULL, +++ "statusIdempotencyKey" TEXT NOT NULL, +++ "githubResponse" JSONB, +++ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, +++ +++ CONSTRAINT "AutomationStatusCheck_pkey" PRIMARY KEY ("id") +++); +++ +++CREATE UNIQUE INDEX "automation_event__idempotency_key__unique" ON "AutomationEvent"("idempotencyKey"); +++CREATE INDEX "AutomationEvent_repo_sha_idx" ON "AutomationEvent"("repo", "sha"); +++CREATE INDEX "AutomationEvent_repo_prNumber_idx" ON "AutomationEvent"("repo", "prNumber"); +++CREATE INDEX "AutomationEvent_repo_issueNumber_idx" ON "AutomationEvent"("repo", "issueNumber"); +++CREATE INDEX "AutomationEvent_workflow_createdAt_idx" ON "AutomationEvent"("workflow", "createdAt"); +++CREATE INDEX "AutomationEvent_classifierVersion_idx" ON "AutomationEvent"("classifierVersion"); +++ +++CREATE UNIQUE INDEX "simulation_automation_snapshot__scope_run_version__unique" ON "SimulationAutomationSnapshot"("scopeKey", "runId", "classifierVersion"); +++CREATE INDEX "SimulationAutomationSnapshot_repo_scopeKey_idx" ON "SimulationAutomationSnapshot"("repo", "scopeKey"); +++CREATE INDEX "SimulationAutomationSnapshot_scopeKey_createdAt_idx" ON "SimulationAutomationSnapshot"("scopeKey", "createdAt"); +++CREATE INDEX "SimulationAutomationSnapshot_classifierVersion_idx" ON "SimulationAutomationSnapshot"("classifierVersion"); +++ +++CREATE UNIQUE INDEX "automation_status_check__status_idempotency_key__unique" ON "AutomationStatusCheck"("statusIdempotencyKey"); +++CREATE INDEX "AutomationStatusCheck_repo_sha_idx" ON "AutomationStatusCheck"("repo", "sha"); +++CREATE INDEX "AutomationStatusCheck_repo_sha_context_idx" ON "AutomationStatusCheck"("repo", "sha", "context"); +++CREATE INDEX "AutomationStatusCheck_automationEventId_idx" ON "AutomationStatusCheck"("automationEventId"); +++CREATE INDEX "AutomationStatusCheck_classifierVersion_idx" ON "AutomationStatusCheck"("classifierVersion"); +++ +++ALTER TABLE "AutomationStatusCheck" +++ ADD CONSTRAINT "automation_status_check__automation_event_id__fk" +++ FOREIGN KEY ("automationEventId") REFERENCES "AutomationEvent"("id") +++ ON DELETE SET NULL ON UPDATE CASCADE; ++diff --git a/prisma/schema.prisma b/prisma/schema.prisma ++index 157c4d2c58d87738f6d098b1a502f8c610c27b7f..4cdc91a684c196c081ce37d1b57cd70a82aca206 100644 ++--- a/prisma/schema.prisma +++++ b/prisma/schema.prisma ++@@ -528,6 +528,75 @@ model DecisionEvent { ++ @@index([userId, createdAt]) ++ } ++ +++model AutomationEvent { +++ id String @id @default(cuid()) +++ repo String +++ sha String? +++ event String +++ source String +++ workflow String +++ status String +++ idempotencyKey String @unique(map: "automation_event__idempotency_key__unique") +++ classifierVersion String +++ outputHash String +++ rawPayload Json +++ normalizedEvent Json +++ classifierOutput Json +++ prNumber Int? +++ issueNumber Int? +++ createdAt DateTime @default(now()) +++ updatedAt DateTime @updatedAt +++ +++ statusChecks AutomationStatusCheck[] +++ +++ @@index([repo, sha]) +++ @@index([repo, prNumber]) +++ @@index([repo, issueNumber]) +++ @@index([workflow, createdAt]) +++ @@index([classifierVersion]) +++} +++ +++model SimulationAutomationSnapshot { +++ id String @id @default(cuid()) +++ repo String +++ scopeKey String +++ runId String +++ classifierVersion String +++ snapshot Json +++ comparisonOutput Json +++ outputHash String +++ previousSnapshotId String? +++ createdAt DateTime @default(now()) +++ +++ @@unique([scopeKey, runId, classifierVersion], map: "simulation_automation_snapshot__scope_run_version__unique") +++ @@index([repo, scopeKey]) +++ @@index([scopeKey, createdAt]) +++ @@index([classifierVersion]) +++} +++ +++model AutomationStatusCheck { +++ id String @id @default(cuid()) +++ repo String +++ sha String +++ context String +++ state String +++ description String +++ targetUrl String? +++ sourceWorkflow String +++ automationEvent AutomationEvent? @relation(fields: [automationEventId], references: [id], onDelete: SetNull, map: "automation_status_check__automation_event_id__fk") +++ automationEventId String? +++ classifierVersion String +++ outputHash String +++ statusIdempotencyKey String @unique(map: "automation_status_check__status_idempotency_key__unique") +++ githubResponse Json? +++ createdAt DateTime @default(now()) +++ +++ @@index([repo, sha]) +++ @@index([repo, sha, context]) +++ @@index([automationEventId]) +++ @@index([classifierVersion]) +++} +++ ++ model IdempotencyKey { ++ userId String ++ key String ++diff --git a/scripts/check-ci-guardrail-coverage.mts b/scripts/check-ci-guardrail-coverage.mts ++index d58631255235b8f20df392157b638837a7e3f93d..544096b963caa2c487b8d15577c7d857e394e435 100644 ++--- a/scripts/check-ci-guardrail-coverage.mts +++++ b/scripts/check-ci-guardrail-coverage.mts ++@@ -28,7 +28,6 @@ const CI_ENTRYPOINT = 'ci:verify'; ++ const GUARDRAIL_ENTRYPOINT_NAME = GUARDRAIL_ENTRYPOINT; ++ const DIRECT_RUNTIME_SCRIPTS = new Set([ ++ 'check', ++- 'check:fast', ++ 'check:runtime', ++ 'check:node', ++ 'check:next', ++diff --git a/scripts/check-ci-must-run-check.mts b/scripts/check-ci-must-run-check.mts ++index b92e20dc0081be6724cbd2bbd4cff918721c0eba..9eb309208a92ac1d9cf4970ca9ca9aed5c6559e6 100644 ++--- a/scripts/check-ci-must-run-check.mts +++++ b/scripts/check-ci-must-run-check.mts ++@@ -21,7 +21,6 @@ const FIX = ++ const REQUIRED_GUARDRAILS = ['check:guardrails']; ++ const DIRECT_RUNTIME_SCRIPTS = new Set([ ++ 'check', ++- 'check:fast', ++ 'check:runtime', ++ 'check:node', ++ 'check:next', ++diff --git a/scripts/lib/prisma-mock.cjs b/scripts/lib/prisma-mock.cjs ++index 603102ae4db2a34b9a7612104e3d811d7f432584..509ebe2d25e13a7d4e56ccb84aad5f9e4d3e4808 100644 ++--- a/scripts/lib/prisma-mock.cjs +++++ b/scripts/lib/prisma-mock.cjs ++@@ -37,6 +37,19 @@ function matchesWhere(record, where) { ++ ) { ++ return record.userId === val.userId && record.externalId === val.externalId; ++ } +++ if ( +++ val !== null && +++ typeof val === 'object' && +++ 'scopeKey' in val && +++ 'runId' in val && +++ 'classifierVersion' in val +++ ) { +++ return ( +++ record.scopeKey === val.scopeKey && +++ record.runId === val.runId && +++ record.classifierVersion === val.classifierVersion +++ ); +++ } ++ return record[key] === val; ++ }); ++ } ++@@ -55,6 +68,10 @@ function createCollection(name) { ++ const composite = where.userId_externalId; ++ return `${composite.userId}:${composite.externalId}`; ++ } +++ if ('scopeKey_runId_classifierVersion' in where) { +++ const composite = where.scopeKey_runId_classifierVersion; +++ return `${composite.scopeKey}:${composite.runId}:${composite.classifierVersion}`; +++ } ++ if ( ++ 'userId' in where && ++ 'key' in where && ++@@ -80,7 +97,13 @@ function createCollection(name) { ++ typeof data.userId === 'string' && typeof data.key === 'string' ++ ? `${data.userId}:${data.key}` ++ : null; ++- const key = data.id ?? compositeKey ?? `${name}-${counter++}`; +++ const simulationKey = +++ typeof data.scopeKey === 'string' && +++ typeof data.runId === 'string' && +++ typeof data.classifierVersion === 'string' +++ ? `${data.scopeKey}:${data.runId}:${data.classifierVersion}` +++ : null; +++ const key = data.id ?? compositeKey ?? simulationKey ?? `${name}-${counter++}`; ++ if (store.has(key)) { ++ const err = new Error(`Unique constraint failed on the fields: (${name}.id)`); ++ err.code = 'P2002'; ++@@ -194,6 +217,9 @@ class MockPrismaClient { ++ simulation = createCollection('simulation'); ++ vineDevice = createCollection('vineDevice'); ++ decisionEvent = createCollection('decisionEvent'); +++ automationEvent = createCollection('automationEvent'); +++ simulationAutomationSnapshot = createCollection('simulationAutomationSnapshot'); +++ automationStatusCheck = createCollection('automationStatusCheck'); ++ idempotencyKey = createCollection('idempotencyKey'); ++ ++ async $disconnect() { ++diff --git a/scripts/lib/prisma-mock.mts b/scripts/lib/prisma-mock.mts ++index 928b3e63d122fcd12de2a7f11a561868ecbd03d9..bf21bd85c6ed6824697f6910eb4c004c61bda6d3 100644 ++--- a/scripts/lib/prisma-mock.mts +++++ b/scripts/lib/prisma-mock.mts ++@@ -72,6 +72,24 @@ function matchesWhere(record: RecordShape, where?: Where): boolean { ++ record['externalId'] === composite.externalId ++ ); ++ } +++ if ( +++ val !== null && +++ typeof val === 'object' && +++ 'scopeKey' in (val as Record) && +++ 'runId' in (val as Record) && +++ 'classifierVersion' in (val as Record) +++ ) { +++ const composite = val as { +++ scopeKey: string; +++ runId: string; +++ classifierVersion: string; +++ }; +++ return ( +++ record['scopeKey'] === composite.scopeKey && +++ record['runId'] === composite.runId && +++ record['classifierVersion'] === composite.classifierVersion +++ ); +++ } ++ return record[key] === val; ++ }); ++ } ++@@ -90,6 +108,14 @@ function createCollection(name: string) { ++ const composite = where['userId_externalId'] as { userId: string; externalId: string }; ++ return `${composite.userId}:${composite.externalId}`; ++ } +++ if ('scopeKey_runId_classifierVersion' in where) { +++ const composite = where['scopeKey_runId_classifierVersion'] as { +++ scopeKey: string; +++ runId: string; +++ classifierVersion: string; +++ }; +++ return `${composite.scopeKey}:${composite.runId}:${composite.classifierVersion}`; +++ } ++ if ( ++ 'userId' in where && ++ 'key' in where && ++@@ -115,8 +141,17 @@ function createCollection(name: string) { ++ typeof data['userId'] === 'string' && typeof data['key'] === 'string' ++ ? `${data['userId']}:${data['key']}` ++ : null; +++ const simulationKey = +++ typeof data['scopeKey'] === 'string' && +++ typeof data['runId'] === 'string' && +++ typeof data['classifierVersion'] === 'string' +++ ? `${data['scopeKey']}:${data['runId']}:${data['classifierVersion']}` +++ : null; ++ const key = ++- (data['id'] as string | undefined) ?? compositeKey ?? `${name}-${counter++}`; +++ (data['id'] as string | undefined) ?? +++ compositeKey ?? +++ simulationKey ?? +++ `${name}-${counter++}`; ++ if (store.has(key)) { ++ const err = Error( ++ `Unique constraint failed on the fields: (${name}.id)` ++@@ -236,6 +271,9 @@ class MockPrismaClient { ++ simulation = createCollection('simulation'); ++ vineDevice = createCollection('vineDevice'); ++ decisionEvent = createCollection('decisionEvent'); +++ automationEvent = createCollection('automationEvent'); +++ simulationAutomationSnapshot = createCollection('simulationAutomationSnapshot'); +++ automationStatusCheck = createCollection('automationStatusCheck'); ++ idempotencyKey = createCollection('idempotencyKey'); ++ ++ async $disconnect(): Promise { ++diff --git a/scripts/schema/manifest.json b/scripts/schema/manifest.json ++index ecbeaabb77b680ec077a2cd24d37c70ccee53d31..805f89fde3b521d07a47bcbc4230184f1bf406b9 100644 ++--- a/scripts/schema/manifest.json +++++ b/scripts/schema/manifest.json ++@@ -1,6 +1,6 @@ ++ { ++- "schemaVersion": "schema_v2", ++- "lastMigration": "20260426090000_add_scheduled_paydowns", +++ "schemaVersion": "schema_v3", +++ "lastMigration": "20260427153000_automation_backend", ++ "invariantsVersion": "db_invariants_v1", ++ "allowlistedDestructiveMigrations": [] ++ } ++diff --git a/tests/db/constraints/automation-constraints.test.ts b/tests/db/constraints/automation-constraints.test.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..eb1302f7f2f0e06d066951f54029901fb405d26d ++--- /dev/null +++++ b/tests/db/constraints/automation-constraints.test.ts ++@@ -0,0 +1,353 @@ +++import * as assert from 'node:assert/strict'; +++import { Prisma, PrismaClient } from '@prisma/client'; +++import { assertPrismaError, getPrismaMetaString } from '../_helpers/assert-prisma-error.js'; +++ +++const prisma = new PrismaClient(); +++ +++const NOT_NULL_CONSTRAINTS = [ +++ 'NOT_NULL:071a671b50fb', +++ 'NOT_NULL:2856ae778f57', +++ 'NOT_NULL:2e723dd620a6', +++ 'NOT_NULL:342ac63ad189', +++ 'NOT_NULL:3758a6585230', +++ 'NOT_NULL:3ca21e9e45fa', +++ 'NOT_NULL:3dd52344230b', +++ 'NOT_NULL:4b10446b95e1', +++ 'NOT_NULL:4b7e6023b536', +++ 'NOT_NULL:568026ae010c', +++ 'NOT_NULL:68a2d5554b15', +++ 'NOT_NULL:6e030f061140', +++ 'NOT_NULL:7043c0f5255c', +++ 'NOT_NULL:776f09fbc5b2', +++ 'NOT_NULL:77c1392dba1e', +++ 'NOT_NULL:7996bef994a5', +++ 'NOT_NULL:7c4f17d2d641', +++ 'NOT_NULL:8005bfc46cf5', +++ 'NOT_NULL:8206bfbc595b', +++ 'NOT_NULL:82c4da434472', +++ 'NOT_NULL:87fa7b2f34a3', +++ 'NOT_NULL:90ad611dd8fd', +++ 'NOT_NULL:a4b229badf88', +++ 'NOT_NULL:afd6dd8e0c16', +++ 'NOT_NULL:b326784ec024', +++ 'NOT_NULL:c2c63cfc4045', +++ 'NOT_NULL:cd4cdd4ace1b', +++ 'NOT_NULL:cfb682211961', +++ 'NOT_NULL:d749fe9b04a7', +++ 'NOT_NULL:db294d632a8c', +++ 'NOT_NULL:dc9bd959f232', +++ 'NOT_NULL:df2cb8c868b2', +++ 'NOT_NULL:ef57d03df80f', +++ 'NOT_NULL:f7ff64432e48', +++] as const; +++ +++const UNIQUE_CONSTRAINTS = [ +++ 'automation_event__idempotency_key__unique', +++ 'automation_status_check__status_idempotency_key__unique', +++ 'simulation_automation_snapshot__scope_run_version__unique', +++] as const; +++ +++void NOT_NULL_CONSTRAINTS; +++void UNIQUE_CONSTRAINTS; +++ +++type TableSpec = { +++ table: string; +++ columns: string[]; +++ jsonColumns: Set; +++ baseRow: (suffix: string) => Record; +++}; +++ +++const at = new Date('2024-01-01T00:00:00Z'); +++ +++const tableSpecs: TableSpec[] = [ +++ { +++ table: 'AutomationEvent', +++ columns: [ +++ 'id', +++ 'repo', +++ 'event', +++ 'source', +++ 'workflow', +++ 'status', +++ 'idempotencyKey', +++ 'classifierVersion', +++ 'outputHash', +++ 'rawPayload', +++ 'normalizedEvent', +++ 'classifierOutput', +++ 'createdAt', +++ 'updatedAt', +++ ], +++ jsonColumns: new Set(['rawPayload', 'normalizedEvent', 'classifierOutput']), +++ baseRow: (suffix) => ({ +++ id: `automation-event-required-${suffix}`, +++ repo: 'div0rce/cherry', +++ event: 'db.constraint', +++ source: 'manual', +++ workflow: 'db-test', +++ status: 'accepted', +++ idempotencyKey: `automation-event-required-${suffix}`, +++ classifierVersion: 'automation_v2', +++ outputHash: `hash-${suffix}`, +++ rawPayload: JSON.stringify({ suffix }), +++ normalizedEvent: JSON.stringify({ suffix }), +++ classifierOutput: JSON.stringify({ suffix }), +++ createdAt: at, +++ updatedAt: at, +++ }), +++ }, +++ { +++ table: 'SimulationAutomationSnapshot', +++ columns: [ +++ 'id', +++ 'repo', +++ 'scopeKey', +++ 'runId', +++ 'classifierVersion', +++ 'snapshot', +++ 'comparisonOutput', +++ 'outputHash', +++ 'createdAt', +++ ], +++ jsonColumns: new Set(['snapshot', 'comparisonOutput']), +++ baseRow: (suffix) => ({ +++ id: `simulation-automation-snapshot-required-${suffix}`, +++ repo: 'div0rce/cherry', +++ scopeKey: `scope-${suffix}`, +++ runId: `run-${suffix}`, +++ classifierVersion: 'automation_v2', +++ snapshot: JSON.stringify({ suffix }), +++ comparisonOutput: JSON.stringify({ suffix }), +++ outputHash: `hash-${suffix}`, +++ createdAt: at, +++ }), +++ }, +++ { +++ table: 'AutomationStatusCheck', +++ columns: [ +++ 'id', +++ 'repo', +++ 'sha', +++ 'context', +++ 'state', +++ 'description', +++ 'sourceWorkflow', +++ 'classifierVersion', +++ 'outputHash', +++ 'statusIdempotencyKey', +++ 'createdAt', +++ ], +++ jsonColumns: new Set(), +++ baseRow: (suffix) => ({ +++ id: `automation-status-check-required-${suffix}`, +++ repo: 'div0rce/cherry', +++ sha: `sha-${suffix}`, +++ context: 'cherry/risk-gate', +++ state: 'success', +++ description: 'DB constraint check', +++ sourceWorkflow: 'db-test', +++ classifierVersion: 'automation_v2', +++ outputHash: `hash-${suffix}`, +++ statusIdempotencyKey: `automation-status-check-required-${suffix}`, +++ createdAt: at, +++ }), +++ }, +++]; +++ +++async function insertRaw(spec: TableSpec, row: Record): Promise { +++ const columns = spec.columns.map((column) => `"${column}"`).join(', '); +++ const placeholders = spec.columns +++ .map((column, index) => `$${index + 1}${spec.jsonColumns.has(column) ? '::jsonb' : ''}`) +++ .join(', '); +++ const values = spec.columns.map((column) => row[column]); +++ await prisma.$executeRawUnsafe( +++ `INSERT INTO "${spec.table}" (${columns}) VALUES (${placeholders})`, +++ ...values +++ ); +++} +++ +++function assertRawSqlCode(error: unknown, expected: '23502'): void { +++ assertPrismaError(error); +++ if (error instanceof Prisma.PrismaClientKnownRequestError) { +++ assert.equal(error.code, 'P2010', 'expected raw query failure'); +++ const code = getPrismaMetaString(error, 'code'); +++ if (code !== undefined) { +++ assert.equal(code, expected); +++ return; +++ } +++ } +++ +++ assert.ok(String(error).includes(expected), `expected SQLSTATE ${expected}`); +++} +++ +++function assertUniqueError(error: unknown): void { +++ assertPrismaError(error); +++ if (error instanceof Prisma.PrismaClientKnownRequestError) { +++ assert.equal(error.code, 'P2002', 'expected unique constraint violation'); +++ return; +++ } +++ throw new Error(`Expected PrismaClientKnownRequestError, got ${String(error)}`); +++} +++ +++async function expectNotNullViolation(spec: TableSpec, column: string): Promise { +++ let error: unknown = null; +++ try { +++ await insertRaw(spec, { +++ ...spec.baseRow(column), +++ [column]: null, +++ }); +++ } catch (err) { +++ error = err; +++ } +++ +++ if (error === null) { +++ throw new Error(`Expected NOT NULL violation on ${spec.table}.${column}`); +++ } +++ assertRawSqlCode(error, '23502'); +++} +++ +++async function expectUniqueViolations(): Promise { +++ await prisma.automationEvent.create({ +++ data: { +++ repo: 'div0rce/cherry', +++ event: 'db.constraint', +++ source: 'manual', +++ workflow: 'db-test', +++ status: 'accepted', +++ idempotencyKey: 'automation-event-unique-key', +++ classifierVersion: 'automation_v2', +++ outputHash: 'hash-event', +++ rawPayload: {}, +++ normalizedEvent: {}, +++ classifierOutput: {}, +++ }, +++ }); +++ +++ let eventError: unknown = null; +++ try { +++ await prisma.automationEvent.create({ +++ data: { +++ repo: 'div0rce/cherry', +++ event: 'db.constraint', +++ source: 'manual', +++ workflow: 'db-test', +++ status: 'accepted', +++ idempotencyKey: 'automation-event-unique-key', +++ classifierVersion: 'automation_v2', +++ outputHash: 'hash-event-duplicate', +++ rawPayload: {}, +++ normalizedEvent: {}, +++ classifierOutput: {}, +++ }, +++ }); +++ } catch (err) { +++ eventError = err; +++ } +++ if (eventError === null) { +++ throw new Error('Expected unique violation on AutomationEvent.idempotencyKey'); +++ } +++ assertUniqueError(eventError); +++ +++ await prisma.simulationAutomationSnapshot.create({ +++ data: { +++ repo: 'div0rce/cherry', +++ scopeKey: 'scope-unique', +++ runId: 'run-unique', +++ classifierVersion: 'automation_v2', +++ snapshot: {}, +++ comparisonOutput: {}, +++ outputHash: 'hash-snapshot', +++ }, +++ }); +++ +++ let snapshotError: unknown = null; +++ try { +++ await prisma.simulationAutomationSnapshot.create({ +++ data: { +++ repo: 'div0rce/cherry', +++ scopeKey: 'scope-unique', +++ runId: 'run-unique', +++ classifierVersion: 'automation_v2', +++ snapshot: {}, +++ comparisonOutput: {}, +++ outputHash: 'hash-snapshot-duplicate', +++ }, +++ }); +++ } catch (err) { +++ snapshotError = err; +++ } +++ if (snapshotError === null) { +++ throw new Error('Expected unique violation on SimulationAutomationSnapshot scope/run/version'); +++ } +++ assertUniqueError(snapshotError); +++ +++ await prisma.automationStatusCheck.create({ +++ data: { +++ repo: 'div0rce/cherry', +++ sha: 'sha-unique', +++ context: 'cherry/risk-gate', +++ state: 'success', +++ description: 'DB constraint check', +++ sourceWorkflow: 'db-test', +++ classifierVersion: 'automation_v2', +++ outputHash: 'hash-status', +++ statusIdempotencyKey: 'automation-status-unique-key', +++ }, +++ }); +++ +++ let statusError: unknown = null; +++ try { +++ await prisma.automationStatusCheck.create({ +++ data: { +++ repo: 'div0rce/cherry', +++ sha: 'sha-unique-duplicate', +++ context: 'cherry/risk-gate', +++ state: 'success', +++ description: 'DB constraint check', +++ sourceWorkflow: 'db-test', +++ classifierVersion: 'automation_v2', +++ outputHash: 'hash-status-duplicate', +++ statusIdempotencyKey: 'automation-status-unique-key', +++ }, +++ }); +++ } catch (err) { +++ statusError = err; +++ } +++ if (statusError === null) { +++ throw new Error('Expected unique violation on AutomationStatusCheck.statusIdempotencyKey'); +++ } +++ assertUniqueError(statusError); +++} +++ +++async function cleanup(): Promise { +++ await prisma.automationStatusCheck.deleteMany({ +++ where: { sourceWorkflow: 'db-test' }, +++ }); +++ await prisma.simulationAutomationSnapshot.deleteMany({ +++ where: { repo: 'div0rce/cherry', classifierVersion: 'automation_v2' }, +++ }); +++ await prisma.automationEvent.deleteMany({ +++ where: { workflow: 'db-test' }, +++ }); +++} +++ +++async function run(): Promise { +++ try { +++ await cleanup(); +++ for (const spec of tableSpecs) { +++ for (const column of spec.columns) { +++ await expectNotNullViolation(spec, column); +++ } +++ } +++ await expectUniqueViolations(); +++ console.warn('db-constraints-automation: ok'); +++ } finally { +++ await cleanup(); +++ await prisma.$disconnect(); +++ } +++} +++ +++run().catch((error: unknown) => { +++ console.error(error); +++ process.exit(1); +++}); ++diff --git a/tests/next/automation-api-routes.test.ts b/tests/next/automation-api-routes.test.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..1f4ab308192b19c79b76b4ca74ec462a69267d7f ++--- /dev/null +++++ b/tests/next/automation-api-routes.test.ts ++@@ -0,0 +1,316 @@ +++import * as assert from 'node:assert/strict'; +++import { prisma } from '../../lib/prisma.js'; +++import { PR_AUTOMATION_CLASSIFIER_VERSION } from '../../lib/automation/classifiers/types.js'; +++ +++type MockRequest = { +++ headers: Headers; +++ url: string; +++ json: () => Promise; +++}; +++ +++function buildRequest(body: unknown, token: string): MockRequest { +++ return { +++ headers: new Headers({ authorization: `Bearer ${token}` }), +++ url: 'https://cherry.test/api/automation', +++ json: async () => body, +++ }; +++} +++ +++function asRecord(value: unknown): Record { +++ assert.equal(typeof value, 'object'); +++ assert.notEqual(value, null); +++ return value as Record; +++} +++ +++async function run(): Promise { +++ const token = 'automation-test-token'; +++ process.env['CHERRY_AUTOMATION_TOKEN'] = token; +++ process.env['GITHUB_TOKEN'] = 'github-test-token'; +++ +++ const originalFetch = globalThis.fetch; +++ let fetchCalls = 0; +++ globalThis.fetch = async () => { +++ fetchCalls += 1; +++ return new Response(JSON.stringify({ id: `status-${fetchCalls}` }), { status: 201 }); +++ }; +++ +++ try { +++ const classifyRoute = await import('../../app/api/automation/classify/pr/route.js'); +++ const eventsRoute = await import('../../app/api/automation/events/route.js'); +++ const replayRoute = await import('../../app/api/automation/replay/route.js'); +++ const simulationRoute = await import( +++ '../../app/api/automation/simulation-snapshots/compare/route.js' +++ ); +++ const githubStatusRoute = await import('../../app/api/automation/statuses/github/route.js'); +++ const githubStatusRetryRoute = await import( +++ '../../app/api/automation/statuses/github/retry/route.js' +++ ); +++ const statusesRoute = await import('../../app/api/automation/statuses/route.js'); +++ +++ const classifyResponse = await classifyRoute.POST( +++ buildRequest( +++ { +++ repo: 'div0rce/cherry', +++ sha: 'route-sha', +++ prNumber: 333, +++ title: 'API change without docs', +++ body: '', +++ labels: [], +++ files: [{ filename: 'app/api/scan/route.ts', status: 'modified' }], +++ sourceWorkflow: 'route-test', +++ }, +++ token +++ ) as never +++ ); +++ assert.equal(classifyResponse.status, 200); +++ const classifyBody = asRecord(await classifyResponse.json()); +++ assert.equal(classifyBody['ok'], true); +++ const automationEventId = classifyBody['automationEventId']; +++ const outputHash = classifyBody['outputHash']; +++ assert.equal(typeof automationEventId, 'string'); +++ assert.equal(typeof outputHash, 'string'); +++ const classifierOutput = asRecord(classifyBody['classifierOutput']); +++ assert.equal(Object.prototype.hasOwnProperty.call(classifierOutput, 'outputHash'), false); +++ +++ const replayResponse = await replayRoute.POST( +++ buildRequest( +++ { +++ automationEventId, +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ }, +++ token +++ ) as never +++ ); +++ assert.equal(replayResponse.status, 200); +++ const replayBody = asRecord(await replayResponse.json()); +++ assert.equal(replayBody['matches'], true); +++ assert.equal(replayBody['outputHash'], outputHash); +++ +++ const eventIngestBody = { +++ repo: 'div0rce/cherry', +++ sha: 'route-event-sha', +++ event: 'manual.test', +++ source: 'manual', +++ workflow: 'route-test', +++ status: 'accepted', +++ idempotencyKey: 'route-event-conflict', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ rawPayload: {}, +++ normalizedEvent: { +++ event: 'manual.test', +++ source: 'manual', +++ repo: 'div0rce/cherry', +++ timestamp: '1970-01-01T00:00:00.000Z', +++ payload: {}, +++ }, +++ classifierOutput: { value: 1 }, +++ }; +++ const eventIngestResponse = await eventsRoute.POST( +++ buildRequest(eventIngestBody, token) as never +++ ); +++ assert.equal(eventIngestResponse.status, 200); +++ const eventConflictResponse = await eventsRoute.POST( +++ buildRequest( +++ { +++ ...eventIngestBody, +++ classifierOutput: { value: 2 }, +++ }, +++ token +++ ) as never +++ ); +++ assert.equal(eventConflictResponse.status, 409); +++ assert.deepEqual(await eventConflictResponse.json(), { +++ error: 'automation_event_idempotency_conflict', +++ }); +++ +++ const invalidStatusResponse = await githubStatusRoute.POST( +++ buildRequest( +++ { +++ repo: 'div0rce/cherry', +++ sha: 'route-sha', +++ context: 'cherry/not-allowed', +++ state: 'failure', +++ description: 'Invalid context', +++ sourceWorkflow: 'route-test', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash, +++ }, +++ token +++ ) as never +++ ); +++ assert.equal(invalidStatusResponse.status, 400); +++ +++ const statusResponse = await githubStatusRoute.POST( +++ buildRequest( +++ { +++ repo: 'div0rce/cherry', +++ sha: 'route-sha', +++ context: 'cherry/docs-drift', +++ state: 'failure', +++ description: 'Docs drift detected.', +++ targetUrl: 'https://example.com/automation/status/route', +++ sourceWorkflow: 'route-test', +++ automationEventId, +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash, +++ }, +++ token +++ ) as never +++ ); +++ assert.equal(statusResponse.status, 200); +++ const statusBody = asRecord(await statusResponse.json()); +++ assert.equal(statusBody['posted'], true); +++ +++ const duplicateStatusResponse = await githubStatusRoute.POST( +++ buildRequest( +++ { +++ repo: 'div0rce/cherry', +++ sha: 'route-sha', +++ context: 'cherry/docs-drift', +++ state: 'success', +++ description: 'Changed fields should not create another status.', +++ targetUrl: 'https://example.com/api/debts/123/mutate', +++ sourceWorkflow: 'route-test', +++ automationEventId, +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash, +++ }, +++ token +++ ) as never +++ ); +++ assert.equal(duplicateStatusResponse.status, 200); +++ const duplicateStatusBody = asRecord(await duplicateStatusResponse.json()); +++ assert.equal(duplicateStatusBody['posted'], false); +++ assert.equal(duplicateStatusBody['idempotent'], true); +++ assert.equal(duplicateStatusBody['statusCheckId'], statusBody['statusCheckId']); +++ assert.equal(fetchCalls, 1); +++ +++ const retryResponse = await githubStatusRetryRoute.POST( +++ buildRequest({ id: statusBody['statusCheckId'] }, token) as never +++ ); +++ assert.equal(retryResponse.status, 200); +++ const retryBody = asRecord(await retryResponse.json()); +++ assert.equal(retryBody['retried'], true); +++ const retriedStatus = asRecord(retryBody['statusCheck']); +++ assert.equal(retriedStatus['id'], statusBody['statusCheckId']); +++ assert.equal(fetchCalls, 2); +++ +++ const retryByKeyResponse = await githubStatusRetryRoute.POST( +++ buildRequest( +++ { statusIdempotencyKey: String(retriedStatus['statusIdempotencyKey']) }, +++ token +++ ) as never +++ ); +++ assert.equal(retryByKeyResponse.status, 200); +++ assert.equal(fetchCalls, 3); +++ +++ const retryMissingResponse = await githubStatusRetryRoute.POST( +++ buildRequest({ id: 'missing-status-check' }, token) as never +++ ); +++ assert.equal(retryMissingResponse.status, 404); +++ +++ const auditResponse = await statusesRoute.GET({ +++ headers: new Headers({ authorization: `Bearer ${token}` }), +++ url: 'https://cherry.test/api/automation/statuses?repo=div0rce/cherry&sha=route-sha&context=cherry/docs-drift', +++ json: async () => ({}), +++ } as never); +++ assert.equal(auditResponse.status, 200); +++ const auditBody = asRecord(await auditResponse.json()); +++ const statuses = auditBody['statuses']; +++ assert.ok(Array.isArray(statuses)); +++ assert.equal(statuses.length, 1); +++ const firstStatus = asRecord(statuses[0]); +++ assert.equal(firstStatus['context'], 'cherry/docs-drift'); +++ assert.equal(firstStatus['targetUrl'], 'https://example.com/automation/status/route'); +++ assert.equal(firstStatus['automationEventId'], automationEventId); +++ +++ const forbiddenRetryStatus = await prisma.automationStatusCheck.create({ +++ data: { +++ repo: 'div0rce/cherry', +++ sha: 'route-forbidden-retry', +++ context: 'cherry/risk-gate', +++ state: 'failure', +++ description: 'Forbidden retry target.', +++ targetUrl: 'https://example.com/api/debts/123/mutate', +++ sourceWorkflow: 'route-test', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash: 'route-forbidden-retry-hash', +++ statusIdempotencyKey: 'route-forbidden-retry-key', +++ githubResponse: {}, +++ }, +++ }); +++ const forbiddenRetryResponse = await githubStatusRetryRoute.POST( +++ buildRequest({ id: forbiddenRetryStatus.id }, token) as never +++ ); +++ assert.equal(forbiddenRetryResponse.status, 400); +++ +++ const unsupportedRetryStatus = await prisma.automationStatusCheck.create({ +++ data: { +++ repo: 'div0rce/cherry', +++ sha: 'route-unsupported-retry', +++ context: 'cherry/not-allowed', +++ state: 'failure', +++ description: 'Unsupported retry context.', +++ targetUrl: 'https://example.com/automation/status/unsupported', +++ sourceWorkflow: 'route-test', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash: 'route-unsupported-retry-hash', +++ statusIdempotencyKey: 'route-unsupported-retry-key', +++ githubResponse: {}, +++ }, +++ }); +++ const unsupportedRetryResponse = await githubStatusRetryRoute.POST( +++ buildRequest({ id: unsupportedRetryStatus.id }, token) as never +++ ); +++ assert.equal(unsupportedRetryResponse.status, 400); +++ +++ const simulationBody = { +++ repo: 'div0rce/cherry', +++ scopeKey: 'route-simulation', +++ runId: 'route-run', +++ sourceWorkflow: 'route-test', +++ snapshot: { +++ score: 80, +++ allocation: { cardA: 10_000 }, +++ strategy: 'minimum', +++ runwayDays: 30, +++ viableCandidateCount: 2, +++ }, +++ }; +++ const simulationResponse = await simulationRoute.POST( +++ buildRequest(simulationBody, token) as never +++ ); +++ assert.equal(simulationResponse.status, 200); +++ const simulationDuplicateConflictResponse = await simulationRoute.POST( +++ buildRequest( +++ { +++ ...simulationBody, +++ snapshot: { +++ score: 10, +++ allocation: { cardA: 100 }, +++ strategy: 'changed', +++ runwayDays: 1, +++ viableCandidateCount: 0, +++ }, +++ }, +++ token +++ ) as never +++ ); +++ assert.equal(simulationDuplicateConflictResponse.status, 409); +++ assert.deepEqual(await simulationDuplicateConflictResponse.json(), { +++ error: 'simulation_snapshot_idempotency_conflict', +++ }); +++ } finally { +++ globalThis.fetch = originalFetch; +++ await prisma.automationStatusCheck.deleteMany({ where: {} }); +++ await prisma.simulationAutomationSnapshot.deleteMany({ where: {} }); +++ await prisma.automationEvent.deleteMany({ where: {} }); +++ } +++ +++ console.warn('automation API routes: ok'); +++} +++ +++run().catch((error: unknown) => { +++ console.error(error); +++ process.exit(1); +++}); ++diff --git a/tests/node/automation-boundary.test.ts b/tests/node/automation-boundary.test.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..27caddbe09136deb4b4559906f877fcc82e780e3 ++--- /dev/null +++++ b/tests/node/automation-boundary.test.ts ++@@ -0,0 +1,81 @@ +++import * as assert from 'node:assert/strict'; +++import * as fs from 'node:fs'; +++import * as path from 'node:path'; +++ +++const repoRoot = process.cwd(); +++const scanRoots = [ +++ path.join(repoRoot, 'lib', 'automation'), +++ path.join(repoRoot, 'app', 'api', 'automation'), +++ path.join(repoRoot, 'lib', 'adapters', 'runtime'), +++]; +++ +++function walk(dir: string): string[] { +++ const entries = fs.readdirSync(dir, { withFileTypes: true }); +++ const files: string[] = []; +++ for (const entry of entries) { +++ const full = path.join(dir, entry.name); +++ if (entry.isDirectory()) { +++ files.push(...walk(full)); +++ } else if (entry.isFile() && /\.[cm]?ts$/.test(entry.name)) { +++ files.push(full); +++ } +++ } +++ return files; +++} +++ +++const forbiddenImports = [ +++ /from ['"][^'"]*\/engine(?:\/|\.js|['"])/, +++ /from ['"][^'"]*\/authority(?:\/|\.js|['"])/, +++ /from ['"][^'"]*lib\/engine/, +++ /from ['"][^'"]*lib\/authority/, +++ /import\(['"][^'"]*\/engine/, +++ /import\(['"][^'"]*\/authority/, +++]; +++ +++const forbiddenFinanceMutationPatterns = [ +++ /\bprisma\.(sessions?|session|ledgers?|ledger|buckets?|bucket|cards?|card|payments?|payment|debts?|debt)\.(create|createMany|update|updateMany|upsert|delete|deleteMany)\b/i, +++ /\/api\/(sessions?|ledgers?|buckets?|payments?|cards?)(\/|$)/i, +++ /\/api\/debts?(\/.*)?\/mutate\b/i, +++ /\b(Session|Ledger|Bucket|Card|Payment)\.(create|createMany|update|updateMany|upsert|delete|deleteMany)\b/, +++]; +++ +++const forbiddenDependencySpecifiers = [ +++ /(?:^|\/)(sessions?|ledgers?|buckets?|cards?|payments?|debts?)(?:\/|\.js|\.ts|$)/i, +++ /buckets-runtime/i, +++]; +++ +++const importSpecifierPattern = /import(?:\s+type)?(?:[\s\S]*?\s+from\s+)?['"]([^'"]+)['"]/g; +++ +++const violations: string[] = []; +++for (const root of scanRoots) { +++ for (const file of walk(root)) { +++ if ( +++ root.endsWith(path.join('lib', 'adapters', 'runtime')) && +++ !/^automation-.*\.[cm]?ts$/.test(path.basename(file)) +++ ) { +++ continue; +++ } +++ const source = fs.readFileSync(file, 'utf8'); +++ if (forbiddenImports.some((pattern) => pattern.test(source))) { +++ violations.push(`${path.relative(repoRoot, file)} imports engine/authority`); +++ } +++ if (forbiddenFinanceMutationPatterns.some((pattern) => pattern.test(source))) { +++ violations.push(`${path.relative(repoRoot, file)} mutates or calls finance truth surface`); +++ } +++ for (const match of source.matchAll(importSpecifierPattern)) { +++ const specifier = match[1] ?? ''; +++ if (forbiddenDependencySpecifiers.some((pattern) => pattern.test(specifier))) { +++ violations.push( +++ `${path.relative(repoRoot, file)} imports forbidden finance dependency ${specifier}` +++ ); +++ } +++ } +++ } +++} +++ +++assert.deepEqual( +++ violations, +++ [], +++ 'automation code must not import engine/authority or mutate finance truth surfaces' +++); +++console.warn('automation boundary: ok'); ++diff --git a/tests/node/automation-classifiers.test.ts b/tests/node/automation-classifiers.test.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..81a73d7ba6e6c2d96cc7d3aa2c003d8252120a30 ++--- /dev/null +++++ b/tests/node/automation-classifiers.test.ts ++@@ -0,0 +1,242 @@ +++import * as assert from 'node:assert/strict'; +++import * as fs from 'node:fs'; +++import * as path from 'node:path'; +++import { classifyDocsDrift } from '../../lib/automation/classifiers/docs-drift.js'; +++import { classifyForbiddenChange } from '../../lib/automation/classifiers/forbidden-change.js'; +++import { classifyPrAutomation } from '../../lib/automation/classifiers/pr.js'; +++import { classifyPrRisk } from '../../lib/automation/classifiers/pr-risk.js'; +++import { classifySimulationDrift } from '../../lib/automation/classifiers/simulation-drift.js'; +++import { +++ DOCS_DRIFT_CLASSIFIER_VERSION, +++ FORBIDDEN_CHANGE_CLASSIFIER_VERSION, +++ PR_AUTOMATION_CLASSIFIER_VERSION, +++ PR_RISK_CLASSIFIER_VERSION, +++ SIMULATION_DRIFT_CLASSIFIER_VERSION, +++ type AutomationFileChange, +++} from '../../lib/automation/classifiers/types.js'; +++ +++const repoRoot = process.cwd(); +++ +++function readFiles(dir: string): string[] { +++ const out: string[] = []; +++ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { +++ const full = path.join(dir, entry.name); +++ if (entry.isDirectory()) { +++ out.push(...readFiles(full)); +++ } else if (entry.isFile() && /\.[cm]?ts$/.test(entry.name)) { +++ out.push(full); +++ } +++ } +++ return out; +++} +++ +++function runVersionConstantTests(): void { +++ assert.equal(PR_RISK_CLASSIFIER_VERSION, 'pr-risk@1'); +++ assert.equal(FORBIDDEN_CHANGE_CLASSIFIER_VERSION, 'forbidden-change@1'); +++ assert.equal(DOCS_DRIFT_CLASSIFIER_VERSION, 'docs-drift@1'); +++ assert.equal(SIMULATION_DRIFT_CLASSIFIER_VERSION, 'simulation-drift@1'); +++ assert.equal( +++ PR_AUTOMATION_CLASSIFIER_VERSION, +++ 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)' +++ ); +++ const automationSources = readFiles(path.join(repoRoot, 'lib', 'automation')); +++ const globalVersionReferences = automationSources.filter((file) => +++ fs.readFileSync(file, 'utf8').includes('automation-classifiers-v1') +++ ); +++ assert.deepEqual(globalVersionReferences, [], 'global automation classifier version is forbidden'); +++} +++ +++function runPrClassifierTests(): void { +++ const files: AutomationFileChange[] = [ +++ { +++ filename: 'lib/engine/solver.ts', +++ status: 'modified', +++ additions: 500, +++ deletions: 400, +++ patch: '+export const changed = true;', +++ }, +++ { +++ filename: 'prisma/schema.prisma', +++ status: 'modified', +++ additions: 10, +++ deletions: 2, +++ }, +++ ]; +++ const input = { +++ repo: 'div0rce/cherry', +++ sha: 'abc123', +++ prNumber: 42, +++ title: 'change engine solver', +++ body: 'No issue link', +++ labels: [], +++ files, +++ }; +++ const first = classifyPrAutomation(input); +++ const second = classifyPrAutomation(input); +++ assert.deepEqual(second, first); +++ assert.equal(first.classifierVersion, PR_AUTOMATION_CLASSIFIER_VERSION); +++ assert.equal(first.risk.classifierVersion, PR_RISK_CLASSIFIER_VERSION); +++ assert.equal( +++ first.forbiddenChange.classifierVersion, +++ FORBIDDEN_CHANGE_CLASSIFIER_VERSION +++ ); +++ assert.equal(first.docsDrift.classifierVersion, DOCS_DRIFT_CLASSIFIER_VERSION); +++ assert.equal(Object.prototype.hasOwnProperty.call(first, 'outputHash'), false); +++ assert.equal(first.risk.level, 'high'); +++ assert.equal(first.risk.statusRequest.state, 'failure'); +++ assert.equal(first.docsDrift.drift, true); +++ +++ const accepted = classifyPrRisk({ ...input, labels: ['risk-accepted'] }); +++ assert.equal(accepted.statusRequest.state, 'success'); +++} +++ +++function runForbiddenChangeTests(): void { +++ const result = classifyForbiddenChange({ +++ files: [ +++ { +++ filename: 'tests/foo.test.ts', +++ status: 'modified', +++ patch: '+it.skip(\"temporarily skips\", () => {});', +++ }, +++ { +++ filename: '.env.local', +++ status: 'modified', +++ }, +++ ], +++ }); +++ assert.equal(result.forbidden, true); +++ assert.deepEqual(result.labels, ['blocked-forbidden-change', 'needs-human-review']); +++ assert.ok(result.violations.some((violation) => violation.startsWith('env_diff'))); +++ assert.ok( +++ result.violations.some((violation) => violation.startsWith('skipped_test_added')) +++ ); +++ assert.equal(result.statusRequest.state, 'failure'); +++ assert.equal(result.classifierVersion, FORBIDDEN_CHANGE_CLASSIFIER_VERSION); +++} +++ +++function runDocsDriftTests(): void { +++ const drift = classifyDocsDrift({ +++ files: [{ filename: 'app/api/scan/route.ts', status: 'modified' }], +++ }); +++ assert.equal(drift.drift, true); +++ assert.deepEqual(drift.domains, ['api']); +++ assert.deepEqual(drift.labels, ['docs-drift', 'needs-human-review']); +++ +++ const clean = classifyDocsDrift({ +++ files: [ +++ { filename: 'app/api/scan/route.ts', status: 'modified' }, +++ { filename: 'docs/api/scan.md', status: 'modified' }, +++ ], +++ }); +++ assert.equal(clean.drift, false); +++ +++ const engineUnrelatedMd = classifyDocsDrift({ +++ files: [ +++ { filename: 'lib/engine/solver.ts', status: 'modified' }, +++ { filename: 'docs/api/update.md', status: 'modified' }, +++ ], +++ }); +++ assert.equal(engineUnrelatedMd.drift, true); +++ +++ const engineDocs = classifyDocsDrift({ +++ files: [ +++ { filename: 'lib/engine/solver.ts', status: 'modified' }, +++ { filename: 'docs/engine/update.md', status: 'modified' }, +++ ], +++ }); +++ assert.equal(engineDocs.drift, false); +++ +++ const apiWrongDocs = classifyDocsDrift({ +++ files: [ +++ { filename: 'app/api/scan/route.ts', status: 'modified' }, +++ { filename: 'docs/engine/update.md', status: 'modified' }, +++ ], +++ }); +++ assert.equal(apiWrongDocs.drift, true); +++ +++ const schemaDocs = classifyDocsDrift({ +++ files: [ +++ { filename: 'prisma/schema.prisma', status: 'modified' }, +++ { filename: 'docs/database/prisma.md', status: 'modified' }, +++ ], +++ }); +++ assert.equal(schemaDocs.drift, false); +++ +++ const schemaReadmeOnly = classifyDocsDrift({ +++ files: [ +++ { filename: 'prisma/schema.prisma', status: 'modified' }, +++ { filename: 'README.md', status: 'modified' }, +++ ], +++ }); +++ assert.equal(schemaReadmeOnly.drift, true); +++ +++ const multiDomainPartialDocs = classifyDocsDrift({ +++ files: [ +++ { filename: 'lib/engine/solver.ts', status: 'modified' }, +++ { filename: 'app/api/scan/route.ts', status: 'modified' }, +++ { filename: 'docs/engine/update.md', status: 'modified' }, +++ ], +++ }); +++ assert.equal(multiDomainPartialDocs.drift, true); +++ assert.deepEqual(multiDomainPartialDocs.domains, ['engine', 'api']); +++ +++ const multiDomainCompleteDocs = classifyDocsDrift({ +++ files: [ +++ { filename: 'lib/engine/solver.ts', status: 'modified' }, +++ { filename: 'app/api/scan/route.ts', status: 'modified' }, +++ { filename: 'docs/engine/update.md', status: 'modified' }, +++ { filename: 'docs/api/scan.md', status: 'modified' }, +++ ], +++ }); +++ assert.equal(multiDomainCompleteDocs.drift, false); +++ assert.equal(drift.classifierVersion, DOCS_DRIFT_CLASSIFIER_VERSION); +++} +++ +++function runSimulationDriftTests(): void { +++ const result = classifySimulationDrift( +++ { +++ score: 90, +++ allocation: { cardA: 10_000 }, +++ strategy: 'pay_minimum', +++ runwayDays: 30, +++ viableCandidateCount: 2, +++ }, +++ { +++ score: 70, +++ allocation: { cardA: 1_000 }, +++ strategy: 'pay_aggressive', +++ runwayDays: 5, +++ viableCandidateCount: 0, +++ } +++ ); +++ assert.equal(result.drift, true); +++ assert.ok(result.reasons.includes('strategy_flip')); +++ assert.ok(result.reasons.includes('runway_collapse')); +++ assert.ok(result.reasons.includes('empty_viable_candidates')); +++ assert.equal(result.classifierVersion, SIMULATION_DRIFT_CLASSIFIER_VERSION); +++} +++ +++function runBranchProtectionDocsTest(): void { +++ const docPath = path.join(repoRoot, 'docs', 'automation', 'branch-protection.md'); +++ const doc = fs.readFileSync(docPath, 'utf8'); +++ for (const context of [ +++ 'cherry/forbidden-change', +++ 'cherry/docs-drift', +++ 'cherry/risk-gate', +++ 'cherry/openclaw-policy', +++ ]) { +++ assert.ok(doc.includes(context), `branch protection docs must list ${context}`); +++ } +++ assert.ok( +++ doc.includes('Without branch protection, Cherry statuses are advisory only.'), +++ 'branch protection docs must state advisory-only behavior without branch protection' +++ ); +++} +++ +++runVersionConstantTests(); +++runPrClassifierTests(); +++runForbiddenChangeTests(); +++runDocsDriftTests(); +++runSimulationDriftTests(); +++runBranchProtectionDocsTest(); +++console.warn('automation classifiers: ok'); ++diff --git a/tests/node/automation-services.test.ts b/tests/node/automation-services.test.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..3d455b809cf61eb16af73b210e4012d33b1ac667 ++--- /dev/null +++++ b/tests/node/automation-services.test.ts ++@@ -0,0 +1,462 @@ +++import * as assert from 'node:assert/strict'; +++import { prisma } from '../../lib/prisma.js'; +++import { +++ classifyAndStorePrAutomation, +++ compareAndStoreSimulationSnapshot, +++ replayAutomationEvent, +++ storeAutomationEvent, +++ outputHashFor, +++} from '../../lib/automation/events.js'; +++import { createAutomationEventRecord } from '../../lib/adapters/runtime/automation-events.prisma.js'; +++import { createGithubStatusCheckRecord } from '../../lib/adapters/runtime/automation-github-status.prisma.js'; +++import { classifyPrAutomation } from '../../lib/automation/classifiers/pr.js'; +++import { +++ buildStatusIdempotencyKey, +++ listLatestGithubStatuses, +++ postGithubStatus, +++ retryGithubStatus, +++} from '../../lib/automation/github-status.js'; +++import { PR_AUTOMATION_CLASSIFIER_VERSION } from '../../lib/automation/classifiers/types.js'; +++ +++async function runReplayHashTest(): Promise { +++ const result = await classifyAndStorePrAutomation({ +++ repo: 'div0rce/cherry', +++ sha: 'sha-replay', +++ prNumber: 101, +++ title: 'touch api without docs', +++ body: '', +++ labels: [], +++ files: [{ filename: 'app/api/scan/route.ts', status: 'modified' }], +++ sourceWorkflow: 'test', +++ }); +++ const replay = await replayAutomationEvent( +++ result.event.id, +++ PR_AUTOMATION_CLASSIFIER_VERSION +++ ); +++ assert.ok(replay); +++ assert.equal(replay.matches, true); +++ assert.equal(replay.outputHash, result.event.outputHash); +++ +++ const replayInput = { +++ repo: 'div0rce/cherry', +++ sha: 'sha-replay-direct', +++ prNumber: 102, +++ title: 'touch api without docs', +++ body: '', +++ labels: [], +++ files: [{ filename: 'app/api/scan/route.ts', status: 'modified' }], +++ }; +++ const recomputed = classifyPrAutomation(replayInput); +++ const directEvent = await createAutomationEventRecord({ +++ repo: replayInput.repo, +++ sha: replayInput.sha, +++ event: 'github.pull_request', +++ source: 'github', +++ workflow: 'test', +++ status: 'accepted', +++ idempotencyKey: 'direct-replay-corrupt-output', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash: outputHashFor(recomputed), +++ rawPayload: replayInput, +++ normalizedEvent: { +++ event: 'github.pull_request', +++ source: 'github', +++ repo: replayInput.repo, +++ timestamp: '1970-01-01T00:00:00.000Z', +++ payload: { +++ prNumber: replayInput.prNumber, +++ title: replayInput.title, +++ body: replayInput.body, +++ labels: replayInput.labels, +++ files: replayInput.files, +++ }, +++ }, +++ classifierOutput: { stale: true }, +++ prNumber: replayInput.prNumber, +++ }); +++ const directReplay = await replayAutomationEvent( +++ directEvent.id, +++ PR_AUTOMATION_CLASSIFIER_VERSION +++ ); +++ assert.ok(directReplay); +++ assert.equal(directReplay.matches, true); +++ assert.deepEqual(directReplay.replayedOutput, recomputed); +++ +++ const mismatchEvent = await createAutomationEventRecord({ +++ repo: replayInput.repo, +++ sha: 'sha-replay-mismatch', +++ event: 'github.pull_request', +++ source: 'github', +++ workflow: 'test', +++ status: 'accepted', +++ idempotencyKey: 'direct-replay-mismatch-output', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash: 'not-the-recomputed-hash', +++ rawPayload: replayInput, +++ normalizedEvent: directEvent.normalizedEvent, +++ classifierOutput: recomputed, +++ prNumber: replayInput.prNumber, +++ }); +++ const mismatchReplay = await replayAutomationEvent( +++ mismatchEvent.id, +++ PR_AUTOMATION_CLASSIFIER_VERSION +++ ); +++ assert.ok(mismatchReplay); +++ assert.equal(mismatchReplay.matches, false); +++ assert.equal(mismatchReplay.reason, 'output_hash_mismatch'); +++ +++ const wrongVersionReplay = await replayAutomationEvent( +++ directEvent.id, +++ 'pr-automation@0' +++ ); +++ assert.ok(wrongVersionReplay); +++ assert.equal(wrongVersionReplay.matches, false); +++ assert.equal(wrongVersionReplay.reason, 'classifier_version_mismatch'); +++ +++ const unsupportedEvent = await createAutomationEventRecord({ +++ repo: replayInput.repo, +++ sha: 'sha-replay-unsupported', +++ event: 'manual.test', +++ source: 'manual', +++ workflow: 'test', +++ status: 'accepted', +++ idempotencyKey: 'direct-replay-unsupported-event', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash: outputHashFor({ unsupported: true }), +++ rawPayload: {}, +++ normalizedEvent: { +++ event: 'manual.test', +++ source: 'manual', +++ repo: replayInput.repo, +++ timestamp: '1970-01-01T00:00:00.000Z', +++ payload: {}, +++ }, +++ classifierOutput: { unsupported: true }, +++ }); +++ const unsupportedReplay = await replayAutomationEvent( +++ unsupportedEvent.id, +++ PR_AUTOMATION_CLASSIFIER_VERSION +++ ); +++ assert.ok(unsupportedReplay); +++ assert.equal(unsupportedReplay.matches, false); +++ assert.equal(unsupportedReplay.reason, 'unsupported_replay_event'); +++ +++ const invalidEvent = await createAutomationEventRecord({ +++ repo: replayInput.repo, +++ sha: 'sha-replay-invalid', +++ event: 'github.pull_request', +++ source: 'github', +++ workflow: 'test', +++ status: 'accepted', +++ idempotencyKey: 'direct-replay-invalid-input', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash: outputHashFor({ invalid: true }), +++ rawPayload: {}, +++ normalizedEvent: { +++ event: 'github.pull_request', +++ source: 'github', +++ repo: replayInput.repo, +++ timestamp: '1970-01-01T00:00:00.000Z', +++ payload: { prNumber: 404 }, +++ }, +++ classifierOutput: { invalid: true }, +++ }); +++ const invalidReplay = await replayAutomationEvent( +++ invalidEvent.id, +++ PR_AUTOMATION_CLASSIFIER_VERSION +++ ); +++ assert.ok(invalidReplay); +++ assert.equal(invalidReplay.matches, false); +++ assert.equal(invalidReplay.reason, 'invalid_replay_input'); +++} +++ +++async function runAutomationEventIdempotencyConflictTest(): Promise { +++ const base = { +++ repo: 'div0rce/cherry', +++ event: 'manual.test', +++ source: 'manual', +++ workflow: 'test', +++ status: 'accepted', +++ idempotencyKey: 'event-conflict-key', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ rawPayload: {}, +++ normalizedEvent: { +++ event: 'manual.test', +++ source: 'manual', +++ repo: 'div0rce/cherry', +++ timestamp: '1970-01-01T00:00:00.000Z', +++ payload: {}, +++ }, +++ classifierOutput: { value: 1 }, +++ }; +++ const first = await storeAutomationEvent(base); +++ const duplicate = await storeAutomationEvent({ ...base }); +++ assert.equal(first.created, true); +++ assert.equal(duplicate.created, false); +++ await assert.rejects( +++ storeAutomationEvent({ ...base, classifierOutput: { value: 2 } }), +++ /automation_event_idempotency_conflict/ +++ ); +++} +++ +++async function runStatusIdempotencyTest(): Promise { +++ const originalFetch = globalThis.fetch; +++ let calls = 0; +++ globalThis.fetch = async () => { +++ calls += 1; +++ return new Response(JSON.stringify({ id: calls }), { status: 201 }); +++ }; +++ +++ try { +++ const linkedEvent = await storeAutomationEvent({ +++ repo: 'div0rce/cherry', +++ sha: 'sha-status', +++ event: 'manual.status', +++ source: 'manual', +++ workflow: 'test', +++ status: 'accepted', +++ idempotencyKey: 'status-linked-event', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ rawPayload: {}, +++ normalizedEvent: { +++ event: 'manual.status', +++ source: 'manual', +++ repo: 'div0rce/cherry', +++ timestamp: '1970-01-01T00:00:00.000Z', +++ payload: {}, +++ }, +++ classifierOutput: { value: 'status' }, +++ }); +++ const input = { +++ repo: 'div0rce/cherry', +++ sha: 'sha-status', +++ context: 'cherry/forbidden-change' as const, +++ state: 'failure' as const, +++ description: 'Forbidden change detected.', +++ targetUrl: 'https://example.com/status/first', +++ sourceWorkflow: 'test', +++ automationEventId: linkedEvent.event.id, +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash: 'hash-status', +++ }; +++ const first = await postGithubStatus(input, { githubToken: 'token' }); +++ assert.equal( +++ buildStatusIdempotencyKey({ +++ ...input, +++ state: 'success', +++ description: 'Changed description should not affect status identity.', +++ targetUrl: 'https://example.com/status/changed', +++ }), +++ buildStatusIdempotencyKey(input) +++ ); +++ const second = await postGithubStatus( +++ { +++ ...input, +++ state: 'success', +++ description: 'Changed description should not change status identity.', +++ targetUrl: 'https://example.com/status/second', +++ }, +++ { githubToken: 'token' } +++ ); +++ assert.equal(first.posted, true); +++ assert.equal(second.posted, false); +++ assert.equal(second.idempotent, true); +++ assert.equal(calls, 1); +++ assert.equal(first.statusCheck.statusIdempotencyKey, buildStatusIdempotencyKey(input)); +++ assert.equal(first.statusCheck.targetUrl, 'https://example.com/status/first'); +++ assert.equal(first.statusCheck.automationEventId, linkedEvent.event.id); +++ assert.equal(second.statusCheck.id, first.statusCheck.id); +++ const countBeforeRetry = await prisma.automationStatusCheck.count({ +++ where: { statusIdempotencyKey: first.statusCheck.statusIdempotencyKey }, +++ }); +++ const retry = await retryGithubStatus( +++ { id: first.statusCheck.id }, +++ { githubToken: 'token' } +++ ); +++ assert.equal(retry.retried, true); +++ assert.equal(retry.statusCheck.id, first.statusCheck.id); +++ assert.equal(calls, 2); +++ const retryByKey = await retryGithubStatus( +++ { statusIdempotencyKey: first.statusCheck.statusIdempotencyKey }, +++ { githubToken: 'token' } +++ ); +++ assert.equal(retryByKey.statusCheck.id, first.statusCheck.id); +++ assert.equal(calls, 3); +++ const countAfterRetry = await prisma.automationStatusCheck.count({ +++ where: { statusIdempotencyKey: first.statusCheck.statusIdempotencyKey }, +++ }); +++ assert.equal(countAfterRetry, countBeforeRetry); +++ await assert.rejects( +++ retryGithubStatus({ id: 'missing-status-check' }, { githubToken: 'token' }), +++ /github_status_not_found/ +++ ); +++ +++ const unsupportedStatus = await createGithubStatusCheckRecord({ +++ repo: 'div0rce/cherry', +++ sha: 'sha-retry-unsupported-context', +++ context: 'cherry/not-allowed', +++ state: 'failure', +++ description: 'Unsupported retry context.', +++ targetUrl: 'https://example.com/status/unsupported', +++ sourceWorkflow: 'test', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash: 'hash-retry-unsupported', +++ statusIdempotencyKey: 'retry-unsupported-context', +++ }); +++ await assert.rejects( +++ retryGithubStatus({ id: unsupportedStatus.id }, { githubToken: 'token' }), +++ /Unsupported GitHub status context/ +++ ); +++ +++ const latest = await listLatestGithubStatuses({ +++ repo: 'div0rce/cherry', +++ sha: 'sha-status', +++ context: 'cherry/forbidden-change', +++ }); +++ assert.equal(latest.length, 1); +++ assert.equal(latest[0]?.context, 'cherry/forbidden-change'); +++ } finally { +++ globalThis.fetch = originalFetch; +++ } +++} +++ +++async function runStatusRejectsForbiddenTargetUrl(): Promise { +++ await assert.rejects( +++ postGithubStatus( +++ { +++ repo: 'div0rce/cherry', +++ sha: 'sha-forbidden-url', +++ context: 'cherry/risk-gate', +++ state: 'failure', +++ description: 'Bad target URL.', +++ targetUrl: 'https://example.com/api/ledger/write', +++ sourceWorkflow: 'test', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash: 'hash-url', +++ }, +++ { githubToken: 'token' } +++ ), +++ /forbidden Cherry finance endpoint/ +++ ); +++ await assert.rejects( +++ postGithubStatus( +++ { +++ repo: 'div0rce/cherry', +++ sha: 'sha-forbidden-url-debt', +++ context: 'cherry/risk-gate', +++ state: 'failure', +++ description: 'Bad target URL.', +++ targetUrl: 'https://example.com/api/debts/123/mutate', +++ sourceWorkflow: 'test', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash: 'hash-url-debt', +++ }, +++ { githubToken: 'token' } +++ ), +++ /forbidden Cherry finance endpoint/ +++ ); +++} +++ +++async function runStatusRetryRejectsForbiddenTargetUrl(): Promise { +++ const statusCheck = await createGithubStatusCheckRecord({ +++ repo: 'div0rce/cherry', +++ sha: 'sha-retry-forbidden-url', +++ context: 'cherry/risk-gate', +++ state: 'failure', +++ description: 'Bad retry target.', +++ targetUrl: 'https://example.com/api/debt/123/mutate', +++ sourceWorkflow: 'test', +++ classifierVersion: PR_AUTOMATION_CLASSIFIER_VERSION, +++ outputHash: 'hash-retry-forbidden', +++ statusIdempotencyKey: 'retry-forbidden-status', +++ }); +++ await assert.rejects( +++ retryGithubStatus({ id: statusCheck.id }, { githubToken: 'token' }), +++ /forbidden Cherry finance endpoint/ +++ ); +++} +++ +++async function runSimulationSnapshotTest(): Promise { +++ const first = await compareAndStoreSimulationSnapshot({ +++ repo: 'div0rce/cherry', +++ scopeKey: 'scenario-a', +++ runId: 'run-1', +++ sourceWorkflow: 'test', +++ snapshot: { +++ score: 90, +++ allocation: { cardA: 10_000 }, +++ strategy: 'minimum', +++ runwayDays: 30, +++ viableCandidateCount: 2, +++ }, +++ }); +++ assert.equal(first.created, true); +++ assert.equal(first.comparisonOutput.drift, false); +++ +++ const second = await compareAndStoreSimulationSnapshot({ +++ repo: 'div0rce/cherry', +++ scopeKey: 'scenario-a', +++ runId: 'run-2', +++ sourceWorkflow: 'test', +++ snapshot: { +++ score: 70, +++ allocation: { cardA: 1_000 }, +++ strategy: 'aggressive', +++ runwayDays: 5, +++ viableCandidateCount: 0, +++ }, +++ }); +++ assert.equal(second.created, true); +++ assert.equal(second.comparisonOutput.drift, true); +++ +++ const duplicate = await compareAndStoreSimulationSnapshot({ +++ repo: 'div0rce/cherry', +++ scopeKey: 'scenario-a', +++ runId: 'run-2', +++ sourceWorkflow: 'test', +++ snapshot: { +++ score: 70, +++ allocation: { cardA: 1_000 }, +++ strategy: 'aggressive', +++ runwayDays: 5, +++ viableCandidateCount: 0, +++ }, +++ }); +++ assert.equal(duplicate.created, false); +++ assert.deepEqual(duplicate.comparisonOutput, second.comparisonOutput); +++ +++ await assert.rejects( +++ compareAndStoreSimulationSnapshot({ +++ repo: 'div0rce/cherry', +++ scopeKey: 'scenario-a', +++ runId: 'run-2', +++ sourceWorkflow: 'test', +++ snapshot: { +++ score: 100, +++ allocation: { cardA: 2_000 }, +++ strategy: 'changed', +++ runwayDays: 50, +++ viableCandidateCount: 3, +++ }, +++ }), +++ /simulation_snapshot_idempotency_conflict/ +++ ); +++} +++ +++async function run(): Promise { +++ await runReplayHashTest(); +++ await runAutomationEventIdempotencyConflictTest(); +++ await runStatusIdempotencyTest(); +++ await runStatusRejectsForbiddenTargetUrl(); +++ await runStatusRetryRejectsForbiddenTargetUrl(); +++ await runSimulationSnapshotTest(); +++ await prisma.automationStatusCheck.deleteMany({ where: {} }); +++ await prisma.simulationAutomationSnapshot.deleteMany({ where: {} }); +++ await prisma.automationEvent.deleteMany({ where: {} }); +++ console.warn('automation services: ok'); +++} +++ +++run().catch((error: unknown) => { +++ console.error(error); +++ process.exit(1); +++}); ++diff --git a/tests/node/automation-workflows.test.ts b/tests/node/automation-workflows.test.ts ++new file mode 100644 ++index 0000000000000000000000000000000000000000..a28656bc6fd0f029807665e8d2fce6c9b2cfc993 ++--- /dev/null +++++ b/tests/node/automation-workflows.test.ts ++@@ -0,0 +1,581 @@ +++import * as assert from 'node:assert/strict'; +++import { execFileSync } from 'node:child_process'; +++import * as fs from 'node:fs'; +++import * as path from 'node:path'; +++import * as vm from 'node:vm'; +++import { z } from 'zod'; +++ +++const repoRoot = process.cwd(); +++const workflowDir = path.join(repoRoot, 'cherry-n8n-workflows'); +++const workflowZip = path.join(repoRoot, 'cherry-n8n-workflows.zip'); +++const expectedWorkflowNames = new Map([ +++ ['01_ci_failure_compression.json', 'Cherry - CI Failure Compression'], +++ ['02_openclaw_issue_router.json', 'Cherry - OpenClaw Issue Router'], +++ ['03_pr_risk_classifier.json', 'Cherry - PR Risk Classifier'], +++ ['04_forbidden_change_detector.json', 'Cherry - Forbidden Change Detector'], +++ ['05_engine_degradation_alerting.json', 'Cherry - Engine Degradation Alerting'], +++ ['06_simulation_drift_detector.json', 'Cherry - Simulation Drift Detector'], +++ ['07_release_summary_generator.json', 'Cherry - Release Summary Generator'], +++ ['08_repo_intelligence_digest.json', 'Cherry - Repo Intelligence Digest'], +++ ['09_docs_drift_detector.json', 'Cherry - Docs Drift Detector'], +++ ['10_backlog_grooming.json', 'Cherry - Backlog Grooming'], +++] as const); +++const allWorkflowFiles = [...expectedWorkflowNames.keys()].sort(); +++const prWorkflowFiles = [ +++ '03_pr_risk_classifier.json', +++ '04_forbidden_change_detector.json', +++ '09_docs_drift_detector.json', +++] as const; +++const expectedWorkflowDocs = [ +++ 'README.md', +++ 'COVERAGE_MATRIX.md', +++ 'VALIDATION_REPORT.md', +++] as const; +++const requiredCherryEndpoints = [ +++ '/api/automation/classify/pr', +++ '/api/automation/events', +++ '/api/automation/statuses/github', +++] as const; +++const forbiddenAuthorityNodeNames = new Set([ +++ 'Score Risk', +++ 'Detect Forbidden Changes', +++ 'Detect Docs Drift', +++]); +++ +++const forbiddenAuthorityPayloadPatterns = [ +++ /\briskScore\b/, +++ /\$json\.riskScore\b/, +++ /\$json\.forbidden\b/, +++ /\$json\.drift\b/, +++ /String\(\$json\.riskScore/, +++ /statusRequest\?\.context/, +++ /statusRequest\?\.state/, +++ /statusRequest\?\.description/, +++ /\?\?\s*'cherry\/risk-gate'/, +++ /\?\?\s*'cherry\/forbidden-change'/, +++ /\?\?\s*'cherry\/docs-drift'/, +++ /risk\.statusRequest/, +++ /forbiddenChange\.statusRequest/, +++ /docsDrift\.statusRequest/, +++]; +++ +++const forbiddenStatusIdentityFallbackPatterns = [ +++ /\?\?\s*'div0rce\/cherry'/, +++ /\?\?\s*'unknown-sha'/, +++ /\?\?\s*'post-cherry-status'/, +++ /\?\?\s*'pr-automation@1/, +++]; +++ +++const WorkflowSchema = z +++ .object({ +++ name: z.unknown().optional(), +++ nodes: z +++ .array( +++ z +++ .object({ +++ name: z.unknown().optional(), +++ parameters: z.unknown().optional(), +++ }) +++ .passthrough() +++ ) +++ .optional(), +++ connections: z.record(z.string(), z.unknown()).optional(), +++ settings: z.unknown().optional(), +++ }) +++ .passthrough(); +++ +++type WorkflowNode = { +++ name?: unknown; +++ id?: unknown; +++ type?: unknown; +++ typeVersion?: unknown; +++ position?: unknown; +++ parameters?: unknown; +++ disabled?: unknown; +++}; +++ +++type Workflow = z.infer; +++ +++function nodeByName(nodes: WorkflowNode[], name: string): WorkflowNode { +++ const node = nodes.find((candidate) => candidate.name === name); +++ assert.notEqual(node, undefined, `workflow must contain ${name}`); +++ return node as WorkflowNode; +++} +++ +++function connectionTargets(workflow: z.infer, source: string): string[] { +++ const connections = workflow.connections; +++ if (connections === undefined) return []; +++ const sourceConnections = connections[source]; +++ if (sourceConnections === null || typeof sourceConnections !== 'object') return []; +++ const main = (sourceConnections as Record)['main']; +++ if (!Array.isArray(main)) return []; +++ return main.flatMap((group) => { +++ if (!Array.isArray(group)) return []; +++ return group +++ .map((connection) => { +++ if (connection === null || typeof connection !== 'object') return null; +++ const node = (connection as Record)['node']; +++ return typeof node === 'string' ? node : null; +++ }) +++ .filter((node): node is string => node !== null); +++ }); +++} +++ +++function isRecord(value: unknown): value is Record { +++ return value !== null && typeof value === 'object' && Array.isArray(value) === false; +++} +++ +++async function loadWorkflow(fileName: string): Promise<{ raw: string; workflow: Workflow }> { +++ const absolutePath = path.join(workflowDir, fileName); +++ const raw = fs.readFileSync(absolutePath, 'utf8'); +++ const parsed = (await new Response(raw).json()) as unknown; +++ assert.equal(Array.isArray(parsed), false, `${fileName} must contain one workflow object`); +++ return { raw, workflow: WorkflowSchema.parse(parsed) }; +++} +++ +++function allConnectionTargets(workflow: Workflow): string[] { +++ const out: string[] = []; +++ for (const source of Object.keys(workflow.connections ?? {})) { +++ out.push(...connectionTargets(workflow, source)); +++ } +++ return out; +++} +++ +++function reachableNodes(workflow: Workflow, start: string): Set { +++ const seen = new Set(); +++ const queue = [start]; +++ while (queue.length > 0) { +++ const current = queue.shift() as string; +++ if (seen.has(current)) continue; +++ seen.add(current); +++ for (const target of connectionTargets(workflow, current)) { +++ if (!seen.has(target)) queue.push(target); +++ } +++ } +++ return seen; +++} +++ +++function headerValue(node: WorkflowNode, headerName: string): string { +++ const parameters = isRecord(node.parameters) ? node.parameters : {}; +++ const headerParameters = parameters['headerParameters']; +++ if (!isRecord(headerParameters)) return ''; +++ const parametersArray = headerParameters['parameters']; +++ if (!Array.isArray(parametersArray)) return ''; +++ const entries: unknown[] = parametersArray; +++ const match: unknown = entries.find( +++ (entry) => +++ isRecord(entry) && +++ String(entry['name']).toLowerCase() === headerName.toLowerCase() +++ ); +++ return isRecord(match) ? String(match['value'] ?? '') : ''; +++} +++ +++async function assertWorkflowIntegrity(): Promise { +++ const actualJsonFiles = fs +++ .readdirSync(workflowDir) +++ .filter((file) => file.endsWith('.json')) +++ .sort(); +++ assert.deepEqual(actualJsonFiles, allWorkflowFiles, 'workflow JSON file set must be stable'); +++ +++ const seenNames = new Set(); +++ const webhookPaths: string[] = []; +++ const workflowRaw = new Map(); +++ for (const fileName of allWorkflowFiles) { +++ const { raw, workflow } = await loadWorkflow(fileName); +++ workflowRaw.set(fileName, raw); +++ assert.equal(workflow.name, expectedWorkflowNames.get(fileName), `${fileName} name drifted`); +++ assert.equal(seenNames.has(String(workflow.name)), false, `${fileName} duplicate name`); +++ seenNames.add(String(workflow.name)); +++ assert.ok(Array.isArray(workflow.nodes), `${fileName} must have nodes array`); +++ assert.ok(isRecord(workflow.connections), `${fileName} must have connections object`); +++ assert.ok(isRecord(workflow.settings), `${fileName} must have settings object`); +++ assert.equal( +++ (workflow.settings as Record)['executionOrder'], +++ 'v1', +++ `${fileName} must use executionOrder v1` +++ ); +++ +++ const nodes = (workflow.nodes ?? []) as WorkflowNode[]; +++ const nodeNames = new Set(); +++ const triggerNodes: WorkflowNode[] = []; +++ const respondNodes: WorkflowNode[] = []; +++ for (const node of nodes) { +++ assert.equal(typeof node.id, 'string', `${fileName} node must have id`); +++ assert.equal(typeof node.name, 'string', `${fileName} node must have name`); +++ assert.equal(typeof node.type, 'string', `${fileName} node ${String(node.name)} must have type`); +++ assert.equal( +++ typeof node.typeVersion, +++ 'number', +++ `${fileName} node ${String(node.name)} must have typeVersion` +++ ); +++ assert.ok( +++ Array.isArray(node.position) && node.position.length === 2, +++ `${fileName} node ${String(node.name)} must have x/y position` +++ ); +++ assert.ok( +++ isRecord(node.parameters), +++ `${fileName} node ${String(node.name)} must have parameters object` +++ ); +++ assert.equal(nodeNames.has(String(node.name)), false, `${fileName} duplicate node name`); +++ nodeNames.add(String(node.name)); +++ if ( +++ node.type === 'n8n-nodes-base.webhook' || +++ node.type === 'n8n-nodes-base.scheduleTrigger' || +++ node.type === 'n8n-nodes-base.manualTrigger' +++ ) { +++ triggerNodes.push(node); +++ } +++ if (node.type === 'n8n-nodes-base.respondToWebhook') { +++ respondNodes.push(node); +++ } +++ if (node.type === 'n8n-nodes-base.webhook') { +++ const parameters = node.parameters as Record; +++ assert.equal(parameters['responseMode'], 'responseNode', `${fileName} webhook must use responseNode`); +++ assert.equal(typeof parameters['path'], 'string', `${fileName} webhook path missing`); +++ webhookPaths.push(String(parameters['path'])); +++ } +++ if (node.type === 'n8n-nodes-base.httpRequest') { +++ assert.notEqual( +++ node.disabled, +++ true, +++ `${fileName} HTTP node ${String(node.name)} must not be disabled` +++ ); +++ } +++ } +++ +++ assert.ok(triggerNodes.length > 0, `${fileName} must have a trigger node`); +++ assert.equal( +++ triggerNodes.length === 1 && triggerNodes[0]?.type === 'n8n-nodes-base.manualTrigger', +++ false, +++ `${fileName} production workflow must not rely on Manual Trigger only` +++ ); +++ +++ for (const source of Object.keys(workflow.connections ?? {})) { +++ assert.ok(nodeNames.has(source), `${fileName} connection source missing node: ${source}`); +++ } +++ for (const target of allConnectionTargets(workflow)) { +++ assert.ok(nodeNames.has(target), `${fileName} connection target missing node: ${target}`); +++ } +++ +++ for (const webhook of nodes.filter((node) => node.type === 'n8n-nodes-base.webhook')) { +++ const reachable = reachableNodes(workflow, String(webhook.name)); +++ assert.ok( +++ respondNodes.some((node) => reachable.has(String(node.name))), +++ `${fileName} webhook must reach Respond to Webhook` +++ ); +++ } +++ } +++ assert.equal(new Set(webhookPaths).size, webhookPaths.length, 'webhook paths must be unique'); +++ +++ for (const endpoint of requiredCherryEndpoints) { +++ assert.ok( +++ [...workflowRaw.values()].some((raw) => raw.includes(endpoint)), +++ `workflow pack must call ${endpoint}` +++ ); +++ } +++} +++ +++async function assertWorkflowHttpSafety(): Promise { +++ for (const fileName of allWorkflowFiles) { +++ const { raw, workflow } = await loadWorkflow(fileName); +++ assert.equal(/api\.github\.com\/repos\/[^'"]+\/statuses\//.test(raw), false, `${fileName} must not call GitHub statuses directly`); +++ assert.equal(/\/check-runs\b|\/check-suites\b/.test(raw), false, `${fileName} must not call GitHub Checks API directly`); +++ assert.equal(/localhost|127\.0\.0\.1/.test(raw), false, `${fileName} must not hardcode local URLs`); +++ assert.equal(/"credentials"\s*:/.test(raw), false, `${fileName} must not contain credentials object`); +++ assert.equal(/credentialId|password|apiKey|webhookSecret/i.test(raw), false, `${fileName} must not contain credential identifiers or secret fields`); +++ assert.equal( +++ /ghp_[A-Za-z0-9_]{20,}|github_pat_[A-Za-z0-9_]{20,}|\bsk-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{20,}/.test(raw), +++ false, +++ `${fileName} must not contain literal secret tokens` +++ ); +++ assert.equal( +++ /\/api\/(sessions?|ledgers?|buckets?|payments?|cards?)(\/|$)|\/api\/debts?(\/.*)?\/mutate\b/i.test(raw), +++ false, +++ `${fileName} must not call forbidden Cherry finance endpoints` +++ ); +++ +++ const nodes = (workflow.nodes ?? []) as WorkflowNode[]; +++ for (const node of nodes) { +++ if (node.type !== 'n8n-nodes-base.httpRequest') continue; +++ assert.equal( +++ node.disabled === true, +++ false, +++ `${fileName} HTTP node ${String(node.name)} must not be disabled` +++ ); +++ assert.equal( +++ (node as { continueOnFail?: unknown }).continueOnFail, +++ true, +++ `${fileName} HTTP node ${String(node.name)} must continueOnFail` +++ ); +++ const parameters = isRecord(node.parameters) ? node.parameters : {}; +++ const url = String(parameters['url'] ?? ''); +++ if (url.includes('/api/automation/') || url.includes('CHERRY_API_BASE_URL')) { +++ assert.ok(url.includes('CHERRY_API_BASE_URL'), `${fileName} Cherry call must use CHERRY_API_BASE_URL`); +++ assert.ok( +++ headerValue(node, 'Authorization').includes('CHERRY_AUTOMATION_TOKEN'), +++ `${fileName} Cherry call must use CHERRY_AUTOMATION_TOKEN` +++ ); +++ } +++ } +++ } +++} +++ +++function assertZipMatchesFolder(): void { +++ assert.ok(fs.existsSync(workflowZip), 'cherry-n8n-workflows.zip must exist'); +++ const entries = execFileSync('unzip', ['-Z1', workflowZip], { +++ cwd: repoRoot, +++ encoding: 'utf8', +++ }) +++ .split('\n') +++ .map((entry) => entry.trim()) +++ .filter((entry) => entry.length > 0); +++ assert.ok( +++ entries.every((entry) => entry === 'cherry-n8n-workflows/' || entry.startsWith('cherry-n8n-workflows/')), +++ 'zip root must contain cherry-n8n-workflows/ only' +++ ); +++ const zippedFiles = entries +++ .filter((entry) => entry.endsWith('/') === false) +++ .map((entry) => entry.replace(/^cherry-n8n-workflows\//, '')) +++ .sort(); +++ const folderFiles = fs +++ .readdirSync(workflowDir, { withFileTypes: true }) +++ .filter((entry) => entry.isFile()) +++ .map((entry) => entry.name) +++ .sort(); +++ assert.deepEqual(zippedFiles, folderFiles, 'zip and workflow folder file sets must match'); +++ assert.deepEqual( +++ zippedFiles.filter((file) => file.endsWith('.json')), +++ allWorkflowFiles, +++ 'zip and folder workflow JSON filenames must match' +++ ); +++ for (const file of [...allWorkflowFiles, ...expectedWorkflowDocs]) { +++ const folderContent = fs.readFileSync(path.join(workflowDir, file), 'utf8'); +++ const zipContent = execFileSync( +++ 'unzip', +++ ['-p', workflowZip, `cherry-n8n-workflows/${file}`], +++ { cwd: repoRoot, encoding: 'utf8' } +++ ); +++ assert.equal(zipContent, folderContent, `zip entry ${file} must match folder source`); +++ } +++} +++ +++function normalizeChangedFiles(jsCode: string, items: Array<{ json: unknown }>): unknown[] { +++ const output = vm.runInNewContext(`(() => {\n${jsCode}\n})()`, { +++ $input: { all: () => items }, +++ $items: (name: string) => +++ name === 'Normalize PR' ? [{ json: { repo: 'div0rce/cherry' } }] : [], +++ }) as Array<{ json: { files?: unknown[] } }>; +++ return output[0]?.json.files ?? []; +++} +++ +++function assertPreservesReturnedFiles( +++ label: string, +++ jsCode: string, +++ items: Array<{ json: unknown }> +++): void { +++ const files = normalizeChangedFiles(jsCode, items); +++ assert.ok( +++ files.length > 0, +++ `${label}: GitHub response contained files but normalized output was empty` +++ ); +++} +++ +++await assertWorkflowIntegrity(); +++await assertWorkflowHttpSafety(); +++assertZipMatchesFolder(); +++ +++for (const fileName of prWorkflowFiles) { +++ const { raw, workflow } = await loadWorkflow(fileName); +++ const nodes = (Array.isArray(workflow.nodes) ? workflow.nodes : []) as WorkflowNode[]; +++ const nodeNames = nodes.map((node) => String(node.name ?? '')); +++ +++ for (const forbiddenName of forbiddenAuthorityNodeNames) { +++ assert.equal( +++ nodeNames.includes(forbiddenName), +++ false, +++ `${fileName} must not contain local authority node ${forbiddenName}` +++ ); +++ } +++ +++ assert.equal( +++ nodeNames.includes('Classify PR In Cherry'), +++ true, +++ `${fileName} must call Cherry PR classifier` +++ ); +++ assert.equal( +++ nodeNames.includes('Normalize Changed Files'), +++ true, +++ `${fileName} must normalize changed files before Cherry classification` +++ ); +++ assert.deepEqual( +++ connectionTargets(workflow, 'Fetch Changed Files'), +++ ['Normalize Changed Files'], +++ `${fileName} must route Fetch Changed Files to Normalize Changed Files` +++ ); +++ assert.deepEqual( +++ connectionTargets(workflow, 'Normalize Changed Files'), +++ ['Classify PR In Cherry'], +++ `${fileName} must route Normalize Changed Files to Classify PR In Cherry` +++ ); +++ assert.equal( +++ nodeNames.includes('Require Status Payload'), +++ true, +++ `${fileName} must fail closed when Cherry omits required status payload fields` +++ ); +++ assert.equal( +++ nodeNames.includes('IF: Has Status Payload?'), +++ true, +++ `${fileName} must branch before posting status` +++ ); +++ assert.deepEqual( +++ connectionTargets(workflow, 'Require Status Payload'), +++ ['IF: Has Status Payload?'], +++ `${fileName} must route status guard to status-payload IF` +++ ); +++ +++ for (const pattern of forbiddenAuthorityPayloadPatterns) { +++ assert.equal( +++ pattern.test(raw), +++ false, +++ `${fileName} must not synthesize local scoring/detection authority with ${pattern}` +++ ); +++ } +++ assert.match( +++ raw, +++ /Cherry status payload missing required fields\. Refusing to post status\./, +++ `${fileName} must include safe missing-status-payload refusal` +++ ); +++ assert.match( +++ raw, +++ /if \(!event\.sha\) missing\.push\('sha'\);/, +++ `${fileName} must fail closed when status payload sha is absent` +++ ); +++ assert.match( +++ raw, +++ /do_not_post_status/, +++ `${fileName} must refuse to post status when Cherry omits required status payload fields` +++ ); +++ +++ assert.equal( +++ /Array\.isArray\(\$json\)\s*\?\s*\$json\s*:\s*\[\]/.test(raw), +++ false, +++ `${fileName} must not drop changed files with Array.isArray($json) fallback` +++ ); +++ +++ const classifyNode = nodeByName(nodes, 'Classify PR In Cherry'); +++ const classifyParameters = classifyNode.parameters; +++ assert.notEqual(classifyParameters, null); +++ assert.equal(typeof classifyParameters, 'object'); +++ const classifyBody = String( +++ (classifyParameters as Record)['jsonBody'] ?? '' +++ ); +++ assert.match( +++ classifyBody, +++ /files:\s*\$json\.files/, +++ `${fileName} classifier request must pass files: $json.files` +++ ); +++ +++ const buildRoutingNode = nodes.find((node) => +++ String(node.name ?? '').startsWith('Build Cherry') +++ ); +++ assert.notEqual(buildRoutingNode, undefined, `${fileName} must build Cherry routing output`); +++ const buildRoutingParameters = buildRoutingNode?.parameters; +++ assert.notEqual(buildRoutingParameters, null); +++ assert.equal(typeof buildRoutingParameters, 'object'); +++ const buildRoutingCode = String( +++ (buildRoutingParameters as Record)['jsCode'] ?? '' +++ ); +++ assert.match( +++ buildRoutingCode, +++ /const sha = prEvent\.payload\.pull_request\.head\.sha;/, +++ `${fileName} must set sha from the normalized PR event before status posting` +++ ); +++ assert.match( +++ buildRoutingCode, +++ /\n\s*sha,\n/, +++ `${fileName} must carry sha as a direct routing output field` +++ ); +++ +++ const normalizeNode = nodeByName(nodes, 'Normalize Changed Files'); +++ const normalizeParameters = normalizeNode.parameters; +++ assert.notEqual(normalizeParameters, null); +++ assert.equal(typeof normalizeParameters, 'object'); +++ const normalizeCode = String( +++ (normalizeParameters as Record)['jsCode'] ?? '' +++ ); +++ assertPreservesReturnedFiles(`${fileName} array payload`, normalizeCode, [ +++ { json: [{ filename: 'lib/engine/solver.ts' }] }, +++ ]); +++ assertPreservesReturnedFiles(`${fileName} json.files`, normalizeCode, [ +++ { json: { files: [{ filename: 'app/api/scan/route.ts' }] } }, +++ ]); +++ assertPreservesReturnedFiles(`${fileName} json.data`, normalizeCode, [ +++ { json: { data: [{ filename: 'prisma/schema.prisma' }] } }, +++ ]); +++ assertPreservesReturnedFiles(`${fileName} per-item object`, normalizeCode, [ +++ { json: { filename: 'docs/engine/update.md' } }, +++ ]); +++ +++ const statusNodes = nodes.filter((node) => String(node.name ?? '').startsWith('Post ')); +++ const statusBodies = statusNodes +++ .map((node) => { +++ const parameters = node.parameters; +++ if (parameters === null || typeof parameters !== 'object') return ''; +++ return String((parameters as Record)['jsonBody'] ?? ''); +++ }) +++ .join('\n'); +++ assert.match( +++ statusBodies, +++ /repo:\s*\$json\.repo/, +++ `${fileName} status bodies must use direct Cherry repo` +++ ); +++ assert.match( +++ statusBodies, +++ /sha:\s*\$json\.sha/, +++ `${fileName} status bodies must use direct Cherry sha` +++ ); +++ assert.match( +++ statusBodies, +++ /\$json\.statusRequest\.context/, +++ `${fileName} status bodies must use direct Cherry statusRequest context` +++ ); +++ assert.match( +++ statusBodies, +++ /\$json\.statusRequest\.state/, +++ `${fileName} status bodies must use direct Cherry statusRequest state` +++ ); +++ assert.match( +++ statusBodies, +++ /\$json\.statusRequest\.description/, +++ `${fileName} status bodies must use direct Cherry statusRequest description` +++ ); +++ assert.match( +++ statusBodies, +++ /\$json\.statusRequest\.targetUrl/, +++ `${fileName} status bodies must use Cherry statusRequests` +++ ); +++ assert.match( +++ statusBodies, +++ /\$json\.outputHash/, +++ `${fileName} status bodies must use Cherry top-level outputHash` +++ ); +++ assert.match( +++ statusBodies, +++ /sourceWorkflow:\s*\$json\.workflow/, +++ `${fileName} status bodies must use direct workflow identity` +++ ); +++ assert.match( +++ statusBodies, +++ /classifierVersion:\s*\$json\.cherryClassifierOutput\.classifierVersion/, +++ `${fileName} status bodies must use direct Cherry classifier version` +++ ); +++ for (const pattern of forbiddenStatusIdentityFallbackPatterns) { +++ assert.equal( +++ pattern.test(statusBodies), +++ false, +++ `${fileName} status bodies must not synthesize status identity with ${pattern}` +++ ); +++ } +++} +++ +++console.warn('automation workflows: ok'); diff --git a/cherry-n8n-workflows.zip b/cherry-n8n-workflows.zip new file mode 100644 index 00000000..d695b420 Binary files /dev/null and b/cherry-n8n-workflows.zip differ diff --git a/cherry-n8n-workflows/01_ci_failure_compression.json b/cherry-n8n-workflows/01_ci_failure_compression.json new file mode 100644 index 00000000..f4ed582d --- /dev/null +++ b/cherry-n8n-workflows/01_ci_failure_compression.json @@ -0,0 +1,570 @@ +{ + "name": "Cherry - CI Failure Compression", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "cherry/github/workflow-completed", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook-ci-completed", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.workflow_completed',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '01_ci_failure_compression',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.workflow_completed.',\n actions: []\n};\nreturn [{ json: output }];" + }, + "id": "normalize-github-payload", + "name": "Normalize GitHub Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 260, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.workflow_run.conclusion\",\"payload.workflow_run.html_url\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" + }, + "id": "validate-payload", + "name": "Validate Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 520, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst run = event.payload?.workflow_run ?? {};\nconst conclusion = run.conclusion ?? 'unknown';\nconst text = [run.name, run.display_title, run.path, run.html_url].filter(Boolean).join(' ').toLowerCase();\nconst category = text.includes('lint') ? 'lint' : text.includes('typecheck') || text.includes('typescript') ? 'typecheck' : text.includes('test') ? 'test' : text.includes('build') ? 'build' : text.includes('closure') || text.includes('guardrail') ? 'repo-closure' : text.includes('migration') || text.includes('prisma') ? 'migration' : 'unknown';\nconst shouldProcess = conclusion === 'failure' || conclusion === 'timed_out' || conclusion === 'cancelled';\nconst workflowName = run.name ?? 'unknown workflow';\nconst branch = run.head_branch ?? 'unknown branch';\nconst sha = run.head_sha ?? 'unknown sha';\nconst url = run.html_url ?? '';\nconst title = '[ci:' + category + '] ' + workflowName + ' failed on ' + branch;\nconst output = { ...event, shouldProcess, failureCategory: category, workflowName, branch, sha, url, searchQuery: encodeURIComponent('repo:' + event.repo + ' is:issue is:open in:title \"[ci:' + category + ']\" \"' + workflowName + '\"'), issueBody: { title, labels: ['ci-failure', 'automation', 'needs-triage', category], body: 'Cherry CI failure compression.\\n\\nWorkflow: ' + workflowName + '\\nBranch: ' + branch + '\\nSHA: ' + sha + '\\nCategory: ' + category + '\\nRun: ' + url + '\\n\\nSuggested verification: npm run ci:verify\\n\\nAdvisory automation only.' }, commentBody: { body: 'Repeated CI failure.\\n\\nWorkflow: ' + workflowName + '\\nBranch: ' + branch + '\\nSHA: ' + sha + '\\nCategory: ' + category + '\\nRun: ' + url }, openclawTask: { source: 'ci_failure', repo: event.repo, title, category, branch, sha, url, guardrails: ['npm run ci:verify', 'no Cherry finance truth mutation'] }, status: shouldProcess ? 'accepted' : 'ignored', summary: shouldProcess ? title : 'Workflow conclusion was ' + conclusion + '; ignoring.', actions: shouldProcess ? ['search_existing_issue', 'comment_or_create_issue', 'optional_openclaw_task', 'archive_event'] : [] };\nreturn [{ json: output }];" + }, + "id": "classify-failure", + "name": "Classify Failure", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 780, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "if-failure-condition", + "leftValue": "={{ $json.shouldProcess === true }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-failure", + "name": "IF: Failure?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1040, + 0 + ] + }, + { + "parameters": { + "method": "GET", + "url": "={{ 'https://api.github.com/search/issues?q=' + $json.searchQuery }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {} + }, + "id": "search-existing-issues", + "name": "Search Existing GitHub Issues", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1300, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "if-existing-issue-condition", + "leftValue": "={{ Array.isArray($json.items) && $json.items.length > 0 }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-existing-issue", + "name": "IF: Existing Issue?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1560, + -160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.items[0].number + '/comments' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $node[\"Classify Failure\"].json.commentBody }}" + }, + "id": "comment-existing-issue", + "name": "Comment Existing Issue", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1820, + -320 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $node[\"Classify Failure\"].json.issueBody }}" + }, + "id": "create-new-issue", + "name": "Create New Issue", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1820, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.OPENCLAW_WEBHOOK_URL }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $node[\"Classify Failure\"].json.openclawTask }}" + }, + "id": "send-ci-failure-to-openclaw", + "name": "Send CI Failure To OpenClaw", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2080, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '01_ci_failure_compression', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" + }, + "id": "route-shared-sinks", + "name": "Route Shared Sinks", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2340, + -160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '01_ci_failure_compression.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '01_ci_failure_compression',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '01_ci_failure_compression') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '01_ci_failure_compression.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" + }, + "id": "archive-event", + "name": "Archive Event", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2600, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.DISCORD_WEBHOOK_URL }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" + }, + "id": "notify-discord", + "name": "Notify Discord", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2860, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'CI failure compressed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-response", + "name": "Build Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3120, + -160 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: 'ignored', summary: event.summary ?? 'CI workflow did not fail; no action taken.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-ignored-response", + "name": "Build Ignored Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1300, + 160 + ] + }, + { + "parameters": { + "respondWith": "firstIncomingItem", + "options": { + "responseCode": 200 + } + }, + "id": "respond-to-webhook", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [ + 3380, + 0 + ] + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Normalize GitHub Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize GitHub Payload": { + "main": [ + [ + { + "node": "Validate Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Payload": { + "main": [ + [ + { + "node": "Classify Failure", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify Failure": { + "main": [ + [ + { + "node": "IF: Failure?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: Failure?": { + "main": [ + [ + { + "node": "Search Existing GitHub Issues", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Build Ignored Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Search Existing GitHub Issues": { + "main": [ + [ + { + "node": "IF: Existing Issue?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: Existing Issue?": { + "main": [ + [ + { + "node": "Comment Existing Issue", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Create New Issue", + "type": "main", + "index": 0 + } + ] + ] + }, + "Comment Existing Issue": { + "main": [ + [ + { + "node": "Send CI Failure To OpenClaw", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create New Issue": { + "main": [ + [ + { + "node": "Send CI Failure To OpenClaw", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send CI Failure To OpenClaw": { + "main": [ + [ + { + "node": "Route Shared Sinks", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route Shared Sinks": { + "main": [ + [ + { + "node": "Archive Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Archive Event": { + "main": [ + [ + { + "node": "Notify Discord", + "type": "main", + "index": 0 + } + ] + ] + }, + "Notify Discord": { + "main": [ + [ + { + "node": "Build Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Ignored Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/cherry-n8n-workflows/02_openclaw_issue_router.json b/cherry-n8n-workflows/02_openclaw_issue_router.json new file mode 100644 index 00000000..45c76c26 --- /dev/null +++ b/cherry-n8n-workflows/02_openclaw_issue_router.json @@ -0,0 +1,389 @@ +{ + "name": "Cherry - OpenClaw Issue Router", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "cherry/github/issue-labeled", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook-issue-labeled", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'github.issue_labeled',\n source: 'github',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '02_openclaw_issue_router',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.issue_labeled.',\n actions: []\n};\nreturn [{ json: output }];" + }, + "id": "normalize-issue-event", + "name": "Normalize Issue Event", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 260, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.issue.number\",\"payload.issue.title\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" + }, + "id": "validate-payload", + "name": "Validate Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 520, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "if-openclaw-label-condition", + "leftValue": "={{ (($json.payload.label?.name ?? '') === 'openclaw') || (($json.payload.issue?.labels ?? []).some((label) => label.name === 'openclaw')) }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-openclaw-label", + "name": "IF: Has openclaw Label?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 780, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst issue = event.payload?.issue ?? {};\nconst body = String(issue.body ?? '');\nconst forbiddenPatterns = ['.env', '.env.local', 'secrets', 'production db config', '/api/session', '/api/ledger', '/api/bucket', '/api/payment', '/api/card'];\nconst forbiddenMatches = forbiddenPatterns.filter((pattern) => body.toLowerCase().includes(pattern.toLowerCase()));\nconst task = { source: 'github_issue_openclaw', repo: event.repo, issueNumber: issue.number, title: issue.title, url: issue.html_url, body, constraints: { advisoryOnly: true, forbiddenFiles: ['.env', '.env.local'], forbiddenEndpointPatterns: ['/api/session*', '/api/ledger*', '/api/bucket*', '/api/payment*', '/api/card*', '/api/debt*/mutate'], requiredReviewLabels: ['needs-human-review'] }, forbiddenMatches };\nconst output = { ...event, openclawTask: task, commentBody: { body: 'OpenClaw task prepared.\\n\\nIssue: #' + issue.number + '\\nForbidden hints: ' + (forbiddenMatches.join(', ') || 'none') + '\\nHuman review remains required before merge.' }, status: forbiddenMatches.length > 0 ? 'failed' : 'accepted', summary: forbiddenMatches.length > 0 ? 'OpenClaw issue contains forbidden-change hints.' : 'OpenClaw task routed for issue #' + issue.number + '.', actions: forbiddenMatches.length > 0 ? ['block_openclaw_task', 'comment_issue', 'archive_event'] : ['send_openclaw_task', 'comment_issue', 'archive_event'] };\nreturn [{ json: output }];" + }, + "id": "build-openclaw-task", + "name": "Build OpenClaw Task", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1040, + -160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.OPENCLAW_WEBHOOK_URL }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.openclawTask }}" + }, + "id": "send-to-openclaw", + "name": "Send To OpenClaw", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1300, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build OpenClaw Task\"].json.openclawTask.issueNumber + '/comments' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $node[\"Build OpenClaw Task\"].json.commentBody }}" + }, + "id": "comment-on-issue", + "name": "Comment On Issue", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1560, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '02_openclaw_issue_router', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" + }, + "id": "route-shared-sinks", + "name": "Route Shared Sinks", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1820, + -160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '02_openclaw_issue_router.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '02_openclaw_issue_router',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '02_openclaw_issue_router') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '02_openclaw_issue_router.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" + }, + "id": "archive-event", + "name": "Archive Event", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2080, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'OpenClaw issue routed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-response", + "name": "Build Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2340, + -160 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: 'ignored', summary: event.summary ?? 'Issue was not labeled openclaw; no action taken.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-ignored-response", + "name": "Build Ignored Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1040, + 160 + ] + }, + { + "parameters": { + "respondWith": "firstIncomingItem", + "options": { + "responseCode": 200 + } + }, + "id": "respond-to-webhook", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [ + 2600, + 0 + ] + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Normalize Issue Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Issue Event": { + "main": [ + [ + { + "node": "Validate Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Payload": { + "main": [ + [ + { + "node": "IF: Has openclaw Label?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: Has openclaw Label?": { + "main": [ + [ + { + "node": "Build OpenClaw Task", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Build Ignored Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build OpenClaw Task": { + "main": [ + [ + { + "node": "Send To OpenClaw", + "type": "main", + "index": 0 + } + ] + ] + }, + "Send To OpenClaw": { + "main": [ + [ + { + "node": "Comment On Issue", + "type": "main", + "index": 0 + } + ] + ] + }, + "Comment On Issue": { + "main": [ + [ + { + "node": "Route Shared Sinks", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route Shared Sinks": { + "main": [ + [ + { + "node": "Archive Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Archive Event": { + "main": [ + [ + { + "node": "Build Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Ignored Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/cherry-n8n-workflows/03_pr_risk_classifier.json b/cherry-n8n-workflows/03_pr_risk_classifier.json new file mode 100644 index 00000000..cc6a09f3 --- /dev/null +++ b/cherry-n8n-workflows/03_pr_risk_classifier.json @@ -0,0 +1,551 @@ +{ + "name": "Cherry - PR Risk Classifier", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "cherry/github/pull-request-risk", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook-pr-risk", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const raw = $input.first()?.json ?? {};\nconst input = raw.body ?? raw.payload ?? raw;\n\nconst item = Array.isArray(input.items) ? input.items[0] : undefined;\n\nconst pr =\n input.pull_request ??\n input.pr ??\n item ??\n input;\n\nconst prNumber =\n pr.number ??\n input.number ??\n input.pull_number ??\n input.pr_number;\n\nconst sha =\n pr.head?.sha ??\n input.head?.sha ??\n input.after ??\n input.sha;\n\nconst repoFullName =\n input.repository?.full_name ??\n input.repo ??\n input.full_name ??\n 'div0rce/cherry';\n\nconst [owner, repo] = String(repoFullName).split('/');\n\nif (!prNumber) {\n return [{\n json: {\n ok: false,\n status: 'failed',\n workflow: '03_pr_risk_classifier',\n error: 'missing_pr_number',\n reason: 'Normalize PR could not derive a PR number from webhook, search, or flat payload',\n receivedKeys: Object.keys(input),\n totalCount: input.total_count,\n searchType: input.search_type,\n repoFullName,\n actions: ['do_not_classify_pr']\n },\n }];\n}\n\nconst labels = Array.isArray(pr.labels)\n ? pr.labels.map((label) => typeof label === 'string' ? label : label.name).filter(Boolean)\n : [];\n\nreturn [{\n json: {\n ...input,\n event: 'github.pull_request',\n source: 'github',\n owner: owner || input.repository?.owner?.login || 'div0rce',\n repo: repoFullName,\n repoName: repo || input.repository?.name || 'cherry',\n repoFullName,\n prNumber,\n sha,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n labels,\n timestamp: raw.timestamp ?? input.workflow_run?.updated_at ?? pr.updated_at ?? input.issue?.updated_at ?? new Date().toISOString(),\n workflow: '03_pr_risk_classifier',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.pull_request.',\n actions: [],\n pull_request: {\n number: prNumber,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n head: { sha },\n labels: Array.isArray(pr.labels) ? pr.labels : [],\n },\n payload: {\n ...input,\n repository: input.repository ?? { full_name: repoFullName, name: repo || input.repository?.name || 'cherry', owner: { login: owner || input.repository?.owner?.login || 'div0rce' } },\n pull_request: {\n number: prNumber,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n head: { sha },\n labels: Array.isArray(pr.labels) ? pr.labels : [],\n }\n }\n },\n}];" + }, + "id": "normalize-pr", + "name": "Normalize PR", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 260, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\nif (!event.prNumber) missing.push('prNumber');\nif (!event.sha) missing.push('sha');\nconst output = {\n ...event,\n valid: missing.length === 0,\n validationErrors: missing,\n status: missing.length === 0 ? event.status : 'failed',\n summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', '),\n actions: missing.length === 0 ? event.actions : [...(event.actions ?? []), 'do_not_classify_pr']\n};\nreturn [{ json: output }];" + }, + "id": "validate-payload", + "name": "Validate Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 520, + 0 + ] + }, + { + "parameters": { + "method": "GET", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls/' + $json.prNumber + '/files?per_page=100' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {} + }, + "id": "fetch-changed-files", + "name": "Fetch Changed Files", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 780, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const prEvent = $node['Normalize PR'].json;\nconst classification = $input.first()?.json ?? {};\nconst classifierOutput = classification.classifierOutput ?? {};\nconst risk = classifierOutput.risk ?? {};\nconst statusRequests = Array.isArray(classifierOutput.statusRequests) ? classifierOutput.statusRequests : [];\nconst statusRequest = statusRequests.find((request) => request.context === 'cherry/risk-gate');\nconst labels = Array.isArray(risk.labels) ? risk.labels : [];\nconst reasons = Array.isArray(risk.reasons) ? risk.reasons : [];\nconst score = typeof risk.score === 'number' ? risk.score : 'unknown';\nconst level = typeof risk.level === 'string' ? risk.level : 'unknown';\nconst prNumber = prEvent.prNumber;\nconst sha = prEvent.sha;\nconst output = {\n ...prEvent,\n sha,\n automationEventId: classification.automationEventId,\n outputHash: classification.outputHash,\n cherryClassifierOutput: classifierOutput,\n statusRequest,\n labels,\n labelBody: { labels },\n commentBody: { body: 'Cherry PR risk classifier.\\n\\nLevel: ' + level + '\\nScore: ' + String(score) + '\\nLabels: ' + labels.join(', ') + '\\nReasons:\\n' + reasons.map((reason) => '- ' + reason).join('\\n') },\n status: 'accepted',\n summary: 'PR #' + String(prNumber ?? 'unknown') + ' risk ' + level + ' from Cherry classifier.',\n actions: ['fetch_changed_files', 'classify_pr_in_cherry', 'post_risk_status', 'apply_labels', 'comment_risk_summary']\n};\nreturn [{ json: output }];" + }, + "id": "build-cherry-pr-routing", + "name": "Build Cherry PR Routing", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1040, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\n\nif (!event.statusRequest) missing.push('statusRequest');\nif (!event.repo) missing.push('repo');\nif (!event.sha) missing.push('sha');\nif (!event.outputHash) missing.push('outputHash');\nif (!event.cherryClassifierOutput?.classifierVersion) missing.push('classifierVersion');\n\nif (missing.length > 0) {\n return [{\n json: {\n ok: false,\n workflow: event.workflow,\n status: 'failed',\n summary: 'Cherry status payload missing required fields. Refusing to post status.',\n actions: ['do_not_post_status'],\n missing\n }\n }];\n}\n\nreturn [{ json: event }];" + }, + "id": "require-risk-status-request", + "name": "Require Status Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1300, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "if-risk-status-request", + "leftValue": "={{ $json.statusRequest !== undefined && $json.statusRequest !== null && $json.repo !== undefined && $json.repo !== null && $json.sha !== undefined && $json.sha !== null && $json.outputHash !== undefined && $json.outputHash !== null && $json.cherryClassifierOutput?.classifierVersion !== undefined && $json.cherryClassifierOutput?.classifierVersion !== null }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-risk-status-request", + "name": "IF: Has Status Payload?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1170, + 160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.prNumber + '/labels' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.labelBody }}" + }, + "id": "apply-labels", + "name": "Apply Labels", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1300, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build Cherry PR Routing\"].json.prNumber + '/comments' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $node[\"Build Cherry PR Routing\"].json.commentBody }}" + }, + "id": "comment-risk-summary", + "name": "Comment Risk Summary", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1560, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '03_pr_risk_classifier', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" + }, + "id": "route-shared-sinks", + "name": "Route Shared Sinks", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1820, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '03_pr_risk_classifier.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '03_pr_risk_classifier',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '03_pr_risk_classifier') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? 'no-sha') + ':' + ($json.outputHash ?? $now.toISO())),\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '03_pr_risk_classifier.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json.cherryClassifierOutput ?? $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" + }, + "id": "archive-event", + "name": "Archive Event", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2080, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'PR risk classified.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-response", + "name": "Build Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2340, + 0 + ] + }, + { + "parameters": { + "respondWith": "firstIncomingItem", + "options": { + "responseCode": 200 + } + }, + "id": "respond-to-webhook", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [ + 2600, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/statuses/github' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n context: $json.statusRequest.context,\n state: $json.statusRequest.state,\n description: $json.statusRequest.description,\n targetUrl: $json.statusRequest.targetUrl,\n sourceWorkflow: $json.workflow,\n automationEventId: $json.automationEventId,\n classifierVersion: $json.cherryClassifierOutput.classifierVersion,\n outputHash: $json.outputHash\n} }}" + }, + "id": "post-risk-status", + "name": "Post Risk Status", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1300, + 160 + ], + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const items = $input.all();\n\nlet files = [];\n\nfor (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n files.push(...json);\n } else if (Array.isArray(json.files)) {\n files.push(...json.files);\n } else if (Array.isArray(json.data)) {\n files.push(...json.data);\n } else if (json && json.filename) {\n files.push(json);\n }\n}\n\nfiles = files.map((file) => ({\n filename: file.filename ?? file.path ?? file.name ?? '',\n status: file.status ?? 'modified',\n additions: Number(file.additions ?? 0),\n deletions: Number(file.deletions ?? 0),\n changes: Number(file.changes ?? 0),\n patch: String(file.patch ?? '')\n})).filter((file) => file.filename.length > 0);\n\nreturn [{\n json: {\n ...($items('Normalize PR')[0]?.json ?? {}),\n files\n }\n}];" + }, + "id": "normalize-changed-files", + "name": "Normalize Changed Files", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 910, + 160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/classify/pr' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n prNumber: $json.prNumber,\n title: $json.title,\n body: $json.body ?? '',\n labels: $json.labels ?? [],\n files: $json.files,\n sourceWorkflow: $json.workflow ?? '03_pr_risk_classifier'\n} }}" + }, + "id": "classify-pr-in-cherry", + "name": "Classify PR In Cherry", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1040, + 160 + ], + "continueOnFail": true + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Normalize PR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize PR": { + "main": [ + [ + { + "node": "Validate Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Payload": { + "main": [ + [ + { + "node": "Fetch Changed Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Changed Files": { + "main": [ + [ + { + "node": "Normalize Changed Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "Apply Labels": { + "main": [ + [ + { + "node": "Comment Risk Summary", + "type": "main", + "index": 0 + } + ] + ] + }, + "Comment Risk Summary": { + "main": [ + [ + { + "node": "Route Shared Sinks", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route Shared Sinks": { + "main": [ + [ + { + "node": "Archive Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Archive Event": { + "main": [ + [ + { + "node": "Build Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Post Risk Status": { + "main": [ + [ + { + "node": "Apply Labels", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify PR In Cherry": { + "main": [ + [ + { + "node": "Build Cherry PR Routing", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Cherry PR Routing": { + "main": [ + [ + { + "node": "Require Status Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Changed Files": { + "main": [ + [ + { + "node": "Classify PR In Cherry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Require Status Payload": { + "main": [ + [ + { + "node": "IF: Has Status Payload?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: Has Status Payload?": { + "main": [ + [ + { + "node": "Post Risk Status", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Build Response", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/cherry-n8n-workflows/04_forbidden_change_detector.json b/cherry-n8n-workflows/04_forbidden_change_detector.json new file mode 100644 index 00000000..b75a8c36 --- /dev/null +++ b/cherry-n8n-workflows/04_forbidden_change_detector.json @@ -0,0 +1,667 @@ +{ + "name": "Cherry - Forbidden Change Detector", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "cherry/github/pull-request-forbidden", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook-pr-forbidden", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const raw = $input.first()?.json ?? {};\nconst input = raw.body ?? raw.payload ?? raw;\n\nconst item = Array.isArray(input.items) ? input.items[0] : undefined;\n\nconst pr =\n input.pull_request ??\n input.pr ??\n item ??\n input;\n\nconst prNumber =\n pr.number ??\n input.number ??\n input.pull_number ??\n input.pr_number;\n\nconst sha =\n pr.head?.sha ??\n input.head?.sha ??\n input.after ??\n input.sha;\n\nconst repoFullName =\n input.repository?.full_name ??\n input.repo ??\n input.full_name ??\n 'div0rce/cherry';\n\nconst [owner, repo] = String(repoFullName).split('/');\n\nif (!prNumber) {\n return [{\n json: {\n ok: false,\n status: 'failed',\n workflow: '04_forbidden_change_detector',\n error: 'missing_pr_number',\n reason: 'Normalize PR could not derive a PR number from webhook, search, or flat payload',\n receivedKeys: Object.keys(input),\n totalCount: input.total_count,\n searchType: input.search_type,\n repoFullName,\n actions: ['do_not_classify_pr']\n },\n }];\n}\n\nconst labels = Array.isArray(pr.labels)\n ? pr.labels.map((label) => typeof label === 'string' ? label : label.name).filter(Boolean)\n : [];\n\nreturn [{\n json: {\n ...input,\n event: 'github.pull_request',\n source: 'github',\n owner: owner || input.repository?.owner?.login || 'div0rce',\n repo: repoFullName,\n repoName: repo || input.repository?.name || 'cherry',\n repoFullName,\n prNumber,\n sha,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n labels,\n timestamp: raw.timestamp ?? input.workflow_run?.updated_at ?? pr.updated_at ?? input.issue?.updated_at ?? new Date().toISOString(),\n workflow: '04_forbidden_change_detector',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.pull_request.',\n actions: [],\n pull_request: {\n number: prNumber,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n head: { sha },\n labels: Array.isArray(pr.labels) ? pr.labels : [],\n },\n payload: {\n ...input,\n repository: input.repository ?? { full_name: repoFullName, name: repo || input.repository?.name || 'cherry', owner: { login: owner || input.repository?.owner?.login || 'div0rce' } },\n pull_request: {\n number: prNumber,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n head: { sha },\n labels: Array.isArray(pr.labels) ? pr.labels : [],\n }\n }\n },\n}];" + }, + "id": "normalize-pr", + "name": "Normalize PR", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 260, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\nif (!event.prNumber) missing.push('prNumber');\nif (!event.sha) missing.push('sha');\nconst output = {\n ...event,\n valid: missing.length === 0,\n validationErrors: missing,\n status: missing.length === 0 ? event.status : 'failed',\n summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', '),\n actions: missing.length === 0 ? event.actions : [...(event.actions ?? []), 'do_not_classify_pr']\n};\nreturn [{ json: output }];" + }, + "id": "validate-payload", + "name": "Validate Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 520, + 0 + ] + }, + { + "parameters": { + "method": "GET", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls/' + $json.prNumber + '/files?per_page=100' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {} + }, + "id": "fetch-changed-files", + "name": "Fetch Changed Files", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 780, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const prEvent = $node['Normalize PR'].json;\nconst classification = $input.first()?.json ?? {};\nconst classifierOutput = classification.classifierOutput ?? {};\nconst forbiddenChange = classifierOutput.forbiddenChange ?? {};\nconst statusRequests = Array.isArray(classifierOutput.statusRequests) ? classifierOutput.statusRequests : [];\nconst statusRequest = statusRequests.find((request) => request.context === 'cherry/forbidden-change');\nconst violations = Array.isArray(forbiddenChange.violations) ? forbiddenChange.violations : [];\nconst labels = Array.isArray(forbiddenChange.labels) ? forbiddenChange.labels : [];\nconst blocked = forbiddenChange.forbidden === true;\nconst prNumber = prEvent.prNumber;\nconst sha = prEvent.sha;\nconst output = {\n ...prEvent,\n sha,\n automationEventId: classification.automationEventId,\n outputHash: classification.outputHash,\n cherryClassifierOutput: classifierOutput,\n forbiddenChange,\n statusRequest,\n labelBody: { labels },\n commentBody: { body: 'Cherry forbidden-change detector.\\n\\n' + (blocked ? 'Blocking patterns detected by Cherry:\\n' + violations.map((violation) => '- ' + violation).join('\\n') : 'Cherry found no blocking patterns.') },\n status: blocked ? 'failed' : 'accepted',\n summary: blocked ? 'Cherry detected forbidden changes in PR #' + String(prNumber ?? 'unknown') + '.' : 'Cherry found no forbidden changes in PR #' + String(prNumber ?? 'unknown') + '.',\n actions: blocked ? ['classify_pr_in_cherry', 'post_forbidden_status', 'add_blocking_label', 'comment_violation'] : ['classify_pr_in_cherry', 'post_forbidden_status']\n};\nreturn [{ json: output }];" + }, + "id": "build-cherry-forbidden-routing", + "name": "Build Cherry Forbidden Routing", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1300, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\n\nif (!event.statusRequest) missing.push('statusRequest');\nif (!event.repo) missing.push('repo');\nif (!event.sha) missing.push('sha');\nif (!event.outputHash) missing.push('outputHash');\nif (!event.cherryClassifierOutput?.classifierVersion) missing.push('classifierVersion');\n\nif (missing.length > 0) {\n return [{\n json: {\n ok: false,\n workflow: event.workflow,\n status: 'failed',\n summary: 'Cherry status payload missing required fields. Refusing to post status.',\n actions: ['do_not_post_status'],\n missing\n }\n }];\n}\n\nreturn [{ json: event }];" + }, + "id": "require-forbidden-status-request", + "name": "Require Status Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1560, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "if-forbidden-status-request", + "leftValue": "={{ $json.statusRequest !== undefined && $json.statusRequest !== null && $json.repo !== undefined && $json.repo !== null && $json.sha !== undefined && $json.sha !== null && $json.outputHash !== undefined && $json.outputHash !== null && $json.cherryClassifierOutput?.classifierVersion !== undefined && $json.cherryClassifierOutput?.classifierVersion !== null }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-forbidden-status-request", + "name": "IF: Has Status Payload?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1430, + 160 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "if-forbidden-condition", + "leftValue": "={{ $json.forbiddenChange?.forbidden === true }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-forbidden", + "name": "IF: Forbidden?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1560, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.prNumber + '/labels' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.labelBody }}" + }, + "id": "add-blocking-label", + "name": "Add blocking label", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1820, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build Cherry Forbidden Routing\"].json.prNumber + '/comments' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $node[\"Build Cherry Forbidden Routing\"].json.commentBody }}" + }, + "id": "comment-violation", + "name": "Comment Violation", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2080, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '04_forbidden_change_detector', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" + }, + "id": "route-shared-sinks", + "name": "Route Shared Sinks", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2340, + -160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '04_forbidden_change_detector.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '04_forbidden_change_detector',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '04_forbidden_change_detector') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? 'no-sha') + ':' + ($json.outputHash ?? $now.toISO())),\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '04_forbidden_change_detector.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json.cherryClassifierOutput ?? $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" + }, + "id": "archive-event", + "name": "Archive Event", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2600, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.DISCORD_WEBHOOK_URL }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" + }, + "id": "notify-discord", + "name": "Notify Discord", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2860, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Forbidden change check completed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-response", + "name": "Build Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 3120, + -160 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'No forbidden changes detected.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-safe-response", + "name": "Build Safe Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1820, + 160 + ] + }, + { + "parameters": { + "respondWith": "firstIncomingItem", + "options": { + "responseCode": 200 + } + }, + "id": "respond-to-webhook", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [ + 3380, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/statuses/github' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n context: $json.statusRequest.context,\n state: $json.statusRequest.state,\n description: $json.statusRequest.description,\n targetUrl: $json.statusRequest.targetUrl,\n sourceWorkflow: $json.workflow,\n automationEventId: $json.automationEventId,\n classifierVersion: $json.cherryClassifierOutput.classifierVersion,\n outputHash: $json.outputHash\n} }}" + }, + "id": "post-forbidden-status", + "name": "Post Forbidden Status", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1560, + 160 + ], + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const items = $input.all();\n\nlet files = [];\n\nfor (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n files.push(...json);\n } else if (Array.isArray(json.files)) {\n files.push(...json.files);\n } else if (Array.isArray(json.data)) {\n files.push(...json.data);\n } else if (json && json.filename) {\n files.push(json);\n }\n}\n\nfiles = files.map((file) => ({\n filename: file.filename ?? file.path ?? file.name ?? '',\n status: file.status ?? 'modified',\n additions: Number(file.additions ?? 0),\n deletions: Number(file.deletions ?? 0),\n changes: Number(file.changes ?? 0),\n patch: String(file.patch ?? '')\n})).filter((file) => file.filename.length > 0);\n\nreturn [{\n json: {\n ...($items('Normalize PR')[0]?.json ?? {}),\n files\n }\n}];" + }, + "id": "normalize-changed-files", + "name": "Normalize Changed Files", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 910, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/classify/pr' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n prNumber: $json.prNumber,\n title: $json.title,\n body: $json.body ?? '',\n labels: $json.labels ?? [],\n files: $json.files,\n sourceWorkflow: $json.workflow ?? '04_forbidden_change_detector'\n} }}" + }, + "id": "classify-pr-in-cherry-forbidden", + "name": "Classify PR In Cherry", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1040, + 0 + ], + "continueOnFail": true + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Normalize PR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize PR": { + "main": [ + [ + { + "node": "Validate Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Payload": { + "main": [ + [ + { + "node": "Fetch Changed Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: Forbidden?": { + "main": [ + [ + { + "node": "Add blocking label", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Build Safe Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Add blocking label": { + "main": [ + [ + { + "node": "Comment Violation", + "type": "main", + "index": 0 + } + ] + ] + }, + "Comment Violation": { + "main": [ + [ + { + "node": "Route Shared Sinks", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route Shared Sinks": { + "main": [ + [ + { + "node": "Archive Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Archive Event": { + "main": [ + [ + { + "node": "Notify Discord", + "type": "main", + "index": 0 + } + ] + ] + }, + "Notify Discord": { + "main": [ + [ + { + "node": "Build Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Safe Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Post Forbidden Status": { + "main": [ + [ + { + "node": "IF: Forbidden?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Cherry Forbidden Routing": { + "main": [ + [ + { + "node": "Require Status Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Changed Files": { + "main": [ + [ + { + "node": "Normalize Changed Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify PR In Cherry": { + "main": [ + [ + { + "node": "Build Cherry Forbidden Routing", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Changed Files": { + "main": [ + [ + { + "node": "Classify PR In Cherry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Require Status Payload": { + "main": [ + [ + { + "node": "IF: Has Status Payload?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: Has Status Payload?": { + "main": [ + [ + { + "node": "Post Forbidden Status", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Build Safe Response", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/cherry-n8n-workflows/05_engine_degradation_alerting.json b/cherry-n8n-workflows/05_engine_degradation_alerting.json new file mode 100644 index 00000000..636e81ce --- /dev/null +++ b/cherry-n8n-workflows/05_engine_degradation_alerting.json @@ -0,0 +1,389 @@ +{ + "name": "Cherry - Engine Degradation Alerting", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "cherry/runtime/degradation", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook-runtime-degradation", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'cherry.runtime_degradation',\n source: 'cherry',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '05_engine_degradation_alerting',\n ok: true,\n status: 'accepted',\n summary: 'Normalized cherry.runtime_degradation.',\n actions: []\n};\nreturn [{ json: output }];" + }, + "id": "normalize-degradation-event", + "name": "Normalize Degradation Event", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 260, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.type\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" + }, + "id": "validate-payload", + "name": "Validate Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 520, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst type = event.payload?.type ?? 'unknown';\nconst severityMap = { missing_debt_truth: 'high', solver_divergence: 'critical', temporal_inconsistency: 'critical', candidate_exclusion: 'medium', advisory_degraded: 'medium', impossible_state: 'critical', route_response_mismatch: 'high', score_drift: 'medium' };\nconst severity = event.payload?.severity ?? severityMap[type] ?? 'low'; const createIssue = severity === 'high' || severity === 'critical';\nconst output = { ...event, degradationType: type, severity, createIssue, issueBody: { title: '[engine:' + severity + '] ' + type, labels: ['engine-degradation', 'automation', 'needs-human-review', severity], body: 'Cherry engine degradation event.\\n\\nType: ' + type + '\\nSeverity: ' + severity + '\\nTimestamp: ' + event.timestamp + '\\n\\nPayload JSON:\\n' + JSON.stringify(event.payload, null, 2) + '\\n\\nAdvisory alert only.' }, notification: { type, severity, repo: event.repo, payload: event.payload }, status: createIssue ? 'accepted' : 'ignored', summary: createIssue ? 'High-severity engine degradation alert for ' + type + '.' : 'Archived medium/low degradation event for ' + type + '.', actions: createIssue ? ['create_github_issue', 'notify_discord', 'archive_event'] : ['notify_discord', 'archive_event'] };\nreturn [{ json: output }];" + }, + "id": "classify-severity", + "name": "Classify Severity", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 780, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "if-high-severity-condition", + "leftValue": "={{ $json.createIssue === true }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-high-severity", + "name": "IF: Severity >= high?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1040, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.issueBody }}" + }, + "id": "create-github-issue", + "name": "Create GitHub Issue", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1300, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '05_engine_degradation_alerting', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" + }, + "id": "route-shared-sinks", + "name": "Route Shared Sinks", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1560, + -160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.DISCORD_WEBHOOK_URL }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" + }, + "id": "notify-discord", + "name": "Notify Discord", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1820, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '05_engine_degradation_alerting.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '05_engine_degradation_alerting',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '05_engine_degradation_alerting') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '05_engine_degradation_alerting.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" + }, + "id": "archive-event", + "name": "Archive Event", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2080, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Engine degradation handled.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-response", + "name": "Build Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2340, + -160 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Engine degradation archived.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-archived-response", + "name": "Build Archived Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1300, + 160 + ] + }, + { + "parameters": { + "respondWith": "firstIncomingItem", + "options": { + "responseCode": 200 + } + }, + "id": "respond-to-webhook", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [ + 2600, + 0 + ] + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Normalize Degradation Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Degradation Event": { + "main": [ + [ + { + "node": "Validate Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Payload": { + "main": [ + [ + { + "node": "Classify Severity", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify Severity": { + "main": [ + [ + { + "node": "IF: Severity >= high?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: Severity >= high?": { + "main": [ + [ + { + "node": "Create GitHub Issue", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Build Archived Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create GitHub Issue": { + "main": [ + [ + { + "node": "Route Shared Sinks", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route Shared Sinks": { + "main": [ + [ + { + "node": "Notify Discord", + "type": "main", + "index": 0 + } + ] + ] + }, + "Notify Discord": { + "main": [ + [ + { + "node": "Archive Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Archive Event": { + "main": [ + [ + { + "node": "Build Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Archived Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/cherry-n8n-workflows/06_simulation_drift_detector.json b/cherry-n8n-workflows/06_simulation_drift_detector.json new file mode 100644 index 00000000..86fb8c1d --- /dev/null +++ b/cherry-n8n-workflows/06_simulation_drift_detector.json @@ -0,0 +1,432 @@ +{ + "name": "Cherry - Simulation Drift Detector", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "cherry/simulation/result", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook-simulation-result", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const input = $input.first()?.json ?? {};\nconst body = input.body ?? input;\nconst output = {\n event: 'cherry.simulation_result',\n source: 'cherry',\n repo: body.repository?.full_name ?? body.repo ?? 'div0rce/cherry',\n timestamp: body.timestamp ?? body.workflow_run?.updated_at ?? body.pull_request?.updated_at ?? body.issue?.updated_at ?? new Date().toISOString(),\n payload: body,\n workflow: '06_simulation_drift_detector',\n ok: true,\n status: 'accepted',\n summary: 'Normalized cherry.simulation_result.',\n actions: []\n};\nreturn [{ json: output }];" + }, + "id": "normalize-simulation-result", + "name": "Normalize Simulation Result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 260, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"payload.runId\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" + }, + "id": "validate-payload", + "name": "Validate Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 520, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const result = $input.first()?.json ?? {};\nconst event = $node['Normalize Simulation Result'].json;\nconst comparison = result.comparisonOutput ?? {};\nconst drift = comparison.drift === true;\nconst reasons = comparison.reasons ?? [];\nconst output = {\n ...event,\n automationSnapshotId: result.snapshotId,\n outputHash: result.outputHash,\n drift,\n driftReasons: reasons,\n issueBody: {\n title: '[simulation-drift] ' + (event.payload?.scenarioId ?? event.payload?.profileId ?? 'default'),\n labels: ['simulation-drift', 'automation', 'needs-human-review'],\n body: 'Cherry simulation drift detected.\\n\\n' + reasons.map((reason) => '- ' + reason).join('\\n') + '\\n\\nSnapshot comparison is stored by Cherry automation, not n8n static data.'\n },\n status: drift ? 'accepted' : 'ignored',\n summary: drift ? 'Simulation drift detected: ' + reasons.join(', ') : 'Simulation snapshot stored with no material drift.',\n actions: drift ? ['compare_snapshot_in_cherry', 'create_issue', 'archive_event'] : ['compare_snapshot_in_cherry', 'archive_event']\n};\nreturn [{ json: output }];" + }, + "id": "compare-snapshot", + "name": "Compare Snapshot", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 780, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "if-drift-condition", + "leftValue": "={{ $json.drift === true }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-drift", + "name": "IF: Drift?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1040, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.issueBody }}" + }, + "id": "create-drift-issue", + "name": "Create Drift Issue", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1300, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '06_simulation_drift_detector', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" + }, + "id": "route-shared-sinks", + "name": "Route Shared Sinks", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1560, + -160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.DISCORD_WEBHOOK_URL }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" + }, + "id": "notify-discord", + "name": "Notify Discord", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1820, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '06_simulation_drift_detector.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '06_simulation_drift_detector',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '06_simulation_drift_detector') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'simulation-drift@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '06_simulation_drift_detector.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" + }, + "id": "archive-event", + "name": "Archive Event", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2080, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Simulation drift handled.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-response", + "name": "Build Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2340, + -160 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Simulation snapshot stored.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-no-drift-response", + "name": "Build No Drift Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1300, + 160 + ] + }, + { + "parameters": { + "respondWith": "firstIncomingItem", + "options": { + "responseCode": 200 + } + }, + "id": "respond-to-webhook", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [ + 2600, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/simulation-snapshots/compare' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n scopeKey: $json.payload.scenarioId ?? $json.payload.profileId ?? 'default',\n runId: $json.payload.runId,\n snapshot: $json.payload,\n sourceWorkflow: $json.workflow ?? '06_simulation_drift_detector'\n} }}" + }, + "id": "compare-simulation-in-cherry", + "name": "Compare Simulation In Cherry", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 780, + 160 + ], + "continueOnFail": true + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Normalize Simulation Result", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Simulation Result": { + "main": [ + [ + { + "node": "Validate Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Payload": { + "main": [ + [ + { + "node": "Compare Simulation In Cherry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compare Snapshot": { + "main": [ + [ + { + "node": "IF: Drift?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: Drift?": { + "main": [ + [ + { + "node": "Create Drift Issue", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Build No Drift Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create Drift Issue": { + "main": [ + [ + { + "node": "Route Shared Sinks", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route Shared Sinks": { + "main": [ + [ + { + "node": "Notify Discord", + "type": "main", + "index": 0 + } + ] + ] + }, + "Notify Discord": { + "main": [ + [ + { + "node": "Archive Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Archive Event": { + "main": [ + [ + { + "node": "Build Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build No Drift Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Compare Simulation In Cherry": { + "main": [ + [ + { + "node": "Compare Snapshot", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/cherry-n8n-workflows/07_release_summary_generator.json b/cherry-n8n-workflows/07_release_summary_generator.json new file mode 100644 index 00000000..01234741 --- /dev/null +++ b/cherry-n8n-workflows/07_release_summary_generator.json @@ -0,0 +1,488 @@ +{ + "name": "Cherry - Release Summary Generator", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "cherry/release/summary", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook-release-summary", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + -160 + ] + }, + { + "parameters": {}, + "id": "manual-release-summary", + "name": "Manual Trigger", + "type": "n8n-nodes-base.manualTrigger", + "typeVersion": 1, + "position": [ + 0, + 160 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const input = $input.first()?.json ?? {}; const body = input.body ?? input; const source = input.body ? 'cherry' : 'manual'; const output = { event: 'cherry.release_summary', source, repo: body.repo ?? 'div0rce/cherry', timestamp: body.timestamp ?? new Date().toISOString(), payload: body, workflow: '07_release_summary_generator', ok: true, status: 'accepted', summary: 'Release summary generation started.', actions: [] }; return [{ json: output }];" + }, + "id": "normalize-release-request", + "name": "Normalize Release Request", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 260, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"repo\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" + }, + "id": "validate-payload", + "name": "Validate Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 520, + 0 + ] + }, + { + "parameters": { + "method": "GET", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/releases/latest' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {} + }, + "id": "fetch-latest-release", + "name": "Fetch Latest Release", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 780, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "GET", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/commits?per_page=100' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {} + }, + "id": "fetch-commits", + "name": "Fetch Commits Since Last Tag", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1040, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const commits = Array.isArray($json) ? $json : []; const groups = { engine: [], api: [], prisma: [], tests: [], docs: [], infra: [], other: [] };\nfor (const commit of commits) { const message = commit.commit?.message ?? commit.message ?? ''; const lower = message.toLowerCase(); const bucket = lower.includes('engine') ? 'engine' : lower.includes('api') || lower.includes('route') ? 'api' : lower.includes('prisma') || lower.includes('migration') ? 'prisma' : lower.includes('test') ? 'tests' : lower.includes('doc') || lower.includes('readme') ? 'docs' : lower.includes('ci') || lower.includes('guardrail') ? 'infra' : 'other'; groups[bucket].push(message.split('\\n')[0]); }\nconst risk = []; if (groups.engine.length > 0) risk.push('engine changes require deterministic review'); if (groups.prisma.length > 0) risk.push('Prisma changes require migration verification'); if (groups.api.length > 0) risk.push('API changes require route tests');\nconst changelog = Object.entries(groups).filter(([, items]) => items.length > 0).map(([name, items]) => '## ' + name + '\\n' + items.map((item) => '- ' + item).join('\\n')).join('\\n\\n'); const releaseBody = '# Cherry Release Draft\\n\\n' + (changelog || 'No commits returned by GitHub API.') + '\\n\\n## Risk Summary\\n' + (risk.length ? risk.map((r) => '- ' + r).join('\\n') : '- Low automation-detected release risk.') + '\\n\\n## Verification\\n- npm run check\\n- npm test\\n- npm run build\\n- npm run ci:verify';\nconst output = { ...$node['Normalize Release Request'].json, groups, changelog, riskSummary: risk, linkedInDraft: 'Cherry release update: guardrails, repo quality, and development automation advanced. Details remain advisory until verified in CI.', releaseBody, releaseDraftBody: { tag_name: $node['Normalize Release Request'].json.payload.tagName ?? 'v-next', name: 'Cherry v-next', body: releaseBody, draft: true, prerelease: true }, status: 'accepted', summary: 'Generated release summary from ' + commits.length + ' commits.', actions: ['fetch_commits', 'group_changes', 'generate_changelog', 'archive_event'] };\nreturn [{ json: output }];" + }, + "id": "generate-release-summary", + "name": "Generate Release Summary", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1300, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/releases' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.releaseDraftBody }}" + }, + "id": "create-github-release-draft", + "name": "Create GitHub Release Draft", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1560, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '07_release_summary_generator', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" + }, + "id": "route-shared-sinks", + "name": "Route Shared Sinks", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1820, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '07_release_summary_generator.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '07_release_summary_generator',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '07_release_summary_generator') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '07_release_summary_generator.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" + }, + "id": "archive-event", + "name": "Archive Event", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2080, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.DISCORD_WEBHOOK_URL }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" + }, + "id": "notify-discord", + "name": "Notify Discord", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2340, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "if-webhook-source-condition", + "leftValue": "={{ $node['Normalize Release Request'].json.source !== 'manual' }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-webhook-source", + "name": "IF: Webhook Source?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 2600, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Release summary generated.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-response", + "name": "Build Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2860, + -160 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Manual release summary generated.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "manual-result-log", + "name": "Manual Result Log", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2860, + 160 + ] + }, + { + "parameters": { + "respondWith": "firstIncomingItem", + "options": { + "responseCode": 200 + } + }, + "id": "respond-to-webhook", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [ + 3120, + -160 + ] + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Normalize Release Request", + "type": "main", + "index": 0 + } + ] + ] + }, + "Manual Trigger": { + "main": [ + [ + { + "node": "Normalize Release Request", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Release Request": { + "main": [ + [ + { + "node": "Validate Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Payload": { + "main": [ + [ + { + "node": "Fetch Latest Release", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Latest Release": { + "main": [ + [ + { + "node": "Fetch Commits Since Last Tag", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Commits Since Last Tag": { + "main": [ + [ + { + "node": "Generate Release Summary", + "type": "main", + "index": 0 + } + ] + ] + }, + "Generate Release Summary": { + "main": [ + [ + { + "node": "Create GitHub Release Draft", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create GitHub Release Draft": { + "main": [ + [ + { + "node": "Route Shared Sinks", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route Shared Sinks": { + "main": [ + [ + { + "node": "Archive Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Archive Event": { + "main": [ + [ + { + "node": "Notify Discord", + "type": "main", + "index": 0 + } + ] + ] + }, + "Notify Discord": { + "main": [ + [ + { + "node": "IF: Webhook Source?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: Webhook Source?": { + "main": [ + [ + { + "node": "Build Response", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Manual Result Log", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/cherry-n8n-workflows/08_repo_intelligence_digest.json b/cherry-n8n-workflows/08_repo_intelligence_digest.json new file mode 100644 index 00000000..7a69895b --- /dev/null +++ b/cherry-n8n-workflows/08_repo_intelligence_digest.json @@ -0,0 +1,378 @@ +{ + "name": "Cherry - Repo Intelligence Digest", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "weeks", + "triggerAtDay": [ + 1 + ], + "triggerAtHour": 9, + "triggerAtMinute": 0 + } + ] + } + }, + "id": "weekly-repo-digest", + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const input = $input.first()?.json ?? {};\nconst output = { event: 'cherry.weekly_repo_digest', source: 'manual', repo: 'div0rce/cherry', timestamp: input.timestamp ?? new Date().toISOString(), payload: input, workflow: '08_repo_intelligence_digest', ok: true, status: 'accepted', summary: 'Scheduled cherry.weekly_repo_digest started.', actions: [] };\nreturn [{ json: output }];" + }, + "id": "normalize-schedule", + "name": "Normalize Schedule", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 260, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"repo\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" + }, + "id": "validate-payload", + "name": "Validate Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 520, + 0 + ] + }, + { + "parameters": { + "method": "GET", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls?state=open&per_page=100' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {} + }, + "id": "fetch-open-prs", + "name": "Fetch Open PRs", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 780, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "GET", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues?state=open&per_page=100' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {} + }, + "id": "fetch-open-issues", + "name": "Fetch Open Issues", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1040, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "GET", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/commits?per_page=50' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {} + }, + "id": "fetch-recent-commits", + "name": "Fetch Recent Commits", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1300, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const prs = Array.isArray($items('Fetch Open PRs')[0]?.json) ? $items('Fetch Open PRs')[0].json : []; const issues = Array.isArray($items('Fetch Open Issues')[0]?.json) ? $items('Fetch Open Issues')[0].json : []; const commits = Array.isArray($json) ? $json : []; const now = Date.now(); const stalePrs = prs.filter((pr) => now - new Date(pr.updated_at ?? pr.created_at ?? now).getTime() > 7 * 24 * 60 * 60 * 1000); const dependabot = prs.filter((pr) => /dependabot/i.test(pr.user?.login ?? '')); const highRisk = prs.filter((pr) => /engine|prisma|migration|api/i.test((pr.title ?? '') + ' ' + (pr.body ?? ''))); const digest = '# Cherry Weekly Repo Intelligence Digest\\n\\n- Open PRs: ' + prs.length + '\\n- Stale PRs: ' + stalePrs.length + '\\n- Open issues: ' + issues.length + '\\n- Recent commits: ' + commits.length + '\\n- Dependabot PRs: ' + dependabot.length + '\\n- High-risk hints: ' + highRisk.length + '\\n\\nAdvisory automation only.'; const output = { ...$node['Normalize Schedule'].json, digest, metrics: { openPrs: prs.length, stalePrs: stalePrs.length, openIssues: issues.length, recentCommits: commits.length, dependabotPrs: dependabot.length, highRiskHints: highRisk.length }, status: 'accepted', summary: 'Weekly repo digest built: ' + prs.length + ' PRs, ' + issues.length + ' issues.', actions: ['fetch_open_prs', 'fetch_open_issues', 'fetch_recent_commits', 'archive_digest', 'notify_discord'] }; return [{ json: output }];" + }, + "id": "build-digest", + "name": "Build Digest", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1560, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '08_repo_intelligence_digest', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" + }, + "id": "route-shared-sinks", + "name": "Route Shared Sinks", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1820, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '08_repo_intelligence_digest.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '08_repo_intelligence_digest',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '08_repo_intelligence_digest') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '08_repo_intelligence_digest.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" + }, + "id": "archive-event", + "name": "Archive Event", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2080, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.DISCORD_WEBHOOK_URL }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" + }, + "id": "notify-discord", + "name": "Notify Discord", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2340, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Weekly repo digest completed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "log-digest", + "name": "Log Digest", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2600, + 0 + ] + } + ], + "connections": { + "Schedule Trigger": { + "main": [ + [ + { + "node": "Normalize Schedule", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Schedule": { + "main": [ + [ + { + "node": "Validate Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Payload": { + "main": [ + [ + { + "node": "Fetch Open PRs", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Open PRs": { + "main": [ + [ + { + "node": "Fetch Open Issues", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Open Issues": { + "main": [ + [ + { + "node": "Fetch Recent Commits", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Recent Commits": { + "main": [ + [ + { + "node": "Build Digest", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Digest": { + "main": [ + [ + { + "node": "Route Shared Sinks", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route Shared Sinks": { + "main": [ + [ + { + "node": "Archive Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Archive Event": { + "main": [ + [ + { + "node": "Notify Discord", + "type": "main", + "index": 0 + } + ] + ] + }, + "Notify Discord": { + "main": [ + [ + { + "node": "Log Digest", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/cherry-n8n-workflows/09_docs_drift_detector.json b/cherry-n8n-workflows/09_docs_drift_detector.json new file mode 100644 index 00000000..f0cd6792 --- /dev/null +++ b/cherry-n8n-workflows/09_docs_drift_detector.json @@ -0,0 +1,628 @@ +{ + "name": "Cherry - Docs Drift Detector", + "nodes": [ + { + "parameters": { + "httpMethod": "POST", + "path": "cherry/github/pull-request-docs-drift", + "responseMode": "responseNode", + "options": {} + }, + "id": "webhook-docs-drift", + "name": "Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const raw = $input.first()?.json ?? {};\nconst input = raw.body ?? raw.payload ?? raw;\n\nconst item = Array.isArray(input.items) ? input.items[0] : undefined;\n\nconst pr =\n input.pull_request ??\n input.pr ??\n item ??\n input;\n\nconst prNumber =\n pr.number ??\n input.number ??\n input.pull_number ??\n input.pr_number;\n\nconst sha =\n pr.head?.sha ??\n input.head?.sha ??\n input.after ??\n input.sha;\n\nconst repoFullName =\n input.repository?.full_name ??\n input.repo ??\n input.full_name ??\n 'div0rce/cherry';\n\nconst [owner, repo] = String(repoFullName).split('/');\n\nif (!prNumber) {\n return [{\n json: {\n ok: false,\n status: 'failed',\n workflow: '09_docs_drift_detector',\n error: 'missing_pr_number',\n reason: 'Normalize PR could not derive a PR number from webhook, search, or flat payload',\n receivedKeys: Object.keys(input),\n totalCount: input.total_count,\n searchType: input.search_type,\n repoFullName,\n actions: ['do_not_classify_pr']\n },\n }];\n}\n\nconst labels = Array.isArray(pr.labels)\n ? pr.labels.map((label) => typeof label === 'string' ? label : label.name).filter(Boolean)\n : [];\n\nreturn [{\n json: {\n ...input,\n event: 'github.pull_request_docs_drift',\n source: 'github',\n owner: owner || input.repository?.owner?.login || 'div0rce',\n repo: repoFullName,\n repoName: repo || input.repository?.name || 'cherry',\n repoFullName,\n prNumber,\n sha,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n labels,\n timestamp: raw.timestamp ?? input.workflow_run?.updated_at ?? pr.updated_at ?? input.issue?.updated_at ?? new Date().toISOString(),\n workflow: '09_docs_drift_detector',\n ok: true,\n status: 'accepted',\n summary: 'Normalized github.pull_request_docs_drift.',\n actions: [],\n pull_request: {\n number: prNumber,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n head: { sha },\n labels: Array.isArray(pr.labels) ? pr.labels : [],\n },\n payload: {\n ...input,\n repository: input.repository ?? { full_name: repoFullName, name: repo || input.repository?.name || 'cherry', owner: { login: owner || input.repository?.owner?.login || 'div0rce' } },\n pull_request: {\n number: prNumber,\n title: pr.title ?? input.title ?? '',\n body: pr.body ?? input.body ?? '',\n head: { sha },\n labels: Array.isArray(pr.labels) ? pr.labels : [],\n }\n }\n },\n}];" + }, + "id": "normalize-pr", + "name": "Normalize PR", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 260, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\nif (!event.prNumber) missing.push('prNumber');\nif (!event.sha) missing.push('sha');\nconst output = {\n ...event,\n valid: missing.length === 0,\n validationErrors: missing,\n status: missing.length === 0 ? event.status : 'failed',\n summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', '),\n actions: missing.length === 0 ? event.actions : [...(event.actions ?? []), 'do_not_classify_pr']\n};\nreturn [{ json: output }];" + }, + "id": "validate-payload", + "name": "Validate Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 520, + 0 + ] + }, + { + "parameters": { + "method": "GET", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/pulls/' + $json.prNumber + '/files?per_page=100' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {} + }, + "id": "fetch-changed-files", + "name": "Fetch Changed Files", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 780, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const prEvent = $node['Normalize PR'].json;\nconst classification = $input.first()?.json ?? {};\nconst classifierOutput = classification.classifierOutput ?? {};\nconst docsDrift = classifierOutput.docsDrift ?? {};\nconst statusRequests = Array.isArray(classifierOutput.statusRequests) ? classifierOutput.statusRequests : [];\nconst statusRequest = statusRequests.find((request) => request.context === 'cherry/docs-drift');\nconst sha = prEvent.sha;\nconst domains = Array.isArray(docsDrift.domains) ? docsDrift.domains : [];\nconst labels = Array.isArray(docsDrift.labels) ? docsDrift.labels : [];\nconst drift = docsDrift.drift === true;\nconst output = {\n ...prEvent,\n sha,\n automationEventId: classification.automationEventId,\n outputHash: classification.outputHash,\n cherryClassifierOutput: classifierOutput,\n docsDrift,\n statusRequest,\n labelBody: { labels },\n commentBody: { body: 'Cherry docs drift detector.\\n\\n' + (drift ? 'Docs update required for changed domains: ' + domains.join(', ') : 'Cherry found no docs drift.') + '\\n\\nDocs must match code reality unless legal constraints require a code fix.' },\n status: drift ? 'accepted' : 'ignored',\n summary: drift ? 'Cherry detected docs drift for ' + domains.join(', ') + '.' : 'Cherry found no docs drift.',\n actions: drift ? ['classify_pr_in_cherry', 'post_docs_status', 'label_docs_drift', 'comment_required_docs_update'] : ['classify_pr_in_cherry', 'post_docs_status']\n};\nreturn [{ json: output }];" + }, + "id": "build-cherry-docs-routing", + "name": "Build Cherry Docs Routing", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1300, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst missing = [];\n\nif (!event.statusRequest) missing.push('statusRequest');\nif (!event.repo) missing.push('repo');\nif (!event.sha) missing.push('sha');\nif (!event.outputHash) missing.push('outputHash');\nif (!event.cherryClassifierOutput?.classifierVersion) missing.push('classifierVersion');\n\nif (missing.length > 0) {\n return [{\n json: {\n ok: false,\n workflow: event.workflow,\n status: 'failed',\n summary: 'Cherry status payload missing required fields. Refusing to post status.',\n actions: ['do_not_post_status'],\n missing\n }\n }];\n}\n\nreturn [{ json: event }];" + }, + "id": "require-docs-status-request", + "name": "Require Status Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1560, + 0 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "if-docs-status-request", + "leftValue": "={{ $json.statusRequest !== undefined && $json.statusRequest !== null && $json.repo !== undefined && $json.repo !== null && $json.sha !== undefined && $json.sha !== null && $json.outputHash !== undefined && $json.outputHash !== null && $json.cherryClassifierOutput?.classifierVersion !== undefined && $json.cherryClassifierOutput?.classifierVersion !== null }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-docs-status-request", + "name": "IF: Has Status Payload?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1170, + 160 + ] + }, + { + "parameters": { + "conditions": { + "options": { + "caseSensitive": true, + "leftValue": "", + "typeValidation": "strict" + }, + "conditions": [ + { + "id": "if-docs-drift-condition", + "leftValue": "={{ $json.docsDrift?.drift === true }}", + "rightValue": true, + "operator": { + "type": "boolean", + "operation": "true", + "singleValue": true + } + } + ], + "combinator": "and" + }, + "options": {} + }, + "id": "if-docs-drift", + "name": "IF: Docs Drift?", + "type": "n8n-nodes-base.if", + "typeVersion": 2, + "position": [ + 1300, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $json.prNumber + '/labels' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.labelBody }}" + }, + "id": "label-docs-drift", + "name": "Label Docs Drift", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1560, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues/' + $node[\"Build Cherry Docs Routing\"].json.prNumber + '/comments' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $node[\"Build Cherry Docs Routing\"].json.commentBody }}" + }, + "id": "comment-docs-drift", + "name": "Comment Required Docs Update", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1820, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '09_docs_drift_detector', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" + }, + "id": "route-shared-sinks", + "name": "Route Shared Sinks", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2080, + -160 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '09_docs_drift_detector.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '09_docs_drift_detector',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '09_docs_drift_detector') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? 'no-sha') + ':' + ($json.outputHash ?? $now.toISO())),\n classifierVersion: $json.cherryClassifierOutput?.classifierVersion ?? 'pr-automation@1(pr-risk@1,forbidden-change@1,docs-drift@1)',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '09_docs_drift_detector.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json.cherryClassifierOutput ?? $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" + }, + "id": "archive-event", + "name": "Archive Event", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2340, + -160 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Docs drift handled.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-response", + "name": "Build Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2600, + -160 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: 'ignored', summary: event.summary ?? 'No docs drift detected.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "build-no-drift-response", + "name": "Build No Drift Response", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1560, + 160 + ] + }, + { + "parameters": { + "respondWith": "firstIncomingItem", + "options": { + "responseCode": 200 + } + }, + "id": "respond-to-webhook", + "name": "Respond to Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1, + "position": [ + 2860, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/statuses/github' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n context: $json.statusRequest.context,\n state: $json.statusRequest.state,\n description: $json.statusRequest.description,\n targetUrl: $json.statusRequest.targetUrl,\n sourceWorkflow: $json.workflow,\n automationEventId: $json.automationEventId,\n classifierVersion: $json.cherryClassifierOutput.classifierVersion,\n outputHash: $json.outputHash\n} }}" + }, + "id": "post-docs-status", + "name": "Post Docs Status", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1300, + 160 + ], + "continueOnFail": true + }, + { + "parameters": { + "jsCode": "const items = $input.all();\n\nlet files = [];\n\nfor (const item of items) {\n const json = item.json;\n\n if (Array.isArray(json)) {\n files.push(...json);\n } else if (Array.isArray(json.files)) {\n files.push(...json.files);\n } else if (Array.isArray(json.data)) {\n files.push(...json.data);\n } else if (json && json.filename) {\n files.push(json);\n }\n}\n\nfiles = files.map((file) => ({\n filename: file.filename ?? file.path ?? file.name ?? '',\n status: file.status ?? 'modified',\n additions: Number(file.additions ?? 0),\n deletions: Number(file.deletions ?? 0),\n changes: Number(file.changes ?? 0),\n patch: String(file.patch ?? '')\n})).filter((file) => file.filename.length > 0);\n\nreturn [{\n json: {\n ...($items('Normalize PR')[0]?.json ?? {}),\n files\n }\n}];" + }, + "id": "normalize-changed-files", + "name": "Normalize Changed Files", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 910, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/classify/pr' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo,\n sha: $json.sha,\n prNumber: $json.prNumber,\n title: $json.title,\n body: $json.body ?? '',\n labels: $json.labels ?? [],\n files: $json.files,\n sourceWorkflow: $json.workflow ?? '09_docs_drift_detector'\n} }}" + }, + "id": "classify-pr-in-cherry-docs", + "name": "Classify PR In Cherry", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [ + 1040, + 0 + ], + "continueOnFail": true + } + ], + "connections": { + "Webhook": { + "main": [ + [ + { + "node": "Normalize PR", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize PR": { + "main": [ + [ + { + "node": "Validate Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Payload": { + "main": [ + [ + { + "node": "Fetch Changed Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Changed Files": { + "main": [ + [ + { + "node": "Normalize Changed Files", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: Docs Drift?": { + "main": [ + [ + { + "node": "Label Docs Drift", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Build No Drift Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Label Docs Drift": { + "main": [ + [ + { + "node": "Comment Required Docs Update", + "type": "main", + "index": 0 + } + ] + ] + }, + "Comment Required Docs Update": { + "main": [ + [ + { + "node": "Route Shared Sinks", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route Shared Sinks": { + "main": [ + [ + { + "node": "Archive Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Archive Event": { + "main": [ + [ + { + "node": "Build Response", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build No Drift Response": { + "main": [ + [ + { + "node": "Respond to Webhook", + "type": "main", + "index": 0 + } + ] + ] + }, + "Post Docs Status": { + "main": [ + [ + { + "node": "IF: Docs Drift?", + "type": "main", + "index": 0 + } + ] + ] + }, + "Build Cherry Docs Routing": { + "main": [ + [ + { + "node": "Require Status Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Classify PR In Cherry": { + "main": [ + [ + { + "node": "Build Cherry Docs Routing", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Changed Files": { + "main": [ + [ + { + "node": "Classify PR In Cherry", + "type": "main", + "index": 0 + } + ] + ] + }, + "Require Status Payload": { + "main": [ + [ + { + "node": "IF: Has Status Payload?", + "type": "main", + "index": 0 + } + ] + ] + }, + "IF: Has Status Payload?": { + "main": [ + [ + { + "node": "Post Docs Status", + "type": "main", + "index": 0 + } + ], + [ + { + "node": "Build Response", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/cherry-n8n-workflows/10_backlog_grooming.json b/cherry-n8n-workflows/10_backlog_grooming.json new file mode 100644 index 00000000..4807a381 --- /dev/null +++ b/cherry-n8n-workflows/10_backlog_grooming.json @@ -0,0 +1,376 @@ +{ + "name": "Cherry - Backlog Grooming", + "nodes": [ + { + "parameters": { + "rule": { + "interval": [ + { + "field": "weeks", + "triggerAtDay": [ + 1 + ], + "triggerAtHour": 9, + "triggerAtMinute": 0 + } + ] + } + }, + "id": "weekly-backlog-grooming", + "name": "Schedule Trigger", + "type": "n8n-nodes-base.scheduleTrigger", + "typeVersion": 1, + "position": [ + 0, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const input = $input.first()?.json ?? {};\nconst output = { event: 'cherry.backlog_grooming', source: 'manual', repo: 'div0rce/cherry', timestamp: input.timestamp ?? new Date().toISOString(), payload: input, workflow: '10_backlog_grooming', ok: true, status: 'accepted', summary: 'Scheduled cherry.backlog_grooming started.', actions: [] };\nreturn [{ json: output }];" + }, + "id": "normalize-schedule", + "name": "Normalize Schedule", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 260, + 0 + ] + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst fields = [\"repo\"];\nconst missing = fields.filter((field) => field.split('.').reduce((value, key) => value?.[key], event) === undefined);\nconst output = { ...event, valid: missing.length === 0, validationErrors: missing, status: missing.length === 0 ? event.status : 'failed', summary: missing.length === 0 ? event.summary : 'Payload missing required fields: ' + missing.join(', ') };\nreturn [{ json: output }];" + }, + "id": "validate-payload", + "name": "Validate Payload", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 520, + 0 + ] + }, + { + "parameters": { + "method": "GET", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues?state=open&per_page=100' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {} + }, + "id": "fetch-open-issues", + "name": "Fetch Open Issues", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 780, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const issues = Array.isArray($json) ? $json.filter((issue) => !issue.pull_request) : []; const now = Date.now(); const stale = issues.filter((issue) => now - new Date(issue.updated_at ?? issue.created_at ?? now).getTime() > 30 * 24 * 60 * 60 * 1000); const unlabeled = issues.filter((issue) => (issue.labels ?? []).length === 0); const blocked = issues.filter((issue) => /blocked|waiting|depends on/i.test((issue.title ?? '') + ' ' + (issue.body ?? ''))); const noAcceptance = issues.filter((issue) => !/acceptance criteria|done when|definition of done/i.test(issue.body ?? '')); const seen = new Map(); const duplicateHints = []; for (const issue of issues) { const key = String(issue.title ?? '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim(); if (seen.has(key)) duplicateHints.push([seen.get(key), issue.number]); else seen.set(key, issue.number); } const body = '# Cherry Backlog Grooming Summary\\n\\n- Open issues: ' + issues.length + '\\n- Stale issues: ' + stale.length + '\\n- Unlabeled issues: ' + unlabeled.length + '\\n- Blocked hints: ' + blocked.length + '\\n- Missing acceptance criteria: ' + noAcceptance.length + '\\n- Duplicate title hints: ' + duplicateHints.length + '\\n\\nSuggested actions are advisory.'; const metrics = { openIssues: issues.length, stale: stale.length, unlabeled: unlabeled.length, blocked: blocked.length, missingAcceptanceCriteria: noAcceptance.length, duplicateHints: duplicateHints.length }; const output = { ...$node['Normalize Schedule'].json, backlogMetrics: metrics, summaryIssueBody: { title: 'Cherry weekly backlog grooming summary', labels: ['backlog-grooming', 'automation'], body }, projectUpdatePayload: { source: 'cherry_backlog_grooming', metrics }, status: 'accepted', summary: 'Backlog grooming summary built for ' + issues.length + ' open issues.', actions: ['find_stale_issues', 'find_duplicates', 'find_unlabeled', 'find_missing_acceptance_criteria', 'archive_event'] }; return [{ json: output }];" + }, + "id": "analyze-backlog", + "name": "Analyze Backlog", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1040, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ 'https://api.github.com/repos/' + $env.GITHUB_OWNER + '/' + $env.GITHUB_REPO + '/issues' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.GITHUB_TOKEN }}" + }, + { + "name": "Accept", + "value": "application/vnd.github+json" + }, + { + "name": "X-GitHub-Api-Version", + "value": "2022-11-28" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $json.summaryIssueBody }}" + }, + "id": "create-grooming-summary-issue", + "name": "Create Grooming Summary Issue", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1300, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ $node[\"Analyze Backlog\"].json.projectUpdatePayload }}" + }, + "id": "update-github-project", + "name": "Update GitHub Project", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 1560, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ...event, workflow: event.workflow ?? '10_backlog_grooming', sinkRouting: {\n discord: '{{ $env.DISCORD_WEBHOOK_URL }}', slack: '{{ $env.SLACK_WEBHOOK_URL }}', email: '{{ $env.EMAIL_WEBHOOK_URL }}', notion: '{{ $env.NOTION_WEBHOOK_URL }}', googleSheets: '{{ $env.GOOGLE_SHEETS_WEBHOOK_URL }}', linearJira: '{{ $env.LINEAR_JIRA_WEBHOOK_URL }}', githubProjects: '{{ $env.GITHUB_PROJECTS_WEBHOOK_URL }}', archive: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/events', incidentTimeline: '{{ $env.CHERRY_API_BASE_URL }}/api/automation/incident-timeline' } };\nreturn [{ json: output }];" + }, + "id": "route-shared-sinks", + "name": "Route Shared Sinks", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 1820, + 0 + ] + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.CHERRY_API_BASE_URL + '/api/automation/events' }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Authorization", + "value": "={{ 'Bearer ' + $env.CHERRY_AUTOMATION_TOKEN }}" + }, + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ {\n repo: $json.repo ?? 'div0rce/cherry',\n ...(($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) ? { sha: ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha) } : {}),\n event: $json.event ?? '10_backlog_grooming.event',\n source: $json.source ?? 'cherry',\n workflow: $json.workflow ?? '10_backlog_grooming',\n status: $json.status ?? 'accepted',\n idempotencyKey: (($json.workflow ?? '10_backlog_grooming') + ':' + ($json.repo ?? 'div0rce/cherry') + ':' + ($json.sha ?? $json.payload?.pull_request?.head?.sha ?? $json.payload?.workflow_run?.head_sha ?? $json.payload?.issue?.number ?? $now.toISO())),\n classifierVersion: 'workflow-advisory@1',\n rawPayload: $json.payload ?? $json,\n normalizedEvent: {\n event: $json.event ?? '10_backlog_grooming.event',\n source: $json.source ?? 'cherry',\n repo: $json.repo ?? 'div0rce/cherry',\n timestamp: $json.timestamp ?? $now.toISO(),\n payload: $json.payload ?? $json\n },\n classifierOutput: $json,\n ...(($json.payload?.pull_request?.number ?? $json.prNumber) ? { prNumber: ($json.payload?.pull_request?.number ?? $json.prNumber) } : {}),\n ...(($json.payload?.issue?.number ?? $json.issueNumber) ? { issueNumber: ($json.payload?.issue?.number ?? $json.issueNumber) } : {})\n} }}" + }, + "id": "archive-event", + "name": "Archive Event", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2080, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "method": "POST", + "url": "={{ $env.DISCORD_WEBHOOK_URL }}", + "sendHeaders": true, + "headerParameters": { + "parameters": [ + { + "name": "Content-Type", + "value": "application/json" + } + ] + }, + "options": {}, + "sendBody": true, + "specifyBody": "json", + "jsonBody": "={{ { content: '[' + ($json.workflow ?? 'Cherry automation') + '] ' + ($json.summary ?? 'Automation event') } }}" + }, + "id": "notify-discord", + "name": "Notify Discord", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4, + "position": [ + 2340, + 0 + ], + "continueOnFail": true + }, + { + "parameters": { + "mode": "runOnceForAllItems", + "language": "javaScript", + "jsCode": "const event = $input.first()?.json ?? {};\nconst output = { ok: event.ok !== false, workflow: event.workflow, status: event.status ?? 'accepted', summary: event.summary ?? 'Backlog grooming completed.', actions: event.actions ?? [] };\nreturn [{ json: output }];" + }, + "id": "log-grooming-result", + "name": "Log Grooming Result", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [ + 2600, + 0 + ] + } + ], + "connections": { + "Schedule Trigger": { + "main": [ + [ + { + "node": "Normalize Schedule", + "type": "main", + "index": 0 + } + ] + ] + }, + "Normalize Schedule": { + "main": [ + [ + { + "node": "Validate Payload", + "type": "main", + "index": 0 + } + ] + ] + }, + "Validate Payload": { + "main": [ + [ + { + "node": "Fetch Open Issues", + "type": "main", + "index": 0 + } + ] + ] + }, + "Fetch Open Issues": { + "main": [ + [ + { + "node": "Analyze Backlog", + "type": "main", + "index": 0 + } + ] + ] + }, + "Analyze Backlog": { + "main": [ + [ + { + "node": "Create Grooming Summary Issue", + "type": "main", + "index": 0 + } + ] + ] + }, + "Create Grooming Summary Issue": { + "main": [ + [ + { + "node": "Update GitHub Project", + "type": "main", + "index": 0 + } + ] + ] + }, + "Update GitHub Project": { + "main": [ + [ + { + "node": "Route Shared Sinks", + "type": "main", + "index": 0 + } + ] + ] + }, + "Route Shared Sinks": { + "main": [ + [ + { + "node": "Archive Event", + "type": "main", + "index": 0 + } + ] + ] + }, + "Archive Event": { + "main": [ + [ + { + "node": "Notify Discord", + "type": "main", + "index": 0 + } + ] + ] + }, + "Notify Discord": { + "main": [ + [ + { + "node": "Log Grooming Result", + "type": "main", + "index": 0 + } + ] + ] + } + }, + "settings": { + "executionOrder": "v1" + } +} diff --git a/cherry-n8n-workflows/COVERAGE_MATRIX.md b/cherry-n8n-workflows/COVERAGE_MATRIX.md new file mode 100644 index 00000000..6463046d --- /dev/null +++ b/cherry-n8n-workflows/COVERAGE_MATRIX.md @@ -0,0 +1,163 @@ +# Cherry n8n Coverage Matrix + +Status: Generated +Last updated: 2026-04-27 + +## Workflow Coverage + +| Workflow | Covers | +| --- | --- | +| `01_ci_failure_compression` | 1, 2, 3, 4, 21-29 | +| `02_openclaw_issue_router` | 41-50 | +| `03_pr_risk_classifier` | 11-17, 30-32, 46-49 | +| `04_forbidden_change_detector` | 32-39, 48 | +| `05_engine_degradation_alerting` | 51-60 | +| `06_simulation_drift_detector` | 61-70 | +| `07_release_summary_generator` | 71-80 | +| `08_repo_intelligence_digest` | 5-10, 20, 91-100 | +| `09_docs_drift_detector` | 81-90 | +| `10_backlog_grooming` | 18-20, 40, 93-100, 106-107 | +| `Shared sink pattern` | 101-110 | + +## Use Case Map + +### Repo Automation + +1. CI failure -> structured issue +2. CI failure -> existing issue comment +3. CI failure -> OpenClaw task +4. flaky test detector +5. dependency update triage +6. Dependabot PR classifier +7. CodeQL alert router +8. secret scan alert router +9. stale branch detector +10. stale PR detector +11. PR size classifier +12. PR risk score +13. PR domain classifier +14. PR checklist generator +15. PR summary generator +16. PR merge-block reminder +17. issue deduplication +18. issue severity labeling +19. issue owner/domain labeling +20. backlog grooming automation + +### Verification Automation + +21. run full verification on demand +22. rerun failed workflow +23. collect failed logs +24. summarize failure cause +25. compare failure to last passing run +26. detect changed files causing failure +27. enforce required scripts exist +28. verify migrations apply cleanly +29. verify Prisma schema drift +30. verify test coverage changed +31. verify route tests are in correct folder +32. verify no forbidden imports +33. verify no production secrets touched +34. verify no .env diff +35. verify no snapshot fraud +36. verify no deleted tests +37. verify no skipped tests added +38. verify no console.log leaks +39. verify no TODO introduced without issue +40. verify issue acceptance criteria updated + +### OpenClaw Automation + +41. issue labeled openclaw -> create OpenClaw task +42. OpenClaw result -> validate schema +43. OpenClaw patch -> attach summary +44. OpenClaw failure -> request retry +45. OpenClaw command log -> archive +46. OpenClaw changed engine -> require tests +47. OpenClaw changed docs only -> lighter checks +48. OpenClaw touched forbidden files -> block +49. OpenClaw PR -> mark needs-human-review +50. OpenClaw output -> generate commit message + +### Cherry Engine Observability + +51. degradation event -> issue +52. missing truth event -> issue +53. solver divergence event -> issue +54. impossible state event -> issue +55. temporal inconsistency event -> issue +56. candidate exclusion spike -> alert +57. simulation instability -> alert +58. score drift -> alert +59. route response mismatch -> alert +60. advisory output degradation -> alert + +### Simulation Automation + +61. scheduled simulation run +62. compare simulation to previous snapshot +63. detect major allocation delta +64. detect paydown strategy flip +65. detect runway collapse +66. detect debt relief regression +67. detect reward-over-safety bias +68. detect malformed candidate set +69. detect empty viable candidates +70. store simulation audit artifact + +### Release Automation + +71. changelog generation +72. release notes generation +73. LinkedIn draft generation +74. GitHub release draft +75. semantic version suggestion +76. breaking-change detector +77. migration warning generator +78. issue closure report +79. release risk summary +80. deployment summary + +### Documentation Automation + +81. docs drift detector +82. README update reminder +83. architecture doc update reminder +84. API contract doc generator +85. endpoint inventory generator +86. env var inventory generator +87. Prisma model change summary +88. test inventory summary +89. issue-to-doc linkage +90. glossary update automation + +### Project Management + +91. weekly progress digest +92. daily issue digest +93. blocked issue detector +94. orphaned issue detector +95. milestone progress report +96. PR-to-issue linkage checker +97. acceptance criteria completeness checker +98. roadmap update generator +99. duplicate backlog detector +100. priority decay detector + +### External Integrations + +101. Discord notifications +102. Slack notifications +103. email summaries +104. Notion sync +105. Google Sheets metrics export +106. Linear/Jira sync +107. GitHub Projects update +108. calendar reminder for releases +109. webhook archive to database +110. incident timeline export + +## Coverage Status + +All use cases 1-110 are mapped to at least one workflow or to the shared sink pattern. diff --git a/cherry-n8n-workflows/README.md b/cherry-n8n-workflows/README.md new file mode 100644 index 00000000..274ffb8e --- /dev/null +++ b/cherry-n8n-workflows/README.md @@ -0,0 +1,73 @@ +# Cherry n8n Minmax Workflow Pack + +Status: Generated +Last updated: 2026-04-27 + +This directory contains 10 importable n8n workflow JSON files for Cherry development automation. The workflows are advisory and development-facing only. They do not touch Cherry payment rails and must not mutate Sessions, Ledger, Buckets, cards, payments, or other financial truth. + +## Import + +Import each JSON file as a single workflow in n8n. Each file contains exactly one workflow object, not an array of workflows. + +The zip is expected to preserve this root folder: + +```bash +cd /Users/nasr/repos/cherry +zip -r cherry-n8n-workflows.zip cherry-n8n-workflows +``` + +## Required Environment Variables + +- `GITHUB_OWNER` +- `GITHUB_REPO` +- `GITHUB_TOKEN` +- `OPENCLAW_WEBHOOK_URL` +- `DISCORD_WEBHOOK_URL` +- `SLACK_WEBHOOK_URL` +- `EMAIL_WEBHOOK_URL` +- `NOTION_WEBHOOK_URL` +- `GOOGLE_SHEETS_WEBHOOK_URL` +- `LINEAR_JIRA_WEBHOOK_URL` +- `GITHUB_PROJECTS_WEBHOOK_URL` +- `CHERRY_API_BASE_URL` +- `CHERRY_AUTOMATION_TOKEN` + +These workflows use n8n `$env.*` expressions. HTTP Request nodes use header +parameters with placeholder expressions only. No credentials are required at +import time. + +## Webhook Paths + +- `POST /cherry/github/workflow-completed` -> CI failure compression +- `POST /cherry/github/issue-labeled` -> OpenClaw issue router +- `POST /cherry/github/pull-request-risk` -> PR risk classifier +- `POST /cherry/github/pull-request-forbidden` -> forbidden-change detector +- `POST /cherry/runtime/degradation` -> engine degradation alerting +- `POST /cherry/simulation/result` -> simulation drift detector +- `POST /cherry/release/summary` -> release summary generator +- `POST /cherry/github/pull-request-docs-drift` -> docs drift detector + +## GitHub Webhook Event Mapping + +- `workflow_run.completed` -> `/cherry/github/workflow-completed` +- `issues.labeled` -> `/cherry/github/issue-labeled` +- `pull_request` -> PR risk, forbidden-change, and docs-drift workflows + +## Scheduled Workflows + +- `08_repo_intelligence_digest.json` runs weekly. +- `10_backlog_grooming.json` runs weekly. + +## Safety Boundary + +Forbidden Cherry endpoint patterns: + +- `/api/session*` +- `/api/ledger*` +- `/api/bucket*` +- `/api/payment*` +- `/api/card*` +- `/api/debt*/mutate` +- any `POST`, `PATCH`, or `DELETE` endpoint that changes financial truth + +Workflow `06_simulation_drift_detector` calls Cherry's `/api/automation/simulation-snapshots/compare` endpoint so snapshot history is durable in Cherry automation storage, not n8n static data. diff --git a/cherry-n8n-workflows/VALIDATION_REPORT.md b/cherry-n8n-workflows/VALIDATION_REPORT.md new file mode 100644 index 00000000..a7d0ddcc --- /dev/null +++ b/cherry-n8n-workflows/VALIDATION_REPORT.md @@ -0,0 +1,83 @@ +# Cherry n8n Validation Report + +Status: Passed +Last updated: 2026-04-27 + +## Parsed Files + +- 01_ci_failure_compression.json +- 02_openclaw_issue_router.json +- 03_pr_risk_classifier.json +- 04_forbidden_change_detector.json +- 05_engine_degradation_alerting.json +- 06_simulation_drift_detector.json +- 07_release_summary_generator.json +- 08_repo_intelligence_digest.json +- 09_docs_drift_detector.json +- 10_backlog_grooming.json + +## Workflow Names + +- Cherry - CI Failure Compression +- Cherry - OpenClaw Issue Router +- Cherry - PR Risk Classifier +- Cherry - Forbidden Change Detector +- Cherry - Engine Degradation Alerting +- Cherry - Simulation Drift Detector +- Cherry - Release Summary Generator +- Cherry - Repo Intelligence Digest +- Cherry - Docs Drift Detector +- Cherry - Backlog Grooming + +## Webhook Paths + +- /cherry/github/workflow-completed +- /cherry/github/issue-labeled +- /cherry/github/pull-request-risk +- /cherry/github/pull-request-forbidden +- /cherry/runtime/degradation +- /cherry/simulation/result +- /cherry/release/summary +- /cherry/github/pull-request-docs-drift + +## Automation Endpoints + +- /api/automation/classify/pr +- /api/automation/events +- /api/automation/simulation-snapshots/compare +- /api/automation/statuses/github + +## Coverage Status 1-110 + +Passed: all use cases 1-110 are covered. + +## Credential Objects + +Detected credential objects: none + +## Connection Reference Check + +Passed + +## HTTP Failure Handling + +Every HTTP Request node has `continueOnFail: true`. + +## Webhook Response Mode + +All Webhook nodes set `responseMode` to `responseNode`. + +## Code Node Language + +All Code nodes use JavaScript. + +## V2 Notes + +- Archive nodes call `/api/automation/events`. +- PR risk workflow calls `/api/automation/classify/pr` and `/api/automation/statuses/github`. +- Forbidden-change and docs-drift workflows call `/api/automation/statuses/github`. +- Simulation drift workflow calls `/api/automation/simulation-snapshots/compare` instead of n8n static data. + +## Errors + +None. diff --git a/docs/automation/branch-protection.md b/docs/automation/branch-protection.md new file mode 100644 index 00000000..65269c00 --- /dev/null +++ b/docs/automation/branch-protection.md @@ -0,0 +1,17 @@ +Status: Active +Last updated: 2026-04-28 + +# Cherry Automation Branch Protection + +Cherry automation V2 posts allowlisted GitHub commit statuses through Cherry-owned API endpoints. These statuses become enforcement only when the repository branch protection rules require them before merge. + +Required Cherry status contexts: + +- `cherry/forbidden-change` +- `cherry/docs-drift` +- `cherry/risk-gate` +- `cherry/openclaw-policy` + +Without branch protection, Cherry statuses are advisory only. + +Configure branch protection for protected branches to require the contexts above, keep administrator bypasses limited, and keep n8n routed through Cherry `/api/automation/*` endpoints rather than posting arbitrary status contexts directly. diff --git a/docs/ci-and-guardrails.md b/docs/ci-and-guardrails.md index 00c4bc97..94042be6 100644 --- a/docs/ci-and-guardrails.md +++ b/docs/ci-and-guardrails.md @@ -1,5 +1,5 @@ Status: Active -Last updated: 2026-04-26 +Last updated: 2026-04-28 # CI and guardrails @@ -9,7 +9,8 @@ Last updated: 2026-04-26 - Runs on every push to `main` and all PRs via `.github/workflows/ci.yml`. - Steps (fail-fast): 1) `npm ci` (postinstall runs `prisma generate`) - 2) `npm run ci:verify` (composite truth gate: check + build) + 2) `npm run check:guardrails` + 3) `npm run ci:verify` (composite truth gate: check + test + build) - Optional env lane (`.github/workflows/env-checks.yml`) provisions Postgres and runs: - `npx prisma generate` - `npx prisma migrate deploy` @@ -25,6 +26,8 @@ Last updated: 2026-04-26 - `check:guardrails` guarantees registry completeness, execution exclusivity, CI coverage, and ordering stability. - `check` is the aggregate of guardrails + node correctness + UI correctness; env checks live in `check:env`. - The last non-empty command in the CI job must be `npm run ci:verify`. +- There must be exactly one canonical runtime execution per CI run: `ci:verify` reaches `npm test`, and `npm test` runs root legacy tests, `tests/node`, then `tests/next`. +- CI must not directly run node/next runtime test steps outside `ci:verify`. ### Temp root requirement - `CHERRY_TMP_ROOT` is required for all guardrails and scripts that allocate temp. @@ -54,12 +57,13 @@ Last updated: 2026-04-26 - It does not mutate env in a way production would not. - It runs the Issue 8 proof slice, then `npm run lint`, `npm run check`, `npm run typecheck`, `npm test`, and `npm run build`. -> If CI ever runs individual guardrail scripts directly, the system is broken. +> If CI directly runs node/next runtime tests outside `ci:verify`, the system is broken. ### Ordering invariant - Guardrails execute before env-specific correctness and build. - `check:guardrails` runs core (env-free) guardrails; `check:env` runs env-dependent guardrails plus DB requirements. - Inside `check:node` and `check:next`, lint runs before typecheck and typecheck runs before tests. +- `npm test` is the partitioned full runtime runner, and ownership is enforced by `tests/node/guardrails/test-runner-ownership.test.ts`. - Build executes after `check` completes. ### Guardrails enforced @@ -71,7 +75,8 @@ Last updated: 2026-04-26 - Guardrail 5 (implicit config): `process.env` access is confined to `app/api/**` and `scripts/**`; load env into typed config via `initConfigFromEnv` and thread it explicitly. `check:config` must pass without allowlists. - Guardrail 6 (config immutability): server config is deep-frozen and locked after boundary load; `setServerConfig` rejects writes post-lock and loader registration fails once locked. `check:config-lock` must pass. - `check:check-contract` enforces the `ci:verify` contract and keeps `check` pure. -- `check:ci-must-run-check` enforces the single CI entrypoint (`ci:verify`). +- `check:ci-must-run-check` enforces fail-fast guardrails before the final CI entrypoint (`ci:verify`) and forbids direct runtime test execution in CI. +- `check:ci-guardrail-coverage` enforces the transitive proof chain from CI to `ci:verify`, `npm test`, and `check:run-tests`. - `check:guardrails-core` exits non-zero on any deviation; CI treats that as a hard failure. ### Guardrail scope invariant @@ -81,7 +86,14 @@ Last updated: 2026-04-26 ### How to run locally -Run the npm scripts: `check:aggregate` (guardrails only), `check` (aggregate + node + next), `test` (tests only), `build`, or the full gate `ci:verify`. +Use the narrowest proof that fully covers the changed surface: +- `npm run check:static` for guardrails, lint, and typecheck. +- `npm run check:fast` for local guardrails + script typecheck. +- `npm run check:runtime` or `npm test` for the partitioned runtime suite. +- `npm run check:local` for `check:fast` plus the partitioned runtime suite. +- `CHERRY_TMP_ROOT="$HOME/.cherry-tmp" CHERRY_VINE_SIGNATURE_MODE=enforce npm run verify:repo-closure` for canonical full proof. + +Agents must not blindly stack `npm run check`, `npm test`, `npm run build`, and `verify:repo-closure`; do not run both `npm test` and `verify:repo-closure` unless explicitly required. ### What CI green means (DB posture) - Standard CI (`ci:verify`) does not exercise a live database; tests run with Prisma mocked. diff --git a/docs/config-snapshot.md b/docs/config-snapshot.md index 02412637..de7d97d2 100644 --- a/docs/config-snapshot.md +++ b/docs/config-snapshot.md @@ -45,16 +45,45 @@ jobs: - name: Guardrails run: npm run check:guardrails - - name: Node runtime tests - run: npm run check:tests:node - - - name: Next runtime tests - run: npm run check:tests:next - - name: Verify CI truth run: npm run ci:verify ``` +```yaml +// .github/workflows/n8n-notify.yml +name: Notify n8n + +on: + workflow_run: + workflows: + - CI + types: + - completed + +jobs: + notify-n8n: + runs-on: ubuntu-latest + + steps: + - name: Send workflow result to n8n + env: + N8N_WEBHOOK_URL: ${{ secrets.N8N_WEBHOOK_URL }} + N8N_WEBHOOK_TOKEN: ${{ secrets.N8N_WEBHOOK_TOKEN }} + run: | + curl -X POST "$N8N_WEBHOOK_URL" \ + -H "Authorization: Bearer $N8N_WEBHOOK_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "event": "github.workflow.completed", + "repo": "${{ github.repository }}", + "workflow": "${{ github.event.workflow_run.name }}", + "status": "${{ github.event.workflow_run.conclusion }}", + "branch": "${{ github.event.workflow_run.head_branch }}", + "sha": "${{ github.event.workflow_run.head_sha }}", + "url": "${{ github.event.workflow_run.html_url }}" + }' +``` + ```yaml // .github/workflows/env-checks.yml # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json @@ -9588,12 +9617,16 @@ export default nextConfig; "engineStrict": true, "packageManager": "npm@11.12.1", "scripts": { - "predev": "npm run check:db-ready", + "predev": "CHERRY_TMP_ROOT=${CHERRY_TMP_ROOT:-$HOME/.cherry-tmp} npm run check:db-ready", "dev": "next dev --webpack", "build": "next build --webpack", "build:strict": "npm run check:guardrails && next build --webpack", "start": "next start", "ci:verify": "npm run check && npm run test && npm run build", + "check:static": "npm run check:guardrails && npm run lint:scripts && npm run typecheck:scripts && npm run lint && npm run typecheck", + "check:runtime": "npm test", + "check:fast": "npm run check:guardrails && npm run typecheck:scripts", + "check:local": "npm run check:fast && npm run check:runtime", "check:clean": "npm run ts:esm -- scripts/execution/run.mts check:clean", "check:db-ready": "npm run ts:esm -- scripts/execution/run-db.mts check:db-ready", "check:dev-login": "npm run ts:esm -- scripts/execution/run.mts check:dev-login", @@ -10353,6 +10386,75 @@ model DecisionEvent { @@index([userId, createdAt]) } +model AutomationEvent { + id String @id @default(cuid()) + repo String + sha String? + event String + source String + workflow String + status String + idempotencyKey String @unique(map: "automation_event__idempotency_key__unique") + classifierVersion String + outputHash String + rawPayload Json + normalizedEvent Json + classifierOutput Json + prNumber Int? + issueNumber Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + statusChecks AutomationStatusCheck[] + + @@index([repo, sha]) + @@index([repo, prNumber]) + @@index([repo, issueNumber]) + @@index([workflow, createdAt]) + @@index([classifierVersion]) +} + +model SimulationAutomationSnapshot { + id String @id @default(cuid()) + repo String + scopeKey String + runId String + classifierVersion String + snapshot Json + comparisonOutput Json + outputHash String + previousSnapshotId String? + createdAt DateTime @default(now()) + + @@unique([scopeKey, runId, classifierVersion], map: "simulation_automation_snapshot__scope_run_version__unique") + @@index([repo, scopeKey]) + @@index([scopeKey, createdAt]) + @@index([classifierVersion]) +} + +model AutomationStatusCheck { + id String @id @default(cuid()) + repo String + sha String + context String + state String + description String + targetUrl String? + sourceWorkflow String + automationEvent AutomationEvent? @relation(fields: [automationEventId], references: [id], onDelete: SetNull, map: "automation_status_check__automation_event_id__fk") + automationEventId String? + classifierVersion String + outputHash String + statusIdempotencyKey String @unique(map: "automation_status_check__status_idempotency_key__unique") + githubResponse Json? + createdAt DateTime @default(now()) + + @@index([repo, sha]) + @@index([repo, sha, context]) + @@index([automationEventId]) + @@index([classifierVersion]) +} + model IdempotencyKey { userId String key String diff --git a/docs/guardrails.md b/docs/guardrails.md index 15f461d4..81a01d52 100644 --- a/docs/guardrails.md +++ b/docs/guardrails.md @@ -1,11 +1,12 @@ Status: Active -Last updated: 2026-01-31 +Last updated: 2026-04-28 # Guardrails ## Current behavior - Guardrail and execution script registration is mandatory; registries are the only authority. -- CI runs `npm run ci:verify` as the sole truth gate; `check` remains pure (guardrails + lint + typecheck), and env checks live in `check:env`. +- CI runs fail-fast `check:guardrails` before final `npm run ci:verify`; `ci:verify` is the sole runtime truth gate, and env checks live in `check:env`. +- `npm test` is the partitioned full runtime runner: root legacy tests, `tests/node`, then `tests/next`; ownership is enforced by `tests/node/guardrails/test-runner-ownership.test.ts`. - Script conventions (no raw JSON.parse, no any, .mts only under scripts) live in `docs/script-standards.md`. - Guardrail checks now enforce JSON.parse bans in scripts and npm arg forwarding (`check:script-json-parse`, `check:npm-arg-forwarding`). - Script runtime boundaries are enforced; scripts may not import app/components/lib-client runtime modules (`check:script-runtime-boundary`). @@ -465,7 +466,8 @@ Any duplication is a hard CI failure. - CI must include a step that runs `npm run ci:verify`. - The last non-empty command in the CI job must be `npm run ci:verify`. -- CI must not invoke other npm scripts directly; `ci:verify` is the only entrypoint. +- CI may run `npm run check:guardrails` before `ci:verify` for fail-fast coverage. +- CI must not invoke direct runtime scripts (`npm test`, `check`, `check:node`, `check:next`, `check:tests:*`, or `check:run-tests:*`) outside `ci:verify`. - Guardrail checks: `check:ci-must-run-check`, `check:ci-guardrail-coverage`. ### Guardrail 23 — Execution Registry Completeness @@ -534,6 +536,7 @@ Any duplication is a hard CI failure. - `ci:verify` must run `check`, `test`, and `build` in order. - `check` must remain pure (no env-dependent scripts). +- `test` must reach `check:run-tests`, the partitioned full runtime runner. - `test` and `build` must not invoke guardrails; use `test:strict` and `build:strict` when needed. - Guardrail: `check:check-contract`. diff --git a/docs/schema-evolution.md b/docs/schema-evolution.md index 0e8cde12..96fde10b 100644 --- a/docs/schema-evolution.md +++ b/docs/schema-evolution.md @@ -1,5 +1,5 @@ Status: Active -Last updated: 2026-04-26 +Last updated: 2026-04-27 # Schema Evolution Rules @@ -9,10 +9,10 @@ Last updated: 2026-04-26 - DB truth scripts/tests under scripts/db-check-* or tests/db/** ## Current schema manifest -- `schemaVersion`: `schema_v2` -- `lastMigration`: `20260426090000_add_scheduled_paydowns` +- `schemaVersion`: `schema_v3` +- `lastMigration`: `20260427153000_automation_backend` - `invariantsVersion`: `db_invariants_v1` -- `schema_v2` adds persisted raw scheduled paydown rows for engine loading. The runtime loader treats these rows as source data only; temporal classification remains in engine evaluation. +- `schema_v3` adds advisory automation audit tables for n8n V2: `AutomationEvent`, `SimulationAutomationSnapshot`, and `AutomationStatusCheck`. These records support replay, classifier output hashes, and GitHub status auditability; they do not mutate finance truth. ## Required steps per schema change 1. Create or update a migration under prisma/migrations/**. diff --git a/docs/script-standards.md b/docs/script-standards.md index 062b9cff..1d15d4f4 100644 --- a/docs/script-standards.md +++ b/docs/script-standards.md @@ -1,5 +1,5 @@ Status: Active -Last updated: 2026-01-02 +Last updated: 2026-04-28 # Script Standards @@ -7,7 +7,8 @@ Last updated: 2026-01-02 - Scripts are ESM by extension; `.mts` only lives under `scripts/`, runtime code stays `.ts`. - Guardrail entrypoints are registered in `scripts/guardrails/registry.mts` and must be reachable from `npm run check`. - Execution entrypoints are registered in `scripts/execution/registry.mts` and run via `npm run ts:esm -- scripts/execution/run.mts `. -- CI must run `npm run ci:verify` and it must be the final non-empty command in the job. +- CI may run `npm run check:guardrails` for fail-fast coverage, then must run one final `npm run ci:verify`. +- Direct CI runtime test scripts are forbidden; `ci:verify` reaches `npm test`, and `npm test` reaches `check:run-tests`. - JSON inputs must be parsed via `scripts/guardrails/lib/read-json.mts`; raw `JSON.parse` is forbidden outside that helper. - NPM script args must be forwarded with `--` (use `npm run