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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,11 @@ and still re-derive authoritative facts themselves.
// 'unknown' = GitHub has not finished recomputing mergeability (kept honest,
// never collapsed to a boolean).
"mergeable": "mergeable" | "conflicting" | "unknown",
// GitHub's mergeStateStatus from the same observation. 'unknown' = not reported;
// fail-closed, treated like mergeable: unknown. A PR can be mergeable yet 'behind'
// its base or 'blocked' by a protection rule, so this is NOT folded into mergeable.
"mergeStateStatus":
"behind" | "blocked" | "clean" | "dirty" | "draft" | "has_hooks" | "unstable" | "unknown",
"state": "open" | "closed" | "merged",
"isDraft": true, // boolean
"unresolvedReviewThreads": 0, // non-negative integer
Expand All @@ -181,6 +186,17 @@ field set and enum domains are also emitted machine-readably under
authoritative schema lives in
[Delta Summary schema](docs/contract.md#delta-summary-schema).

### Baseline state emission (`--baseline-emit-state`)

By default the first run seeds a baseline silently (`deltas: []`), so a PR that is
_already_ stuck — in merge conflict or blocked on CI at seed time — stays invisible
until it changes again. Pass `--baseline-emit-state` to emit one synthetic
`baseline-state` delta per tracked open item on that first run instead; the run then
exits `10` with `baseline: true` and a non-empty `deltas` array. The delta ids are
content-addressed and stable across re-baselining, so idempotent consumers dedupe
them for free. Off by default — existing behavior is byte-identical. Do not treat a
`baseline-state` delta as newly created.

## Watch Loops and Outposts

See [RUNBOOK.md](RUNBOOK.md) for timer-driven loop patterns. The recommended
Expand Down
124 changes: 71 additions & 53 deletions docs/contract.md

Large diffs are not rendered by default.

17 changes: 14 additions & 3 deletions lib/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,7 @@ function detailForClass(delta, klass) {
switch (klass) {
case 'new':
case 'first-seen':
case 'baseline-state':
details.push({ class: klass, field: 'presence', from: null, to: 'present' });
pushFieldDetail(details, klass, delta, 'state');
break;
Expand Down Expand Up @@ -295,6 +296,7 @@ const CLI_OPTIONS = {
format: { type: 'string', default: 'json' },
detail: { type: 'boolean', default: false },
summaries: { type: 'boolean', default: false },
'baseline-emit-state': { 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 @@ -581,7 +583,9 @@ export function run(argv, deps = {}) {
}
let baseline, deltas, snapshot;
try {
({ baseline, deltas, snapshot } = detectDeltas(old, current));
({ baseline, deltas, snapshot } = detectDeltas(old, current, {
emitBaselineState: values['baseline-emit-state'],
}));
// Attach the content-addressed id here: detect.mjs is repo-agnostic, but the
// identity is scoped by repo. Rebuild each delta with `id` first so the
// dedupe key leads the serialized object.
Expand Down Expand Up @@ -627,7 +631,9 @@ export function run(argv, deps = {}) {
}
}
const summary = baseline
? `baseline established: ${Object.keys(snapshot.pr).length} PRs, ${Object.keys(snapshot.issue).length} issues`
? `baseline established: ${Object.keys(snapshot.pr).length} PRs, ${Object.keys(snapshot.issue).length} issues${
deltas.length ? `; ${deltas.length} baseline-state delta(s)` : ''
}`
: `${deltas.length} delta(s)`;
const report = {
schemaVersion: REPORT_SCHEMA_VERSION,
Expand All @@ -642,7 +648,12 @@ export function run(argv, deps = {}) {
summary,
};
return {
code: baseline || deltas.length === 0 ? 0 : 10,
// A baseline normally exits 0 with empty deltas; --baseline-emit-state makes
// it emit baseline-state deltas, and any run with deltas exits 10. Since only
// that flag can pair baseline === true with a non-empty deltas array, this
// stays byte-identical to `baseline || deltas.length === 0 ? 0 : 10` on every
// pre-existing path.
code: deltas.length === 0 ? 0 : 10,
report,
format,
warnings: derivationWarnings,
Expand Down
14 changes: 14 additions & 0 deletions lib/contract.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export const DELTA_SUMMARY_FIELDS = Object.freeze([
'ciRollup',
'reviewDecision',
'mergeable',
'mergeStateStatus',
'state',
'isDraft',
'unresolvedReviewThreads',
Expand All @@ -88,6 +89,16 @@ 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']),
mergeStateStatus: Object.freeze([
'behind',
'blocked',
'clean',
'dirty',
'draft',
'has_hooks',
'unstable',
'unknown',
]),
state: Object.freeze(['open', 'closed', 'merged']),
});

Expand All @@ -108,6 +119,7 @@ export const DELTA_DETAIL_FIELDS = Object.freeze([
export const DELTA_CLASSES = Object.freeze([
'new',
'first-seen',
'baseline-state',
'closed',
'reopened',
'new-comments',
Expand All @@ -130,6 +142,7 @@ export const DELTA_CLASSES = Object.freeze([
export const DELTA_DETAIL_FIELDS_BY_CLASS = Object.freeze({
new: Object.freeze(['presence', 'state']),
'first-seen': Object.freeze(['presence', 'state']),
'baseline-state': Object.freeze(['presence', 'state']),
closed: Object.freeze(['state']),
reopened: Object.freeze(['state']),
'new-comments': Object.freeze(['comments']),
Expand All @@ -140,6 +153,7 @@ export const DELTA_DETAIL_FIELDS_BY_CLASS = Object.freeze({
'isDraft',
'labels',
'mergeable',
'mergeStateStatus',
'review',
'reviewThreads',
'reviews',
Expand Down
45 changes: 39 additions & 6 deletions lib/detect.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,19 @@ function classifyIssue(oldFp, fp) {
}

function fingerprintChanged(a, b) {
return (
JSON.stringify(stableValue(comparableFingerprint(a))) !==
JSON.stringify(stableValue(comparableFingerprint(b)))
);
const ca = comparableFingerprint(a);
let cb = comparableFingerprint(b);
// Additive-field upgrade: `mergeStateStatus` is a compared field, but a snapshot
// written before it existed lacks the key. Its first appearance is not a real
// change, so when the prior side (a) lacks it, ignore it on the current side (b)
// too. This suppresses a one-time fleet-wide `updated` burst on the first tick
// after upgrade while still catching every genuine transition once both sides
// carry the field. (`b` always has it — normalizePr defaults absent to UNKNOWN.)
if (ca && cb && !('mergeStateStatus' in ca) && 'mergeStateStatus' in cb) {
const { mergeStateStatus: _ignored, ...rest } = cb;
cb = rest;
}
return JSON.stringify(stableValue(ca)) !== JSON.stringify(stableValue(cb));
}

/**
Expand Down Expand Up @@ -162,18 +171,38 @@ function diffEntity(kind, oldMap, objects, fpFn, classifyFn) {
return { deltas, nextMap };
}

// Opt-in baseline emission (--baseline-emit-state). On a baseline run diffEntity
// already builds a `new` delta for every fetched item; they are normally
// discarded because a baseline seeds memory silently. Reuse exactly those,
// keeping only the OPEN ones, and relabel them to the informational
// `baseline-state` class so a consumer can react to trouble that already existed
// at seed time (a PR already conflicting or already blocked on CI). The delta id
// is unaffected: deltaIdentity hashes {repo, entity, number, to} when to != null,
// so re-seeding over unchanged state yields identical ids (idempotent dedupe).
function baselineStateDeltas(prDeltas, issueDeltas) {
return [...prDeltas, ...issueDeltas]
.filter((d) => d.to != null && d.to.state === 'OPEN')
.map((d) => ({ ...d, classes: ['baseline-state'] }));
}

/**
* Compare a previous snapshot with the current GitHub fetch.
*
* Missing old snapshots seed a baseline with no deltas. Fetched collections are
* authoritative only for their entity family; omitted families are preserved so
* partial `--entities` runs do not erase watcher memory.
*
* `options.emitBaselineState` (default `false`) opts a baseline run into emitting
* one synthetic `baseline-state` delta per tracked OPEN item instead of the usual
* empty `deltas`; every non-baseline run and every baseline run without the flag is
* byte-identical to before.
*
* @param {null|{pr?: Record<string, Record<string, unknown>>, issue?: Record<string, Record<string, unknown>>}} oldSnapshot
* @param {{pr?: Array<Record<string, unknown>>, issue?: Array<Record<string, unknown>>}} current
* @param {{emitBaselineState?: boolean}} [options]
* @returns {{baseline: boolean, deltas: Array<Record<string, unknown>>, snapshot: {pr: Record<string, unknown>, issue: Record<string, unknown>}}}
*/
export function detectDeltas(oldSnapshot, current) {
export function detectDeltas(oldSnapshot, current, { emitBaselineState = false } = {}) {
const baseline = oldSnapshot == null;
const oldPr = oldSnapshot?.pr ?? {};
const oldIssue = oldSnapshot?.issue ?? {};
Expand All @@ -184,6 +213,10 @@ export function detectDeltas(oldSnapshot, current) {
? diffEntity('issue', oldIssue, current.issue, issueFingerprint, classifyIssue)
: { deltas: [], nextMap: oldIssue };
const snapshot = { pr: prRes.nextMap, issue: issueRes.nextMap };
const deltas = baseline ? [] : [...prRes.deltas, ...issueRes.deltas];
const deltas = baseline
? emitBaselineState
? baselineStateDeltas(prRes.deltas, issueRes.deltas)
: []
: [...prRes.deltas, ...issueRes.deltas];
return { baseline, deltas, snapshot };
}
10 changes: 8 additions & 2 deletions lib/fingerprint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ export function prFingerprint(pr) {
reviews: hashReviews(pr.latestReviews),
reviewSummary: summarizeReviews(pr.latestReviews),
mergeable: pr.mergeable ?? 'UNKNOWN',
mergeStateStatus: pr.mergeStateStatus ?? 'UNKNOWN',
comments: pr.totalCommentsCount ?? 0,
reviewThreads: pr.reviewThreads ?? 0,
unresolvedReviewThreads: pr.unresolvedReviewThreads ?? 0,
Expand Down Expand Up @@ -126,8 +127,13 @@ export function issueFingerprint(issue) {
* `ciChecks` / `reviewSummary` are detail-only mirrors of the `ci` / `reviews`
* digests (derived from the same fetch data), so dropping them keeps change
* comparison and delta ids stable across snapshots written before the summaries
* existed. When the fingerprint carries PR review-context fields, absent thread
* counts are backfilled to zero so an older snapshot compares equal to a current one.
* existed. `mergeStateStatus`, by contrast, has no compared digest counterpart, so
* it is a first-class compared field (like `mergeable`) and is deliberately NOT
* stripped — a CLEAN->BEHIND transition must be observable. The upgrade case (an
* older snapshot that predates the field) is handled pairwise in the detector, not
* by stripping here. When the fingerprint carries PR review-context fields, absent
* thread counts are backfilled to zero so an older snapshot compares equal to a
* current one.
*/
export function comparableFingerprint(fp) {
if (!fp) return fp;
Expand Down
6 changes: 5 additions & 1 deletion lib/gh.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ query($owner: String!, $name: String!, $states: [PullRequestState!], $endCursor:
repository(owner: $owner, name: $name) {
items: pullRequests(states: $states, orderBy: {field: UPDATED_AT, direction: DESC}, first: ${PAGE_SIZE}, after: $endCursor) {
nodes {
number title state updatedAt isDraft mergeable reviewDecision totalCommentsCount headRefOid headRefName
number title state updatedAt isDraft mergeable mergeStateStatus reviewDecision totalCommentsCount headRefOid headRefName
commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: ${PAGE_SIZE}) {
nodes { __typename ... on CheckRun { name status conclusion } ... on StatusContext { context state } }
pageInfo { hasNextPage }
Expand Down Expand Up @@ -132,6 +132,10 @@ function normalizePr(node, repo) {
updatedAt: node.updatedAt,
isDraft: node.isDraft ?? false,
mergeable: node.mergeable ?? 'UNKNOWN',
// Observed here so the summary layer reads it from the same fetch as the
// fingerprint. Stored raw and stripped from change comparison (see
// comparableFingerprint), so it never fires a spurious delta.
mergeStateStatus: node.mergeStateStatus ?? 'UNKNOWN',
reviewDecision: node.reviewDecision ?? '',
statusCheckRollup: (contexts?.nodes ?? []).filter(Boolean),
latestReviews: (node.latestReviews?.nodes ?? []).filter(Boolean),
Expand Down
13 changes: 10 additions & 3 deletions lib/help.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,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] [--summaries] [--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] [--baseline-emit-state] [--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 @@ -192,7 +192,14 @@ const HELP_SPECS = {
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.',
'Add a normalized semantic delta.summary to PR deltas (ciRollup, reviewDecision, mergeable, mergeStateStatus, 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.',
},
{
name: '--baseline-emit-state',
type: 'boolean',
required: false,
description:
'On the run that seeds a baseline, also emit one synthetic baseline-state delta per tracked OPEN item (from: null, to: the observed fingerprint) so pre-existing trouble (already conflicting, already CI-blocked) is visible instead of silent until the next change. Off by default; the run then exits 10 with baseline: true and a non-empty deltas array. Ids are content-addressed and stable across re-baselining. Do NOT treat baseline-state as newly created.',
},
OPTION_OUTPOST_URL,
OPTION_OUTPOST_TIMEOUT_MS,
Expand All @@ -212,7 +219,7 @@ const HELP_SPECS = {
deltaSummaryFields: DELTA_SUMMARY_FIELDS,
deltaSummaryEnums: DELTA_SUMMARY_ENUMS,
description:
"JSON output contains schemaVersion, baseline, repo, repoSource ('flag' | 'git-remote' | 'gh', how --repo was resolved), 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.",
"JSON output contains schemaVersion, baseline, repo, repoSource ('flag' | 'git-remote' | 'gh', how --repo was resolved), 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), mergeStateStatus (behind|blocked|clean|dirty|draft|has_hooks|unstable|unknown; the same observation's mergeStateStatus, where unknown means not reported/absent — fail-closed, treated like mergeable: unknown, so a PR that is mergeable yet behind its base or blocked by a protection rule is not mistaken for ready), 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