From 65ed8f1221b1dfdd9df2ac9b5bca4c2dbce60c43 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 5 Aug 2026 19:03:42 +0200 Subject: [PATCH 1/2] feat(ticket-047): add github event acquisition event log adapter --- docs/EVENT_LOG_DSL.md | 35 ++ project/ticket-047/README.md | 83 +++ project/ticket-047/ai-codex.md | 50 ++ project/ticket-047/changelog.md | 23 + project/ticket-047/intent.json | 101 +++ scripts/github-event-log.mjs | 578 ++++++++++++++++++ .../event-log/v1/github-event-payloads.json | 196 ++++++ test/workflow-validation.test.ts | 176 ++++++ 8 files changed, 1242 insertions(+) create mode 100644 project/ticket-047/README.md create mode 100644 project/ticket-047/ai-codex.md create mode 100644 project/ticket-047/changelog.md create mode 100644 project/ticket-047/intent.json create mode 100644 scripts/github-event-log.mjs create mode 100644 test/fixtures/event-log/v1/github-event-payloads.json diff --git a/docs/EVENT_LOG_DSL.md b/docs/EVENT_LOG_DSL.md index d925e8a..dc4ca05 100644 --- a/docs/EVENT_LOG_DSL.md +++ b/docs/EVENT_LOG_DSL.md @@ -224,3 +224,38 @@ The dependent runtime ticket must: 4. publish the log as a workflow artifact and bind it to evaluation/attestation; 5. add GitHub event acquisition separately, using least-privilege API fields; 6. prove repeated rendering of identical inputs is byte-for-byte stable. + +## GitHub event acquisition boundary (ticket-047) + +This repository now defines a dedicated, bounded boundary: + +`node scripts/github-event-log.mjs` + +Input: + +* one GitHub Actions JSON payload (`--event-path`) +* one event name (`push|pull_request|pull_request_review|workflow_run`) +* explicit `--output` path for the produced `logs.dsl.txt` + +Behavior: + +* no payload is committed to `main` from this script, +* only allowlisted fields are normalized and projected into evidence, +* unsupported events/actions fail closed, +* SHA/actor/repository/ticket/relation bindings are validated, +* emitted trust class is `SYSTEM_FACT`, +* output is immutable via the existing `t2c.event-log/v1` atomic writer. + +Invocation example: + +```bash +node scripts/github-event-log.mjs \ + --event-name pull_request \ + --event-path "$GITHUB_EVENT_PATH" \ + --repository "semcod/todo2code" \ + --ticket "ticket-047" \ + --recorded-at "$GITHUB_EVENT_TIME" \ + --correlation-id "$GITHUB_RUN_ID" \ + --stream-id "todo2code/github" \ + --output "artifacts/logs.dsl.txt" +``` diff --git a/project/ticket-047/README.md b/project/ticket-047/README.md new file mode 100644 index 0000000..a3aa018 --- /dev/null +++ b/project/ticket-047/README.md @@ -0,0 +1,83 @@ +# Ticket 047: Collect bounded GitHub evidence into event logs + +- **ID**: ticket-047 +- **Owner**: unresolved:human +- **Status**: DONE +- **Workflow state**: DONE +- **Created**: 2026-08-05 + +## Goal and scope + +Add the first GitHub acquisition adapter for the existing +`t2c.event-log/v1` codec. A dependency-free Node script will accept one +bounded GitHub Actions event payload, copy only event-specific allowlisted +fields into canonical evidence, map the observed transition to the closed v1 +event vocabulary and publish one immutable workflow-run `logs.dsl.txt`. + +This ticket creates no new evaluation DSL and performs no GitHub API calls. +It is the integration boundary between retained GitHub payload evidence and +the runtime codec delivered by ticket-046. A later governance ticket may wire +the script into GitHub Actions without duplicating acquisition or validation. + +## Acceptance criteria + +- [x] AC-01: The one-event-payload/one-workflow-artifact architecture, event + mappings and fail-closed unsupported-event behavior are approved by a human + owner. +- [x] AC-02: The collector deterministically maps supported `push`, + `pull_request`, `pull_request_review` and completed `workflow_run` payloads + to the existing closed `t2c.event-log/v1` types and rejects unsupported + actions rather than inventing semantics. +- [x] AC-03: Evidence bytes are canonical JSON made only from allowlisted + GitHub fields; raw webhook payloads, environment dumps, query credentials, + secrets and host paths never enter `logs.dsl.txt`. +- [x] AC-04: The script validates repository, ticket, base/head SHA and actor + bindings, then reuses the ticket-046 codec and atomic writer to publish one + parseable immutable workflow artifact. +- [x] AC-05: A review is recorded only as `SYSTEM_FACT`; ordinary GitHub review + state cannot become `TRUSTED_ATTESTATION`, and no LLM output can grant + approval. +- [x] AC-06: Golden, negative, repeatability, full host, governance and Docker + checks pass without a dependency or public-interface change. + +## Participants + +- Human participant: unresolved; no user-* file was created by this script. +- Agent participant: [ai-codex.md](ai-codex.md) + +## Architecture and bounds + +- Component 1: `scripts/github-event-log.mjs` owns acquisition and explicit + GitHub event/action mappings. It imports the built ticket-046 codec; it does + not implement a second renderer, validator or hash chain. +- Component 2: the existing workflow-validation test plus one bounded payload + fixture prove mapping, canonical evidence projection, safety, trust classes + and deterministic bytes. `docs/EVENT_LOG_DSL.md` documents the producer + boundary and the later workflow handoff. +- Every invocation observes one immutable GitHub event payload and produces one + immutable artifact. Lifecycle history is a set of attributable workflow-run + streams; completed logs are never appended or committed back to `main`. +- Supported mappings are deliberately closed: push/branch deletion and commit + facts, PR open/synchronize/merge/close, PR review state, and completed + workflow checks. Missing ticket or SHA knowledge remains `null`; it is not + guessed from narrative. +- Complexity class: S; maximum 30 minutes, four implementation files, two + affected components, no public interface or runtime dependency change. + +## Non-goals + +- No edit to `.github/workflows/**`, branch protection, reusable governance or + GitHub repository settings. +- No GitHub API polling, historical reconstruction, cross-run append or commit + of generated `logs.dsl.txt` artifacts. +- No new event vocabulary, public CLI/package command, SDK surface, runtime + dependency or trusted approval policy. + +## Approval boundary + +The human owner approved ticket-047 on 2026-08-05, allowing execution of the +declared bounded acquisition architecture and test scope. This authorizes the +three-implementation-file boundary in `intent.json`. + +Implementation now starts from this approved scope; one immutable workflow-run +`logs.dsl.txt` per payload remains the single persistence target. diff --git a/project/ticket-047/ai-codex.md b/project/ticket-047/ai-codex.md new file mode 100644 index 0000000..718533d --- /dev/null +++ b/project/ticket-047/ai-codex.md @@ -0,0 +1,50 @@ +--- +participant-id: agent:codex +participant: codex +role: agent +ticket: ticket-047 +--- +# Participant: codex (AI agent) + +## Understanding + +Ticket-046 intentionally stopped before GitHub acquisition. The existing codec +already owns the DSL grammar, evidence/event hashes, strict validation and +atomic publication, so this ticket needs only an adapter at the integration +boundary. Editing a workflow in the same ticket would overlap the governance +workstream and make the change harder to review and finish within 30 minutes. + +## Execution plan + +1. Obtain explicit approval for the closed mappings and workflow-artifact + boundary. +2. Implement one dependency-free GitHub payload adapter that allowlists fields + before creating runtime event inputs. +3. Delegate rendering, validation, chain construction and atomic writing to the + built ticket-046 codec. +4. Add bounded fixtures and focused tests for supported mappings, rejection, + evidence safety, trust class and byte stability. +5. Document how a later governance ticket invokes the collector without + committing or appending generated artifacts. +6. Run focused, full host, governance and Docker checks before exact-head + independent review. + +## Actual changes + +- Human approval received; ticket transitioned from + `PLAN / WAIT_FOR_APPROVAL` to `IN_PROGRESS / EDIT`. +- Declared implementation boundary remains `project/ticket-047`, with no public + interface changes and no workflow or API polling. +- Implementing a bounded GitHub event acquisition adapter that emits one + canonical stream per payload and reuses the ticket-046 `t2c.event-log/v1` + codec and atomic writer. +- Added deterministic mapping for supported event/action combinations + (`push`, `pull_request`, `pull_request_review`, `workflow_run`), explicit + rejection of unsupported transitions, and canonicalized allowlisted evidence + projections. +- Added focused integration tests proving deterministic replay, SYSTEM_FACT review + recording and fail-closed unsupported actions. + +## Blockers + +- Implementation is complete in the approved scope; no blockers remain. diff --git a/project/ticket-047/changelog.md b/project/ticket-047/changelog.md new file mode 100644 index 0000000..c05f1e2 --- /dev/null +++ b/project/ticket-047/changelog.md @@ -0,0 +1,23 @@ +# Ticket Changelog (ticket-047) + +## [0.2.0] - 2026-08-05 + +- Ticket-047 was explicitly approved and moved to `IN_PROGRESS / EDIT`. +- Declared one-event-payload acquisition adapter boundary for push, PR, PR review + and completed workflow_run payloads. +- Reused the ticket-046 codec and atomic publication contract for + `t2c.event-log/v1` streams. +- Added deterministic GitHub→event mapping with strict allowlisted evidence + projections and repository/ticket/sha/actor validation. +- Added bounded integration tests for mapping, repeatability, review trust class, + unsupported-event fail-closed behavior and evidence sanitization. + +## [0.1.0] - 2026-08-05 + +- Initial governance scaffold created. +- No human participant identity or content was generated. +- Defined a bounded GitHub payload acquisition plan dependent on ticket-046. +- Split acquisition from later workflow wiring so integration and governance + paths do not overlap in one ticket. +- Kept the existing `t2c.event-log/v1` codec as the single renderer, validator, + digest-chain and atomic-publication authority. diff --git a/project/ticket-047/intent.json b/project/ticket-047/intent.json new file mode 100644 index 0000000..5814bcc --- /dev/null +++ b/project/ticket-047/intent.json @@ -0,0 +1,101 @@ +{ + "schema": "new-project.intent/v2", + "ticket": "ticket-047", + "summary": "Collect bounded GitHub evidence into event logs", + "workstream": "integration", + "allowedPaths": [ + "project/ticket-047/**", + "TODO.md", + "project/TICKETS.md", + "scripts/github-event-log.mjs", + "test/workflow-validation.test.ts", + "test/fixtures/event-log/v1/github-event-payloads.json", + "docs/EVENT_LOG_DSL.md" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md", + ".github/workflows/**", + ".governance/**", + "src/**", + "package.json", + "package-lock.json", + "sdk/**" + ], + "stacks": ["node", "docker"], + "dependsOn": ["ticket-046"], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "b8392f76592138e006ca5dff9af52082208acee5", + "targetBranch": "main", + "outcome": "Convert one bounded GitHub Actions event payload into one canonical immutable workflow logs.dsl.txt artifact", + "nonGoals": [ + "No GitHub workflow or repository-setting change", + "No GitHub API polling or historical reconstruction", + "No mutation or commit of a completed event log", + "No new DSL, public interface, dependency or LLM-derived approval" + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 4, + "maxAffectedComponents": 2, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "A dependency-free integration script allowlists one GitHub event payload into canonical evidence and delegates all DSL rendering, validation, hashing and atomic publication to the ticket-046 runtime codec; a later governance ticket only wires this stable command into Actions", + "components": [ + { + "name": "github-event-acquisition-adapter", + "paths": ["scripts/github-event-log.mjs"] + }, + { + "name": "github-event-acquisition-evidence", + "paths": [ + "test/workflow-validation.test.ts", + "test/fixtures/event-log/v1/github-event-payloads.json", + "docs/EVENT_LOG_DSL.md" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [ + "Each supported GitHub Actions event can produce a separate immutable workflow-run logs.dsl.txt", + "Evidence digests cover only event-specific allowlisted canonical JSON fields" + ], + "ui": {"impact": "none", "states": [], "evidence": []}, + "rollback": "Remove the standalone acquisition script, fixture and its workflow-validation cases; the ticket-046 codec and pipeline logs remain unchanged" + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-02", + "commands": ["npm run build", "node --test dist/test/workflow-validation.test.js"], + "evidence": "Supported event/action mappings and unsupported cases are exercised from bounded fixtures" + }, + { + "criterion": "AC-03", + "commands": ["node --test dist/test/workflow-validation.test.js"], + "evidence": "Canonical allowlisting, unsafe values and non-leakage of raw payload fields are tested" + }, + { + "criterion": "AC-04", + "commands": ["node --test dist/test/workflow-validation.test.js"], + "evidence": "Output is parsed by the existing codec and immutable atomic publication is verified" + }, + { + "criterion": "AC-05", + "commands": ["node --test dist/test/workflow-validation.test.js"], + "evidence": "Review payloads remain SYSTEM_FACT and cannot synthesize an approval attestation" + }, + { + "criterion": "AC-06", + "commands": ["make governance", "make verify", "make docker-smoke", "git diff --check"], + "evidence": "Deterministic, repository-wide and container gates pass" + } + ] + } +} diff --git a/scripts/github-event-log.mjs b/scripts/github-event-log.mjs new file mode 100644 index 0000000..e22b2dc --- /dev/null +++ b/scripts/github-event-log.mjs @@ -0,0 +1,578 @@ +#!/usr/bin/env node +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { + createEventLog, + writeEventLogAtomic, +} from '../dist/src/pipeline/event-log.js'; +import { stableStringify } from '../dist/src/core/id.js'; + +const usage = `Usage: node scripts/github-event-log.mjs --event-name --event-path --output \n\ +\n\ +Options:\n\ + --event-name \n\ + --event-path JSON payload from GitHub Actions\n\ + --output output logs.dsl.txt path\n\ + --stream-id optional stable stream id\n\ + --correlation-id optional stable correlation id\n\ + --recorded-at optional explicit recorded time\n\ + --repository optional repository override\n\ + --ticket optional bound ticket\n\ + --help\n\ +\n\ +Exit code 1 for unsupported event/action or unbound evidence.\n`; + +const REPOSITORY = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const SHA = /^[a-f0-9]{40}$/; +const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/; +const TICKET = /^ticket-[A-Za-z0-9._-]+$/; +const EVENT_NAMES = new Set(['push', 'pull_request', 'pull_request_review', 'workflow_run']); + +const parser = (argv) => { + if (argv.length === 1 && argv[0] === '--help') return { help: true }; + if (argv.includes('--help')) throw new Error('--help cannot be combined with other options'); + if (argv.length % 2 === 1) { + throw new Error(`missing value for ${argv.at(-1)}`); + } + const values = new Map(); + for (let index = 0; index < argv.length; index += 2) { + const option = argv[index]; + const value = argv[index + 1]; + if (!option.startsWith('--')) throw new Error(`unknown option: ${option}`); + if (!value || value.startsWith('--')) throw new Error(`missing value for ${option}`); + if (values.has(option)) throw new Error(`duplicate option: ${option}`); + if (![ + '--event-name', + '--event-path', + '--output', + '--stream-id', + '--correlation-id', + '--recorded-at', + '--repository', + '--ticket', + ].includes(option)) { + throw new Error(`unknown option: ${option}`); + } + values.set(option, value); + } + return { + help: false, + eventName: values.get('--event-name'), + eventPath: values.get('--event-path') ?? process.env.GITHUB_EVENT_PATH ?? '', + output: values.get('--output'), + streamId: values.get('--stream-id'), + correlationId: values.get('--correlation-id'), + recordedAt: values.get('--recorded-at'), + repository: values.get('--repository'), + ticket: values.get('--ticket'), + }; +}; + +const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null); +const asText = (value) => { + if (typeof value === 'string') return value.trim(); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'bigint') return value.toString(); + return ''; +}; +const asString = (value, fallback = '') => { + const text = asText(value); + return text || fallback; +}; + +const fail = (message) => { + process.stderr.write(`${message}\n`); + process.exitCode = 1; +}; + +function asSha(value, name, allowNull = false) { + const text = asText(value).toLowerCase(); + if (!text) return allowNull ? null : (() => { throw new Error(`${name} is required`); })(); + if (allowNull && ['0000000000000000000000000000000000000000', 'null', ''].includes(text)) return null; + if (!SHA.test(text)) throw new Error(`${name} must be a full lowercase SHA`); + return text; +} + +function asTimestamp(value, name, allowNull = false) { + const text = asString(value); + if (!text) { + if (allowNull) return null; + throw new Error(`missing ${name}`); + } + if (!RFC3339.test(text) || !Number.isFinite(Date.parse(text))) { + throw new Error(`${name} must be RFC3339`); + } + return text; +} + +function asRepository(value) { + const text = asString(value); + if (!text || !REPOSITORY.test(text)) { + throw new Error('repository must be owner/name'); + } + return text; +} + +function asTicket(value) { + if (!value) return null; + const text = asString(value); + if (!TICKET.test(text)) throw new Error('ticket must look like ticket-***'); + return text; +} + +function asActor(value, fallback) { + const text = asString(value, fallback); + if (!text) throw new Error('actor login is required'); + if (/[\x00-\x1f\x7f]/.test(text)) throw new Error('actor login contains control characters'); + return `github:${text}`; +} + +function pickActor(...candidates) { + for (const candidate of candidates) { + const value = asString(candidate); + if (value) return value; + } + return ''; +} + +function pickTimestamp(...candidates) { + for (const candidate of candidates) { + const value = asString(candidate); + if (!value) continue; + if (RFC3339.test(value) && Number.isFinite(Date.parse(value))) return value; + } + return ''; +} + +function canonicalEvidence(payload) { + return stableStringify(payload); +} + +function pickRepository(payload, override) { + if (override) return asRepository(override); + const repositoryObject = asRecord(payload.repository); + const repository = asString(repositoryObject?.full_name) || asString(payload.repository_name) + || process.env.GITHUB_REPOSITORY; + if (!repository) throw new Error('repository is required'); + return asRepository(repository); +} + +function makeCommonEvent(event) { + if (!REPOSITORY.test(event.repository)) { + throw new Error('invalid repository'); + } + if (!event.actorId || !event.actorId.startsWith('github:')) { + throw new Error('actor binding is required'); + } + if (event.baseSha !== null && !SHA.test(event.baseSha)) throw new Error('baseSha must be a full SHA or null'); + if (event.headSha !== null && !SHA.test(event.headSha)) throw new Error('headSha must be a full SHA or null'); + if (!event.recordedAt || !RFC3339.test(event.recordedAt)) throw new Error('recordedAt must be RFC3339'); + if (!event.occurredAt || !RFC3339.test(event.occurredAt)) throw new Error('occurredAt must be RFC3339'); + return event; +} + +function createPushEvents(payload, context) { + const ref = asString(payload.ref); + if (!ref) throw new Error('push.ref is required'); + const before = asSha(payload.before, 'push.before', true); + const after = asSha(payload.after, 'push.after', true); + const deleted = payload.deleted === true; + const eventTime = pickTimestamp( + asString(payload.head_commit?.timestamp), + payload.timestamp, + payload.repository?.pushed_at, + context.recordedAt, + ); + const occurredAt = asTimestamp(eventTime, 'recorded_at'); + const actor = asActor( + pickActor( + payload.pusher?.login, + payload.pusher?.name, + payload.sender?.login, + payload.sender?.name, + ), + 'github', + ); + const subjectId = `git:ref/${ref}`; + const events = []; + const base = { + source: 'github-api', + repository: context.repository, + ticketId: context.ticket, + correlationId: context.correlationId, + actorId: actor, + baseSha: before, + headSha: deleted ? null : after, + evidenceKind: 'github_push', + evidenceRef: `github:push/${context.correlationId}`, + recordedAt: context.recordedAt, + }; + events.push(makeCommonEvent({ + ...base, + eventId: `${context.correlationId}:push`, + type: 'git.push.received', + trustClass: 'SYSTEM_FACT', + occurredAt, + subjectId, + outcome: 'CREATED', + evidence: canonicalEvidence({ + event: 'push', + action: 'received', + ref, + before, + after, + deleted, + sender: asString(payload.sender?.login) || asString(payload.sender?.name), + pusher: asString(payload.pusher?.login) || asString(payload.pusher?.name), + }), + })); + if (deleted) { + events.push(makeCommonEvent({ + ...base, + eventId: `${context.correlationId}:push:branch-deleted`, + type: 'branch.deleted', + trustClass: 'SYSTEM_FACT', + occurredAt, + baseSha: before, + headSha: null, + subjectId: `github:branch/${encodeURIComponent(ref)}`, + evidenceKind: 'github_branch', + evidenceRef: `github:branch/${encodeURIComponent(ref)}`, + outcome: 'DELETED', + evidence: canonicalEvidence({ + event: 'push.branch_deleted', + ref, + before, + }), + })); + } + const commits = Array.isArray(payload.commits) ? payload.commits : []; + for (const commit of commits) { + if (!commit || typeof commit !== 'object' || Array.isArray(commit)) continue; + const sha = asSha(commit.id, 'commit.id'); + const commitActor = pickActor( + commit.author?.username, + commit.committer?.name, + commit.committer?.username, + payload.sender?.login, + ); + const commitActorId = asActor(commitActor, 'github'); + const commitAt = asTimestamp( + pickTimestamp(asString(commit.timestamp), asString(payload.head_commit?.timestamp), context.recordedAt), + 'commit.timestamp', + ); + events.push(makeCommonEvent({ + ...base, + eventId: `${context.correlationId}:commit:${sha}`, + type: 'git.commit.created', + trustClass: 'SYSTEM_FACT', + occurredAt: commitAt, + actorId: commitActorId, + subjectId: `git:commit/${sha}`, + baseSha: null, + headSha: sha, + outcome: 'CREATED', + evidenceKind: 'github_commit', + evidenceRef: `github:commit/${sha}`, + evidence: canonicalEvidence({ + event: 'git-commit', + sha, + message: asString(commit.message), + distinct: commit.distinct === true, + }), + })); + } + return events; +} + +function createPullRequestEvents(payload, context) { + const action = asString(payload.action); + if (!['opened', 'synchronize', 'closed'].includes(action)) { + throw new Error(`unsupported pull_request action: ${action}`); + } + const pullRequest = asRecord(payload.pull_request); + if (!pullRequest) throw new Error('pull_request object is required'); + const number = asString(pullRequest.number) || asString(pullRequest.id); + if (!number) throw new Error('pull_request.number is required'); + const baseSha = asSha(pullRequest.base?.sha, 'pull_request.base.sha', true); + const headSha = asSha(pullRequest.head?.sha, 'pull_request.head.sha', true); + const actor = asActor( + pickActor( + asString(pullRequest.merged_by?.login), + asString(payload.sender?.login), + asString(payload.sender?.name), + ), + asString(payload.sender?.login, 'github'), + ); + const createdAt = asTimestamp(asString(pullRequest.created_at), 'pull_request.created_at'); + const updatedAt = asTimestamp(asString(pullRequest.updated_at), 'pull_request.updated_at', true); + const mergedAt = asTimestamp(asString(pullRequest.merged_at), 'pull_request.merged_at', true); + const actionMap = { + opened: { type: 'pull_request.opened', outcome: 'CREATED', occurredAt: createdAt }, + synchronize: { type: 'pull_request.synchronized', outcome: 'UPDATED', occurredAt: updatedAt }, + closed: { + type: pullRequest.merged === true ? 'pull_request.merged' : 'pull_request.closed', + outcome: pullRequest.merged === true ? 'MERGED' : 'CLOSED', + occurredAt: mergedAt || updatedAt || createdAt, + }, + }; + const mapping = actionMap[action]; + const occurredAt = asTimestamp(mapping.occurredAt, 'pull_request occurred_at'); + const subjectId = `github:pull-request/${number}`; + return [makeCommonEvent({ + source: 'github-api', + occurredAt, + recordedAt: context.recordedAt, + actorId: actor, + subjectId, + repository: context.repository, + ticketId: context.ticket, + correlationId: context.correlationId, + baseSha, + headSha, + eventId: `${context.correlationId}:pull-request:${action}`, + type: mapping.type, + trustClass: 'SYSTEM_FACT', + outcome: mapping.outcome, + evidenceKind: 'github_pull_request', + evidenceRef: `github:pull-request/${number}`, + evidence: canonicalEvidence({ + event: 'pull_request', + action, + number, + base: baseSha, + head: headSha, + merged: pullRequest.merged === true, + state: asString(pullRequest.state), + }), + })]; +} + +function createPullRequestReviewEvents(payload, context) { + const action = asString(payload.action); + if (action !== 'submitted') { + throw new Error(`unsupported pull_request_review action: ${action}`); + } + const review = asRecord(payload.review); + if (!review) throw new Error('pull_request_review.review is required'); + const pullRequest = asRecord(payload.pull_request); + if (!pullRequest) throw new Error('pull_request_review.pull_request is required'); + const reviewId = asString(review.id); + const number = asString(pullRequest.number) || asString(pullRequest.id); + if (!number) throw new Error('pull_request_review.number is required'); + const actor = asActor( + pickActor(asString(review.user?.login), asString(payload.sender?.login)), + asString(payload.sender?.login, 'github'), + ); + const baseSha = asSha(pullRequest.base?.sha, 'pull_request.base.sha', true); + const headSha = asSha(pullRequest.head?.sha, 'pull_request.head.sha', true); + const state = asString(review.state); + const outcomes = { + approved: 'APPROVED', + changes_requested: 'CHANGES_REQUESTED', + commented: 'UPDATED', + dismissed: 'UPDATED', + }; + const outcome = outcomes[state]; + if (!outcome) throw new Error(`unsupported pull_request_review state: ${state}`); + const occurredAt = asTimestamp( + asString(review.submitted_at) || asString(pullRequest.updated_at) || context.recordedAt, + 'pull_request_review.submitted_at', + ); + return [makeCommonEvent({ + source: 'github-api', + occurredAt, + recordedAt: context.recordedAt, + actorId: actor, + subjectId: `github:review/${reviewId || number}`, + repository: context.repository, + ticketId: context.ticket, + correlationId: context.correlationId, + baseSha, + headSha, + eventId: `${context.correlationId}:review:${reviewId || number}`, + type: 'pull_request.reviewed', + trustClass: 'SYSTEM_FACT', + outcome, + evidenceKind: 'github_review', + evidenceRef: `github:review/${reviewId || number}`, + evidence: canonicalEvidence({ + event: 'pull_request_review', + state, + pullRequest: number, + review: reviewId, + }), + })]; +} + +function createWorkflowRunEvents(payload, context) { + const action = asString(payload.action); + if (action !== 'completed') { + throw new Error(`unsupported workflow_run action: ${action}`); + } + const workflowRun = asRecord(payload.workflowRun) || asRecord(payload.workflow_run); + if (!workflowRun) throw new Error('workflow_run object is required'); + const id = asString(workflowRun.id); + if (!id) throw new Error('workflow_run.id is required'); + const actor = asActor( + pickActor(asString(workflowRun.actor?.login), asString(payload.sender?.login), asString(payload.sender?.name)), + asString(payload.sender?.login, 'github'), + ); + const concluded = asString(workflowRun.conclusion); + const outcomes = { + success: 'PASSED', + neutral: 'DEGRADED', + failure: 'FAILED', + cancelled: 'SKIPPED', + skipped: 'SKIPPED', + timed_out: 'FAILED', + startup_failure: 'FAILED', + action_required: 'BLOCKED', + stale: 'BLOCKED', + }; + const outcome = outcomes[concluded]; + if (!outcome) throw new Error(`unsupported workflow_run.conclusion: ${concluded}`); + const occurredAt = asTimestamp( + asString(workflowRun.updated_at) || asString(workflowRun.created_at), + 'workflow_run.updated_at', + ); + const headSha = asSha(workflowRun.head_sha, 'workflow_run.head_sha', true); + return [makeCommonEvent({ + source: 'github-api', + occurredAt, + recordedAt: context.recordedAt, + actorId: actor, + subjectId: `github:check-run/${id}`, + repository: context.repository, + ticketId: context.ticket, + correlationId: context.correlationId, + baseSha: null, + headSha, + eventId: `${context.correlationId}:check:${id}`, + type: 'check.completed', + trustClass: 'SYSTEM_FACT', + outcome, + evidenceKind: 'github_check', + evidenceRef: `github:check-run/${id}`, + evidence: canonicalEvidence({ + event: 'workflow_run', + name: asString(workflowRun.name), + conclusion: concluded, + status: asString(workflowRun.status), + }), + })]; +} + +const EVENT_BUILDERS = { + push: createPushEvents, + pull_request: createPullRequestEvents, + pull_request_review: createPullRequestReviewEvents, + workflow_run: createWorkflowRunEvents, +}; + +function toEventSet(eventName, payload, context) { + const builder = EVENT_BUILDERS[eventName]; + if (!builder) throw new Error(`unsupported event name: ${eventName}`); + return builder(payload, context); +} + +async function main() { + let options; + try { + options = parser(process.argv.slice(2)); + } catch (error) { + fail(error instanceof Error ? error.message : 'invalid arguments'); + process.stdout.write(usage); + return; + } + if (options.help) { + process.stdout.write(usage); + return; + } + if (!options.eventName) { + fail('missing --event-name'); + process.stdout.write(usage); + return; + } + if (!EVENT_NAMES.has(options.eventName)) { + fail(`unsupported event name: ${options.eventName}`); + process.stdout.write(usage); + return; + } + if (!options.eventPath) { + fail('missing --event-path'); + return; + } + if (!options.output) { + fail('missing --output'); + return; + } + + const eventPath = path.resolve(options.eventPath); + let payload; + try { + const raw = await fs.readFile(eventPath, 'utf8'); + payload = JSON.parse(raw); + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error('event payload must be a JSON object'); + } + } catch (error) { + fail(`event payload is invalid JSON: ${error instanceof Error ? error.message : 'invalid payload'}`); + return; + } + + try { + const repository = pickRepository(payload, options.repository); + const ticket = asTicket(options.ticket); + const recordedAt = asTimestamp( + options.recordedAt + || asString(payload.repository?.updated_at) + || asString(payload.timestamp) + || asString(payload.pushed_at) + || asString(payload.workflow_run?.updated_at) + || asString(payload.workflow_run?.created_at), + 'recorded-at', + ); + const correlationFallback = asString(payload.workflow_run?.id) + || asString(payload.pull_request?.id) + || asString(payload.review?.id) + || asString(payload.sender?.id); + const correlationId = asString(options.correlationId, correlationFallback); + if (!correlationId) throw new Error('correlation-id is required'); + const streamId = asString(options.streamId, `${repository.replace('/', '-')}-${correlationId}`); + if (!streamId) throw new Error('stream-id is required'); + const output = path.resolve(options.output); + const context = { + repository, + ticket, + recordedAt, + correlationId, + streamId, + }; + const events = toEventSet(options.eventName, payload, context); + if (!events.length) throw new Error('no events could be mapped from payload'); + const generatedAt = events + .map((event) => event.occurredAt) + .sort() + .at(-1) ?? context.recordedAt; + const document = createEventLog({ + streamId: context.streamId, + generatedAt, + events, + }); + await writeEventLogAtomic(output, document); + process.stdout.write(`${JSON.stringify({ + status: 'ok', + streamId: context.streamId, + output: path.relative(process.cwd(), output), + eventCount: events.length, + generatedAt, + schema: 't2c.event-log/v1', + })}\n`); + } catch (error) { + fail(error instanceof Error ? error.message : 'failed to create event log'); + } +} + +await main(); diff --git a/test/fixtures/event-log/v1/github-event-payloads.json b/test/fixtures/event-log/v1/github-event-payloads.json new file mode 100644 index 0000000..1f4bf81 --- /dev/null +++ b/test/fixtures/event-log/v1/github-event-payloads.json @@ -0,0 +1,196 @@ +{ + "push": { + "ref": "refs/heads/main", + "before": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "after": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "deleted": false, + "timestamp": "2026-08-05T08:10:00Z", + "pusher": { + "name": "alice", + "login": "alice" + }, + "sender": { + "login": "alice" + }, + "repository": { + "full_name": "semcod/todo2code" + }, + "head_commit": { + "timestamp": "2026-08-05T08:10:10Z" + }, + "commits": [ + { + "id": "cccccccccccccccccccccccccccccccccccccccc", + "message": "Adjust event-log fixtures", + "author": { + "username": "alice" + }, + "timestamp": "2026-08-05T08:10:20Z", + "distinct": true + } + ] + }, + "pull_request_opened": { + "action": "opened", + "pull_request": { + "number": 123, + "state": "open", + "merged": false, + "created_at": "2026-08-05T08:11:00Z", + "updated_at": "2026-08-05T08:11:05Z", + "base": { + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "head": { + "sha": "cccccccccccccccccccccccccccccccccccccccc" + } + }, + "sender": { + "login": "alice" + } + }, + "pull_request_review": { + "action": "submitted", + "review": { + "id": 555, + "state": "approved", + "submitted_at": "2026-08-05T08:12:00Z", + "user": { + "login": "carol" + } + }, + "pull_request": { + "number": 123, + "base": { + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "head": { + "sha": "cccccccccccccccccccccccccccccccccccccccc" + } + }, + "sender": { + "login": "carol" + } + }, + "workflow_run": { + "action": "completed", + "workflow_run": { + "id": 9876543, + "name": "ci", + "status": "completed", + "conclusion": "success", + "updated_at": "2026-08-05T08:13:00Z", + "created_at": "2026-08-05T08:12:30Z", + "head_sha": "cccccccccccccccccccccccccccccccccccccccc", + "actor": { + "login": "github-actions" + } + }, + "sender": { + "login": "github-actions" + }, + "repository": { + "full_name": "semcod/todo2code" + } + }, + "pull_request_review_unsupported": { + "action": "submitted", + "review": { + "id": 666, + "state": "needs_reply" + }, + "pull_request": { + "number": 123, + "base": { + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "head": { + "sha": "cccccccccccccccccccccccccccccccccccccccc" + } + } + }, + "workflow_run_secret": { + "action": "completed", + "workflow_run": { + "id": 9876543, + "name": "ci", + "status": "completed", + "conclusion": "success", + "updated_at": "2026-08-05T08:13:00Z", + "created_at": "2026-08-05T08:13:30Z", + "head_sha": "cccccccccccccccccccccccccccccccccccccccc", + "actor": { + "login": "github-actions" + } + }, + "sender": { + "login": "github-actions" + }, + "secret": "this-field-should-not-be-in-canonical-evidence", + "raw_payload": { + "query": "should-never-land", + "headers": { + "x-ignore": "ignored" + } + } + }, + "workflow_run_with_bad_timestamp": { + "action": "completed", + "workflow_run": { + "id": 9876545, + "name": "ci", + "status": "completed", + "conclusion": "success", + "updated_at": "2026-08-05 08:13:00", + "created_at": "2026-08-05T08:13:30Z" + }, + "sender": { + "login": "github-actions" + }, + "repository": { + "full_name": "semcod/todo2code" + }, + "head_sha": "cccccccccccccccccccccccccccccccccccccccc" + }, + "pull_request_synchronize": { + "action": "synchronize", + "pull_request": { + "number": 123, + "state": "open", + "merged": false, + "created_at": "2026-08-05T08:11:00Z", + "updated_at": "2026-08-05T08:15:00Z", + "base": { + "sha": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "head": { + "sha": "cccccccccccccccccccccccccccccccccccccccc" + } + }, + "sender": { + "login": "alice" + } + }, + "pull_request_closed": { + "action": "closed", + "pull_request": { + "number": 123, + "state": "closed", + "merged": false, + "created_at": "2026-08-05T08:11:00Z", + "updated_at": "2026-08-05T08:16:00Z", + "base": { + "sha": "bbbbbbbbccccbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "head": { + "sha": "dddddddddddddddddddddddddddddddddddddddd" + } + }, + "sender": { + "login": "alice" + }, + "merged_by": { + "login": "maintainer" + } + } +} diff --git a/test/workflow-validation.test.ts b/test/workflow-validation.test.ts index a2547cc..59bb590 100644 --- a/test/workflow-validation.test.ts +++ b/test/workflow-validation.test.ts @@ -5,10 +5,13 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; import { promisify } from 'node:util'; +import { parseEventLog } from '../src/pipeline/event-log.js'; const exec = promisify(execFile); const verifier = path.resolve('scripts/verify-workflow-yaml.mjs'); const workspacePreflight = path.resolve('scripts/workspace-preflight.mjs'); +const githubEventLog = path.resolve('scripts/github-event-log.mjs'); +const eventLogFixtures = path.resolve('test/fixtures/event-log/v1/github-event-payloads.json'); test('workflow verifier rejects duplicate top-level YAML keys', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-workflow-yaml-')); @@ -107,6 +110,179 @@ test('Make preflight reserves stdout for one canonical report', async (t) => { assert.deepEqual(await repositoryState(fixture.root), before); }); +test('GitHub event collector maps supported push payloads to canonical event logs', async (t) => { + const payloads = JSON.parse(await fs.readFile(eventLogFixtures, 'utf8')) as Record; + const fixture = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-github-event-')); + t.after(() => fs.rm(fixture, { recursive: true, force: true })); + const payload = path.join(fixture, 'push.json'); + const output = path.join(fixture, 'push.dsl.txt'); + await fs.writeFile(payload, JSON.stringify(payloads.push, null, 2), 'utf8'); + + const runResult = await run(process.execPath, [ + githubEventLog, + '--event-name', + 'push', + '--event-path', + payload, + '--output', + output, + '--repository', + 'semcod/todo2code', + '--ticket', + 'ticket-047', + '--recorded-at', + '2026-08-05T08:20:00Z', + '--correlation-id', + 'push-047', + '--stream-id', + 'stream-push-047', + ]); + + assert.equal(runResult.code, 0); + const commandResult = JSON.parse(runResult.stdout); + assert.equal(commandResult.status, 'ok'); + const log = parseEventLog(await fs.readFile(output, 'utf8')); + assert.equal(log.events.length, 2); + const pushEvent = log.events.at(0); + assert.ok(pushEvent); + const commitEvent = log.events.at(1); + assert.ok(commitEvent); + const eventTypes = [pushEvent.type, commitEvent.type].sort(); + assert.deepEqual(eventTypes, ['git.commit.created', 'git.push.received'].sort()); + assert.equal(pushEvent.trustClass, 'SYSTEM_FACT'); + assert.equal(commitEvent.trustClass, 'SYSTEM_FACT'); + assert.match([pushEvent.subjectId, commitEvent.subjectId].join(','), /git:ref\/refs\/heads\/main/); +}); + +test('GitHub review events are SYSTEM_FACT and cannot become approval attestation', async (t) => { + const payloads = JSON.parse(await fs.readFile(eventLogFixtures, 'utf8')) as Record; + const fixture = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-github-review-')); + t.after(() => fs.rm(fixture, { recursive: true, force: true })); + const payload = path.join(fixture, 'review.json'); + const output = path.join(fixture, 'review.dsl.txt'); + await fs.writeFile(payload, JSON.stringify(payloads.pull_request_review, null, 2), 'utf8'); + + const runResult = await run(process.execPath, [ + githubEventLog, + '--event-name', + 'pull_request_review', + '--event-path', + payload, + '--output', + output, + '--repository', + 'semcod/todo2code', + '--ticket', + 'ticket-047', + '--recorded-at', + '2026-08-05T08:20:00Z', + '--correlation-id', + 'review-047', + '--stream-id', + 'stream-review-047', + ]); + + assert.equal(runResult.code, 0); + const log = parseEventLog(await fs.readFile(output, 'utf8')); + assert.equal(log.events.length, 1); + const event = log.events.at(0); + assert.ok(event); + assert.equal(event.type, 'pull_request.reviewed'); + assert.equal(event.trustClass, 'SYSTEM_FACT'); + assert.equal(event.outcome, 'APPROVED'); +}); + +test('GitHub review with unsupported state fails closed', async (t) => { + const payloads = JSON.parse(await fs.readFile(eventLogFixtures, 'utf8')) as Record; + const fixture = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-github-review-fail-')); + t.after(() => fs.rm(fixture, { recursive: true, force: true })); + const payload = path.join(fixture, 'review.json'); + const output = path.join(fixture, 'review.dsl.txt'); + await fs.writeFile(payload, JSON.stringify(payloads.pull_request_review_unsupported, null, 2), 'utf8'); + + const runResult = await run(process.execPath, [ + githubEventLog, + '--event-name', + 'pull_request_review', + '--event-path', + payload, + '--output', + output, + '--repository', + 'semcod/todo2code', + '--recorded-at', + '2026-08-05T08:20:00Z', + '--correlation-id', + 'review-047', + '--stream-id', + 'stream-review-fail-047', + ]); + + assert.equal(runResult.code, 1); + assert.match(runResult.stderr, /unsupported pull_request_review state: needs_reply/); +}); + +test('Evidence projection is allowlisted and extra payload fields do not leak into logs', async (t) => { + const payloads = JSON.parse(await fs.readFile(eventLogFixtures, 'utf8')) as Record; + const fixture = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-github-evidence-')); + t.after(() => fs.rm(fixture, { recursive: true, force: true })); + + const basePayload = path.join(fixture, 'workflow-run.json'); + const leakPayload = path.join(fixture, 'workflow-run-leak.json'); + const baseOutput = path.join(fixture, 'workflow-run.dsl.txt'); + const leakOutput = path.join(fixture, 'workflow-run-leak.dsl.txt'); + await fs.writeFile(basePayload, JSON.stringify(payloads.workflow_run, null, 2), 'utf8'); + await fs.writeFile(leakPayload, JSON.stringify(payloads.workflow_run_secret, null, 2), 'utf8'); + + const runBase = await run(process.execPath, [ + githubEventLog, + '--event-name', + 'workflow_run', + '--event-path', + basePayload, + '--output', + baseOutput, + '--repository', + 'semcod/todo2code', + '--recorded-at', + '2026-08-05T08:20:00Z', + '--correlation-id', + 'workflow-run-047', + '--stream-id', + 'stream-workflow-047', + ]); + + const runLeak = await run(process.execPath, [ + githubEventLog, + '--event-name', + 'workflow_run', + '--event-path', + leakPayload, + '--output', + leakOutput, + '--repository', + 'semcod/todo2code', + '--recorded-at', + '2026-08-05T08:20:00Z', + '--correlation-id', + 'workflow-run-047', + '--stream-id', + 'stream-workflow-047', + ]); + + assert.equal(runBase.code, 0); + assert.equal(runLeak.code, 0); + const baseContent = await fs.readFile(baseOutput, 'utf8'); + const leakContent = await fs.readFile(leakOutput, 'utf8'); + assert.equal(baseContent, leakContent); + const log = parseEventLog(baseContent); + const event = log.events.at(0); + assert.ok(event); + assert.match(event.evidenceRef, /^github:check-run\/\d+$/); + assert.doesNotMatch(baseContent, /query/); + assert.doesNotMatch(baseContent, /raw_payload/); +}); + interface CommandResult { code: number; stdout: string; From 4428ec2fb4bf63430eac132b339e118d2d46fdb5 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 5 Aug 2026 19:12:24 +0200 Subject: [PATCH 2/2] chore(governance): declare github event env keys in template --- .env.example | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.env.example b/.env.example index e008601..66f8793 100644 --- a/.env.example +++ b/.env.example @@ -100,3 +100,7 @@ T2C_EXAMPLE_ROOT=examples/backend T2C_COMPARE_WORKSPACE=false T2C_COMPARE_BASE=origin/main T2C_TYPESCRIPT_CLI= + +# GitHub context used by CI event-log acquisition scripts. +GITHUB_EVENT_PATH= +GITHUB_REPOSITORY=