diff --git a/README.md b/README.md index 278a7c4..e904ae5 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,41 @@ when it also needs structured class-level explanations. The full report shape is specified in [Report Shape](docs/contract.md#report-shape), and practical output examples are in [docs/usage.md](docs/usage.md#output). +### Semantic summaries (`--summaries`) + +The per-delta `from`/`to` fingerprints are opaque digests tuned for change +_detection_ — correct for "did CI change?" but useless for "is CI green?". Pass +`--summaries` to add a normalized, typed `summary` object to every PR delta that +has an observed `to` state. It is derived from the **same single observation** +that produced the fingerprints (no second GitHub fetch), and is a **sibling** of +`to`, so the content-addressed `delta.id` and every existing field stay +byte-identical whether or not the flag is set. Consumers may treat it as a hint +and still re-derive authoritative facts themselves. + +```jsonc +"summary": { + // 'none' means ZERO checks ran — never conflated with 'green'. Fail-closed + // consumers decide what "no CI" means. Precedence is failed > pending > green. + "ciRollup": "green" | "failed" | "pending" | "none", + // 'none' also covers "no review-required rule" and "required but none submitted + // yet"; GitHub does not distinguish these without a branch-protection fetch. + "reviewDecision": "approved" | "changes_requested" | "review_required" | "none", + // 'unknown' = GitHub has not finished recomputing mergeability (kept honest, + // never collapsed to a boolean). + "mergeable": "mergeable" | "conflicting" | "unknown", + "state": "open" | "closed" | "merged", + "isDraft": true, // boolean + "unresolvedReviewThreads": 0, // non-negative integer + "headSha": "" +} +``` + +Issue deltas and the missing lifecycle (`to` is null) carry no `summary`. The +field set and enum domains are also emitted machine-readably under +`output.deltaSummaryFields` / `output.deltaSummaryEnums` in `--help-json`, and the +authoritative schema lives in +[Delta Summary schema](docs/contract.md#delta-summary-schema). + ## Watch Loops and Outposts See [RUNBOOK.md](RUNBOOK.md) for timer-driven loop patterns. The recommended diff --git a/RUNBOOK.md b/RUNBOOK.md index de599a9..e6f5bd7 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -147,6 +147,35 @@ The endpoint owns filtering, deduplication by `eventId`, and any downstream action. Do not put secrets in the outpost URL. If authentication is added later, headers or tokens must not be printed in logs. +## Semantic Summaries + +Add `--summaries` to attach a normalized, typed `summary` object to every PR +delta that has a current object. It is derived from the same single observation +as the opaque fingerprints — no second GitHub call — and is a sibling of `to`, so +the content-addressed `delta.id` and every existing field stay byte-identical +whether or not the flag is set. Fields, enum domains, and honesty semantics +(`ciRollup: none` for zero checks, `mergeable: unknown` for not-yet-computed) are +specified in [Delta Summary schema](docs/contract.md#delta-summary-schema). + +Live acceptance check (proves the load-bearing `ciRollup` end to end against real +GitHub, using a scratch PR you own): + +```bash +STATE=$(mktemp -d) +REPO=you/scratch # a repo with NO required checks on the PR's base +PR=1 # an open PR whose head has no commit status yet + +# 1. Seed a baseline while the PR has zero checks. +gh-delta --repo "$REPO" --monitor-id acc --state-dir "$STATE" --entities pr --summaries + +# 2. Post a successful commit status on the PR head and re-run. +HEAD=$(gh pr view "$PR" --repo "$REPO" --json headRefOid -q .headRefOid) +gh api "repos/$REPO/statuses/$HEAD" -f state=success -f context=acceptance >/dev/null +gh-delta --repo "$REPO" --monitor-id acc --state-dir "$STATE" --entities pr --summaries \ + | jq '.deltas[] | select(.classes | index("ci-changed")) | .summary.ciRollup' +# expect: "green" (and a fresh baseline against the zero-check PR reports "none") +``` + ## Scheduler Choices ### Plain Cron Or Equivalent diff --git a/docs/contract.md b/docs/contract.md index 3cbca11..260328d 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -14,7 +14,7 @@ form of this document is available at `gh-delta --help-json`. gh-delta --repo [--monitor-id ] [--state-file | --state-dir ] [--entities pr,issue] [--format json|text] - [--summary-line] [--detail] + [--summary-line] [--detail] [--summaries] [--outpost-url ] [--outpost-timeout-ms ] [--outpost-max-posts ] [--gh-timeout-ms ] [--no-registry] @@ -46,6 +46,11 @@ gh-delta --repo [--monitor-id ] - `--detail` adds structured `details` to each delta, also adds `summaryLine`, and keeps the backward-compatible `line` alias. Consumers should prefer `summaryLine` for the human line and `details` / `classes` for decisions. +- `--summaries` adds a normalized, typed `summary` object to each PR delta that + has a current object — the semantic state (`ciRollup`, `reviewDecision`, + `mergeable`, …) read from the same observation as the opaque fingerprints, with + no second GitHub call. Additive and off by default; see + [Delta Summary schema](#delta-summary-schema). - `--outpost-url` is optional at-most-once HTTP delivery; see [Outpost Payload](#outpost-payload-schema-v1). It does not affect the JSON report, exit code, or snapshot. @@ -387,6 +392,10 @@ Each delta: - `details` (array): present **only** with `--detail`. Structured explanation of the selected `classes`. Entries are additive; tolerate new fields and new detail shapes. +- `summary` (object): present **only** with `--summaries`, and **only** on PR + deltas that have an observed `to` state. A normalized, typed semantic view of + the current PR state — see [Delta Summary schema](#delta-summary-schema). It is + a sibling of `to`, not nested inside it, so it never affects `id`. Detail entries use `class` to name the class being explained. Common shapes: @@ -435,6 +444,54 @@ The public field catalogs are also available without parsing Markdown through the order follows the GitHub fetch result. Do not rely on positional access (`deltas[0]`) or on a stable within-family order across GitHub API changes. +### Delta Summary schema + +Added by `--summaries`. The `from`/`to` [fingerprints](#fingerprint-fields) are +opaque digests built for change _detection_; they cannot answer "is CI green?" or +"what did reviewers decide?" without a second GitHub call. The `summary` object +answers those from the **same single observation** that produced the fingerprints +(no extra fetch). It is present only on PR deltas with an observed `to` state +(absent on issue deltas and the missing lifecycle) and is a **sibling of `to`**, +so `id` and every pre-existing field are byte-identical with or without the flag. +It is an optional **hint**: a fail-closed consumer may re-derive authoritative +facts itself. + +```json +{ + "ciRollup": "green", + "reviewDecision": "approved", + "mergeable": "mergeable", + "state": "open", + "isDraft": false, + "unresolvedReviewThreads": 0, + "headSha": "9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c" +} +``` + +Every field is a total function of the observed `to` state; the shape is fixed +(no field is ever omitted when `summary` is present). + +| Field | Type | Domain / Notes | +| ------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ciRollup` | enum | `green` \| `failed` \| `pending` \| `none`. Rolled up from the CI checks with precedence `failed > pending > green`. **`none` means zero checks ran** — never conflated with `green`, so a fail-closed gate decides what "no CI" means. | +| `reviewDecision` | enum | `approved` \| `changes_requested` \| `review_required` \| `none`. Normalized from GitHub `reviewDecision`. `none` covers both "no review-required rule" and "required but none submitted yet" — GitHub does not distinguish these here. | +| `mergeable` | enum | `mergeable` \| `conflicting` \| `unknown`. `unknown` = GitHub has not finished recomputing mergeability (common right after a base-branch change). Deliberately **not** a boolean, so `conflicting` and "not computed" stay distinct. | +| `state` | enum | `open` \| `closed` \| `merged`. Lowercased PR state. | +| `isDraft` | boolean | Draft status, as a real boolean. | +| `unresolvedReviewThreads` | integer | Non-negative count of unresolved review threads (same value as the `to` fingerprint's field). | +| `headSha` | string | The head commit SHA (git OID), under an unambiguous name. Empty string `""` if unobserved. | + +The field set and enum domains are also emitted machine-readably under +`output.deltaSummaryFields` and `output.deltaSummaryEnums` in `gh-delta --help-json`, +and are importable as `DELTA_SUMMARY_FIELDS` / `DELTA_SUMMARY_ENUMS` from +`gh-delta/contract` — enough to generate a Zod or JSON-Schema validator without +parsing this document. `summary` is additive and does **not** bump `schemaVersion` +(see [schemaVersion policy](#schemaversion-policy)). + +When `--summaries` and `--outpost-url` are combined, the same `summary` object is +mirrored onto the [outpost payload](#outpost-payload-schema-v1) so webhook +consumers see the identical field. + ### Fingerprint fields (`from` / `to`) The fingerprint is the detector's stable-shaped but **semi-opaque** change-detection @@ -644,3 +701,9 @@ PR head branch name, retained by GitHub after the branch is deleted) mirrors the report delta exactly: present only on PR payloads that have a current object, and omitted from issue payloads and the missing lifecycle (`missing` / `still-missing` / `presumed-deleted`). + +When the detector runs with `--summaries`, PR payloads also carry the normalized +[`summary`](#delta-summary-schema) object, mirrored from the report delta, so a +webhook receiver reads the same semantic state (`ciRollup`, `reviewDecision`, +`mergeable`, …) as a consumer of the JSON report. It is omitted when `--summaries` +is not set and on payloads without a current object. diff --git a/lib/cli.mjs b/lib/cli.mjs index 7a9533c..5b06412 100644 --- a/lib/cli.mjs +++ b/lib/cli.mjs @@ -7,6 +7,7 @@ import { mkdirSync, statSync } from 'node:fs'; import { fetchPRs as ghPRs, fetchIssues as ghIssues } from './gh.mjs'; import { detectDeltas } from './detect.mjs'; import { deltaId, deltaIdentity } from './fingerprint.mjs'; +import { deltaSummary } from './summary.mjs'; import { defaultStateDir, horizonCutoff, @@ -230,11 +231,22 @@ function detailDelta(delta) { // Exported so docs tooling (tools/examples) can render fixture deltas through // the exact same enrichment the CLI uses, keeping example artifacts faithful. -export function enrichDelta(delta, { summaryLine = false, legacyLine = false, details = false }) { +export function enrichDelta( + delta, + { summaryLine = false, legacyLine = false, details = false, summaries = false }, +) { const rendered = line(delta); if (summaryLine) delta.summaryLine = rendered; if (legacyLine) delta.line = rendered; if (details) delta.details = detailDelta(delta); + // Optional semantic layer. Attached only for PR deltas with an observed `to` + // state (deltaSummary returns null otherwise). It is a SIBLING of `to`, never + // nested inside it, so the content-addressed delta.id -- which hashes `to` -- + // stays byte-identical whether or not summaries are requested. + if (summaries) { + const summary = deltaSummary(delta); + if (summary) delta.summary = summary; + } } // Permanent errors exit 2; transient errors exit 1. @@ -272,6 +284,7 @@ const CLI_OPTIONS = { 'state-dir': { type: 'string' }, format: { type: 'string', default: 'json' }, detail: { type: 'boolean', default: false }, + summaries: { type: 'boolean', default: false }, 'summary-line': { type: 'boolean', default: false }, 'outpost-url': { type: 'string' }, 'outpost-timeout-ms': { type: 'string', default: '4000' }, @@ -530,6 +543,7 @@ export function run(argv, deps = {}) { summaryLine: values['summary-line'] || values.detail, legacyLine: values.detail || format === 'text', details: values.detail, + summaries: values.summaries, }); } } catch (err) { diff --git a/lib/contract.mjs b/lib/contract.mjs index 74cc713..2562c1f 100644 --- a/lib/contract.mjs +++ b/lib/contract.mjs @@ -60,12 +60,36 @@ export const DELTA_FIELDS = Object.freeze([ 'classes', 'from', 'to', + 'summary', 'missingTicks', 'summaryLine', 'line', 'details', ]); +// Normalized semantic summary attached to PR deltas under `--summaries`. Additive +// and optional: a delta carries `summary` only when the flag is set and the delta +// is a PR with an observed `to` state. See lib/summary.mjs for the derivation. +export const DELTA_SUMMARY_FIELDS = Object.freeze([ + 'ciRollup', + 'reviewDecision', + 'mergeable', + 'state', + 'isDraft', + 'unresolvedReviewThreads', + 'headSha', +]); + +// Closed enum domains for the typed summary fields, so a consumer can build a +// Zod/JSON-Schema validator from the help/contract alone. `unresolvedReviewThreads` +// is a non-negative integer and `headSha` a (possibly empty) hex string. +export const DELTA_SUMMARY_ENUMS = Object.freeze({ + ciRollup: Object.freeze(['green', 'failed', 'pending', 'none']), + reviewDecision: Object.freeze(['approved', 'changes_requested', 'review_required', 'none']), + mergeable: Object.freeze(['mergeable', 'conflicting', 'unknown']), + state: Object.freeze(['open', 'closed', 'merged']), +}); + export const DELTA_DETAIL_FIELDS = Object.freeze([ 'class', 'field', diff --git a/lib/help.mjs b/lib/help.mjs index 4cb18fb..a8c5168 100644 --- a/lib/help.mjs +++ b/lib/help.mjs @@ -3,6 +3,8 @@ import { DELTA_DETAIL_FIELDS, DELTA_DETAIL_FIELDS_BY_CLASS, DELTA_FIELDS, + DELTA_SUMMARY_ENUMS, + DELTA_SUMMARY_FIELDS, LIST_MONITOR_FIELDS, LIST_REPORT_FIELDS, REPORT_FIELDS, @@ -153,7 +155,7 @@ const HELP_SPECS = { version: PACKAGE_METADATA.version, summary: 'Deterministic GitHub issue and pull request delta detector.', usage: - 'gh-delta --repo [--monitor-id ] [--state-file | --state-dir ] [--entities pr,issue] [--format json|text] [--summary-line] [--detail] [--outpost-url ] [--outpost-timeout-ms ] [--outpost-max-posts ] [--gh-timeout-ms ] [--no-registry]', + 'gh-delta --repo [--monitor-id ] [--state-file | --state-dir ] [--entities pr,issue] [--format json|text] [--summary-line] [--detail] [--summaries] [--outpost-url ] [--outpost-timeout-ms ] [--outpost-max-posts ] [--gh-timeout-ms ] [--no-registry]', purpose: 'Run one deterministic detection pass, update the snapshot after a successful fetch, print JSON or operator text, and exit. Scheduling belongs to the caller.', subcommands: [ @@ -184,6 +186,13 @@ const HELP_SPECS = { description: 'Add structured details per delta, plus summaryLine and the backward-compatible line alias.', }, + { + name: '--summaries', + type: 'boolean', + required: false, + description: + 'Add a normalized semantic delta.summary to PR deltas (ciRollup, reviewDecision, mergeable, state, isDraft, unresolvedReviewThreads, headSha) derived from the same observation as the opaque fingerprints, so a consumer reads the semantic state without a second GitHub fetch. Additive and off by default.', + }, OPTION_OUTPOST_URL, OPTION_OUTPOST_TIMEOUT_MS, OPTION_OUTPOST_MAX_POSTS, @@ -199,8 +208,10 @@ const HELP_SPECS = { deltaFields: DELTA_FIELDS, deltaDetailFields: DELTA_DETAIL_FIELDS, deltaDetailFieldsByClass: DELTA_DETAIL_FIELDS_BY_CLASS, + deltaSummaryFields: DELTA_SUMMARY_FIELDS, + deltaSummaryEnums: DELTA_SUMMARY_ENUMS, description: - 'JSON output contains schemaVersion, baseline, repo, monitorId, entities, stateFile (the resolved snapshot path), at, deltas, and summary fields. Every delta carries a stable content-addressed delta.id (64-char sha256 hex of repo, entity, number, and the observed to-state; from+classes+missingTicks when to is null) for idempotent dedupe; it excludes monitorId, so the same observed change from any monitor yields the same id. PR deltas with a current object also carry delta.headRefName (the PR head branch name, retained by GitHub even after the branch is deleted, contextual metadata that is NOT a change trigger); issue deltas and the missing lifecycle omit it. --summary-line adds delta.summaryLine, and --detail adds delta.details plus the backward-compatible delta.line alias. ci-changed and review-changed details name the exact checks/reviews that changed (added, removed, changed) when both fingerprint sides carry the persisted normalized summaries; opaque: true marks a digest transition the detail cannot name (e.g. a snapshot written before summaries were persisted). Error output is schemaVersion, error, kind, at, and optional repo and monitorId. Text output contains an operator heartbeat and suggested actions.', + 'JSON output contains schemaVersion, baseline, repo, monitorId, entities, stateFile (the resolved snapshot path), at, deltas, and summary fields. Every delta carries a stable content-addressed delta.id (64-char sha256 hex of repo, entity, number, and the observed to-state; from+classes+missingTicks when to is null) for idempotent dedupe; it excludes monitorId, so the same observed change from any monitor yields the same id. PR deltas with a current object also carry delta.headRefName (the PR head branch name, retained by GitHub even after the branch is deleted, contextual metadata that is NOT a change trigger); issue deltas and the missing lifecycle omit it. --summary-line adds delta.summaryLine, and --detail adds delta.details plus the backward-compatible delta.line alias. ci-changed and review-changed details name the exact checks/reviews that changed (added, removed, changed) when both fingerprint sides carry the persisted normalized summaries; opaque: true marks a digest transition the detail cannot name (e.g. a snapshot written before summaries were persisted). --summaries adds a normalized delta.summary to every PR delta that has an observed to-state (a sibling of to, so delta.id is unchanged): ciRollup (green|failed|pending|none; a PR with zero checks is none, never green), reviewDecision (approved|changes_requested|review_required|none; none also covers "no review-required rule" and "required but none submitted yet", which GitHub does not distinguish here), mergeable (mergeable|conflicting|unknown; unknown means GitHub has not finished recomputing), state (open|closed|merged), isDraft (boolean), unresolvedReviewThreads (integer), and headSha (the head commit SHA). See output.deltaSummaryFields and output.deltaSummaryEnums for the exact field set and enum domains. The summary is an optional hint reflecting the same single observation as the fingerprints; consumers may re-derive authoritative facts themselves. The opaque fingerprints and the rest of the report shape are byte-identical whether or not --summaries is set. Error output is schemaVersion, error, kind, at, and optional repo and monitorId. Text output contains an operator heartbeat and suggested actions.', }, exitCodes: EXIT_CODES, safety: [ diff --git a/lib/outpost.mjs b/lib/outpost.mjs index bea6e15..45db46a 100644 --- a/lib/outpost.mjs +++ b/lib/outpost.mjs @@ -108,6 +108,11 @@ export function buildOutpostPayload({ report, delta }) { ? { headRefName: delta.headRefName ?? null } : {}), classes: [...(delta.classes ?? [])], + // Semantic summary, mirrored from the report delta. Present only when the CLI + // ran with --summaries (which stamps delta.summary), so webhook consumers and + // JSON-report consumers see the same optional field rather than it silently + // vanishing on the delivery edge. + ...(delta.summary != null ? { summary: delta.summary } : {}), state: to?.state ?? from?.state ?? null, labels: to?.labels ?? from?.labels ?? [], line: diff --git a/lib/summary.mjs b/lib/summary.mjs new file mode 100644 index 0000000..3730c1c --- /dev/null +++ b/lib/summary.mjs @@ -0,0 +1,176 @@ +// Optional semantic summary layer. Derives typed, normalized facts (is CI green? +// what did reviewers decide? is it mergeable?) from the SAME `to` fingerprint the +// opaque digests were hashed from -- no second GitHub fetch. The fingerprints stay +// the authoritative change-detection artifact; this layer is a read-only hint a +// consumer can trust or re-derive. Honesty over richness: an un-observed or +// not-yet-computed value is reported as an explicit `none`/`unknown`, never faked. + +// GitHub check/status tokens partitioned into a fail-closed rollup. A row is +// classified by inspecting BOTH its `status` and `conclusion`, which lets one +// table cover CheckRun rows (status in CheckStatusState, conclusion in +// CheckConclusionState or '' while running) and StatusContext rows (where +// summarizeCiRollup sets status === conclusion === the StatusState) without +// needing the original __typename. +const CI_FAILED_TOKENS = new Set([ + 'FAILURE', + 'ERROR', + 'TIMED_OUT', + 'CANCELLED', + 'ACTION_REQUIRED', + 'STARTUP_FAILURE', + 'STALE', +]); +const CI_PENDING_TOKENS = new Set([ + 'QUEUED', + 'IN_PROGRESS', + 'WAITING', + 'PENDING', + 'REQUESTED', + 'EXPECTED', +]); +// SUCCESS / NEUTRAL / SKIPPED / COMPLETED are the non-blocking remainder: they +// contribute 'green' and are never listed explicitly. + +const REVIEW_DECISIONS = { + APPROVED: 'approved', + CHANGES_REQUESTED: 'changes_requested', + REVIEW_REQUIRED: 'review_required', +}; + +const MERGEABLE_STATES = { + MERGEABLE: 'mergeable', + CONFLICTING: 'conflicting', +}; + +const PR_STATES = { + OPEN: 'open', + CLOSED: 'closed', + MERGED: 'merged', +}; + +const upper = (value) => String(value ?? '').toUpperCase(); + +/** + * Roll a normalized CI check list up to a single typed verdict. + * + * Input is `to.ciChecks` -- the sorted `{name, status, conclusion}` rows produced + * by summarizeCiRollup, the exact data the opaque `ci` digest was hashed from. + * + * CRITICAL: an empty list is `'none'`, never `'green'`. GitHub's own + * statusCheckRollup.state reports SUCCESS-like values for a PR with zero checks; + * collapsing that to `'green'` would let a fail-closed merge gate wave through a + * PR that never ran CI. `'none'` hands that policy decision back to the consumer. + * + * Precedence is fail-closed: any failing check wins, else any pending check, else + * green. A row counts as failed/pending if EITHER its status or its conclusion is + * a failing/pending token (see the token tables above). + * + * @param {Array<{name?: string, status?: string, conclusion?: string}>} [ciChecks] + * @returns {'green' | 'failed' | 'pending' | 'none'} + */ +export function deriveCiRollup(ciChecks) { + const rows = Array.isArray(ciChecks) ? ciChecks : []; + if (rows.length === 0) return 'none'; + let pending = false; + for (const row of rows) { + const tokens = [upper(row?.status), upper(row?.conclusion)]; + if (tokens.some((token) => CI_FAILED_TOKENS.has(token))) return 'failed'; + if (tokens.some((token) => CI_PENDING_TOKENS.has(token))) pending = true; + } + return pending ? 'pending' : 'green'; +} + +/** + * Normalize GitHub's `reviewDecision` to a lowercase enum. + * + * GraphQL emits APPROVED | CHANGES_REQUESTED | REVIEW_REQUIRED or null. The null + * case maps to `'none'`. Note: GitHub returns null for two distinct reasons the + * fingerprint cannot tell apart without a branch-protection fetch we deliberately + * skip -- "no review-required rule on this branch" and "review required but none + * submitted yet" -- so `'none'` means "no decision observed", not "not required". + * + * @param {string|null|undefined} review - raw `to.review` + * @returns {'approved' | 'changes_requested' | 'review_required' | 'none'} + */ +export function normalizeReviewDecision(review) { + return REVIEW_DECISIONS[upper(review)] ?? 'none'; +} + +/** + * Normalize GitHub's tri-state `mergeable` to a lowercase enum. + * + * MERGEABLE and CONFLICTING map directly; anything else (UNKNOWN, or absent) + * becomes `'unknown'`. UNKNOWN is GitHub still recomputing mergeability (common + * right after a base-branch change), which is why this stays an enum rather than a + * boolean: a fail-closed consumer must be able to tell `'conflicting'` from + * "not computed yet" and decide for itself. + * + * @param {string|null|undefined} mergeable - raw `to.mergeable` + * @returns {'mergeable' | 'conflicting' | 'unknown'} + */ +export function normalizeMergeable(mergeable) { + return MERGEABLE_STATES[upper(mergeable)] ?? 'unknown'; +} + +/** + * Normalize a PR's GraphQL state (OPEN | CLOSED | MERGED) to lowercase. + * + * An unexpected value is lowercased rather than dropped so the field never + * silently disappears; the three documented values are the only ones GitHub emits + * for a pull request. + * + * @param {string|null|undefined} state - raw `to.state` + * @returns {'open' | 'closed' | 'merged' | string} + */ +export function normalizePrState(state) { + return PR_STATES[upper(state)] ?? String(state ?? '').toLowerCase(); +} + +/** + * Build the normalized semantic summary for one observed PR `to` fingerprint. + * + * Every field is a pure function of `to` -- no I/O, no second fetch -- so the + * summary reflects the exact observation that produced the fingerprints. Returns + * null when there is no observed state (the missing/presumed-deleted lifecycle). + * + * @param {Record|null|undefined} to + * @returns {null | { + * ciRollup: 'green'|'failed'|'pending'|'none', + * reviewDecision: 'approved'|'changes_requested'|'review_required'|'none', + * mergeable: 'mergeable'|'conflicting'|'unknown', + * state: 'open'|'closed'|'merged'|string, + * isDraft: boolean, + * unresolvedReviewThreads: number, + * headSha: string, + * }} + */ +export function prSummary(to) { + if (to == null) return null; + return { + ciRollup: deriveCiRollup(to.ciChecks), + reviewDecision: normalizeReviewDecision(to.review), + mergeable: normalizeMergeable(to.mergeable), + state: normalizePrState(to.state), + isDraft: to.isDraft === true, + unresolvedReviewThreads: Number.isInteger(to.unresolvedReviewThreads) + ? to.unresolvedReviewThreads + : 0, + headSha: typeof to.head === 'string' ? to.head : '', + }; +} + +/** + * Return the semantic summary for a delta, or null when one does not apply. + * + * PR deltas with a current object (`to != null`) get a summary regardless of which + * class fired -- a fail-closed gate wants the current observed state, not just what + * changed. Issue deltas (no CI/review/mergeability) and the missing lifecycle + * (no `to`) get null. + * + * @param {Record|null|undefined} delta + * @returns {ReturnType} + */ +export function deltaSummary(delta) { + if (!delta || delta.entity !== 'pr' || delta.to == null) return null; + return prSummary(delta.to); +} diff --git a/test/cli.test.mjs b/test/cli.test.mjs index f622704..5949d5e 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -347,6 +347,104 @@ test('pre-summary snapshots upgrade without phantom deltas and persist summaries assert.deepEqual(d.stored.pr['42'].reviewSummary, []); }); +const SUMMARIES_ARGS = [ + '--repo', + 'o/r', + '--monitor-id', + 'main', + '--state-file', + '/tmp/x.json', + '--summaries', +]; + +test('--summaries acceptance: posting a successful status makes summary.ciRollup green', () => { + // A PR with zero checks, re-observed after a successful commit status lands on + // the head: a ci-changed delta whose semantic summary reports the CI as green. + const before = { ...basePr, statusCheckRollup: [] }; + const after = { + ...basePr, + updatedAt: '2026-07-01T11:00:00Z', + statusCheckRollup: [{ context: 'ci/deploy', state: 'SUCCESS' }], + }; + const d = deps([[after]], { existing: { pr: { 42: prFingerprint(before) }, issue: {} } }); + const { code, report } = run(SUMMARIES_ARGS, d); + assert.equal(code, 10); + const delta = report.deltas[0]; + assert.ok(delta.classes.includes('ci-changed'), 'the status transition is a ci-changed delta'); + assert.deepEqual(delta.summary, { + ciRollup: 'green', + reviewDecision: 'review_required', + mergeable: 'unknown', + state: 'open', + isDraft: false, + unresolvedReviewThreads: 0, + headSha: 'sha1', + }); +}); + +test('--summaries acceptance: a PR that lost its checks reports ciRollup none, not green', () => { + const before = { ...basePr, statusCheckRollup: [{ context: 'ci/deploy', state: 'SUCCESS' }] }; + const after = { ...basePr, updatedAt: '2026-07-01T11:00:00Z', statusCheckRollup: [] }; + const d = deps([[after]], { existing: { pr: { 42: prFingerprint(before) }, issue: {} } }); + const { code, report } = run(SUMMARIES_ARGS, d); + assert.equal(code, 10); + const delta = report.deltas[0]; + assert.ok(delta.classes.includes('ci-changed')); + assert.equal(delta.summary.ciRollup, 'none'); +}); + +test('--summaries is purely additive: delta.id and every other field are byte-identical', () => { + const before = { ...basePr, statusCheckRollup: [] }; + const after = { + ...basePr, + updatedAt: '2026-07-01T11:00:00Z', + statusCheckRollup: [{ context: 'ci/deploy', state: 'SUCCESS' }], + }; + const seed = () => ({ pr: { 42: prFingerprint(before) }, issue: {} }); + const baseArgs = ['--repo', 'o/r', '--monitor-id', 'main', '--state-file', '/tmp/x.json']; + const withFlag = run([...baseArgs, '--summaries'], deps([[after]], { existing: seed() })).report + .deltas[0]; + const without = run(baseArgs, deps([[after]], { existing: seed() })).report.deltas[0]; + // The opaque fingerprints already sit in `to`; the only difference the flag makes + // is the extra sibling `summary` key. Same content-addressed id, same everything else. + assert.equal(withFlag.id, without.id); + assert.equal('summary' in without, false, 'without the flag there is no summary field'); + const { summary, ...withFlagRest } = withFlag; + assert.ok(summary, 'the flag adds a summary'); + assert.deepEqual(withFlagRest, without); +}); + +test('--help-json documents the summary schema well enough to build a validator', () => { + const d = { + fetchPRs: () => { + throw new Error('should not fetch'); + }, + fetchIssues: () => { + throw new Error('should not fetch'); + }, + now: () => '2026-07-01T12:00:00Z', + }; + const { code, report } = run(['--help-json'], d); + assert.equal(code, 0); + const help = JSON.parse(report); + assert.ok(help.output.deltaFields.includes('summary'), 'deltaFields advertises summary'); + assert.deepEqual(help.output.deltaSummaryFields, [ + 'ciRollup', + 'reviewDecision', + 'mergeable', + 'state', + 'isDraft', + 'unresolvedReviewThreads', + 'headSha', + ]); + assert.deepEqual(help.output.deltaSummaryEnums.ciRollup, ['green', 'failed', 'pending', 'none']); + assert.deepEqual(help.output.deltaSummaryEnums.mergeable, [ + 'mergeable', + 'conflicting', + 'unknown', + ]); +}); + test('--help returns usage text without fetching GitHub', () => { const d = { fetchPRs: () => { diff --git a/test/examples.test.mjs b/test/examples.test.mjs index f4ee81a..7ceb16f 100644 --- a/test/examples.test.mjs +++ b/test/examples.test.mjs @@ -59,8 +59,10 @@ test('fully enriched deltas jointly cover exactly the frozen DELTA_FIELDS', () = // attach it to both representative deltas so the union also covers `id`. missing.id = deltaId(deltaIdentity('owner/repo', missing)); change.id = deltaId(deltaIdentity('owner/repo', change)); - enrichDelta(missing, { summaryLine: true, legacyLine: true, details: true }); - enrichDelta(change, { summaryLine: true, legacyLine: true, details: true }); + // `summaries: true` on the PR change delta (which has a to-state) adds `summary`; + // the missing delta (to === null) correctly gets none, so the union covers it. + enrichDelta(missing, { summaryLine: true, legacyLine: true, details: true, summaries: true }); + enrichDelta(change, { summaryLine: true, legacyLine: true, details: true, summaries: true }); const union = new Set([...keySet(missing), ...keySet(change)]); assert.deepEqual( [...union].sort(), diff --git a/test/fixtures/summaries/pr-ci-failed.json b/test/fixtures/summaries/pr-ci-failed.json new file mode 100644 index 0000000..7bc6450 --- /dev/null +++ b/test/fixtures/summaries/pr-ci-failed.json @@ -0,0 +1,206 @@ +{ + "data": { + "repository": { + "items": { + "nodes": [ + { + "number": 36863, + "title": "[compiler] Unskip lone surrogate string fixture", + "state": "OPEN", + "updatedAt": "2026-07-10T18:36:11Z", + "isDraft": false, + "mergeable": "MERGEABLE", + "reviewDecision": "REVIEW_REQUIRED", + "totalCommentsCount": 0, + "headRefOid": "b3504a075f080f0a82b1288153b71feff40cbe69", + "headRefName": "preserve-lone-surrogate-string-tests", + "commits": { + "nodes": [ + { + "commit": { + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": "Tests", + "status": "COMPLETED", + "conclusion": "FAILURE" + }, + { + "__typename": "CheckRun", + "name": "check_access", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Test playground", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Discover yarn workspaces", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "check_access", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Run prettier", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "check_maintainer", + "status": "COMPLETED", + "conclusion": "SKIPPED" + }, + { + "__typename": "CheckRun", + "name": "check_maintainer", + "status": "COMPLETED", + "conclusion": "SKIPPED" + }, + { + "__typename": "CheckRun", + "name": "Lint babel-plugin-react-compiler", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Run eslint", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "notify", + "status": "COMPLETED", + "conclusion": "SKIPPED" + }, + { + "__typename": "CheckRun", + "name": "label", + "status": "COMPLETED", + "conclusion": "SKIPPED" + }, + { + "__typename": "CheckRun", + "name": "Jest babel-plugin-react-compiler", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Check license", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Test eslint-plugin-react-compiler", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Test print warnings", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Test make-read-only-util", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Test babel-plugin-react-compiler-rust", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Test react-mcp-server", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Test snap", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Test react-compiler-runtime", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Test react-compiler-healthcheck", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Test babel-plugin-react-compiler", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Test react-forgive", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Meta CLA Check", + "status": "COMPLETED", + "conclusion": "SUCCESS" + } + ], + "pageInfo": { + "hasNextPage": false + } + } + } + } + } + ] + }, + "latestReviews": { + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + }, + "reviewThreads": { + "totalCount": 0, + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + } + } + } +} diff --git a/test/fixtures/summaries/pr-ci-green.json b/test/fixtures/summaries/pr-ci-green.json new file mode 100644 index 0000000..e075aad --- /dev/null +++ b/test/fixtures/summaries/pr-ci-green.json @@ -0,0 +1,90 @@ +{ + "data": { + "repository": { + "items": { + "nodes": [ + { + "number": 12, + "title": "feat: name the exact checks and reviews behind ci-changed/review-changed details", + "state": "MERGED", + "updatedAt": "2026-07-09T14:36:50Z", + "isDraft": false, + "mergeable": "UNKNOWN", + "reviewDecision": null, + "totalCommentsCount": 3, + "headRefOid": "27c7dac8d6a7d8ad99c7e51d91fec41217ab0b4a", + "headRefName": "claude/opaque-check-digest-todos-06xc35", + "commits": { + "nodes": [ + { + "commit": { + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": "Node 18.x", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Node 20.x", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Node 22.x", + "status": "COMPLETED", + "conclusion": "SUCCESS" + } + ], + "pageInfo": { + "hasNextPage": false + } + } + } + } + } + ] + }, + "latestReviews": { + "nodes": [ + { + "id": "PRR_kwDOTRska88AAAABFaaEmA", + "submittedAt": "2026-07-08T21:53:40Z", + "state": "COMMENTED", + "author": { + "login": "chatgpt-codex-connector" + }, + "commit": { + "oid": "10b172304b0cf86a08ae604fd598ac3190177edc" + } + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "reviewThreads": { + "totalCount": 1, + "nodes": [ + { + "isResolved": true + } + ], + "pageInfo": { + "hasNextPage": false + } + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + } + } + } +} diff --git a/test/fixtures/summaries/pr-ci-none.json b/test/fixtures/summaries/pr-ci-none.json new file mode 100644 index 0000000..21e991a --- /dev/null +++ b/test/fixtures/summaries/pr-ci-none.json @@ -0,0 +1,64 @@ +{ + "data": { + "repository": { + "items": { + "nodes": [ + { + "number": 64097, + "title": "Add benchmarks for node only mode and mock timers.", + "state": "OPEN", + "updatedAt": "2026-07-10T18:03:01Z", + "isDraft": false, + "mergeable": "MERGEABLE", + "reviewDecision": "APPROVED", + "totalCommentsCount": 6, + "headRefOid": "e5623a838c21279f883d88a2c6f6e34fe136ef4c", + "headRefName": "benchmark-test-runner-only-mock-timers", + "commits": { + "nodes": [ + { + "commit": { + "statusCheckRollup": null + } + } + ] + }, + "latestReviews": { + "nodes": [ + { + "id": "PRR_kwDOAZ7xs88AAAABFpW--Q", + "submittedAt": "2026-07-10T18:03:01Z", + "state": "APPROVED", + "author": { + "login": "avivkeller" + }, + "commit": { + "oid": "e5623a838c21279f883d88a2c6f6e34fe136ef4c" + } + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "reviewThreads": { + "totalCount": 1, + "nodes": [ + { + "isResolved": false + } + ], + "pageInfo": { + "hasNextPage": false + } + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + } + } + } +} diff --git a/test/fixtures/summaries/pr-ci-pending.json b/test/fixtures/summaries/pr-ci-pending.json new file mode 100644 index 0000000..65f6fba --- /dev/null +++ b/test/fixtures/summaries/pr-ci-pending.json @@ -0,0 +1,260 @@ +{ + "data": { + "repository": { + "items": { + "nodes": [ + { + "number": 63949, + "title": "crypto: support loading private keys through STORE loaders", + "state": "OPEN", + "updatedAt": "2026-07-10T21:31:28Z", + "isDraft": false, + "mergeable": "MERGEABLE", + "reviewDecision": "REVIEW_REQUIRED", + "totalCommentsCount": 6, + "headRefOid": "70d8d79a6e65b268cc0419aa591f15c79a646f62", + "headRefName": "keyobject-stores", + "commits": { + "nodes": [ + { + "commit": { + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "__typename": "CheckRun", + "name": "build-tarball", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "coverage-linux", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "coverage-windows", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "lint-commit-message", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "lint-addon-docs", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "test-quic", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "test-linux (ubuntu-24.04)", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "Build slim tarball", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "build-docs", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "test-macOS", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "test-linux (ubuntu-24.04-arm)", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "test-tarball-linux", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "lint-cpp", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "x86_64-linux: with shared libraries / build", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "x86_64-darwin: with shared libraries / build", + "status": "IN_PROGRESS", + "conclusion": null + }, + { + "__typename": "CheckRun", + "name": "aarch64-darwin: with shared libraries / build", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "format-cpp", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "aarch64-linux: Cache V8 build", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "lint-js-and-md", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "aarch64-linux: with shared boringssl-0.20260526.0 / build", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "aarch64-linux: with shared openssl-1.1.1w / build", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "aarch64-linux: with shared openssl-3.0.21 / build", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "aarch64-linux: with shared openssl-3.5.7 / build", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "aarch64-linux: with shared openssl-3.6.2 / build", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "aarch64-linux: with shared openssl-4.0.1 / build", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "lint-nix", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "lint-py", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "lint-yaml", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "lint-sh", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "lint-codeowners", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "lint-pr-url", + "status": "COMPLETED", + "conclusion": "SUCCESS" + }, + { + "__typename": "CheckRun", + "name": "lint-readme", + "status": "COMPLETED", + "conclusion": "SUCCESS" + } + ], + "pageInfo": { + "hasNextPage": false + } + } + } + } + } + ] + }, + "latestReviews": { + "nodes": [ + { + "id": "PRR_kwDOAZ7xs88AAAABE_bnGQ", + "submittedAt": "2026-07-04T16:14:43Z", + "state": "APPROVED", + "author": { + "login": "jasnell" + }, + "commit": { + "oid": "6f3d2cb58bed46180b82e006afa6270c7c5661b4" + } + } + ], + "pageInfo": { + "hasNextPage": false + } + }, + "reviewThreads": { + "totalCount": 0, + "nodes": [], + "pageInfo": { + "hasNextPage": false + } + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": null + } + } + } + } +} diff --git a/test/summary.test.mjs b/test/summary.test.mjs new file mode 100644 index 0000000..8209871 --- /dev/null +++ b/test/summary.test.mjs @@ -0,0 +1,208 @@ +// Semantic summary tests. The integration cases run REAL captured GitHub GraphQL +// payloads (test/fixtures/summaries/*.json, recorded from live PRs on 2026-07-11) +// through the exact fetchPRs -> normalizePr -> prFingerprint -> prSummary pipeline +// the CLI uses. Recording from real repos -- rather than hand-building rollup rows +// -- is deliberate: a constructed fixture that omitted the {status:'IN_PROGRESS', +// conclusion:null} shape is exactly the trap that let a bad "green" slip past a +// downstream consumer. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fetchPRs } from '../lib/gh.mjs'; +import { prFingerprint, canonicalizeCiRollup } from '../lib/fingerprint.mjs'; +import { + deriveCiRollup, + normalizeReviewDecision, + normalizeMergeable, + normalizePrState, + prSummary, + deltaSummary, +} from '../lib/summary.mjs'; + +// Load a captured page fixture and run it through the real fetch/normalize path, +// returning the single normalized PR row exactly as the CLI would see it. +function fixtureRow(name) { + const bytes = readFileSync( + new URL(`./fixtures/summaries/pr-ci-${name}.json`, import.meta.url), + 'utf8', + ); + const rows = fetchPRs('o/r', { exec: () => bytes, horizonCutoff: null }); + assert.equal(rows.length, 1, `fixture ${name} must contain exactly one PR`); + return rows[0]; +} + +// --- deriveCiRollup: the load-bearing verdict -------------------------------- + +test('deriveCiRollup: zero checks is none, never green', () => { + assert.equal(deriveCiRollup([]), 'none'); + assert.equal(deriveCiRollup(), 'none'); + assert.equal(deriveCiRollup(null), 'none'); +}); + +test('deriveCiRollup: all-success checks are green', () => { + assert.equal( + deriveCiRollup([ + { name: 'a', status: 'COMPLETED', conclusion: 'SUCCESS' }, + { name: 'b', status: 'COMPLETED', conclusion: 'SUCCESS' }, + ]), + 'green', + ); +}); + +test('deriveCiRollup: NEUTRAL and SKIPPED are non-blocking (green)', () => { + assert.equal( + deriveCiRollup([ + { name: 'a', status: 'COMPLETED', conclusion: 'SUCCESS' }, + { name: 'b', status: 'COMPLETED', conclusion: 'NEUTRAL' }, + { name: 'c', status: 'COMPLETED', conclusion: 'SKIPPED' }, + ]), + 'green', + ); +}); + +test('deriveCiRollup: an in-progress CheckRun with empty conclusion is pending', () => { + // The peer-review-critical case: a classifier that only read `conclusion` would + // see '' (no token) and wrongly return green. Keying on `status` too fixes it. + assert.equal( + deriveCiRollup([ + { name: 'a', status: 'COMPLETED', conclusion: 'SUCCESS' }, + { name: 'b', status: 'IN_PROGRESS', conclusion: '' }, + ]), + 'pending', + ); +}); + +test('deriveCiRollup: StatusContext PENDING/EXPECTED are pending', () => { + assert.equal( + deriveCiRollup([{ name: 'ci', status: 'PENDING', conclusion: 'PENDING' }]), + 'pending', + ); + assert.equal( + deriveCiRollup([{ name: 'ci', status: 'EXPECTED', conclusion: 'EXPECTED' }]), + 'pending', + ); +}); + +test('deriveCiRollup: a failure dominates pending and success (fail-closed)', () => { + assert.equal( + deriveCiRollup([ + { name: 'a', status: 'COMPLETED', conclusion: 'SUCCESS' }, + { name: 'b', status: 'IN_PROGRESS', conclusion: '' }, + { name: 'c', status: 'COMPLETED', conclusion: 'FAILURE' }, + ]), + 'failed', + ); +}); + +test('deriveCiRollup: StatusContext ERROR and CheckRun ACTION_REQUIRED are failed', () => { + assert.equal(deriveCiRollup([{ name: 'ci', status: 'ERROR', conclusion: 'ERROR' }]), 'failed'); + assert.equal( + deriveCiRollup([{ name: 'ci', status: 'COMPLETED', conclusion: 'ACTION_REQUIRED' }]), + 'failed', + ); +}); + +// --- enum normalizers -------------------------------------------------------- + +test('normalizeReviewDecision maps the GraphQL enum and empty to none', () => { + assert.equal(normalizeReviewDecision('APPROVED'), 'approved'); + assert.equal(normalizeReviewDecision('CHANGES_REQUESTED'), 'changes_requested'); + assert.equal(normalizeReviewDecision('REVIEW_REQUIRED'), 'review_required'); + assert.equal(normalizeReviewDecision(''), 'none'); + assert.equal(normalizeReviewDecision(null), 'none'); + assert.equal(normalizeReviewDecision(undefined), 'none'); +}); + +test('normalizeMergeable keeps UNKNOWN honest', () => { + assert.equal(normalizeMergeable('MERGEABLE'), 'mergeable'); + assert.equal(normalizeMergeable('CONFLICTING'), 'conflicting'); + assert.equal(normalizeMergeable('UNKNOWN'), 'unknown'); + assert.equal(normalizeMergeable(''), 'unknown'); + assert.equal(normalizeMergeable(undefined), 'unknown'); +}); + +test('normalizePrState lowercases the three PR states', () => { + assert.equal(normalizePrState('OPEN'), 'open'); + assert.equal(normalizePrState('CLOSED'), 'closed'); + assert.equal(normalizePrState('MERGED'), 'merged'); +}); + +// --- prSummary shape --------------------------------------------------------- + +test('prSummary returns null for a missing observed state', () => { + assert.equal(prSummary(null), null); + assert.equal(prSummary(undefined), null); +}); + +test('prSummary normalizes types and names headSha unambiguously', () => { + const summary = prSummary({ + state: 'OPEN', + isDraft: false, + ciChecks: [{ name: 'build', status: 'COMPLETED', conclusion: 'SUCCESS' }], + review: 'APPROVED', + mergeable: 'MERGEABLE', + unresolvedReviewThreads: 0, + head: 'a'.repeat(40), + }); + assert.deepEqual(summary, { + ciRollup: 'green', + reviewDecision: 'approved', + mergeable: 'mergeable', + state: 'open', + isDraft: false, + unresolvedReviewThreads: 0, + headSha: 'a'.repeat(40), + }); + assert.equal(typeof summary.isDraft, 'boolean'); +}); + +test('deltaSummary applies only to PR deltas with an observed to-state', () => { + const to = { state: 'OPEN', ciChecks: [], review: '', mergeable: 'MERGEABLE' }; + assert.equal(deltaSummary({ entity: 'pr', to }).ciRollup, 'none'); + assert.equal(deltaSummary({ entity: 'issue', to }), null); + assert.equal(deltaSummary({ entity: 'pr', to: null }), null); + assert.equal(deltaSummary(null), null); +}); + +// --- integration against REAL captured payloads ------------------------------ + +test('real fixture: a PR with zero checks yields ciRollup none (the empty-rollup digest proves it)', () => { + const row = fixtureRow('none'); + const fp = prFingerprint(row); + assert.deepEqual(fp.ciChecks, [], 'the captured PR genuinely has no checks'); + // 'da39a3ee5e6b' is the frozen digest of an empty rollup (see fingerprint.test). + assert.equal(canonicalizeCiRollup(row.statusCheckRollup), 'da39a3ee5e6b'); + assert.equal(prSummary(fp).ciRollup, 'none'); +}); + +test('real fixture: an all-SUCCESS PR yields ciRollup green', () => { + const fp = prFingerprint(fixtureRow('green')); + assert.ok(fp.ciChecks.length >= 1, 'the captured PR has real checks'); + assert.ok( + fp.ciChecks.every((c) => c.conclusion === 'SUCCESS'), + 'the captured green PR is genuinely all-success', + ); + assert.equal(prSummary(fp).ciRollup, 'green'); +}); + +test('real fixture: a PR with an in-progress CheckRun yields ciRollup pending', () => { + const row = fixtureRow('pending'); + // Guard the fixture's realness: it must actually contain the in-progress, + // empty-conclusion CheckRun shape this branch exists to classify. + const hasInProgress = row.statusCheckRollup.some( + (c) => c.status === 'IN_PROGRESS' && (c.conclusion === null || c.conclusion === undefined), + ); + assert.ok(hasInProgress, 'fixture must carry a real in-progress CheckRun'); + assert.equal(prSummary(prFingerprint(row)).ciRollup, 'pending'); +}); + +test('real fixture: a PR with a failing check yields ciRollup failed', () => { + const row = fixtureRow('failed'); + const fp = prFingerprint(row); + assert.ok( + fp.ciChecks.some((c) => c.conclusion === 'FAILURE'), + 'fixture must carry a real failing check', + ); + // And it also carries SKIPPED/SUCCESS rows -- proof the failure dominates them. + assert.equal(prSummary(fp).ciRollup, 'failed'); +});