openvibers/openvibe.events Commits
GitHub webhook ingress for pull-request follow-ups (AL5) (#298)
Changes
@@@ -127,6 +127,10 @@ EVENTS_RECEIPT_RETENTION_DAYS=90# characters or more; it is the one Events signs its own deliveries with.#EVENTS_EVENTS_URL=https://openvibe.events#EVENTS_EVENTS_SECRET=++# GitHub webhooks in (AL5: server/github.js): the secret GitHub signs deliveries with (repo Settings →++# Webhooks → the webhook's Secret; 16 characters or more, comma-separated while rotating). Unset: POST++# /api/v1/in/github answers 404 and no github.* event is ever stored.++#EVENTS_GITHUB_WEBHOOK_SECRET=#OV_BILLING_AUDIENCE=openvibe.billing#OV_BILLING_TIMEOUT_MS=10000@@@ -132,6 +132,49 @@ Events is the bus, so two of its columns name a person: the `actor` (the SubjectThe route is `POST /internal/events`, signed (v2) and loopback only, mounted by `server/app.js`; `server/index.js` creates the two subscriptions at boot with `startSubscriptions` (`EVENTS_EVENTS_URL`, `EVENTS_EVENTS_SECRET` and the service's own OAuth client). Without the secret the route is off and the account events do not reach Events. The receipts of what it applied are in `account_data_events` (migration 0010), so a redelivered export or deletion changes nothing. `test/account-data.test.js` asserts `uncovered()` is empty, so a new table with a person column fails CI until it is covered or exempted with a reason.++### GitHub webhooks in (AL5: a ticket follows its pull request)++++`server/github.js` is the one receiver of GitHub's pull-request events on this estate (nothing else receives++them). GitHub POSTs a delivery to `POST /api/v1/in/github`; the ingress verifies `X-Hub-Signature-256` against++`EVENTS_GITHUB_WEBHOOK_SECRET` (HMAC-SHA256 of the raw body; comma-separated while rotating; 16 characters or++more), claims `X-GitHub-Delivery` in `github_deliveries` (migration 0012) so a redelivery stores nothing, and++normalizes it into the ordinary github.* envelopes any subscriber can consume:++++- `github.check.completed` — a `check_run`, `check_suite` or `workflow_run` finished: `kind`, `conclusion`,++ `check_name`, `head_sha`, `html_url`, `details_url`, and `output` (title/summary/text — the failing job's log++ tail when GitHub sends one). Codes decides which conclusions are red.++- `github.pr.updated` — a `pull_request` opened/synchronize/reopened/closed: `merged`, `mergeable`,++ `mergeable_state`, `head_sha`, `head_ref`, `base`, `changed_files`, `html_url`, `files_url`.++- `github.pr.commented` — an `issue_comment` on a PR, a submitted `pull_request_review` or a++ `pull_request_review_comment`: `author`, `author_type` (Bot/User), `author_association`, `state`, `body`,++ `comment_id`, `path`, `line`, `head_sha`. A bare approval, an edited comment and a dismissed review are not++ new instructions and store nothing.++++The subject is `{ type: 'pr', id: '<owner>/<name>#<number>' }` (repo lowercased), the key Codes' `pr_bindings`++matches on. `202` answers `{ event_ids }` (or `{ ignored }` for `ping` and kinds with no pull request); a++redelivery answers `{ event_ids, duplicate: true }`; `401` is a bad signature; `404` while++`EVENTS_GITHUB_WEBHOOK_SECRET` is unset. The route is off by default and never stores a secret. The body limit is++1 MB; the per-address window is 300 deliveries a minute.++++**What the owner adds** (this PR creates nothing on GitHub):++++1. `/etc/openvibe/events.env`: `EVENTS_GITHUB_WEBHOOK_SECRET=<at least 16 random characters>`; restart++ `openvibe-events`.++2. Apply the `/api/v1/in/github` location from [deploy/nginx/openvibe.events.conf](deploy/nginx/openvibe.events.conf)++ and reload nginx (the path must reach 127.0.0.1:4300; the catch-all under `/api/` answers 404).++3. On the repository (`OpenVibers/*`) → Settings → Webhooks → Add webhook: Payload URL++ `https://openvibe.events/api/v1/in/github`, content type `application/json`, the same secret, SSL++ verification on, and the events **Check runs**, **Check suites**, **Pull requests**, **Issue comments**,++ **Pull request reviews**, **Pull request review comments** (optionally **Workflow runs**). A `ping` delivery++ answers `202 {"ignored":"ping"}`.++4. On Codes: set `CODES_PR_FOLLOW_ENABLED=1`; Codes subscribes to the github.* event types above at boot and++ receives them at `/internal/events` under its existing `CODES_EVENTS_SECRET`, with the usual Events delivery++ v2 signature check. The pull request the worker opened binds to the task there, and the same receiver wakes it.++++`test/github.test.js` covers the signature, the normalization of every event type (including the log tail and a++review comment's path/line), a multi-PR delivery claimed once, an idempotent redelivery, and the off-by-default++route; `test/fixtures/github/` holds the recorded payloads.++### Retention: three tiers[ADR-042](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-042-events-fabric.md) decision 8. An event lives in one tier at a time:@@@ -150,6 +150,22 @@ server {proxy_set_header X-Forwarded-Proto $scheme;}++ # ── GitHub webhooks in (AL5: server/github.js) ──++ # GitHub POSTs signed deliveries here; the service verifies X-Hub-Signature-256. Off (404) while++ # EVENTS_GITHUB_WEBHOOK_SECRET is unset. The raw body is at most 1 MB in the service.++ location = /api/v1/in/github {++ limit_req zone=ovevents_apex_api burst=40 nodelay;++ client_max_body_size 2m;++ proxy_pass http://127.0.0.1:4300;++ proxy_http_version 1.1;++ proxy_set_header Connection "";++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ }++# ── Everything else under /api/: a problem document, never a page; the API stays host-local ──location /api/ {default_type application/problem+json;nnew file mode 100644@@@ -0,0 +1,12 @@++-- phase: expand++-- GitHub webhook ingress (AL5: a ticket follows its pull request). One row per GitHub delivery GUID, holding the++-- event ids it stored, so a redelivered webhook answers them and stores no second event. The normalized github.*++-- events live in the hot store like any other event; this table is only the ingress's idempotency record. One row++-- per delivery (not per event) because a delivery can name several pull requests and they must be claimed together.++-- Nothing here holds a secret.++CREATE TABLE IF NOT EXISTS github_deliveries (++ delivery_id text COLLATE "C" PRIMARY KEY, -- X-GitHub-Delivery++ event_ids jsonb NOT NULL, -- ["evt_…", …] stored for it++ event_types jsonb NOT NULL, -- ["github.check.completed", …] in the same order++ received_at timestamptz NOT NULL DEFAULT now()++);@@@ -73,6 +73,14 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLoapp.options('/realtime/stream', realtime.cors);app.get('/realtime/stream', realtime.cors, realtime.handler);++ // GitHub webhooks in (AL5: server/github.js): raw body (the HMAC covers it), mounted before the JSON++ // parser. Off (404) without EVENTS_GITHUB_WEBHOOK_SECRET.++ {++ const { createGithubIngress } = require('./github');++ const ingress = createGithubIngress({ config, store, log });++ app.post('/api/v1/in/github', ingress.raw, ingress.handler);++ }++app.use('/api', express.json({ limit: '2mb', type: ['application/json', 'application/*+json'] }));app.get('/api/health', (_req, res) => {@@@ -27,6 +27,8 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openr'bot',// Watch (OpenVibe.Watch): watch.observation.recorded, watch.watch.triggered, watch.check.failed'watch',++ // GitHub webhooks in (AL5): the ingress stores github.check.completed|github.pr.updated|github.pr.commented.++ 'github',// Run (OpenVibe.Run, plan T14): run.job.queued|started|succeeded|failed|cancelled|expired'run',// Space (OpenVibe.Space, plan T10 step 3): space.thread.created, space.post.created, space.moderation.action@@@ -277,6 +279,12 @@ function load(env = process.env) {secret: String(env.EVENTS_EVENTS_SECRET || '').trim(),},++ // GitHub webhooks in (AL5: server/github.js): HMAC secrets for X-Hub-Signature-256, comma-separated for++ // rotation. Empty: POST /api/v1/in/github answers 404 and no github.* event is ever stored.++ github: {++ secrets: String(env.EVENTS_GITHUB_WEBHOOK_SECRET || '').split(',').map((s) => s.trim()).filter((s) => s.length >= 16),++ },++// Realtime (SSE) gatewayrealtime: {maxConnections: int(env.REALTIME_MAX_CONNECTIONS, 2000),nnew file mode 100644@@@ -0,0 +1,352 @@++'use strict';++++/**++ * GitHub webhooks in (AL5: a ticket follows its pull request).++ *++ * POST /api/v1/in/github++ * X-GitHub-Delivery: the delivery GUID (idempotency: a redelivery answers the stored event ids)++ * X-GitHub-Event: check_run | check_suite | workflow_run | pull_request |++ * pull_request_review | pull_request_review_comment | issue_comment | ping++ * X-Hub-Signature-256: sha256=<hex HMAC of the raw body> with one of config.github.secrets++ *++ * A delivery is normalized to one source-'github' envelope per pull request and stored through++ * store.insertBatch like any other event, so Codes (and the portal) subscribes to it with the usual++ * Events subscription — nothing polls GitHub:++ *++ * github.check.completed a check_run, check_suite or workflow_run finished → { repo, kind, conclusion,++ * check_name, head_sha, html_url, details_url, output } (Codes decides what is red;++ * output is the failing job's log tail when GitHub sends one)++ * github.pr.updated a pull_request synchronize|reopened|closed|opened → { repo, pr_number, action,++ * merged, mergeable, mergeable_state, head_sha, head_ref, base, base_ref,++ * changed_files, additions, deletions, draft, title, html_url, files_url }++ * (mergeable_state is GitHub's, often null on the webhook: conflicts through it are++ * best-effort; files_url is where a worker can list the changed files)++ * github.pr.commented an issue_comment on a PR, a submitted review or a review comment →++ * { repo, pr_number, author, author_type, author_association, state, body,++ * comment_id, path, line, head_sha, html_url }++ *++ * The subject of every envelope is { type: 'pr', id: '<owner>/<name>#<number>' } with the slug lowercased,++ * the key Codes' pr_bindings matches on. 202 answers { event_ids } (or { ignored } for ping and event kinds++ * with no pull request); a redelivery answers { event_ids, duplicate: true }; 401 on a bad signature; 404 while++ * EVENTS_GITHUB_WEBHOOK_SECRET is unset. Nothing here prints or stores a secret.++ *++ * const ingress = createGithubIngress({ config, store, log });++ * app.post('/api/v1/in/github', ingress.raw, ingress.handler); // before the JSON body parser++ */++const crypto = require('crypto');++const express = require('express');++const { ids, validate, http } = require('openvibe-contracts');++++const SOURCE = 'github';++const ACTOR = Object.freeze({ type: 'service', id: 'github' });++const MAX_BODY = 4000; // a comment/review body kept on an envelope++const MAX_LOG_TAIL = 8_000; // a check's output text (the log tail GitHub sends) kept on an envelope++const MAX_DELIVERIES = 20; // a check run is fanned out per pull request, at most this many++const MIN_SECRET = 16;++++const cut = (v, n) => (typeof v === 'string' && v.length > n ? v.slice(0, n) : (typeof v === 'string' ? v : undefined));++const lower = (v) => (typeof v === 'string' ? v.toLowerCase() : null);++const repoOf = (p) => lower(p && p.repository && p.repository.full_name);++const num = (v) => (Number.isInteger(v) && v > 0 ? v : null);++++/** pull request numbers a check payload names (check_run, check_suite, workflow_run). */++function checkPrNumbers(p) {++ const out = [];++ const seen = new Set();++ const take = (n) => { const k = num(n); if (k && !seen.has(k) && out.length < MAX_DELIVERIES) { seen.add(k); out.push(k); } };++ const run = p.check_run || p.check_suite || p.workflow_run;++ for (const pr of (run && run.pull_requests) || []) {++ if (num(pr.number)) take(pr.number);++ else if (typeof pr.url === 'string') { const m = /\/pulls\/(\d+)(?:\/|$)/.exec(pr.url); if (m) take(Number(m[1])); }++ }++ return out;++}++++/** A check payload finished: conclusion, name, links and its output log tail, or null when it did not finish. */++function checkCompleted(ghEvent, p) {++ const run = p.check_run || p.check_suite || p.workflow_run;++ if (!run || run.status !== 'completed') return null;++ const url = run.html_url || run.url || (p.check_run && p.check_run.details_url) || null;++ const out = run.output && typeof run.output === 'object' && !Array.isArray(run.output) ? run.output : null;++ const output = out ? {++ title: cut(out.title, 500),++ summary: cut(out.summary, 4_000),++ text: cut(out.text, MAX_LOG_TAIL),++ annotations: Array.isArray(out.annotations) ? out.annotations.length : undefined,++ } : null;++ return {++ event_type: 'github.check.completed',++ check: {++ kind: p.check_run ? 'check_run' : p.check_suite ? 'check_suite' : 'workflow_run',++ conclusion: typeof run.conclusion === 'string' ? run.conclusion.slice(0, 40) : null,++ check_name: cut(run.name, 200),++ head_sha: typeof run.head_sha === 'string' ? run.head_sha.slice(0, 64) : null,++ html_url: typeof url === 'string' ? url.slice(0, 2000) : null,++ details_url: cut(p.check_run && p.check_run.details_url, 2000),++ started_at: cut(run.started_at, 40),++ completed_at: cut(run.completed_at, 40),++ output,++ },++ prNumbers: checkPrNumbers(p),++ };++}++++function prUpdated(p) {++ const pr = p.pull_request;++ if (!pr || !num(p.number)) return null;++ const head = pr.head && typeof pr.head === 'object' ? pr.head : {};++ const base = pr.base && typeof pr.base === 'object' ? pr.base : {};++ return {++ event_type: 'github.pr.updated',++ update: {++ action: typeof p.action === 'string' ? p.action.slice(0, 40) : null,++ merged: pr.merged === true,++ mergeable: pr.mergeable === true ? true : pr.mergeable === false ? false : null,++ mergeable_state: typeof pr.mergeable_state === 'string' ? pr.mergeable_state.slice(0, 40) : null,++ draft: pr.draft === true,++ title: cut(pr.title, 500),++ head_sha: cut(head.sha, 64),++ head_ref: cut(head.ref, 200),++ base: cut(base.ref, 200),++ base_ref: cut(base.ref, 200),++ base_sha: cut(base.sha, 64),++ changed_files: num(pr.changed_files),++ additions: num(pr.additions),++ deletions: num(pr.deletions),++ html_url: cut(pr.html_url, 2000),++ files_url: cut(pr.url ? `${String(pr.url).replace(/\/+$/, '')}/files` : null, 2000),++ },++ prNumbers: [p.number],++ };++}++++const userOf = (u) => (u && typeof u.login === 'string' ? { login: u.login.slice(0, 200), type: u.type === 'Bot' ? 'Bot' : 'User', association: cut(u.author_association, 40) } : null);++++/** A comment id; GitHub sends it as a number. */++const commentId = (v) => (Number.isInteger(v) && v > 0 ? v : null);++++function prCommented(ghEvent, p) {++ const repo = repoOf(p);++ if (!repo) return null;++ if (ghEvent === 'issue_comment') {++ // An issue_comment is a PR comment only when the issue is a pull request. Only a newly posted one wakes++ // anyone: an edited or deleted comment is not a new instruction (and a deleted "stop" must not end a ticket).++ if (p.action !== 'created') return null;++ if (!p.issue || !p.issue.pull_request || !num(p.issue.number)) return null;++ const by = userOf(p.comment && p.comment.user);++ if (!by || typeof (p.comment && p.comment.body) !== 'string') return null;++ const pr = p.issue.pull_request || {};++ return {++ event_type: 'github.pr.commented',++ comment: {++ author: by.login, author_type: by.type, author_association: by.association, state: 'commented',++ body: cut(p.comment.body, MAX_BODY),++ comment_id: commentId(p.comment.id),++ head_sha: cut(pr.head && pr.head.sha, 64),++ html_url: cut(p.comment.html_url, 2000),++ },++ prNumbers: [p.issue.number],++ };++ }++ if (ghEvent === 'pull_request_review') {++ const pr = p.pull_request;++ if (!pr || !num(pr.number)) return null;++ // A dismissal is not a new instruction; a bare approval is not a comment. A review that asks for changes++ // without a summary still wakes the worker, with a note to read the review on the pull request.++ if (p.action !== 'submitted') return null;++ const review = p.review && typeof p.review === 'object' ? p.review : {};++ const by = userOf(review.user);++ if (!by) return null;++ const state = cut(review.state, 40) || 'commented';++ let body = cut(review.body, MAX_BODY) || '';++ if (state === 'approved' && !body) return null;++ if (!body && state === 'changes_requested') body = 'The reviewer requested changes without a summary; read the review on the pull request.';++ if (!body) return null;++ return {++ event_type: 'github.pr.commented',++ comment: {++ author: by.login, author_type: by.type, author_association: by.association, state,++ body,++ comment_id: commentId(review.id),++ head_sha: cut(pr.head && pr.head.sha, 64),++ html_url: cut(review.html_url || pr.html_url, 2000),++ },++ prNumbers: [pr.number],++ };++ }++ if (ghEvent === 'pull_request_review_comment') {++ const pr = p.pull_request;++ if (!pr || !num(pr.number)) return null;++ if (p.action !== 'created') return null;++ const by = userOf(p.comment && p.comment.user);++ if (!by || typeof (p.comment && p.comment.body) !== 'string') return null;++ const c = p.comment;++ return {++ event_type: 'github.pr.commented',++ comment: {++ author: by.login, author_type: by.type, author_association: by.association, state: 'commented',++ body: cut(c.body, MAX_BODY),++ comment_id: commentId(c.id),++ path: cut(c.path, 500),++ line: num(c.line) || num(c.original_line),++ head_sha: cut(pr.head && pr.head.sha, 64),++ html_url: cut(c.html_url, 2000),++ },++ prNumbers: [pr.number],++ };++ }++ return null;++}++++/**++ * One normalized result per envelope: { event_type, prNumber, payload }. null: nothing to store++ * (an unfinished check, a non-PR issue, an uninteresting action). Throws on a malformed envelope.++ */++function normalize(ghEvent, p) {++ if (!p || typeof p !== 'object' || Array.isArray(p)) throw new Error('the webhook body must be a JSON object');++ const repo = repoOf(p);++ if (!repo) throw new Error('the webhook body names no repository');++ if (ghEvent === 'check_run' || ghEvent === 'check_suite' || ghEvent === 'workflow_run') {++ const c = checkCompleted(ghEvent, p);++ if (!c) return [];++ return c.prNumbers.map((n) => ({++ event_type: c.event_type, prNumber: n,++ payload: {++ repo, kind: c.check.kind, conclusion: c.check.conclusion, check_name: c.check.check_name,++ head_sha: c.check.head_sha, html_url: c.check.html_url, details_url: c.check.details_url,++ started_at: c.check.started_at, completed_at: c.check.completed_at, output: c.check.output || null,++ },++ }));++ }++ if (ghEvent === 'pull_request') {++ if (!['synchronize', 'reopened', 'closed', 'opened'].includes(p.action)) return [];++ const u = prUpdated(p);++ if (!u) return [];++ return u.prNumbers.map((n) => ({++ event_type: u.event_type, prNumber: n,++ payload: { repo, pr_number: n, ...u.update },++ }));++ }++ if (ghEvent === 'issue_comment' || ghEvent === 'pull_request_review' || ghEvent === 'pull_request_review_comment') {++ const c = prCommented(ghEvent, p);++ if (!c) return [];++ return c.prNumbers.map((n) => ({++ event_type: c.event_type, prNumber: n,++ payload: { repo, pr_number: n, ...c.comment },++ }));++ }++ return [];++}++++/** sha256=<hex> over the raw body with one of the secrets, compared in constant time. */++function verifies(raw, header, secrets) {++ if (typeof header !== 'string') return false;++ const m = /^sha256=([0-9a-f]{64})$/.exec(header.trim());++ if (!m) return false;++ const got = Buffer.from(m[1], 'hex');++ return (secrets || []).some((s) => {++ if (typeof s !== 'string' || !s.length) return false;++ const want = crypto.createHmac('sha256', s).update(raw).digest();++ return want.length === got.length && crypto.timingSafeEqual(want, got);++ });++}++++/** A tiny per-address sliding window (GitHub delivers from many addresses; this stops one stuck sender). */++function createLimiter({ perMinute = 300 } = {}) {++ const hits = new Map();++ return (ip) => {++ const now = Date.now();++ const list = (hits.get(ip) || []).filter((t) => now - t < 60_000);++ list.push(now);++ if (hits.size > 10000 && !hits.has(ip)) hits.delete(hits.keys().next().value);++ hits.set(ip, list);++ return list.length <= perMinute;++ };++}++++function createGithubIngress({ config, store, log = console, now = () => Date.now() } = {}) {++ const db = store.db;++ const secrets = ((config.github && config.github.secrets) || []).filter((s) => typeof s === 'string' && s.length >= MIN_SECRET);++ const limited = createLimiter();++ const stats = { received: 0, stored: 0, duplicates: 0, ignored: 0, refused: 0 };++++ async function handler(req, res) {++ const ctx = http.requestContext(req.headers);++ const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });++ if (!secrets.length) return problem(404, 'events.github_disabled', 'GitHub webhooks are not configured');++ if (!limited(req.ip || req.socket.remoteAddress || 'unknown')) { stats.refused++; return problem(429, 'events.rate_limited', 'too many webhook deliveries'); }++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);++ if (!verifies(raw, req.headers['x-hub-signature-256'], secrets)) { stats.refused++; return problem(401, 'events.bad_signature', 'X-Hub-Signature-256 does not verify'); }++ const ghEvent = String(req.headers['x-github-event'] || '');++ const delivery = String(req.headers['x-github-delivery'] || '').slice(0, 100);++ if (!/^[0-9a-f-]{8,100}$/i.test(delivery)) { stats.refused++; return problem(400, 'events.bad_delivery', 'X-GitHub-Delivery is required'); }++ stats.received++;++ if (ghEvent === 'ping') return res.status(202).json({ ignored: 'ping' });++ let payload;++ try { payload = JSON.parse(raw.toString('utf8')); } catch { stats.refused++; return problem(400, 'events.bad_delivery', 'the webhook body is not JSON'); }++ let normalized;++ try { normalized = normalize(ghEvent, payload); } catch (e) { stats.refused++; return problem(400, 'events.bad_delivery', e.message); }++ if (!normalized.length) { stats.ignored++; return res.status(202).json({ ignored: ghEvent || 'unknown' }); }++ const at = new Date(now()).toISOString();++ const trace = crypto.randomBytes(16).toString('hex');++ const items = normalized.map((n) => ({++ event_id: ids.newId('event', now()), event_type: n.event_type, version: 1, source: SOURCE,++ actor: ACTOR, timestamp: at, trace_id: trace, priority: 'important', visibility: 'internal',++ subject: { type: 'pr', id: `${repoOf(payload)}#${n.prNumber}` }, payload: n.payload,++ }));++ for (const env of items) {++ const v = validate('events.event-envelope@1', env);++ if (!v.valid) {++ stats.refused++;++ return problem(400, 'events.bad_delivery', 'the webhook names no pull request Codes can follow');++ }++ }++ // One claim per delivery (it may name several pull requests, and they must be claimed together): a++ // redelivery answers these ids without storing again. insertBatch runs its own transaction after the++ // claim commits; a failure releases the claim so GitHub's retry stores the events.++ const eventIds = items.map((i) => i.event_id);++ const seen = await db.maybe('SELECT event_ids FROM github_deliveries WHERE delivery_id = $1', [delivery]);++ if (seen) {++ // A claim is written just before the batch. If the process died in that window the claimed events do++ // not exist; release the stale claim and store them now instead of answering a duplicate for nothing.++ const claimed = Array.isArray(seen.event_ids) ? seen.event_ids : [];++ const present = await Promise.all(claimed.map((id) => store.getEvent(id).catch(() => null)));++ if (claimed.length && present.every(Boolean)) {++ stats.duplicates++;++ return res.status(202).json({ event_ids: claimed, duplicate: true });++ }++ await db.exec('DELETE FROM github_deliveries WHERE delivery_id = $1', [delivery]).catch(() => {});++ }++ try {++ await db.exec(++ 'INSERT INTO github_deliveries (delivery_id, event_ids, event_types) VALUES ($1, $2::jsonb, $3::jsonb)',++ [delivery, JSON.stringify(eventIds), JSON.stringify(items.map((i) => i.event_type))],++ );++ } catch (err) {++ const twin = await db.maybe('SELECT event_ids FROM github_deliveries WHERE delivery_id = $1', [delivery]).catch(() => null);++ if (twin) {++ stats.duplicates++;++ return res.status(202).json({ event_ids: twin.event_ids, duplicate: true });++ }++ // A concurrent twin claimed it first and has not committed yet: GitHub retries onto the committed row.++ log.error(`[github] ingress claim failed for ${delivery}:`, err.message);++ return problem(503, 'events.ingress_failed', 'the delivery was not stored; GitHub will retry it');++ }++ try {++ await store.insertBatch(items, { publisher: 'github-ingress', requestId: `github:${delivery}` });++ } catch (err) {++ // The claim is released so the redelivery stores the events (a duplicate delivery onto committed++ // envelopes answers duplicate from the receipts instead of storing twice).++ await db.exec('DELETE FROM github_deliveries WHERE delivery_id = $1', [delivery]).catch(() => {});++ log.error(`[github] ingress failed for ${delivery}:`, err.message);++ return problem(503, 'events.ingress_failed', 'the delivery was not stored; GitHub will retry it');++ }++ stats.stored += items.length;++ return res.status(202).json({ event_ids: eventIds });++ }++++ const raw = express.raw({ type: () => true, limit: '1mb' });++ return { handler, raw, normalize, verifies, stats: () => ({ ...stats, enabled: secrets.length > 0 }) };++}++++module.exports = { createGithubIngress, normalize, verifies, SOURCE };@@@ -102,6 +102,24 @@ route('get', '/events/{id}', {responses: { 200: json('The event and its position', eventItem), ...errors([401, 403, 404, 429]) },});++// ── GitHub webhooks in (AL5) ───────────────────────────────────────────────────────────────────++route('post', '/in/github', {++ operationId: 'receiveGithubWebhook', summary: 'Receive a signed GitHub webhook (AL5)', tags: ['github'], security: [],++ description: 'GitHub\'s pull request, check and comment deliveries. The body is verified with X-Hub-Signature-256 (HMAC-SHA256 with EVENTS_GITHUB_WEBHOOK_SECRET) and X-GitHub-Delivery is claimed once; a delivery is normalized to github.check.completed, github.pr.updated or github.pr.commented envelopes and stored for subscribers. Off (404) while the secret is unset; a ping answers 202 and stores nothing.',++ parameters: [++ { name: 'X-Hub-Signature-256', in: 'header', required: true, schema: str({ pattern: '^sha256=[0-9a-f]{64}$' }) },++ { name: 'X-GitHub-Event', in: 'header', required: true, schema: str() },++ { name: 'X-GitHub-Delivery', in: 'header', required: true, schema: str() },++ ],++ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', additionalProperties: true } } } },++ responses: {++ 202: json('Stored, or ignored when the delivery carries no pull request', { type: 'object', properties: {++ event_ids: arrayOf(str({ pattern: '^evt_[0-9A-HJKMNP-TV-Z]{26}$' })), duplicate: { type: 'boolean' }, ignored: str(),++ } }),++ ...errors([400, 401, 404, 429]),++ },++});++// ── Checkpoints ───────────────────────────────────────────────────────────────────────────────route('get', '/checkpoints', {operationId: 'getCheckpoint', summary: 'The caller\'s stored cursor for a topic pattern', tags: ['checkpoints'], security: [...SERVICE, ...APP],nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "action": "completed",++ "check_run": {++ "id": 224,++ "name": "codes-ci / tests",++ "status": "completed",++ "conclusion": "failure",++ "head_sha": "def456abc123def456abc123def456abc123def4",++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/113/job/224",++ "output": { "title": "1 failing test", "summary": "one check names two pull requests", "text": "not ok 1 - shared" },++ "pull_requests": [++ { "id": 888, "number": 888, "base": { "ref": "main" }, "head": { "ref": "al5-a" }, "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/888" },++ { "id": 999, "number": 999, "base": { "ref": "main" }, "head": { "ref": "al5-scratch" }, "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999" }++ ]++ },++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },++ "sender": { "login": "github-actions[bot]", "type": "Bot" }++}nnew file mode 100644@@@ -0,0 +1,31 @@++{++ "action": "completed",++ "check_run": {++ "id": 222,++ "name": "codes-ci / tests",++ "status": "completed",++ "conclusion": "failure",++ "head_sha": "abc123def456abc123def456abc123def456abc1",++ "details_url": "https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/111",++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/111/job/222",++ "started_at": "2026-11-10T10:00:00Z",++ "completed_at": "2026-11-10T10:04:12Z",++ "output": {++ "title": "1 failing test",++ "summary": "test/pr-bindings.test.js failed on the branch",++ "text": "not ok 3 - a red check wakes the worker once\n AssertionError: expected 0 to equal 1\n at Test.<anonymous> (test/pr-bindings.test.js:88:9)\n# tests 12\n# pass 11\n# fail 1\n",++ "annotations_count": 0++ },++ "pull_requests": [++ {++ "id": 999,++ "number": 999,++ "base": { "ref": "main" },++ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },++ "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999"++ }++ ]++ },++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },++ "sender": { "login": "github-actions[bot]", "type": "Bot" }++}nnew file mode 100644@@@ -0,0 +1 @@++{"action":"completed","check_run":{"conclusion":"success","details_url":"https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/112","head_sha":"abc123def456abc123def456abc123def456abc1","html_url":"https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/112/job/223","id":223,"name":"codes-ci / tests","pull_requests":[{"id":999,"number":999,"url":"https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999"}],"status":"completed"},"repository":{"full_name":"OpenVibers/OpenVibe.Codes"}}nnew file mode 100644@@@ -0,0 +1,16 @@++{++ "action": "completed",++ "check_suite": {++ "id": 300,++ "status": "completed",++ "conclusion": "failure",++ "head_branch": "al5-scratch",++ "head_sha": "abc123def456abc123def456abc123def456abc1",++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/120",++ "pull_requests": [++ { "number": 999, "base": { "ref": "main" }, "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" }, "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999" }++ ]++ },++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },++ "sender": { "login": "github-actions[bot]", "type": "Bot" }++}nnew file mode 100644@@@ -0,0 +1 @@++{"action":"created","comment":{"body":"All checks have passed. 2/2 successful checks.","html_url":"https://github.com/OpenVibers/OpenVibe.Codes/pull/999#issuecomment-2","id":2,"user":{"login":"github-actions[bot]","type":"Bot"}},"issue":{"html_url":"https://github.com/OpenVibers/OpenVibe.Codes/pull/999","number":999,"pull_request":{"url":"https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999"}},"repository":{"full_name":"OpenVibers/OpenVibe.Codes"},"sender":{"login":"github-actions[bot]","type":"Bot"}}nnew file mode 100644@@@ -0,0 +1,15 @@++{++ "action": "edited",++ "comment": {++ "id": 3,++ "body": "stop",++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999#issuecomment-3",++ "user": { "login": "owner-person", "type": "User" }++ },++ "issue": {++ "number": 999,++ "pull_request": { "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999" }++ },++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },++ "sender": { "login": "owner-person", "type": "User" }++}nnew file mode 100644@@@ -0,0 +1 @@++{"action":"created","comment":{"body":"stop — I will take this one from here","html_url":"https://github.com/OpenVibers/OpenVibe.Codes/pull/999#issuecomment-1","id":1,"user":{"login":"owner-person","type":"User"}},"issue":{"html_url":"https://github.com/OpenVibers/OpenVibe.Codes/pull/999","number":999,"pull_request":{"url":"https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999"}},"repository":{"full_name":"OpenVibers/OpenVibe.Codes"},"sender":{"login":"owner-person","type":"User"}}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "action": "closed",++ "number": 999,++ "pull_request": {++ "number": 999,++ "title": "AL5: a ticket follows its pull request",++ "merged": true,++ "mergeable": true,++ "mergeable_state": "clean",++ "draft": false,++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",++ "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999",++ "changed_files": 7,++ "additions": 412,++ "deletions": 9,++ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },++ "base": { "ref": "main", "sha": "b9354f8b9354f8b9354f8b9354f8b9354f8b9354" }++ },++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },++ "sender": { "login": "OpenVibers", "type": "User" }++}nnew file mode 100644@@@ -0,0 +1,21 @@++{++ "action": "synchronize",++ "number": 999,++ "pull_request": {++ "number": 999,++ "title": "AL5: a ticket follows its pull request",++ "merged": false,++ "mergeable": false,++ "mergeable_state": "dirty",++ "draft": false,++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",++ "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999",++ "changed_files": 5,++ "additions": 220,++ "deletions": 4,++ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },++ "base": { "ref": "main", "sha": "b9354f8b9354f8b9354f8b9354f8b9354f8b9354" }++ },++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },++ "sender": { "login": "owner-person", "type": "User" }++}nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "action": "submitted",++ "pull_request": {++ "number": 999,++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",++ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },++ "base": { "ref": "main" }++ },++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },++ "review": {++ "id": 2,++ "state": "approved",++ "body": "",++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999#pullrequestreview-2",++ "user": { "login": "owner-person", "type": "User" }++ },++ "sender": { "login": "owner-person", "type": "User" }++}nnew file mode 100644@@@ -0,0 +1,18 @@++{++ "action": "submitted",++ "pull_request": {++ "number": 999,++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",++ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },++ "base": { "ref": "main" }++ },++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },++ "review": {++ "id": 1,++ "state": "changes_requested",++ "body": "This helper swallows the error; return it instead.",++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999#pullrequestreview-1",++ "user": { "login": "owner-person", "type": "User" }++ },++ "sender": { "login": "owner-person", "type": "User" }++}nnew file mode 100644@@@ -0,0 +1,19 @@++{++ "action": "created",++ "pull_request": {++ "number": 999,++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",++ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },++ "base": { "ref": "main" }++ },++ "comment": {++ "id": 4,++ "body": "This swallow hides the 401; return the problem instead.",++ "path": "server/github.js",++ "line": 212,++ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999#discussion_r4",++ "user": { "login": "owner-person", "type": "User" }++ },++ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },++ "sender": { "login": "owner-person", "type": "User" }++}nnew file mode 100644@@@ -0,0 +1,227 @@++'use strict';++/**++ * GitHub webhooks in (AL5): signature verification, normalization to github.* envelopes, idempotent redelivery.++ * The fixtures in test/fixtures/github are recorded GitHub payload shapes against a fake PR++ * (OpenVibers/OpenVibe.Codes#999); the HMAC is computed here with the test-only secret.++ */++const assert = require('assert');++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const { boot, request, serviceToken, suite } = require('./helpers');++++const SECRET = 'test-only-github-secret-0001';++const OTHER_SECRET = 'test-only-github-secret-0002';++++const fixture = (name) => fs.readFileSync(path.join(__dirname, 'fixtures', 'github', name));++++function sign(raw, secret = SECRET) {++ return `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`;++}++++const t = suite('github');++let h;++const read = serviceToken('codes', ['events.event.read']);++++t('boot with the webhook secret', async () => {++ h = await boot({ env: { EVENTS_GITHUB_WEBHOOK_SECRET: `${SECRET},${OTHER_SECRET}` } });++});++++async function deliver(name, { event, delivery, secret } = {}) {++ const raw = fixture(name);++ return await request(h.base, 'POST', '/api/v1/in/github', {++ raw,++ headers: {++ 'content-type': 'application/json',++ 'x-github-event': event,++ 'x-github-delivery': delivery || `deli-${name.replace(/[^0-9a-z-]/gi, '')}-${event || 'ping'}-0000`,++ 'x-hub-signature-256': sign(raw, secret),++ },++ });++}++++async function deliverRaw(raw, { event, delivery, secret } = {}) {++ return await request(h.base, 'POST', '/api/v1/in/github', {++ raw,++ headers: {++ 'content-type': 'application/json',++ 'x-github-event': event,++ 'x-github-delivery': delivery,++ 'x-hub-signature-256': sign(raw, secret),++ },++ });++}++++async function storedAs(eventId) {++ const r = await request(h.base, 'GET', `/api/v1/events/${eventId}`, { token: read });++ assert.strictEqual(r.status, 200, r.text);++ return r.body.event;++}++++t('a bad signature is refused; ping answers 202 without storing', async () => {++ const raw = fixture('check-run-failed.json');++ let r = await request(h.base, 'POST', '/api/v1/in/github', {++ raw,++ headers: { 'content-type': 'application/json', 'x-github-event': 'check_run', 'x-github-delivery': 'bad-sig-1', 'x-hub-signature-256': sign(raw, 'wrong-secret-0000000000000000') },++ });++ assert.strictEqual(r.status, 401, r.text);++ assert.strictEqual(r.body.code, 'events.bad_signature');++ r = await deliver('check-run-failed.json', { event: 'ping', delivery: 'deadbeef-0000-4000-8000-000000000000' });++ assert.strictEqual(r.status, 202, r.text);++ assert.strictEqual(r.body.ignored, 'ping');++});++++t('a failed check_run stores one github.check.completed per pull request, with the log tail', async () => {++ const r = await deliver('check-run-failed.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000001' });++ assert.strictEqual(r.status, 202, r.text);++ assert.strictEqual(r.body.event_ids.length, 1);++ const env = await storedAs(r.body.event_ids[0]);++ assert.strictEqual(env.event_type, 'github.check.completed');++ assert.strictEqual(env.source, 'github');++ assert.deepStrictEqual(env.subject, { type: 'pr', id: 'openvibers/openvibe.codes#999' });++ assert.strictEqual(env.payload.kind, 'check_run');++ assert.strictEqual(env.payload.conclusion, 'failure');++ assert.strictEqual(env.payload.check_name, 'codes-ci / tests');++ assert.strictEqual(env.payload.repo, 'openvibers/openvibe.codes');++ assert.strictEqual(env.payload.head_sha, 'abc123def456abc123def456abc123def456abc1');++ assert.strictEqual(env.payload.details_url, 'https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/111');++ assert.match(env.payload.output.text, /expected 0 to equal 1/);++ assert.strictEqual(env.payload.output.title, '1 failing test');++});++++t('a redelivery answers the stored ids and stores nothing new', async () => {++ const first = await deliver('check-run-failed.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000001' });++ assert.strictEqual(first.status, 202);++ assert.strictEqual(first.body.duplicate, true);++ assert.strictEqual(first.body.event_ids.length, 1);++ const again = await storedAs(first.body.event_ids[0]);++ assert.strictEqual(again.event_type, 'github.check.completed');++});++++t('one delivery naming two pull requests stores both and claims the delivery once', async () => {++ const first = await deliver('check-run-failed-two-prs.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000007' });++ assert.strictEqual(first.status, 202, first.text);++ assert.strictEqual(first.body.event_ids.length, 2);++ const one = await storedAs(first.body.event_ids[0]);++ const two = await storedAs(first.body.event_ids[1]);++ assert.deepStrictEqual([one.subject.id, two.subject.id], ['openvibers/openvibe.codes#888', 'openvibers/openvibe.codes#999']);++ const again = await deliver('check-run-failed-two-prs.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000007' });++ assert.strictEqual(again.status, 202);++ assert.strictEqual(again.body.duplicate, true);++ assert.deepStrictEqual(again.body.event_ids, first.body.event_ids);++});++++t('a check_suite failure normalizes to the same github.check.completed', async () => {++ const r = await deliver('check-suite-failed.json', { event: 'check_suite', delivery: 'deadbeef-0000-4000-8000-000000000008' });++ assert.strictEqual(r.status, 202, r.text);++ const env = await storedAs(r.body.event_ids[0]);++ assert.strictEqual(env.event_type, 'github.check.completed');++ assert.strictEqual(env.payload.kind, 'check_suite');++ assert.strictEqual(env.payload.conclusion, 'failure');++ assert.strictEqual(env.subject.id, 'openvibers/openvibe.codes#999');++});++++t('a successful check, a stop comment, a bot comment, a conflict and a review each normalize', async () => {++ let r = await deliver('check-run-success.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000002' });++ assert.strictEqual(r.status, 202, r.text);++ assert.strictEqual((await storedAs(r.body.event_ids[0])).payload.conclusion, 'success');++++ r = await deliver('issue-comment-stop.json', { event: 'issue_comment', delivery: 'deadbeef-0000-4000-8000-000000000003' });++ assert.strictEqual(r.status, 202, r.text);++ let env = await storedAs(r.body.event_ids[0]);++ assert.strictEqual(env.event_type, 'github.pr.commented');++ assert.strictEqual(env.payload.author_type, 'User');++ assert.strictEqual(env.payload.comment_id, 1);++ assert.match(env.payload.body, /^stop/);++++ r = await deliver('issue-comment-bot.json', { event: 'issue_comment', delivery: 'deadbeef-0000-4000-8000-000000000004' });++ assert.strictEqual(r.status, 202, r.text);++ env = await storedAs(r.body.event_ids[0]);++ assert.strictEqual(env.payload.author_type, 'Bot');++++ r = await deliver('pull-request-dirty.json', { event: 'pull_request', delivery: 'deadbeef-0000-4000-8000-000000000005' });++ assert.strictEqual(r.status, 202, r.text);++ env = await storedAs(r.body.event_ids[0]);++ assert.strictEqual(env.event_type, 'github.pr.updated');++ assert.strictEqual(env.payload.mergeable_state, 'dirty');++ assert.strictEqual(env.payload.mergeable, false);++ assert.strictEqual(env.payload.changed_files, 5);++ assert.match(env.payload.files_url, /\/pulls\/999\/files$/);++++ r = await deliver('pull-request-review-changes.json', { event: 'pull_request_review', delivery: 'deadbeef-0000-4000-8000-000000000006' });++ assert.strictEqual(r.status, 202, r.text);++ env = await storedAs(r.body.event_ids[0]);++ assert.strictEqual(env.payload.state, 'changes_requested');++ assert.strictEqual(env.payload.author_type, 'User');++ assert.strictEqual(env.payload.comment_id, 1);++++ r = await deliver('pull-request-review-comment.json', { event: 'pull_request_review_comment', delivery: 'deadbeef-0000-4000-8000-000000000009' });++ assert.strictEqual(r.status, 202, r.text);++ env = await storedAs(r.body.event_ids[0]);++ assert.strictEqual(env.payload.path, 'server/github.js');++ assert.strictEqual(env.payload.line, 212);++ assert.strictEqual(env.payload.comment_id, 4);++});++++t('a claim left by a crash between claim and store is released, not answered as a duplicate', async () => {++ const delivery = 'deadbeef-0000-4000-8000-00000000000d';++ await h.store.db.exec(++ 'INSERT INTO github_deliveries (delivery_id, event_ids, event_types) VALUES ($1, $2::jsonb, $3::jsonb)',++ [delivery, JSON.stringify(['evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2Q1']), JSON.stringify(['github.check.completed'])],++ );++ const r = await deliver('check-run-success.json', { event: 'check_run', delivery });++ assert.strictEqual(r.status, 202, r.text);++ assert.strictEqual(r.body.duplicate, undefined);++ assert.strictEqual(r.body.event_ids.length, 1);++ assert.notStrictEqual(r.body.event_ids[0], 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2Q1');++ const env = await storedAs(r.body.event_ids[0]);++ assert.strictEqual(env.payload.conclusion, 'success');++});++++t('a merged pull request ends the binding downstream: the envelope carries merged', async () => {++ const r = await deliver('pull-request-closed-merged.json', { event: 'pull_request', delivery: 'deadbeef-0000-4000-8000-00000000000a' });++ assert.strictEqual(r.status, 202, r.text);++ const env = await storedAs(r.body.event_ids[0]);++ assert.strictEqual(env.payload.action, 'closed');++ assert.strictEqual(env.payload.merged, true);++ assert.strictEqual(env.payload.base, 'main');++});++++t('a bare approval and an edited comment are not new instructions', async () => {++ const r = await deliver('pull-request-review-approved.json', { event: 'pull_request_review', delivery: 'deadbeef-0000-4000-8000-00000000000b' });++ assert.strictEqual(r.status, 202, r.text);++ assert.strictEqual(r.body.ignored, 'pull_request_review');++ const r2 = await deliver('issue-comment-edited.json', { event: 'issue_comment', delivery: 'deadbeef-0000-4000-8000-00000000000c' });++ assert.strictEqual(r2.status, 202, r2.text);++ assert.strictEqual(r2.body.ignored, 'issue_comment');++});++++t('an unfinished check and a non-PR issue store nothing', async () => {++ const raw = Buffer.from(JSON.stringify({++ action: 'created', check_run: { status: 'in_progress', name: 'x', pull_requests: [{ number: 999, url: 'https://api.github.com/repos/a/b/pulls/999' }] },++ repository: { full_name: 'a/b' },++ }));++ let r = await deliverRaw(raw, { event: 'check_run', delivery: '1c1cec1c-1111-4a1a-8a1a-111111111111' });++ assert.strictEqual(r.status, 202, r.text);++ assert.strictEqual(r.body.ignored, 'check_run');++ const plain = Buffer.from(JSON.stringify({++ action: 'created', issue: { number: 7 }, comment: { body: 'hi', user: { login: 'u', type: 'User' } },++ repository: { full_name: 'a/b' },++ }));++ r = await deliverRaw(plain, { event: 'issue_comment', delivery: '1cc0e7e1-1111-4a1a-8a1a-111111111111' });++ assert.strictEqual(r.status, 202, r.text);++ assert.strictEqual(r.body.ignored, 'issue_comment');++});++++t('without a secret the route is off', async () => {++ const { createGithubIngress } = require('../server/github');++ const ing = createGithubIngress({ config: { github: { secrets: [] } }, store: h.store });++ assert.strictEqual(ing.stats().enabled, false);++ const fakeRes = { statusCode: 0, setHeader() {}, end() {} };++ await ing.handler({ headers: {}, ip: '127.0.0.9', body: Buffer.alloc(0) }, fakeRes);++ assert.strictEqual(fakeRes.statusCode, 404);++});++++t('stop', async () => { await h.stop(); });++++t.run();@@@ -34,6 +34,8 @@ const PLANTED = {// The account-data consumer (ADR-033): the secret its own loopback subscription signs the two// account topics with. It is Events' own signing secret, never echoed.EVENTS_EVENTS_SECRET: `ovsecret-events-account-${crypto.randomBytes(12).toString('hex')}`,++ // The GitHub webhook ingress (AL5): the HMAC secret GitHub signs deliveries with, never echoed.++ EVENTS_GITHUB_WEBHOOK_SECRET: `ovsecret-events-github-${crypto.randomBytes(12).toString('hex')}`,};t('Events reads no secret from its environment but the planted ones', async () => {
@@ -127,6 +127,10 @@ EVENTS_RECEIPT_RETENTION_DAYS=90
@@ -127,6 +127,10 @@ EVENTS_RECEIPT_RETENTION_DAYS=90
# characters or more; it is the one Events signs its own deliveries with.
# characters or more; it is the one Events signs its own deliveries with.
#EVENTS_EVENTS_URL=https://openvibe.events
#EVENTS_EVENTS_URL=https://openvibe.events
#EVENTS_EVENTS_SECRET=
#EVENTS_EVENTS_SECRET=
+# GitHub webhooks in (AL5: server/github.js): the secret GitHub signs deliveries with (repo Settings →
+# Webhooks → the webhook's Secret; 16 characters or more, comma-separated while rotating). Unset: POST
+# /api/v1/in/github answers 404 and no github.* event is ever stored.
+#EVENTS_GITHUB_WEBHOOK_SECRET=
#OV_BILLING_AUDIENCE=openvibe.billing
#OV_BILLING_AUDIENCE=openvibe.billing
#OV_BILLING_TIMEOUT_MS=10000
#OV_BILLING_TIMEOUT_MS=10000
@@ -132,6 +132,49 @@ Events is the bus, so two of its columns name a person: the `actor` (the Subject
@@ -132,6 +132,49 @@ Events is the bus, so two of its columns name a person: the `actor` (the Subject
The route is `POST /internal/events`, signed (v2) and loopback only, mounted by `server/app.js`; `server/index.js` creates the two subscriptions at boot with `startSubscriptions` (`EVENTS_EVENTS_URL`, `EVENTS_EVENTS_SECRET` and the service's own OAuth client). Without the secret the route is off and the account events do not reach Events. The receipts of what it applied are in `account_data_events` (migration 0010), so a redelivered export or deletion changes nothing. `test/account-data.test.js` asserts `uncovered()` is empty, so a new table with a person column fails CI until it is covered or exempted with a reason.
The route is `POST /internal/events`, signed (v2) and loopback only, mounted by `server/app.js`; `server/index.js` creates the two subscriptions at boot with `startSubscriptions` (`EVENTS_EVENTS_URL`, `EVENTS_EVENTS_SECRET` and the service's own OAuth client). Without the secret the route is off and the account events do not reach Events. The receipts of what it applied are in `account_data_events` (migration 0010), so a redelivered export or deletion changes nothing. `test/account-data.test.js` asserts `uncovered()` is empty, so a new table with a person column fails CI until it is covered or exempted with a reason.
+### GitHub webhooks in (AL5: a ticket follows its pull request)
+
+`server/github.js` is the one receiver of GitHub's pull-request events on this estate (nothing else receives
+them). GitHub POSTs a delivery to `POST /api/v1/in/github`; the ingress verifies `X-Hub-Signature-256` against
+`EVENTS_GITHUB_WEBHOOK_SECRET` (HMAC-SHA256 of the raw body; comma-separated while rotating; 16 characters or
+more), claims `X-GitHub-Delivery` in `github_deliveries` (migration 0012) so a redelivery stores nothing, and
+normalizes it into the ordinary github.* envelopes any subscriber can consume:
+
+- `github.check.completed` — a `check_run`, `check_suite` or `workflow_run` finished: `kind`, `conclusion`,
+ `check_name`, `head_sha`, `html_url`, `details_url`, and `output` (title/summary/text — the failing job's log
+ tail when GitHub sends one). Codes decides which conclusions are red.
+- `github.pr.updated` — a `pull_request` opened/synchronize/reopened/closed: `merged`, `mergeable`,
+ `mergeable_state`, `head_sha`, `head_ref`, `base`, `changed_files`, `html_url`, `files_url`.
+- `github.pr.commented` — an `issue_comment` on a PR, a submitted `pull_request_review` or a
+ `pull_request_review_comment`: `author`, `author_type` (Bot/User), `author_association`, `state`, `body`,
+ `comment_id`, `path`, `line`, `head_sha`. A bare approval, an edited comment and a dismissed review are not
+ new instructions and store nothing.
+
+The subject is `{ type: 'pr', id: '<owner>/<name>#<number>' }` (repo lowercased), the key Codes' `pr_bindings`
+matches on. `202` answers `{ event_ids }` (or `{ ignored }` for `ping` and kinds with no pull request); a
+redelivery answers `{ event_ids, duplicate: true }`; `401` is a bad signature; `404` while
+`EVENTS_GITHUB_WEBHOOK_SECRET` is unset. The route is off by default and never stores a secret. The body limit is
+1 MB; the per-address window is 300 deliveries a minute.
+
+**What the owner adds** (this PR creates nothing on GitHub):
+
+1. `/etc/openvibe/events.env`: `EVENTS_GITHUB_WEBHOOK_SECRET=<at least 16 random characters>`; restart
+ `openvibe-events`.
+2. Apply the `/api/v1/in/github` location from [deploy/nginx/openvibe.events.conf](deploy/nginx/openvibe.events.conf)
+ and reload nginx (the path must reach 127.0.0.1:4300; the catch-all under `/api/` answers 404).
+3. On the repository (`OpenVibers/*`) → Settings → Webhooks → Add webhook: Payload URL
+ `https://openvibe.events/api/v1/in/github`, content type `application/json`, the same secret, SSL
+ verification on, and the events **Check runs**, **Check suites**, **Pull requests**, **Issue comments**,
+ **Pull request reviews**, **Pull request review comments** (optionally **Workflow runs**). A `ping` delivery
+ answers `202 {"ignored":"ping"}`.
+4. On Codes: set `CODES_PR_FOLLOW_ENABLED=1`; Codes subscribes to the github.* event types above at boot and
+ receives them at `/internal/events` under its existing `CODES_EVENTS_SECRET`, with the usual Events delivery
+ v2 signature check. The pull request the worker opened binds to the task there, and the same receiver wakes it.
+
+`test/github.test.js` covers the signature, the normalization of every event type (including the log tail and a
+review comment's path/line), a multi-PR delivery claimed once, an idempotent redelivery, and the off-by-default
+route; `test/fixtures/github/` holds the recorded payloads.
+
### Retention: three tiers
### Retention: three tiers
[ADR-042](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-042-events-fabric.md) decision 8. An event lives in one tier at a time:
[ADR-042](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-042-events-fabric.md) decision 8. An event lives in one tier at a time:
@@ -150,6 +150,22 @@ server {
@@ -150,6 +150,22 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+ # ── GitHub webhooks in (AL5: server/github.js) ──
+ # GitHub POSTs signed deliveries here; the service verifies X-Hub-Signature-256. Off (404) while
+ # EVENTS_GITHUB_WEBHOOK_SECRET is unset. The raw body is at most 1 MB in the service.
+ location = /api/v1/in/github {
+ limit_req zone=ovevents_apex_api burst=40 nodelay;
+ client_max_body_size 2m;
+ proxy_pass http://127.0.0.1:4300;
+ proxy_http_version 1.1;
+ proxy_set_header Connection "";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+
# ── Everything else under /api/: a problem document, never a page; the API stays host-local ──
# ── Everything else under /api/: a problem document, never a page; the API stays host-local ──
location /api/ {
location /api/ {
default_type application/problem+json;
default_type application/problem+json;
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+-- phase: expand
+-- GitHub webhook ingress (AL5: a ticket follows its pull request). One row per GitHub delivery GUID, holding the
+-- event ids it stored, so a redelivered webhook answers them and stores no second event. The normalized github.*
+-- events live in the hot store like any other event; this table is only the ingress's idempotency record. One row
+-- per delivery (not per event) because a delivery can name several pull requests and they must be claimed together.
+-- Nothing here holds a secret.
+CREATE TABLE IF NOT EXISTS github_deliveries (
+ delivery_id text COLLATE "C" PRIMARY KEY, -- X-GitHub-Delivery
+ event_ids jsonb NOT NULL, -- ["evt_…", …] stored for it
+ event_types jsonb NOT NULL, -- ["github.check.completed", …] in the same order
+ received_at timestamptz NOT NULL DEFAULT now()
+);
@@ -73,6 +73,14 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
@@ -73,6 +73,14 @@ function createApp({ config, store, auth, keys, worker, realtime, metrics, dnsLo
app.options('/realtime/stream', realtime.cors);
app.options('/realtime/stream', realtime.cors);
app.get('/realtime/stream', realtime.cors, realtime.handler);
app.get('/realtime/stream', realtime.cors, realtime.handler);
+ // GitHub webhooks in (AL5: server/github.js): raw body (the HMAC covers it), mounted before the JSON
+ // parser. Off (404) without EVENTS_GITHUB_WEBHOOK_SECRET.
+ {
+ const { createGithubIngress } = require('./github');
+ const ingress = createGithubIngress({ config, store, log });
+ app.post('/api/v1/in/github', ingress.raw, ingress.handler);
+ }
+
app.use('/api', express.json({ limit: '2mb', type: ['application/json', 'application/*+json'] }));
app.use('/api', express.json({ limit: '2mb', type: ['application/json', 'application/*+json'] }));
app.get('/api/health', (_req, res) => {
app.get('/api/health', (_req, res) => {
@@ -27,6 +27,8 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openr
@@ -27,6 +27,8 @@ const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openr
'bot',
'bot',
// Watch (OpenVibe.Watch): watch.observation.recorded, watch.watch.triggered, watch.check.failed
// Watch (OpenVibe.Watch): watch.observation.recorded, watch.watch.triggered, watch.check.failed
'watch',
'watch',
+ // GitHub webhooks in (AL5): the ingress stores github.check.completed|github.pr.updated|github.pr.commented.
+ 'github',
// Run (OpenVibe.Run, plan T14): run.job.queued|started|succeeded|failed|cancelled|expired
// Run (OpenVibe.Run, plan T14): run.job.queued|started|succeeded|failed|cancelled|expired
'run',
'run',
// Space (OpenVibe.Space, plan T10 step 3): space.thread.created, space.post.created, space.moderation.action
// Space (OpenVibe.Space, plan T10 step 3): space.thread.created, space.post.created, space.moderation.action
@@ -277,6 +279,12 @@ function load(env = process.env) {
@@ -277,6 +279,12 @@ function load(env = process.env) {
secret: String(env.EVENTS_EVENTS_SECRET || '').trim(),
secret: String(env.EVENTS_EVENTS_SECRET || '').trim(),
},
},
+ // GitHub webhooks in (AL5: server/github.js): HMAC secrets for X-Hub-Signature-256, comma-separated for
+ // rotation. Empty: POST /api/v1/in/github answers 404 and no github.* event is ever stored.
+ github: {
+ secrets: String(env.EVENTS_GITHUB_WEBHOOK_SECRET || '').split(',').map((s) => s.trim()).filter((s) => s.length >= 16),
+ },
+
// Realtime (SSE) gateway
// Realtime (SSE) gateway
realtime: {
realtime: {
maxConnections: int(env.REALTIME_MAX_CONNECTIONS, 2000),
maxConnections: int(env.REALTIME_MAX_CONNECTIONS, 2000),
new file mode 100644
new file mode 100644
@@ -0,0 +1,352 @@
@@ -0,0 +1,352 @@
+'use strict';
+
+/**
+ * GitHub webhooks in (AL5: a ticket follows its pull request).
+ *
+ * POST /api/v1/in/github
+ * X-GitHub-Delivery: the delivery GUID (idempotency: a redelivery answers the stored event ids)
+ * X-GitHub-Event: check_run | check_suite | workflow_run | pull_request |
+ * pull_request_review | pull_request_review_comment | issue_comment | ping
+ * X-Hub-Signature-256: sha256=<hex HMAC of the raw body> with one of config.github.secrets
+ *
+ * A delivery is normalized to one source-'github' envelope per pull request and stored through
+ * store.insertBatch like any other event, so Codes (and the portal) subscribes to it with the usual
+ * Events subscription — nothing polls GitHub:
+ *
+ * github.check.completed a check_run, check_suite or workflow_run finished → { repo, kind, conclusion,
+ * check_name, head_sha, html_url, details_url, output } (Codes decides what is red;
+ * output is the failing job's log tail when GitHub sends one)
+ * github.pr.updated a pull_request synchronize|reopened|closed|opened → { repo, pr_number, action,
+ * merged, mergeable, mergeable_state, head_sha, head_ref, base, base_ref,
+ * changed_files, additions, deletions, draft, title, html_url, files_url }
+ * (mergeable_state is GitHub's, often null on the webhook: conflicts through it are
+ * best-effort; files_url is where a worker can list the changed files)
+ * github.pr.commented an issue_comment on a PR, a submitted review or a review comment →
+ * { repo, pr_number, author, author_type, author_association, state, body,
+ * comment_id, path, line, head_sha, html_url }
+ *
+ * The subject of every envelope is { type: 'pr', id: '<owner>/<name>#<number>' } with the slug lowercased,
+ * the key Codes' pr_bindings matches on. 202 answers { event_ids } (or { ignored } for ping and event kinds
+ * with no pull request); a redelivery answers { event_ids, duplicate: true }; 401 on a bad signature; 404 while
+ * EVENTS_GITHUB_WEBHOOK_SECRET is unset. Nothing here prints or stores a secret.
+ *
+ * const ingress = createGithubIngress({ config, store, log });
+ * app.post('/api/v1/in/github', ingress.raw, ingress.handler); // before the JSON body parser
+ */
+const crypto = require('crypto');
+const express = require('express');
+const { ids, validate, http } = require('openvibe-contracts');
+
+const SOURCE = 'github';
+const ACTOR = Object.freeze({ type: 'service', id: 'github' });
+const MAX_BODY = 4000; // a comment/review body kept on an envelope
+const MAX_LOG_TAIL = 8_000; // a check's output text (the log tail GitHub sends) kept on an envelope
+const MAX_DELIVERIES = 20; // a check run is fanned out per pull request, at most this many
+const MIN_SECRET = 16;
+
+const cut = (v, n) => (typeof v === 'string' && v.length > n ? v.slice(0, n) : (typeof v === 'string' ? v : undefined));
+const lower = (v) => (typeof v === 'string' ? v.toLowerCase() : null);
+const repoOf = (p) => lower(p && p.repository && p.repository.full_name);
+const num = (v) => (Number.isInteger(v) && v > 0 ? v : null);
+
+/** pull request numbers a check payload names (check_run, check_suite, workflow_run). */
+function checkPrNumbers(p) {
+ const out = [];
+ const seen = new Set();
+ const take = (n) => { const k = num(n); if (k && !seen.has(k) && out.length < MAX_DELIVERIES) { seen.add(k); out.push(k); } };
+ const run = p.check_run || p.check_suite || p.workflow_run;
+ for (const pr of (run && run.pull_requests) || []) {
+ if (num(pr.number)) take(pr.number);
+ else if (typeof pr.url === 'string') { const m = /\/pulls\/(\d+)(?:\/|$)/.exec(pr.url); if (m) take(Number(m[1])); }
+ }
+ return out;
+}
+
+/** A check payload finished: conclusion, name, links and its output log tail, or null when it did not finish. */
+function checkCompleted(ghEvent, p) {
+ const run = p.check_run || p.check_suite || p.workflow_run;
+ if (!run || run.status !== 'completed') return null;
+ const url = run.html_url || run.url || (p.check_run && p.check_run.details_url) || null;
+ const out = run.output && typeof run.output === 'object' && !Array.isArray(run.output) ? run.output : null;
+ const output = out ? {
+ title: cut(out.title, 500),
+ summary: cut(out.summary, 4_000),
+ text: cut(out.text, MAX_LOG_TAIL),
+ annotations: Array.isArray(out.annotations) ? out.annotations.length : undefined,
+ } : null;
+ return {
+ event_type: 'github.check.completed',
+ check: {
+ kind: p.check_run ? 'check_run' : p.check_suite ? 'check_suite' : 'workflow_run',
+ conclusion: typeof run.conclusion === 'string' ? run.conclusion.slice(0, 40) : null,
+ check_name: cut(run.name, 200),
+ head_sha: typeof run.head_sha === 'string' ? run.head_sha.slice(0, 64) : null,
+ html_url: typeof url === 'string' ? url.slice(0, 2000) : null,
+ details_url: cut(p.check_run && p.check_run.details_url, 2000),
+ started_at: cut(run.started_at, 40),
+ completed_at: cut(run.completed_at, 40),
+ output,
+ },
+ prNumbers: checkPrNumbers(p),
+ };
+}
+
+function prUpdated(p) {
+ const pr = p.pull_request;
+ if (!pr || !num(p.number)) return null;
+ const head = pr.head && typeof pr.head === 'object' ? pr.head : {};
+ const base = pr.base && typeof pr.base === 'object' ? pr.base : {};
+ return {
+ event_type: 'github.pr.updated',
+ update: {
+ action: typeof p.action === 'string' ? p.action.slice(0, 40) : null,
+ merged: pr.merged === true,
+ mergeable: pr.mergeable === true ? true : pr.mergeable === false ? false : null,
+ mergeable_state: typeof pr.mergeable_state === 'string' ? pr.mergeable_state.slice(0, 40) : null,
+ draft: pr.draft === true,
+ title: cut(pr.title, 500),
+ head_sha: cut(head.sha, 64),
+ head_ref: cut(head.ref, 200),
+ base: cut(base.ref, 200),
+ base_ref: cut(base.ref, 200),
+ base_sha: cut(base.sha, 64),
+ changed_files: num(pr.changed_files),
+ additions: num(pr.additions),
+ deletions: num(pr.deletions),
+ html_url: cut(pr.html_url, 2000),
+ files_url: cut(pr.url ? `${String(pr.url).replace(/\/+$/, '')}/files` : null, 2000),
+ },
+ prNumbers: [p.number],
+ };
+}
+
+const userOf = (u) => (u && typeof u.login === 'string' ? { login: u.login.slice(0, 200), type: u.type === 'Bot' ? 'Bot' : 'User', association: cut(u.author_association, 40) } : null);
+
+/** A comment id; GitHub sends it as a number. */
+const commentId = (v) => (Number.isInteger(v) && v > 0 ? v : null);
+
+function prCommented(ghEvent, p) {
+ const repo = repoOf(p);
+ if (!repo) return null;
+ if (ghEvent === 'issue_comment') {
+ // An issue_comment is a PR comment only when the issue is a pull request. Only a newly posted one wakes
+ // anyone: an edited or deleted comment is not a new instruction (and a deleted "stop" must not end a ticket).
+ if (p.action !== 'created') return null;
+ if (!p.issue || !p.issue.pull_request || !num(p.issue.number)) return null;
+ const by = userOf(p.comment && p.comment.user);
+ if (!by || typeof (p.comment && p.comment.body) !== 'string') return null;
+ const pr = p.issue.pull_request || {};
+ return {
+ event_type: 'github.pr.commented',
+ comment: {
+ author: by.login, author_type: by.type, author_association: by.association, state: 'commented',
+ body: cut(p.comment.body, MAX_BODY),
+ comment_id: commentId(p.comment.id),
+ head_sha: cut(pr.head && pr.head.sha, 64),
+ html_url: cut(p.comment.html_url, 2000),
+ },
+ prNumbers: [p.issue.number],
+ };
+ }
+ if (ghEvent === 'pull_request_review') {
+ const pr = p.pull_request;
+ if (!pr || !num(pr.number)) return null;
+ // A dismissal is not a new instruction; a bare approval is not a comment. A review that asks for changes
+ // without a summary still wakes the worker, with a note to read the review on the pull request.
+ if (p.action !== 'submitted') return null;
+ const review = p.review && typeof p.review === 'object' ? p.review : {};
+ const by = userOf(review.user);
+ if (!by) return null;
+ const state = cut(review.state, 40) || 'commented';
+ let body = cut(review.body, MAX_BODY) || '';
+ if (state === 'approved' && !body) return null;
+ if (!body && state === 'changes_requested') body = 'The reviewer requested changes without a summary; read the review on the pull request.';
+ if (!body) return null;
+ return {
+ event_type: 'github.pr.commented',
+ comment: {
+ author: by.login, author_type: by.type, author_association: by.association, state,
+ body,
+ comment_id: commentId(review.id),
+ head_sha: cut(pr.head && pr.head.sha, 64),
+ html_url: cut(review.html_url || pr.html_url, 2000),
+ },
+ prNumbers: [pr.number],
+ };
+ }
+ if (ghEvent === 'pull_request_review_comment') {
+ const pr = p.pull_request;
+ if (!pr || !num(pr.number)) return null;
+ if (p.action !== 'created') return null;
+ const by = userOf(p.comment && p.comment.user);
+ if (!by || typeof (p.comment && p.comment.body) !== 'string') return null;
+ const c = p.comment;
+ return {
+ event_type: 'github.pr.commented',
+ comment: {
+ author: by.login, author_type: by.type, author_association: by.association, state: 'commented',
+ body: cut(c.body, MAX_BODY),
+ comment_id: commentId(c.id),
+ path: cut(c.path, 500),
+ line: num(c.line) || num(c.original_line),
+ head_sha: cut(pr.head && pr.head.sha, 64),
+ html_url: cut(c.html_url, 2000),
+ },
+ prNumbers: [pr.number],
+ };
+ }
+ return null;
+}
+
+/**
+ * One normalized result per envelope: { event_type, prNumber, payload }. null: nothing to store
+ * (an unfinished check, a non-PR issue, an uninteresting action). Throws on a malformed envelope.
+ */
+function normalize(ghEvent, p) {
+ if (!p || typeof p !== 'object' || Array.isArray(p)) throw new Error('the webhook body must be a JSON object');
+ const repo = repoOf(p);
+ if (!repo) throw new Error('the webhook body names no repository');
+ if (ghEvent === 'check_run' || ghEvent === 'check_suite' || ghEvent === 'workflow_run') {
+ const c = checkCompleted(ghEvent, p);
+ if (!c) return [];
+ return c.prNumbers.map((n) => ({
+ event_type: c.event_type, prNumber: n,
+ payload: {
+ repo, kind: c.check.kind, conclusion: c.check.conclusion, check_name: c.check.check_name,
+ head_sha: c.check.head_sha, html_url: c.check.html_url, details_url: c.check.details_url,
+ started_at: c.check.started_at, completed_at: c.check.completed_at, output: c.check.output || null,
+ },
+ }));
+ }
+ if (ghEvent === 'pull_request') {
+ if (!['synchronize', 'reopened', 'closed', 'opened'].includes(p.action)) return [];
+ const u = prUpdated(p);
+ if (!u) return [];
+ return u.prNumbers.map((n) => ({
+ event_type: u.event_type, prNumber: n,
+ payload: { repo, pr_number: n, ...u.update },
+ }));
+ }
+ if (ghEvent === 'issue_comment' || ghEvent === 'pull_request_review' || ghEvent === 'pull_request_review_comment') {
+ const c = prCommented(ghEvent, p);
+ if (!c) return [];
+ return c.prNumbers.map((n) => ({
+ event_type: c.event_type, prNumber: n,
+ payload: { repo, pr_number: n, ...c.comment },
+ }));
+ }
+ return [];
+}
+
+/** sha256=<hex> over the raw body with one of the secrets, compared in constant time. */
+function verifies(raw, header, secrets) {
+ if (typeof header !== 'string') return false;
+ const m = /^sha256=([0-9a-f]{64})$/.exec(header.trim());
+ if (!m) return false;
+ const got = Buffer.from(m[1], 'hex');
+ return (secrets || []).some((s) => {
+ if (typeof s !== 'string' || !s.length) return false;
+ const want = crypto.createHmac('sha256', s).update(raw).digest();
+ return want.length === got.length && crypto.timingSafeEqual(want, got);
+ });
+}
+
+/** A tiny per-address sliding window (GitHub delivers from many addresses; this stops one stuck sender). */
+function createLimiter({ perMinute = 300 } = {}) {
+ const hits = new Map();
+ return (ip) => {
+ const now = Date.now();
+ const list = (hits.get(ip) || []).filter((t) => now - t < 60_000);
+ list.push(now);
+ if (hits.size > 10000 && !hits.has(ip)) hits.delete(hits.keys().next().value);
+ hits.set(ip, list);
+ return list.length <= perMinute;
+ };
+}
+
+function createGithubIngress({ config, store, log = console, now = () => Date.now() } = {}) {
+ const db = store.db;
+ const secrets = ((config.github && config.github.secrets) || []).filter((s) => typeof s === 'string' && s.length >= MIN_SECRET);
+ const limited = createLimiter();
+ const stats = { received: 0, stored: 0, duplicates: 0, ignored: 0, refused: 0 };
+
+ async function handler(req, res) {
+ const ctx = http.requestContext(req.headers);
+ const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });
+ if (!secrets.length) return problem(404, 'events.github_disabled', 'GitHub webhooks are not configured');
+ if (!limited(req.ip || req.socket.remoteAddress || 'unknown')) { stats.refused++; return problem(429, 'events.rate_limited', 'too many webhook deliveries'); }
+ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
+ if (!verifies(raw, req.headers['x-hub-signature-256'], secrets)) { stats.refused++; return problem(401, 'events.bad_signature', 'X-Hub-Signature-256 does not verify'); }
+ const ghEvent = String(req.headers['x-github-event'] || '');
+ const delivery = String(req.headers['x-github-delivery'] || '').slice(0, 100);
+ if (!/^[0-9a-f-]{8,100}$/i.test(delivery)) { stats.refused++; return problem(400, 'events.bad_delivery', 'X-GitHub-Delivery is required'); }
+ stats.received++;
+ if (ghEvent === 'ping') return res.status(202).json({ ignored: 'ping' });
+ let payload;
+ try { payload = JSON.parse(raw.toString('utf8')); } catch { stats.refused++; return problem(400, 'events.bad_delivery', 'the webhook body is not JSON'); }
+ let normalized;
+ try { normalized = normalize(ghEvent, payload); } catch (e) { stats.refused++; return problem(400, 'events.bad_delivery', e.message); }
+ if (!normalized.length) { stats.ignored++; return res.status(202).json({ ignored: ghEvent || 'unknown' }); }
+ const at = new Date(now()).toISOString();
+ const trace = crypto.randomBytes(16).toString('hex');
+ const items = normalized.map((n) => ({
+ event_id: ids.newId('event', now()), event_type: n.event_type, version: 1, source: SOURCE,
+ actor: ACTOR, timestamp: at, trace_id: trace, priority: 'important', visibility: 'internal',
+ subject: { type: 'pr', id: `${repoOf(payload)}#${n.prNumber}` }, payload: n.payload,
+ }));
+ for (const env of items) {
+ const v = validate('events.event-envelope@1', env);
+ if (!v.valid) {
+ stats.refused++;
+ return problem(400, 'events.bad_delivery', 'the webhook names no pull request Codes can follow');
+ }
+ }
+ // One claim per delivery (it may name several pull requests, and they must be claimed together): a
+ // redelivery answers these ids without storing again. insertBatch runs its own transaction after the
+ // claim commits; a failure releases the claim so GitHub's retry stores the events.
+ const eventIds = items.map((i) => i.event_id);
+ const seen = await db.maybe('SELECT event_ids FROM github_deliveries WHERE delivery_id = $1', [delivery]);
+ if (seen) {
+ // A claim is written just before the batch. If the process died in that window the claimed events do
+ // not exist; release the stale claim and store them now instead of answering a duplicate for nothing.
+ const claimed = Array.isArray(seen.event_ids) ? seen.event_ids : [];
+ const present = await Promise.all(claimed.map((id) => store.getEvent(id).catch(() => null)));
+ if (claimed.length && present.every(Boolean)) {
+ stats.duplicates++;
+ return res.status(202).json({ event_ids: claimed, duplicate: true });
+ }
+ await db.exec('DELETE FROM github_deliveries WHERE delivery_id = $1', [delivery]).catch(() => {});
+ }
+ try {
+ await db.exec(
+ 'INSERT INTO github_deliveries (delivery_id, event_ids, event_types) VALUES ($1, $2::jsonb, $3::jsonb)',
+ [delivery, JSON.stringify(eventIds), JSON.stringify(items.map((i) => i.event_type))],
+ );
+ } catch (err) {
+ const twin = await db.maybe('SELECT event_ids FROM github_deliveries WHERE delivery_id = $1', [delivery]).catch(() => null);
+ if (twin) {
+ stats.duplicates++;
+ return res.status(202).json({ event_ids: twin.event_ids, duplicate: true });
+ }
+ // A concurrent twin claimed it first and has not committed yet: GitHub retries onto the committed row.
+ log.error(`[github] ingress claim failed for ${delivery}:`, err.message);
+ return problem(503, 'events.ingress_failed', 'the delivery was not stored; GitHub will retry it');
+ }
+ try {
+ await store.insertBatch(items, { publisher: 'github-ingress', requestId: `github:${delivery}` });
+ } catch (err) {
+ // The claim is released so the redelivery stores the events (a duplicate delivery onto committed
+ // envelopes answers duplicate from the receipts instead of storing twice).
+ await db.exec('DELETE FROM github_deliveries WHERE delivery_id = $1', [delivery]).catch(() => {});
+ log.error(`[github] ingress failed for ${delivery}:`, err.message);
+ return problem(503, 'events.ingress_failed', 'the delivery was not stored; GitHub will retry it');
+ }
+ stats.stored += items.length;
+ return res.status(202).json({ event_ids: eventIds });
+ }
+
+ const raw = express.raw({ type: () => true, limit: '1mb' });
+ return { handler, raw, normalize, verifies, stats: () => ({ ...stats, enabled: secrets.length > 0 }) };
+}
+
+module.exports = { createGithubIngress, normalize, verifies, SOURCE };
@@ -102,6 +102,24 @@ route('get', '/events/{id}', {
@@ -102,6 +102,24 @@ route('get', '/events/{id}', {
responses: { 200: json('The event and its position', eventItem), ...errors([401, 403, 404, 429]) },
responses: { 200: json('The event and its position', eventItem), ...errors([401, 403, 404, 429]) },
});
});
+// ── GitHub webhooks in (AL5) ───────────────────────────────────────────────────────────────────
+route('post', '/in/github', {
+ operationId: 'receiveGithubWebhook', summary: 'Receive a signed GitHub webhook (AL5)', tags: ['github'], security: [],
+ description: 'GitHub\'s pull request, check and comment deliveries. The body is verified with X-Hub-Signature-256 (HMAC-SHA256 with EVENTS_GITHUB_WEBHOOK_SECRET) and X-GitHub-Delivery is claimed once; a delivery is normalized to github.check.completed, github.pr.updated or github.pr.commented envelopes and stored for subscribers. Off (404) while the secret is unset; a ping answers 202 and stores nothing.',
+ parameters: [
+ { name: 'X-Hub-Signature-256', in: 'header', required: true, schema: str({ pattern: '^sha256=[0-9a-f]{64}$' }) },
+ { name: 'X-GitHub-Event', in: 'header', required: true, schema: str() },
+ { name: 'X-GitHub-Delivery', in: 'header', required: true, schema: str() },
+ ],
+ requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', additionalProperties: true } } } },
+ responses: {
+ 202: json('Stored, or ignored when the delivery carries no pull request', { type: 'object', properties: {
+ event_ids: arrayOf(str({ pattern: '^evt_[0-9A-HJKMNP-TV-Z]{26}$' })), duplicate: { type: 'boolean' }, ignored: str(),
+ } }),
+ ...errors([400, 401, 404, 429]),
+ },
+});
+
// ── Checkpoints ───────────────────────────────────────────────────────────────────────────────
// ── Checkpoints ───────────────────────────────────────────────────────────────────────────────
route('get', '/checkpoints', {
route('get', '/checkpoints', {
operationId: 'getCheckpoint', summary: 'The caller\'s stored cursor for a topic pattern', tags: ['checkpoints'], security: [...SERVICE, ...APP],
operationId: 'getCheckpoint', summary: 'The caller\'s stored cursor for a topic pattern', tags: ['checkpoints'], security: [...SERVICE, ...APP],
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "action": "completed",
+ "check_run": {
+ "id": 224,
+ "name": "codes-ci / tests",
+ "status": "completed",
+ "conclusion": "failure",
+ "head_sha": "def456abc123def456abc123def456abc123def4",
+ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/113/job/224",
+ "output": { "title": "1 failing test", "summary": "one check names two pull requests", "text": "not ok 1 - shared" },
+ "pull_requests": [
+ { "id": 888, "number": 888, "base": { "ref": "main" }, "head": { "ref": "al5-a" }, "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/888" },
+ { "id": 999, "number": 999, "base": { "ref": "main" }, "head": { "ref": "al5-scratch" }, "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999" }
+ ]
+ },
+ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
+ "sender": { "login": "github-actions[bot]", "type": "Bot" }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+{
+ "action": "completed",
+ "check_run": {
+ "id": 222,
+ "name": "codes-ci / tests",
+ "status": "completed",
+ "conclusion": "failure",
+ "head_sha": "abc123def456abc123def456abc123def456abc1",
+ "details_url": "https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/111",
+ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/111/job/222",
+ "started_at": "2026-11-10T10:00:00Z",
+ "completed_at": "2026-11-10T10:04:12Z",
+ "output": {
+ "title": "1 failing test",
+ "summary": "test/pr-bindings.test.js failed on the branch",
+ "text": "not ok 3 - a red check wakes the worker once\n AssertionError: expected 0 to equal 1\n at Test.<anonymous> (test/pr-bindings.test.js:88:9)\n# tests 12\n# pass 11\n# fail 1\n",
+ "annotations_count": 0
+ },
+ "pull_requests": [
+ {
+ "id": 999,
+ "number": 999,
+ "base": { "ref": "main" },
+ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },
+ "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999"
+ }
+ ]
+ },
+ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
+ "sender": { "login": "github-actions[bot]", "type": "Bot" }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"action":"completed","check_run":{"conclusion":"success","details_url":"https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/112","head_sha":"abc123def456abc123def456abc123def456abc1","html_url":"https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/112/job/223","id":223,"name":"codes-ci / tests","pull_requests":[{"id":999,"number":999,"url":"https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999"}],"status":"completed"},"repository":{"full_name":"OpenVibers/OpenVibe.Codes"}}
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -0,0 +1,16 @@
+{
+ "action": "completed",
+ "check_suite": {
+ "id": 300,
+ "status": "completed",
+ "conclusion": "failure",
+ "head_branch": "al5-scratch",
+ "head_sha": "abc123def456abc123def456abc123def456abc1",
+ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/120",
+ "pull_requests": [
+ { "number": 999, "base": { "ref": "main" }, "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" }, "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999" }
+ ]
+ },
+ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
+ "sender": { "login": "github-actions[bot]", "type": "Bot" }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"action":"created","comment":{"body":"All checks have passed. 2/2 successful checks.","html_url":"https://github.com/OpenVibers/OpenVibe.Codes/pull/999#issuecomment-2","id":2,"user":{"login":"github-actions[bot]","type":"Bot"}},"issue":{"html_url":"https://github.com/OpenVibers/OpenVibe.Codes/pull/999","number":999,"pull_request":{"url":"https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999"}},"repository":{"full_name":"OpenVibers/OpenVibe.Codes"},"sender":{"login":"github-actions[bot]","type":"Bot"}}
new file mode 100644
new file mode 100644
@@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
+{
+ "action": "edited",
+ "comment": {
+ "id": 3,
+ "body": "stop",
+ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999#issuecomment-3",
+ "user": { "login": "owner-person", "type": "User" }
+ },
+ "issue": {
+ "number": 999,
+ "pull_request": { "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999" }
+ },
+ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
+ "sender": { "login": "owner-person", "type": "User" }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{"action":"created","comment":{"body":"stop — I will take this one from here","html_url":"https://github.com/OpenVibers/OpenVibe.Codes/pull/999#issuecomment-1","id":1,"user":{"login":"owner-person","type":"User"}},"issue":{"html_url":"https://github.com/OpenVibers/OpenVibe.Codes/pull/999","number":999,"pull_request":{"url":"https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999"}},"repository":{"full_name":"OpenVibers/OpenVibe.Codes"},"sender":{"login":"owner-person","type":"User"}}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "action": "closed",
+ "number": 999,
+ "pull_request": {
+ "number": 999,
+ "title": "AL5: a ticket follows its pull request",
+ "merged": true,
+ "mergeable": true,
+ "mergeable_state": "clean",
+ "draft": false,
+ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",
+ "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999",
+ "changed_files": 7,
+ "additions": 412,
+ "deletions": 9,
+ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },
+ "base": { "ref": "main", "sha": "b9354f8b9354f8b9354f8b9354f8b9354f8b9354" }
+ },
+ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
+ "sender": { "login": "OpenVibers", "type": "User" }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+{
+ "action": "synchronize",
+ "number": 999,
+ "pull_request": {
+ "number": 999,
+ "title": "AL5: a ticket follows its pull request",
+ "merged": false,
+ "mergeable": false,
+ "mergeable_state": "dirty",
+ "draft": false,
+ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",
+ "url": "https://api.github.com/repos/OpenVibers/OpenVibe.Codes/pulls/999",
+ "changed_files": 5,
+ "additions": 220,
+ "deletions": 4,
+ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },
+ "base": { "ref": "main", "sha": "b9354f8b9354f8b9354f8b9354f8b9354f8b9354" }
+ },
+ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
+ "sender": { "login": "owner-person", "type": "User" }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "action": "submitted",
+ "pull_request": {
+ "number": 999,
+ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",
+ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },
+ "base": { "ref": "main" }
+ },
+ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
+ "review": {
+ "id": 2,
+ "state": "approved",
+ "body": "",
+ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999#pullrequestreview-2",
+ "user": { "login": "owner-person", "type": "User" }
+ },
+ "sender": { "login": "owner-person", "type": "User" }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+{
+ "action": "submitted",
+ "pull_request": {
+ "number": 999,
+ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",
+ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },
+ "base": { "ref": "main" }
+ },
+ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
+ "review": {
+ "id": 1,
+ "state": "changes_requested",
+ "body": "This helper swallows the error; return it instead.",
+ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999#pullrequestreview-1",
+ "user": { "login": "owner-person", "type": "User" }
+ },
+ "sender": { "login": "owner-person", "type": "User" }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+{
+ "action": "created",
+ "pull_request": {
+ "number": 999,
+ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999",
+ "head": { "ref": "al5-scratch", "sha": "abc123def456abc123def456abc123def456abc1" },
+ "base": { "ref": "main" }
+ },
+ "comment": {
+ "id": 4,
+ "body": "This swallow hides the 401; return the problem instead.",
+ "path": "server/github.js",
+ "line": 212,
+ "html_url": "https://github.com/OpenVibers/OpenVibe.Codes/pull/999#discussion_r4",
+ "user": { "login": "owner-person", "type": "User" }
+ },
+ "repository": { "full_name": "OpenVibers/OpenVibe.Codes" },
+ "sender": { "login": "owner-person", "type": "User" }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,227 @@
@@ -0,0 +1,227 @@
+'use strict';
+/**
+ * GitHub webhooks in (AL5): signature verification, normalization to github.* envelopes, idempotent redelivery.
+ * The fixtures in test/fixtures/github are recorded GitHub payload shapes against a fake PR
+ * (OpenVibers/OpenVibe.Codes#999); the HMAC is computed here with the test-only secret.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const { boot, request, serviceToken, suite } = require('./helpers');
+
+const SECRET = 'test-only-github-secret-0001';
+const OTHER_SECRET = 'test-only-github-secret-0002';
+
+const fixture = (name) => fs.readFileSync(path.join(__dirname, 'fixtures', 'github', name));
+
+function sign(raw, secret = SECRET) {
+ return `sha256=${crypto.createHmac('sha256', secret).update(raw).digest('hex')}`;
+}
+
+const t = suite('github');
+let h;
+const read = serviceToken('codes', ['events.event.read']);
+
+t('boot with the webhook secret', async () => {
+ h = await boot({ env: { EVENTS_GITHUB_WEBHOOK_SECRET: `${SECRET},${OTHER_SECRET}` } });
+});
+
+async function deliver(name, { event, delivery, secret } = {}) {
+ const raw = fixture(name);
+ return await request(h.base, 'POST', '/api/v1/in/github', {
+ raw,
+ headers: {
+ 'content-type': 'application/json',
+ 'x-github-event': event,
+ 'x-github-delivery': delivery || `deli-${name.replace(/[^0-9a-z-]/gi, '')}-${event || 'ping'}-0000`,
+ 'x-hub-signature-256': sign(raw, secret),
+ },
+ });
+}
+
+async function deliverRaw(raw, { event, delivery, secret } = {}) {
+ return await request(h.base, 'POST', '/api/v1/in/github', {
+ raw,
+ headers: {
+ 'content-type': 'application/json',
+ 'x-github-event': event,
+ 'x-github-delivery': delivery,
+ 'x-hub-signature-256': sign(raw, secret),
+ },
+ });
+}
+
+async function storedAs(eventId) {
+ const r = await request(h.base, 'GET', `/api/v1/events/${eventId}`, { token: read });
+ assert.strictEqual(r.status, 200, r.text);
+ return r.body.event;
+}
+
+t('a bad signature is refused; ping answers 202 without storing', async () => {
+ const raw = fixture('check-run-failed.json');
+ let r = await request(h.base, 'POST', '/api/v1/in/github', {
+ raw,
+ headers: { 'content-type': 'application/json', 'x-github-event': 'check_run', 'x-github-delivery': 'bad-sig-1', 'x-hub-signature-256': sign(raw, 'wrong-secret-0000000000000000') },
+ });
+ assert.strictEqual(r.status, 401, r.text);
+ assert.strictEqual(r.body.code, 'events.bad_signature');
+ r = await deliver('check-run-failed.json', { event: 'ping', delivery: 'deadbeef-0000-4000-8000-000000000000' });
+ assert.strictEqual(r.status, 202, r.text);
+ assert.strictEqual(r.body.ignored, 'ping');
+});
+
+t('a failed check_run stores one github.check.completed per pull request, with the log tail', async () => {
+ const r = await deliver('check-run-failed.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000001' });
+ assert.strictEqual(r.status, 202, r.text);
+ assert.strictEqual(r.body.event_ids.length, 1);
+ const env = await storedAs(r.body.event_ids[0]);
+ assert.strictEqual(env.event_type, 'github.check.completed');
+ assert.strictEqual(env.source, 'github');
+ assert.deepStrictEqual(env.subject, { type: 'pr', id: 'openvibers/openvibe.codes#999' });
+ assert.strictEqual(env.payload.kind, 'check_run');
+ assert.strictEqual(env.payload.conclusion, 'failure');
+ assert.strictEqual(env.payload.check_name, 'codes-ci / tests');
+ assert.strictEqual(env.payload.repo, 'openvibers/openvibe.codes');
+ assert.strictEqual(env.payload.head_sha, 'abc123def456abc123def456abc123def456abc1');
+ assert.strictEqual(env.payload.details_url, 'https://github.com/OpenVibers/OpenVibe.Codes/actions/runs/111');
+ assert.match(env.payload.output.text, /expected 0 to equal 1/);
+ assert.strictEqual(env.payload.output.title, '1 failing test');
+});
+
+t('a redelivery answers the stored ids and stores nothing new', async () => {
+ const first = await deliver('check-run-failed.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000001' });
+ assert.strictEqual(first.status, 202);
+ assert.strictEqual(first.body.duplicate, true);
+ assert.strictEqual(first.body.event_ids.length, 1);
+ const again = await storedAs(first.body.event_ids[0]);
+ assert.strictEqual(again.event_type, 'github.check.completed');
+});
+
+t('one delivery naming two pull requests stores both and claims the delivery once', async () => {
+ const first = await deliver('check-run-failed-two-prs.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000007' });
+ assert.strictEqual(first.status, 202, first.text);
+ assert.strictEqual(first.body.event_ids.length, 2);
+ const one = await storedAs(first.body.event_ids[0]);
+ const two = await storedAs(first.body.event_ids[1]);
+ assert.deepStrictEqual([one.subject.id, two.subject.id], ['openvibers/openvibe.codes#888', 'openvibers/openvibe.codes#999']);
+ const again = await deliver('check-run-failed-two-prs.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000007' });
+ assert.strictEqual(again.status, 202);
+ assert.strictEqual(again.body.duplicate, true);
+ assert.deepStrictEqual(again.body.event_ids, first.body.event_ids);
+});
+
+t('a check_suite failure normalizes to the same github.check.completed', async () => {
+ const r = await deliver('check-suite-failed.json', { event: 'check_suite', delivery: 'deadbeef-0000-4000-8000-000000000008' });
+ assert.strictEqual(r.status, 202, r.text);
+ const env = await storedAs(r.body.event_ids[0]);
+ assert.strictEqual(env.event_type, 'github.check.completed');
+ assert.strictEqual(env.payload.kind, 'check_suite');
+ assert.strictEqual(env.payload.conclusion, 'failure');
+ assert.strictEqual(env.subject.id, 'openvibers/openvibe.codes#999');
+});
+
+t('a successful check, a stop comment, a bot comment, a conflict and a review each normalize', async () => {
+ let r = await deliver('check-run-success.json', { event: 'check_run', delivery: 'deadbeef-0000-4000-8000-000000000002' });
+ assert.strictEqual(r.status, 202, r.text);
+ assert.strictEqual((await storedAs(r.body.event_ids[0])).payload.conclusion, 'success');
+
+ r = await deliver('issue-comment-stop.json', { event: 'issue_comment', delivery: 'deadbeef-0000-4000-8000-000000000003' });
+ assert.strictEqual(r.status, 202, r.text);
+ let env = await storedAs(r.body.event_ids[0]);
+ assert.strictEqual(env.event_type, 'github.pr.commented');
+ assert.strictEqual(env.payload.author_type, 'User');
+ assert.strictEqual(env.payload.comment_id, 1);
+ assert.match(env.payload.body, /^stop/);
+
+ r = await deliver('issue-comment-bot.json', { event: 'issue_comment', delivery: 'deadbeef-0000-4000-8000-000000000004' });
+ assert.strictEqual(r.status, 202, r.text);
+ env = await storedAs(r.body.event_ids[0]);
+ assert.strictEqual(env.payload.author_type, 'Bot');
+
+ r = await deliver('pull-request-dirty.json', { event: 'pull_request', delivery: 'deadbeef-0000-4000-8000-000000000005' });
+ assert.strictEqual(r.status, 202, r.text);
+ env = await storedAs(r.body.event_ids[0]);
+ assert.strictEqual(env.event_type, 'github.pr.updated');
+ assert.strictEqual(env.payload.mergeable_state, 'dirty');
+ assert.strictEqual(env.payload.mergeable, false);
+ assert.strictEqual(env.payload.changed_files, 5);
+ assert.match(env.payload.files_url, /\/pulls\/999\/files$/);
+
+ r = await deliver('pull-request-review-changes.json', { event: 'pull_request_review', delivery: 'deadbeef-0000-4000-8000-000000000006' });
+ assert.strictEqual(r.status, 202, r.text);
+ env = await storedAs(r.body.event_ids[0]);
+ assert.strictEqual(env.payload.state, 'changes_requested');
+ assert.strictEqual(env.payload.author_type, 'User');
+ assert.strictEqual(env.payload.comment_id, 1);
+
+ r = await deliver('pull-request-review-comment.json', { event: 'pull_request_review_comment', delivery: 'deadbeef-0000-4000-8000-000000000009' });
+ assert.strictEqual(r.status, 202, r.text);
+ env = await storedAs(r.body.event_ids[0]);
+ assert.strictEqual(env.payload.path, 'server/github.js');
+ assert.strictEqual(env.payload.line, 212);
+ assert.strictEqual(env.payload.comment_id, 4);
+});
+
+t('a claim left by a crash between claim and store is released, not answered as a duplicate', async () => {
+ const delivery = 'deadbeef-0000-4000-8000-00000000000d';
+ await h.store.db.exec(
+ 'INSERT INTO github_deliveries (delivery_id, event_ids, event_types) VALUES ($1, $2::jsonb, $3::jsonb)',
+ [delivery, JSON.stringify(['evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2Q1']), JSON.stringify(['github.check.completed'])],
+ );
+ const r = await deliver('check-run-success.json', { event: 'check_run', delivery });
+ assert.strictEqual(r.status, 202, r.text);
+ assert.strictEqual(r.body.duplicate, undefined);
+ assert.strictEqual(r.body.event_ids.length, 1);
+ assert.notStrictEqual(r.body.event_ids[0], 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2Q1');
+ const env = await storedAs(r.body.event_ids[0]);
+ assert.strictEqual(env.payload.conclusion, 'success');
+});
+
+t('a merged pull request ends the binding downstream: the envelope carries merged', async () => {
+ const r = await deliver('pull-request-closed-merged.json', { event: 'pull_request', delivery: 'deadbeef-0000-4000-8000-00000000000a' });
+ assert.strictEqual(r.status, 202, r.text);
+ const env = await storedAs(r.body.event_ids[0]);
+ assert.strictEqual(env.payload.action, 'closed');
+ assert.strictEqual(env.payload.merged, true);
+ assert.strictEqual(env.payload.base, 'main');
+});
+
+t('a bare approval and an edited comment are not new instructions', async () => {
+ const r = await deliver('pull-request-review-approved.json', { event: 'pull_request_review', delivery: 'deadbeef-0000-4000-8000-00000000000b' });
+ assert.strictEqual(r.status, 202, r.text);
+ assert.strictEqual(r.body.ignored, 'pull_request_review');
+ const r2 = await deliver('issue-comment-edited.json', { event: 'issue_comment', delivery: 'deadbeef-0000-4000-8000-00000000000c' });
+ assert.strictEqual(r2.status, 202, r2.text);
+ assert.strictEqual(r2.body.ignored, 'issue_comment');
+});
+
+t('an unfinished check and a non-PR issue store nothing', async () => {
+ const raw = Buffer.from(JSON.stringify({
+ action: 'created', check_run: { status: 'in_progress', name: 'x', pull_requests: [{ number: 999, url: 'https://api.github.com/repos/a/b/pulls/999' }] },
+ repository: { full_name: 'a/b' },
+ }));
+ let r = await deliverRaw(raw, { event: 'check_run', delivery: '1c1cec1c-1111-4a1a-8a1a-111111111111' });
+ assert.strictEqual(r.status, 202, r.text);
+ assert.strictEqual(r.body.ignored, 'check_run');
+ const plain = Buffer.from(JSON.stringify({
+ action: 'created', issue: { number: 7 }, comment: { body: 'hi', user: { login: 'u', type: 'User' } },
+ repository: { full_name: 'a/b' },
+ }));
+ r = await deliverRaw(plain, { event: 'issue_comment', delivery: '1cc0e7e1-1111-4a1a-8a1a-111111111111' });
+ assert.strictEqual(r.status, 202, r.text);
+ assert.strictEqual(r.body.ignored, 'issue_comment');
+});
+
+t('without a secret the route is off', async () => {
+ const { createGithubIngress } = require('../server/github');
+ const ing = createGithubIngress({ config: { github: { secrets: [] } }, store: h.store });
+ assert.strictEqual(ing.stats().enabled, false);
+ const fakeRes = { statusCode: 0, setHeader() {}, end() {} };
+ await ing.handler({ headers: {}, ip: '127.0.0.9', body: Buffer.alloc(0) }, fakeRes);
+ assert.strictEqual(fakeRes.statusCode, 404);
+});
+
+t('stop', async () => { await h.stop(); });
+
+t.run();
@@ -34,6 +34,8 @@ const PLANTED = {
@@ -34,6 +34,8 @@ const PLANTED = {
// The account-data consumer (ADR-033): the secret its own loopback subscription signs the two
// The account-data consumer (ADR-033): the secret its own loopback subscription signs the two
// account topics with. It is Events' own signing secret, never echoed.
// account topics with. It is Events' own signing secret, never echoed.
EVENTS_EVENTS_SECRET: `ovsecret-events-account-${crypto.randomBytes(12).toString('hex')}`,
EVENTS_EVENTS_SECRET: `ovsecret-events-account-${crypto.randomBytes(12).toString('hex')}`,
+ // The GitHub webhook ingress (AL5): the HMAC secret GitHub signs deliveries with, never echoed.
+ EVENTS_GITHUB_WEBHOOK_SECRET: `ovsecret-events-github-${crypto.randomBytes(12).toString('hex')}`,
};
};
t('Events reads no secret from its environment but the planted ones', async () => {
t('Events reads no secret from its environment but the planted ones', async () => {