Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "<head commit SHA, or '' if unobserved>"
}
```

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
Expand Down
29 changes: 29 additions & 0 deletions RUNBOOK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 64 additions & 1 deletion docs/contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ form of this document is available at `gh-delta --help-json`.
gh-delta --repo <owner/name> [--monitor-id <id>]
[--state-file <path> | --state-dir <dir>]
[--entities pr,issue] [--format json|text]
[--summary-line] [--detail]
[--summary-line] [--detail] [--summaries]
[--outpost-url <url>]
[--outpost-timeout-ms <ms>] [--outpost-max-posts <n>]
[--gh-timeout-ms <ms>] [--no-registry]
Expand Down Expand Up @@ -46,6 +46,11 @@ gh-delta --repo <owner/name> [--monitor-id <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.
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
16 changes: 15 additions & 1 deletion lib/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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' },
Expand Down Expand Up @@ -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) {
Expand Down
24 changes: 24 additions & 0 deletions lib/contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
15 changes: 13 additions & 2 deletions lib/help.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -153,7 +155,7 @@ const HELP_SPECS = {
version: PACKAGE_METADATA.version,
summary: 'Deterministic GitHub issue and pull request delta detector.',
usage:
'gh-delta --repo <owner/name> [--monitor-id <id>] [--state-file <path> | --state-dir <dir>] [--entities pr,issue] [--format json|text] [--summary-line] [--detail] [--outpost-url <url>] [--outpost-timeout-ms <ms>] [--outpost-max-posts <n>] [--gh-timeout-ms <ms>] [--no-registry]',
'gh-delta --repo <owner/name> [--monitor-id <id>] [--state-file <path> | --state-dir <dir>] [--entities pr,issue] [--format json|text] [--summary-line] [--detail] [--summaries] [--outpost-url <url>] [--outpost-timeout-ms <ms>] [--outpost-max-posts <n>] [--gh-timeout-ms <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: [
Expand Down Expand Up @@ -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,
Expand All @@ -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: [
Expand Down
Loading
Loading