From 6fb3bbebb5ef52a87364e74c2863aacf655b95ef Mon Sep 17 00:00:00 2001 From: diegomarino Date: Tue, 28 Jul 2026 17:03:09 +0200 Subject: [PATCH 1/5] feat: observe mergeStateStatus in the PR fingerprint boundary --- lib/cli.mjs | 9 ++++++++- lib/fingerprint.mjs | 19 ++++++++++++++----- lib/gh.mjs | 6 +++++- test/fingerprint.test.mjs | 26 ++++++++++++++++++++++++++ test/gh.test.mjs | 22 ++++++++++++++++++++++ 5 files changed, 75 insertions(+), 7 deletions(-) diff --git a/lib/cli.mjs b/lib/cli.mjs index e020165..4b6e0e8 100644 --- a/lib/cli.mjs +++ b/lib/cli.mjs @@ -93,7 +93,14 @@ function changedFingerprintFields(delta) { // ciChecks/reviewSummary mirror the ci/reviews digests; surfacing them // here would only duplicate the digest transition (or invent one when a // pre-summary snapshot side simply lacks the field). - !['missing', 'missingTicks', 'commentsOverflow', 'ciChecks', 'reviewSummary'].includes(key), + ![ + 'missing', + 'missingTicks', + 'commentsOverflow', + 'ciChecks', + 'reviewSummary', + 'mergeStateStatus', + ].includes(key), ) .filter((key) => JSON.stringify(delta.from[key]) !== JSON.stringify(delta.to[key])) .sort(); diff --git a/lib/fingerprint.mjs b/lib/fingerprint.mjs index af77155..39056c4 100644 --- a/lib/fingerprint.mjs +++ b/lib/fingerprint.mjs @@ -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, @@ -124,15 +125,23 @@ export function issueFingerprint(issue) { * is a legacy saturation flag; none describe the observed change, so they must not * influence either change comparison or the content-addressed delta id. * `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. + * digests (derived from the same fetch data), and `mergeStateStatus` is a + * summary-only observation; dropping all three keeps change comparison and delta + * ids stable across snapshots written before those fields 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. */ export function comparableFingerprint(fp) { if (!fp) return fp; let normalized = fp; - for (const key of ['missing', 'missingTicks', 'commentsOverflow', 'ciChecks', 'reviewSummary']) { + for (const key of [ + 'missing', + 'missingTicks', + 'commentsOverflow', + 'ciChecks', + 'reviewSummary', + 'mergeStateStatus', + ]) { if (Object.hasOwn(normalized, key)) { const { [key]: _dropped, ...rest } = normalized; normalized = rest; diff --git a/lib/gh.mjs b/lib/gh.mjs index f6359b0..708dcbc 100644 --- a/lib/gh.mjs +++ b/lib/gh.mjs @@ -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 } @@ -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), diff --git a/test/fingerprint.test.mjs b/test/fingerprint.test.mjs index 113ac7d..872888d 100644 --- a/test/fingerprint.test.mjs +++ b/test/fingerprint.test.mjs @@ -94,6 +94,32 @@ test('comparableFingerprint drops the detail-only summaries', () => { assert.deepEqual(comparableFingerprint(legacy), comparable); }); +test('prFingerprint stores mergeStateStatus but comparableFingerprint strips it', () => { + const fp = prFingerprint({ + state: 'OPEN', + updatedAt: '2026-07-01T10:00:00Z', + mergeStateStatus: 'BLOCKED', + statusCheckRollup: [], + latestReviews: [], + }); + assert.equal(fp.mergeStateStatus, 'BLOCKED'); + assert.equal('mergeStateStatus' in comparableFingerprint(fp), false); +}); + +test('a snapshot gaining mergeStateStatus does not compare as changed', () => { + // Mirrors the ciChecks/reviewSummary upgrade guarantee: an older snapshot that + // predates the field must diff to zero against a current one carrying it. + const withField = prFingerprint({ + state: 'OPEN', + updatedAt: '2026-07-01T10:00:00Z', + mergeStateStatus: 'CLEAN', + statusCheckRollup: [], + latestReviews: [], + }); + const { mergeStateStatus: _dropped, ...legacy } = withField; + assert.deepEqual(comparableFingerprint(legacy), comparableFingerprint(withField)); +}); + test('hashReviews is order-independent and reflects state', () => { const one = [ { author: { login: 'alice' }, state: 'APPROVED' }, diff --git a/test/gh.test.mjs b/test/gh.test.mjs index bacac1b..45d7d3e 100644 --- a/test/gh.test.mjs +++ b/test/gh.test.mjs @@ -231,3 +231,25 @@ test('normalizePr filters null elements from statusCheckRollup contexts nodes', { __typename: 'CheckRun', name: 'build', status: 'COMPLETED', conclusion: 'SUCCESS' }, ]); }); + +test('the PR query requests mergeStateStatus and normalizePr defaults it to UNKNOWN if absent', () => { + let sentQuery = ''; + const exec = (_cmd, args) => { + sentQuery = args.find((a) => a.startsWith('query=')) ?? ''; + return page([prNode()]); // prNode() omits mergeStateStatus + }; + const rows = fetchPRs('o/r', { exec, horizonCutoff: null }); + assert.ok( + sentQuery.includes('mergeStateStatus'), + 'PR GraphQL selection must request mergeStateStatus', + ); + assert.equal(rows[0].mergeStateStatus, 'UNKNOWN'); +}); + +test('normalizePr passes through a present mergeStateStatus verbatim', () => { + const rows = fetchPRs('o/r', { + exec: () => page([prNode({ mergeStateStatus: 'BEHIND' })]), + horizonCutoff: null, + }); + assert.equal(rows[0].mergeStateStatus, 'BEHIND'); +}); From 1cfecdb9095fe5e09003256528d04af2417d6d9d Mon Sep 17 00:00:00 2001 From: diegomarino Date: Tue, 28 Jul 2026 17:14:58 +0200 Subject: [PATCH 2/5] feat: add mergeStateStatus enum to the delta summary --- README.md | 5 ++++ docs/contract.md | 63 +++++++++++++++++++++++-------------------- lib/contract.mjs | 11 ++++++++ lib/help.mjs | 4 +-- lib/summary.mjs | 30 +++++++++++++++++++++ test/cli.test.mjs | 29 ++++++++++++++++++++ test/summary.test.mjs | 26 ++++++++++++++++++ 7 files changed, 137 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index e2bfeb4..a0a0222 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/contract.md b/docs/contract.md index 4fd4f0b..cf042ee 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -249,8 +249,9 @@ Behavioral notes for consumers: legacy snapshots without `meta`); a `null` snapshot yields `null` (open-items- only fetch). - `comparableFingerprint` strips bookkeeping and detail-only keys - (`missing`, `missingTicks`, `commentsOverflow`, `ciChecks`, `reviewSummary`) - from a stored fingerprint before it is compared or hashed into a delta id. + (`missing`, `missingTicks`, `commentsOverflow`, `ciChecks`, `reviewSummary`, + `mergeStateStatus`) from a stored fingerprint before it is compared or hashed + into a delta id. - `stableValue` recursively key-sorts an object so GitHub's field ordering never changes a fingerprint hash or a delta `id`. - `deltaIdentity` builds the `{ repo, entity, number, to }` (or, when `to` is @@ -514,6 +515,7 @@ facts itself. "ciRollup": "green", "reviewDecision": "approved", "mergeable": "mergeable", + "mergeStateStatus": "clean", "state": "open", "isDraft": false, "unresolvedReviewThreads": 0, @@ -524,15 +526,16 @@ facts itself. 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. | +| 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. | +| `mergeStateStatus` | enum | `behind` \| `blocked` \| `clean` \| `dirty` \| `draft` \| `has_hooks` \| `unstable` \| `unknown`. GitHub's `mergeStateStatus` from the same observation. `unknown` = not reported / absent / unrecognized — fail-closed, meaning "not computed", exactly like `mergeable: unknown`. A PR can be `mergeable` yet `behind` its base (repos requiring the branch be up to date) or `blocked` by an unsatisfied protection rule, so this is deliberately **not** folded into `mergeable`. | +| `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`, @@ -554,23 +557,24 @@ consumers must tolerate new keys and must not assume a closed shape. PR fingerprint: -| Field | Readable? | Notes | -| ------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `state` | yes | `OPEN` \| `CLOSED` \| `MERGED`. | -| `updatedAt` | yes | ISO-8601. | -| `isDraft` | yes | boolean. | -| `mergeable` | yes | `MERGEABLE` \| `CONFLICTING` \| `UNKNOWN`. | -| `review` | yes | GitHub `reviewDecision` (e.g. `APPROVED`, `REVIEW_REQUIRED`, `""`). | -| `comments` | yes | exact integer total from GraphQL `totalCommentsCount`. | -| `reviewThreads` | yes | integer count of PR review threads. | -| `unresolvedReviewThreads` | yes | integer count of unresolved PR review threads. | -| `ci` | **opaque** | sha1 digest of the CI rollup. Observe inequality only; never parse. | -| `ciChecks` | yes | normalized CI rollup: sorted `{name, status, conclusion}` rows behind the `ci` digest. Absent from snapshots written before it was introduced. Not part of the change comparison or the delta id. | -| `reviews` | **opaque** | sha1 digest of latest reviews. Observe inequality only; never parse. | -| `reviewSummary` | yes | compact latest-review rows: sorted `{author, state, submittedAt, commit}` behind the `reviews` digest. Absent from snapshots written before it was introduced. Not part of the change comparison or the delta id. | -| `head` | opaque-ish | head ref OID (git SHA). Treat as a change indicator; every push changes it. | -| `missing` | bookkeeping | boolean; present on fingerprints stored for missing items. Not part of the change comparison. | -| `missingTicks` | bookkeeping | number; consecutive ticks an item has been absent. Present alongside `missing: true`. | +| Field | Readable? | Notes | +| ------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `state` | yes | `OPEN` \| `CLOSED` \| `MERGED`. | +| `updatedAt` | yes | ISO-8601. | +| `isDraft` | yes | boolean. | +| `mergeable` | yes | `MERGEABLE` \| `CONFLICTING` \| `UNKNOWN`. | +| `mergeStateStatus` | yes | GitHub `mergeStateStatus` (`BEHIND` \| `BLOCKED` \| `CLEAN` \| `DIRTY` \| `DRAFT` \| `HAS_HOOKS` \| `UNSTABLE` \| `UNKNOWN`). Stored for the summary layer; **not** part of the change comparison or the delta id (stripped by `comparableFingerprint`, like `ciChecks` / `reviewSummary`). | +| `review` | yes | GitHub `reviewDecision` (e.g. `APPROVED`, `REVIEW_REQUIRED`, `""`). | +| `comments` | yes | exact integer total from GraphQL `totalCommentsCount`. | +| `reviewThreads` | yes | integer count of PR review threads. | +| `unresolvedReviewThreads` | yes | integer count of unresolved PR review threads. | +| `ci` | **opaque** | sha1 digest of the CI rollup. Observe inequality only; never parse. | +| `ciChecks` | yes | normalized CI rollup: sorted `{name, status, conclusion}` rows behind the `ci` digest. Absent from snapshots written before it was introduced. Not part of the change comparison or the delta id. | +| `reviews` | **opaque** | sha1 digest of latest reviews. Observe inequality only; never parse. | +| `reviewSummary` | yes | compact latest-review rows: sorted `{author, state, submittedAt, commit}` behind the `reviews` digest. Absent from snapshots written before it was introduced. Not part of the change comparison or the delta id. | +| `head` | opaque-ish | head ref OID (git SHA). Treat as a change indicator; every push changes it. | +| `missing` | bookkeeping | boolean; present on fingerprints stored for missing items. Not part of the change comparison. | +| `missingTicks` | bookkeeping | number; consecutive ticks an item has been absent. Present alongside `missing: true`. | Issue fingerprint: `state`, `updatedAt`, `labels` (string[], sorted), `comments` (exact integer total from GraphQL `totalCount`). @@ -681,7 +685,8 @@ their derived filename or a registry entry. rolling back to gh-delta 0.2.0 over a snapshot last written by 0.3.0 fires a bounded burst of spurious `updated` deltas — one per open PR — on the first tick after the downgrade, because 0.2.0's `comparableFingerprint` does not - tolerate the newer persisted keys (e.g. `ciChecks`, `reviewSummary`). The + tolerate the newer persisted keys (e.g. `ciChecks`, `reviewSummary`, + `mergeStateStatus`). The burst is self-correcting: the next tick re-establishes a clean baseline under the older binary. Prefer not to downgrade a monitor across a snapshot; if you must, expect and discard that one-time burst. diff --git a/lib/contract.mjs b/lib/contract.mjs index 1e0a080..48fe242 100644 --- a/lib/contract.mjs +++ b/lib/contract.mjs @@ -75,6 +75,7 @@ export const DELTA_SUMMARY_FIELDS = Object.freeze([ 'ciRollup', 'reviewDecision', 'mergeable', + 'mergeStateStatus', 'state', 'isDraft', 'unresolvedReviewThreads', @@ -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']), }); diff --git a/lib/help.mjs b/lib/help.mjs index 3d1fe33..97eacd6 100644 --- a/lib/help.mjs +++ b/lib/help.mjs @@ -192,7 +192,7 @@ 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.', }, OPTION_OUTPOST_URL, OPTION_OUTPOST_TIMEOUT_MS, @@ -212,7 +212,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: [ diff --git a/lib/summary.mjs b/lib/summary.mjs index 3730c1c..f716def 100644 --- a/lib/summary.mjs +++ b/lib/summary.mjs @@ -42,6 +42,17 @@ const MERGEABLE_STATES = { CONFLICTING: 'conflicting', }; +const MERGE_STATE_STATUSES = { + BEHIND: 'behind', + BLOCKED: 'blocked', + CLEAN: 'clean', + DIRTY: 'dirty', + DRAFT: 'draft', + HAS_HOOKS: 'has_hooks', + UNSTABLE: 'unstable', + UNKNOWN: 'unknown', +}; + const PR_STATES = { OPEN: 'open', CLOSED: 'closed', @@ -112,6 +123,23 @@ export function normalizeMergeable(mergeable) { return MERGEABLE_STATES[upper(mergeable)] ?? 'unknown'; } +/** + * Normalize GitHub's `mergeStateStatus` to a lowercase enum. + * + * BEHIND | BLOCKED | CLEAN | DIRTY | DRAFT | HAS_HOOKS | UNSTABLE map directly; + * UNKNOWN, null, absent, or any unrecognized value becomes `'unknown'`. Fail-closed + * on purpose: a PR can be `mergeable` yet BEHIND its base (repos that require the + * branch be up to date) or BLOCKED by an unsatisfied protection rule, so a consumer + * treats `'unknown'` as "not computed", exactly like `mergeable: unknown`, rather + * than reading a merge-readiness verdict that was never observed. + * + * @param {string|null|undefined} mergeStateStatus - raw `to.mergeStateStatus` + * @returns {'behind'|'blocked'|'clean'|'dirty'|'draft'|'has_hooks'|'unstable'|'unknown'} + */ +export function normalizeMergeStateStatus(mergeStateStatus) { + return MERGE_STATE_STATUSES[upper(mergeStateStatus)] ?? 'unknown'; +} + /** * Normalize a PR's GraphQL state (OPEN | CLOSED | MERGED) to lowercase. * @@ -138,6 +166,7 @@ export function normalizePrState(state) { * ciRollup: 'green'|'failed'|'pending'|'none', * reviewDecision: 'approved'|'changes_requested'|'review_required'|'none', * mergeable: 'mergeable'|'conflicting'|'unknown', + * mergeStateStatus: 'behind'|'blocked'|'clean'|'dirty'|'draft'|'has_hooks'|'unstable'|'unknown', * state: 'open'|'closed'|'merged'|string, * isDraft: boolean, * unresolvedReviewThreads: number, @@ -150,6 +179,7 @@ export function prSummary(to) { ciRollup: deriveCiRollup(to.ciChecks), reviewDecision: normalizeReviewDecision(to.review), mergeable: normalizeMergeable(to.mergeable), + mergeStateStatus: normalizeMergeStateStatus(to.mergeStateStatus), state: normalizePrState(to.state), isDraft: to.isDraft === true, unresolvedReviewThreads: Number.isInteger(to.unresolvedReviewThreads) diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 315b426..426631a 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -376,6 +376,7 @@ test('--summaries acceptance: posting a successful status makes summary.ciRollup ciRollup: 'green', reviewDecision: 'review_required', mergeable: 'unknown', + mergeStateStatus: 'unknown', state: 'open', isDraft: false, unresolvedReviewThreads: 0, @@ -383,6 +384,23 @@ test('--summaries acceptance: posting a successful status makes summary.ciRollup }); }); +test('--summaries surfaces mergeStateStatus behind for an up-to-date-required branch', () => { + // A PR that GitHub reports mergeable yet BEHIND its base (repos requiring the + // branch be up to date): the summary must expose that distinctly so a consumer + // does not emit a false "ready to merge". + const before = { ...basePr, statusCheckRollup: [] }; + const after = { + ...basePr, + updatedAt: '2026-07-01T11:00:00Z', + mergeStateStatus: 'BEHIND', + 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); + assert.equal(report.deltas[0].summary.mergeStateStatus, 'behind'); +}); + 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: [] }; @@ -433,6 +451,7 @@ test('--help-json documents the summary schema well enough to build a validator' 'ciRollup', 'reviewDecision', 'mergeable', + 'mergeStateStatus', 'state', 'isDraft', 'unresolvedReviewThreads', @@ -444,6 +463,16 @@ test('--help-json documents the summary schema well enough to build a validator' 'conflicting', 'unknown', ]); + assert.deepEqual(help.output.deltaSummaryEnums.mergeStateStatus, [ + 'behind', + 'blocked', + 'clean', + 'dirty', + 'draft', + 'has_hooks', + 'unstable', + 'unknown', + ]); }); test('--help returns usage text without fetching GitHub', () => { diff --git a/test/summary.test.mjs b/test/summary.test.mjs index 8209871..7131a36 100644 --- a/test/summary.test.mjs +++ b/test/summary.test.mjs @@ -14,6 +14,7 @@ import { deriveCiRollup, normalizeReviewDecision, normalizeMergeable, + normalizeMergeStateStatus, normalizePrState, prSummary, deltaSummary, @@ -121,6 +122,21 @@ test('normalizeMergeable keeps UNKNOWN honest', () => { assert.equal(normalizeMergeable(undefined), 'unknown'); }); +test('normalizeMergeStateStatus maps the GraphQL enum and defaults to unknown', () => { + assert.equal(normalizeMergeStateStatus('BEHIND'), 'behind'); + assert.equal(normalizeMergeStateStatus('BLOCKED'), 'blocked'); + assert.equal(normalizeMergeStateStatus('CLEAN'), 'clean'); + assert.equal(normalizeMergeStateStatus('DIRTY'), 'dirty'); + assert.equal(normalizeMergeStateStatus('DRAFT'), 'draft'); + assert.equal(normalizeMergeStateStatus('HAS_HOOKS'), 'has_hooks'); + assert.equal(normalizeMergeStateStatus('UNSTABLE'), 'unstable'); + assert.equal(normalizeMergeStateStatus('UNKNOWN'), 'unknown'); + assert.equal(normalizeMergeStateStatus(''), 'unknown'); + assert.equal(normalizeMergeStateStatus(null), 'unknown'); + assert.equal(normalizeMergeStateStatus(undefined), 'unknown'); + assert.equal(normalizeMergeStateStatus('SOMETHING_NEW'), 'unknown'); +}); + test('normalizePrState lowercases the three PR states', () => { assert.equal(normalizePrState('OPEN'), 'open'); assert.equal(normalizePrState('CLOSED'), 'closed'); @@ -141,6 +157,7 @@ test('prSummary normalizes types and names headSha unambiguously', () => { ciChecks: [{ name: 'build', status: 'COMPLETED', conclusion: 'SUCCESS' }], review: 'APPROVED', mergeable: 'MERGEABLE', + mergeStateStatus: 'CLEAN', unresolvedReviewThreads: 0, head: 'a'.repeat(40), }); @@ -148,6 +165,7 @@ test('prSummary normalizes types and names headSha unambiguously', () => { ciRollup: 'green', reviewDecision: 'approved', mergeable: 'mergeable', + mergeStateStatus: 'clean', state: 'open', isDraft: false, unresolvedReviewThreads: 0, @@ -175,6 +193,14 @@ test('real fixture: a PR with zero checks yields ciRollup none (the empty-rollup assert.equal(prSummary(fp).ciRollup, 'none'); }); +test('real fixture: an older recording without mergeStateStatus yields unknown (fail-closed)', () => { + // The captured payloads predate the field (the query did not request it), so the + // summary must default to unknown — the same "not computed" signal as + // mergeable: unknown — rather than inventing a merge-readiness verdict. + const fp = prFingerprint(fixtureRow('green')); + assert.equal(prSummary(fp).mergeStateStatus, 'unknown'); +}); + 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'); From 6288815cab81293e81378f7156707f632c07aa26 Mon Sep 17 00:00:00 2001 From: diegomarino Date: Tue, 28 Jul 2026 17:16:26 +0200 Subject: [PATCH 3/5] feat: add opt-in baseline-state delta class to the detector --- lib/contract.mjs | 2 ++ lib/detect.mjs | 28 +++++++++++++++++++++++++-- test/detect.test.mjs | 46 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 2 deletions(-) diff --git a/lib/contract.mjs b/lib/contract.mjs index 48fe242..e3d7abb 100644 --- a/lib/contract.mjs +++ b/lib/contract.mjs @@ -119,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', @@ -141,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']), diff --git a/lib/detect.mjs b/lib/detect.mjs index 27e517d..7524aa1 100644 --- a/lib/detect.mjs +++ b/lib/detect.mjs @@ -162,6 +162,20 @@ 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. * @@ -169,11 +183,17 @@ function diffEntity(kind, oldMap, objects, fpFn, classifyFn) { * 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>, issue?: Record>}} oldSnapshot * @param {{pr?: Array>, issue?: Array>}} current + * @param {{emitBaselineState?: boolean}} [options] * @returns {{baseline: boolean, deltas: Array>, snapshot: {pr: Record, issue: Record}}} */ -export function detectDeltas(oldSnapshot, current) { +export function detectDeltas(oldSnapshot, current, { emitBaselineState = false } = {}) { const baseline = oldSnapshot == null; const oldPr = oldSnapshot?.pr ?? {}; const oldIssue = oldSnapshot?.issue ?? {}; @@ -184,6 +204,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 }; } diff --git a/test/detect.test.mjs b/test/detect.test.mjs index 0ec3b05..f077ea7 100644 --- a/test/detect.test.mjs +++ b/test/detect.test.mjs @@ -2,6 +2,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { detectDeltas } from '../lib/detect.mjs'; +import { DELTA_CLASSES, DELTA_DETAIL_FIELDS_BY_CLASS } from '../lib/contract.mjs'; const pr = (over = {}) => ({ number: 42, @@ -27,6 +28,51 @@ test('first run establishes a baseline with no deltas', () => { assert.ok(r.snapshot.pr['42']); }); +test('baseline-state is a registered closed-set class with a detail field map', () => { + assert.ok(DELTA_CLASSES.includes('baseline-state')); + assert.deepEqual(DELTA_DETAIL_FIELDS_BY_CLASS['baseline-state'], ['presence', 'state']); +}); + +test('baseline with emitBaselineState off stays empty (byte-identical default)', () => { + const r = detectDeltas(null, { pr: [pr()], issue: [] }); + assert.equal(r.baseline, true); + assert.deepEqual(r.deltas, []); +}); + +test('baseline with emitBaselineState on emits one baseline-state delta per open item', () => { + const r = detectDeltas(null, { pr: [pr()], issue: [] }, { emitBaselineState: true }); + assert.equal(r.baseline, true); + assert.equal(r.deltas.length, 1); + const d = r.deltas[0]; + assert.deepEqual(d.classes, ['baseline-state']); + assert.equal(d.from, null); + assert.equal(d.to.state, 'OPEN'); + assert.equal(d.entity, 'pr'); + assert.equal(d.number, 42); +}); + +test('baseline-state covers both PR and issue open items within entities', () => { + const issue = { + number: 7, + title: 'bug', + state: 'OPEN', + updatedAt: '2026-07-01T10:00:00Z', + labels: [], + comments: 0, + }; + const r = detectDeltas(null, { pr: [pr()], issue: [issue] }, { emitBaselineState: true }); + const entities = r.deltas.map((d) => d.entity).sort(); + assert.deepEqual(entities, ['issue', 'pr']); + assert.ok(r.deltas.every((d) => d.classes[0] === 'baseline-state')); +}); + +test('emitBaselineState is inert on a non-baseline run', () => { + const base = detectDeltas(null, { pr: [pr()], issue: [] }); + const r = detectDeltas(base.snapshot, { pr: [pr()], issue: [] }, { emitBaselineState: true }); + assert.equal(r.baseline, false); + assert.deepEqual(r.deltas, []); +}); + test('a brand-new PR after baseline emits `new`', () => { const base = detectDeltas(null, { pr: [], issue: [] }); const r = detectDeltas(base.snapshot, { pr: [pr()], issue: [] }); From b903d4ba5f42bd8bb345e62bd9fb1135e640588a Mon Sep 17 00:00:00 2001 From: diegomarino Date: Tue, 28 Jul 2026 17:24:14 +0200 Subject: [PATCH 4/5] feat: add --baseline-emit-state flag for pre-existing open items --- README.md | 11 ++++++++ docs/contract.md | 66 +++++++++++++++++++++++++++------------------ lib/cli.mjs | 17 +++++++++--- lib/help.mjs | 9 ++++++- lib/text-output.mjs | 4 +++ test/cli.test.mjs | 64 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 141 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index a0a0222..4609d8f 100644 --- a/README.md +++ b/README.md @@ -186,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 diff --git a/docs/contract.md b/docs/contract.md index cf042ee..4ee7224 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -15,7 +15,7 @@ machine-readable 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] [--summaries] + [--summary-line] [--detail] [--summaries] [--baseline-emit-state] [--outpost-url ] [--outpost-timeout-ms ] [--outpost-max-posts ] [--gh-timeout-ms ] [--no-registry] @@ -76,6 +76,13 @@ gh-delta [--repo ] [--monitor-id ] `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). +- `--baseline-emit-state` is optional and off by default. On the run that seeds a + baseline, it emits one synthetic `baseline-state` delta per tracked OPEN item + (`from: null`, `to`: the observed fingerprint) so state that already existed at + baseline is visible instead of silent until the item next changes. The run then + exits `10` with `baseline: true` and a non-empty `deltas` array; ids are stable + across re-baselining. Without the flag, baseline behavior is byte-identical. See + the [`baseline-state`](#delta-classes) class and [Exit Codes](#exit-codes). - `--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. @@ -210,7 +217,10 @@ deltas), the CLI writes one small breadcrumb per monitor: ## Exit Codes - `0`: baseline established or no deltas. -- `10`: deltas found. +- `10`: deltas found. Also emitted when `--baseline-emit-state` seeds a baseline + that observes at least one tracked open item: the report then carries + `baseline: true` **and** a non-empty `deltas` array of `baseline-state` deltas. + Watchers that chain on exit `10` feed this baseline report like any other. - `1`: **transient error** — GitHub CLI, network, timeout, or snapshot write failure. The snapshot is not updated; the next scheduled tick should retry automatically. @@ -275,27 +285,28 @@ is **never empty** (`updated` is the catch-all). `classes` is a **set** — seve can co-occur on one delta (e.g. `ci-changed` + `review-changed`). Order within the array is not significant and not guaranteed stable. -| Class | Applies to | Meaning | -| ----------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `new` | pr, issue | New issue or PR after the baseline. `from` is `null`. | -| `first-seen` | pr, issue | First time this watcher observed a non-open item. It may predate the baseline; `from` is `null`, but consumers should not treat it as newly created. | -| `closed` | pr, issue | Issue or PR was closed. | -| `reopened` | pr, issue | Issue or PR was reopened (state returned to `OPEN`). | -| `new-comments` | pr, issue | Comment count increased. | -| `updated` | pr, issue | Fingerprint changed with no more specific class. A PR-branch push alone (head SHA change) surfaces here. | -| `missing` | pr, issue | An item the snapshot believes OPEN vanished from the fetch. Check pagination, permissions, or scope before trusting it. Absent closed items are dormant memory, not a missing delta. `to` is `null`. | -| `still-missing` | pr, issue | An already-missing open item is still absent (tick 2). Unresolved operational state, not a fresh delta. `to` is `null`. | -| `presumed-deleted` | pr, issue | Absent for 3 consecutive ticks; treated as deleted, transferred, or converted. Emitted once; the object then goes silent but stays in memory (`missingTicks` counter in the stored fingerprint). `reappeared` still fires if the object returns. `to` is `null`. | -| `reappeared` | pr, issue | An object previously marked `missing` returned to the fetch. It may co-occur with other classes if the fingerprint also changed. | -| `merged` | pr only | PR was merged. | -| `draft-ready` | pr only | PR moved from draft to ready for review. | -| `ci-changed` | pr only | Check run or status context changed. | -| `review-changed` | pr only | Review decision or latest review states changed. | -| `became-mergeable` | pr only | PR moved from `CONFLICTING` to `MERGEABLE` (an `UNKNOWN` mid-recompute placeholder does not count). | -| `unresolved-threads-added` | pr only | Unresolved PR review thread count increased. | -| `unresolved-threads-resolved` | pr only | Unresolved PR review thread count decreased. | -| `review-threads-changed` | pr only | PR review thread total changed while the unresolved count held steady. | -| `relabeled` | issue only | Issue labels changed. (The PR fetch does not collect labels, so PRs never emit this.) | +| Class | Applies to | Meaning | +| ----------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `new` | pr, issue | New issue or PR after the baseline. `from` is `null`. | +| `first-seen` | pr, issue | First time this watcher observed a non-open item. It may predate the baseline; `from` is `null`, but consumers should not treat it as newly created. | +| `baseline-state` | pr, issue | Emitted only under `--baseline-emit-state`, once per tracked OPEN item on the run that seeds a baseline. `from` is `null`, `to` is the freshly observed fingerprint. Surfaces state that already existed at baseline (e.g. an already-conflicting or already-CI-blocked PR). The content-addressed `id` is stable across re-baselining. Consumers must **not** treat it as newly created (like `first-seen`). | +| `closed` | pr, issue | Issue or PR was closed. | +| `reopened` | pr, issue | Issue or PR was reopened (state returned to `OPEN`). | +| `new-comments` | pr, issue | Comment count increased. | +| `updated` | pr, issue | Fingerprint changed with no more specific class. A PR-branch push alone (head SHA change) surfaces here. | +| `missing` | pr, issue | An item the snapshot believes OPEN vanished from the fetch. Check pagination, permissions, or scope before trusting it. Absent closed items are dormant memory, not a missing delta. `to` is `null`. | +| `still-missing` | pr, issue | An already-missing open item is still absent (tick 2). Unresolved operational state, not a fresh delta. `to` is `null`. | +| `presumed-deleted` | pr, issue | Absent for 3 consecutive ticks; treated as deleted, transferred, or converted. Emitted once; the object then goes silent but stays in memory (`missingTicks` counter in the stored fingerprint). `reappeared` still fires if the object returns. `to` is `null`. | +| `reappeared` | pr, issue | An object previously marked `missing` returned to the fetch. It may co-occur with other classes if the fingerprint also changed. | +| `merged` | pr only | PR was merged. | +| `draft-ready` | pr only | PR moved from draft to ready for review. | +| `ci-changed` | pr only | Check run or status context changed. | +| `review-changed` | pr only | Review decision or latest review states changed. | +| `became-mergeable` | pr only | PR moved from `CONFLICTING` to `MERGEABLE` (an `UNKNOWN` mid-recompute placeholder does not count). | +| `unresolved-threads-added` | pr only | Unresolved PR review thread count increased. | +| `unresolved-threads-resolved` | pr only | Unresolved PR review thread count decreased. | +| `review-threads-changed` | pr only | PR review thread total changed while the unresolved count held steady. | +| `relabeled` | issue only | Issue labels changed. (The PR fetch does not collect labels, so PRs never emit this.) | **Forward compatibility:** new classes may be added in a later minor version. Consumers must treat an unrecognized class as "something changed, inspect," @@ -371,9 +382,12 @@ Field guarantees: - `schemaVersion` (number): report shape version. Bumped **only** on a breaking change — a field removed or renamed. Additive changes (new optional keys on the report, a delta, or a fingerprint) do not bump it. Assert `schemaVersion === 1`. -- `baseline` (boolean): `true` on the first run for a snapshot. When `true`, - `deltas` is always `[]` even though every tracked object is new — a baseline - seeds memory, it does not report. Handle it distinctly from "no deltas." +- `baseline` (boolean): `true` on the first run for a snapshot. Without + `--baseline-emit-state`, `deltas` is always `[]` when `true` even though every + tracked object is new — a baseline seeds memory, it does not report. With + `--baseline-emit-state`, a baseline that observes at least one tracked open item + instead carries a non-empty `deltas` array of `baseline-state` deltas (and the + run exits `10`). Handle `baseline` distinctly from "no deltas" either way. - `repo`, `monitorId` (string): echo the flags. - `repoSource` (`"flag"` | `"git-remote"` | `"gh"`): how `--repo` was resolved — `"flag"` when passed explicitly, `"git-remote"` when derived from the diff --git a/lib/cli.mjs b/lib/cli.mjs index 4b6e0e8..7ba0bd3 100644 --- a/lib/cli.mjs +++ b/lib/cli.mjs @@ -158,6 +158,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; @@ -302,6 +303,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' }, @@ -588,7 +590,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. @@ -634,7 +638,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, @@ -649,7 +655,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, diff --git a/lib/help.mjs b/lib/help.mjs index 97eacd6..c5138f4 100644 --- a/lib/help.mjs +++ b/lib/help.mjs @@ -156,7 +156,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] [--summaries] [--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] [--baseline-emit-state] [--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: [ @@ -194,6 +194,13 @@ const HELP_SPECS = { description: '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, OPTION_OUTPOST_MAX_POSTS, diff --git a/lib/text-output.mjs b/lib/text-output.mjs index e285433..fbe7528 100644 --- a/lib/text-output.mjs +++ b/lib/text-output.mjs @@ -32,6 +32,10 @@ const SUGGESTIONS = [ matches: ['first-seen'], text: 'first observed item. Inspect before treating it as newly created.', }, + { + matches: ['baseline-state'], + text: 'state observed at baseline seed. Inspect for pre-existing trouble; not newly created.', + }, { matches: ['became-mergeable'], text: 'conflicts resolved. Consider the merge path after review.', diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 426631a..f0463a4 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -401,6 +401,70 @@ test('--summaries surfaces mergeStateStatus behind for an up-to-date-required br assert.equal(report.deltas[0].summary.mergeStateStatus, 'behind'); }); +const BASELINE_EMIT_ARGS = [ + '--repo', + 'o/r', + '--monitor-id', + 'main', + '--state-file', + '/tmp/x.json', + '--baseline-emit-state', +]; + +test('--baseline-emit-state off: baseline stays exit 0 with empty deltas', () => { + const d = deps([[basePr]]); + const { code, report } = run( + ['--repo', 'o/r', '--monitor-id', 'main', '--state-file', '/tmp/x.json'], + d, + ); + assert.equal(code, 0); + assert.equal(report.baseline, true); + assert.deepEqual(report.deltas, []); +}); + +test('--baseline-emit-state on: baseline exits 10 with baseline:true and non-empty deltas', () => { + const d = deps([[basePr]]); + const { code, report } = run(BASELINE_EMIT_ARGS, d); + assert.equal(code, 10); + assert.equal(report.baseline, true); + assert.equal(report.deltas.length, 1); + const delta = report.deltas[0]; + assert.deepEqual(delta.classes, ['baseline-state']); + assert.equal(delta.from, null); + assert.equal(delta.to.state, 'OPEN'); + assert.match(delta.id, /^[0-9a-f]{64}$/); +}); + +test('--baseline-emit-state ids are stable across a re-baseline over unchanged state', () => { + // Fresh state both times (readSnapshot returns null), same observed PR: the + // content-addressed id must match so idempotent consumers dedupe for free. + const first = run(BASELINE_EMIT_ARGS, deps([[basePr]])); + const second = run(BASELINE_EMIT_ARGS, deps([[basePr]])); + assert.equal(first.report.deltas[0].id, second.report.deltas[0].id); +}); + +test('--baseline-emit-state composes with --summaries (PR baseline-state carries a summary)', () => { + const d = deps([[basePr]]); + const { code, report } = run([...BASELINE_EMIT_ARGS, '--summaries'], d); + assert.equal(code, 10); + const delta = report.deltas[0]; + assert.equal(delta.classes[0], 'baseline-state'); + assert.equal(delta.summary.state, 'open'); + assert.equal(delta.summary.mergeStateStatus, 'unknown'); +}); + +test('--help-json advertises --baseline-emit-state', () => { + const d = { + fetchPRs: () => { + throw new Error('should not fetch'); + }, + now: () => '2026-07-01T12:00:00Z', + }; + const { report } = run(['--help-json'], d); + const help = JSON.parse(report); + assert.ok(help.options.some((o) => o.name === '--baseline-emit-state')); +}); + 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: [] }; From bf0d36b85e102fd948d603fe1f23b6e21d71f186 Mon Sep 17 00:00:00 2001 From: diegomarino Date: Tue, 28 Jul 2026 17:47:17 +0200 Subject: [PATCH 5/5] fix: compare mergeStateStatus so merge-state transitions are not swallowed A CLEAN->BEHIND transition (base branch advances, nothing else changes) was dropped because mergeStateStatus was excluded from the fingerprint comparison, silently writing the new status to the snapshot and leaving consumers at a stale 'ready to merge'. Treat it as a compared field like mergeable: transitions fire an updated delta and enter the delta id. A snapshot predating the field is suppressed pairwise so the first post-upgrade tick does not emit a fleet-wide spurious burst. --- docs/contract.md | 41 +++++++++++++++++++-------------------- lib/cli.mjs | 9 +-------- lib/contract.mjs | 1 + lib/detect.mjs | 17 ++++++++++++---- lib/fingerprint.mjs | 23 ++++++++++------------ test/cli.test.mjs | 13 +++++++++++++ test/detect.test.mjs | 34 ++++++++++++++++++++++++++++++++ test/fingerprint.test.mjs | 22 ++++++--------------- 8 files changed, 98 insertions(+), 62 deletions(-) diff --git a/docs/contract.md b/docs/contract.md index 4ee7224..eb15e17 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -259,9 +259,8 @@ Behavioral notes for consumers: legacy snapshots without `meta`); a `null` snapshot yields `null` (open-items- only fetch). - `comparableFingerprint` strips bookkeeping and detail-only keys - (`missing`, `missingTicks`, `commentsOverflow`, `ciChecks`, `reviewSummary`, - `mergeStateStatus`) from a stored fingerprint before it is compared or hashed - into a delta id. + (`missing`, `missingTicks`, `commentsOverflow`, `ciChecks`, `reviewSummary`) + from a stored fingerprint before it is compared or hashed into a delta id. - `stableValue` recursively key-sorts an object so GitHub's field ordering never changes a fingerprint hash or a delta `id`. - `deltaIdentity` builds the `{ repo, entity, number, to }` (or, when `to` is @@ -571,24 +570,24 @@ consumers must tolerate new keys and must not assume a closed shape. PR fingerprint: -| Field | Readable? | Notes | -| ------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `state` | yes | `OPEN` \| `CLOSED` \| `MERGED`. | -| `updatedAt` | yes | ISO-8601. | -| `isDraft` | yes | boolean. | -| `mergeable` | yes | `MERGEABLE` \| `CONFLICTING` \| `UNKNOWN`. | -| `mergeStateStatus` | yes | GitHub `mergeStateStatus` (`BEHIND` \| `BLOCKED` \| `CLEAN` \| `DIRTY` \| `DRAFT` \| `HAS_HOOKS` \| `UNSTABLE` \| `UNKNOWN`). Stored for the summary layer; **not** part of the change comparison or the delta id (stripped by `comparableFingerprint`, like `ciChecks` / `reviewSummary`). | -| `review` | yes | GitHub `reviewDecision` (e.g. `APPROVED`, `REVIEW_REQUIRED`, `""`). | -| `comments` | yes | exact integer total from GraphQL `totalCommentsCount`. | -| `reviewThreads` | yes | integer count of PR review threads. | -| `unresolvedReviewThreads` | yes | integer count of unresolved PR review threads. | -| `ci` | **opaque** | sha1 digest of the CI rollup. Observe inequality only; never parse. | -| `ciChecks` | yes | normalized CI rollup: sorted `{name, status, conclusion}` rows behind the `ci` digest. Absent from snapshots written before it was introduced. Not part of the change comparison or the delta id. | -| `reviews` | **opaque** | sha1 digest of latest reviews. Observe inequality only; never parse. | -| `reviewSummary` | yes | compact latest-review rows: sorted `{author, state, submittedAt, commit}` behind the `reviews` digest. Absent from snapshots written before it was introduced. Not part of the change comparison or the delta id. | -| `head` | opaque-ish | head ref OID (git SHA). Treat as a change indicator; every push changes it. | -| `missing` | bookkeeping | boolean; present on fingerprints stored for missing items. Not part of the change comparison. | -| `missingTicks` | bookkeeping | number; consecutive ticks an item has been absent. Present alongside `missing: true`. | +| Field | Readable? | Notes | +| ------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `state` | yes | `OPEN` \| `CLOSED` \| `MERGED`. | +| `updatedAt` | yes | ISO-8601. | +| `isDraft` | yes | boolean. | +| `mergeable` | yes | `MERGEABLE` \| `CONFLICTING` \| `UNKNOWN`. | +| `mergeStateStatus` | yes | GitHub `mergeStateStatus` (`BEHIND` \| `BLOCKED` \| `CLEAN` \| `DIRTY` \| `DRAFT` \| `HAS_HOOKS` \| `UNSTABLE` \| `UNKNOWN`). A compared field like `mergeable`: a transition (e.g. `CLEAN` → `BEHIND` after the base branch advances) participates in the change comparison and the delta id and surfaces as an `updated` delta. A snapshot that predates the field does not fire on its first appearance (upgrade case handled in the detector). | +| `review` | yes | GitHub `reviewDecision` (e.g. `APPROVED`, `REVIEW_REQUIRED`, `""`). | +| `comments` | yes | exact integer total from GraphQL `totalCommentsCount`. | +| `reviewThreads` | yes | integer count of PR review threads. | +| `unresolvedReviewThreads` | yes | integer count of unresolved PR review threads. | +| `ci` | **opaque** | sha1 digest of the CI rollup. Observe inequality only; never parse. | +| `ciChecks` | yes | normalized CI rollup: sorted `{name, status, conclusion}` rows behind the `ci` digest. Absent from snapshots written before it was introduced. Not part of the change comparison or the delta id. | +| `reviews` | **opaque** | sha1 digest of latest reviews. Observe inequality only; never parse. | +| `reviewSummary` | yes | compact latest-review rows: sorted `{author, state, submittedAt, commit}` behind the `reviews` digest. Absent from snapshots written before it was introduced. Not part of the change comparison or the delta id. | +| `head` | opaque-ish | head ref OID (git SHA). Treat as a change indicator; every push changes it. | +| `missing` | bookkeeping | boolean; present on fingerprints stored for missing items. Not part of the change comparison. | +| `missingTicks` | bookkeeping | number; consecutive ticks an item has been absent. Present alongside `missing: true`. | Issue fingerprint: `state`, `updatedAt`, `labels` (string[], sorted), `comments` (exact integer total from GraphQL `totalCount`). diff --git a/lib/cli.mjs b/lib/cli.mjs index 7ba0bd3..8a6e877 100644 --- a/lib/cli.mjs +++ b/lib/cli.mjs @@ -93,14 +93,7 @@ function changedFingerprintFields(delta) { // ciChecks/reviewSummary mirror the ci/reviews digests; surfacing them // here would only duplicate the digest transition (or invent one when a // pre-summary snapshot side simply lacks the field). - ![ - 'missing', - 'missingTicks', - 'commentsOverflow', - 'ciChecks', - 'reviewSummary', - 'mergeStateStatus', - ].includes(key), + !['missing', 'missingTicks', 'commentsOverflow', 'ciChecks', 'reviewSummary'].includes(key), ) .filter((key) => JSON.stringify(delta.from[key]) !== JSON.stringify(delta.to[key])) .sort(); diff --git a/lib/contract.mjs b/lib/contract.mjs index e3d7abb..c370329 100644 --- a/lib/contract.mjs +++ b/lib/contract.mjs @@ -153,6 +153,7 @@ export const DELTA_DETAIL_FIELDS_BY_CLASS = Object.freeze({ 'isDraft', 'labels', 'mergeable', + 'mergeStateStatus', 'review', 'reviewThreads', 'reviews', diff --git a/lib/detect.mjs b/lib/detect.mjs index 7524aa1..7d845ec 100644 --- a/lib/detect.mjs +++ b/lib/detect.mjs @@ -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)); } /** diff --git a/lib/fingerprint.mjs b/lib/fingerprint.mjs index 39056c4..1f6ca95 100644 --- a/lib/fingerprint.mjs +++ b/lib/fingerprint.mjs @@ -125,23 +125,20 @@ export function issueFingerprint(issue) { * is a legacy saturation flag; none describe the observed change, so they must not * influence either change comparison or the content-addressed delta id. * `ciChecks` / `reviewSummary` are detail-only mirrors of the `ci` / `reviews` - * digests (derived from the same fetch data), and `mergeStateStatus` is a - * summary-only observation; dropping all three keeps change comparison and delta - * ids stable across snapshots written before those fields 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. + * digests (derived from the same fetch data), so dropping them keeps change + * comparison and delta ids stable across snapshots written before the summaries + * 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; let normalized = fp; - for (const key of [ - 'missing', - 'missingTicks', - 'commentsOverflow', - 'ciChecks', - 'reviewSummary', - 'mergeStateStatus', - ]) { + for (const key of ['missing', 'missingTicks', 'commentsOverflow', 'ciChecks', 'reviewSummary']) { if (Object.hasOwn(normalized, key)) { const { [key]: _dropped, ...rest } = normalized; normalized = rest; diff --git a/test/cli.test.mjs b/test/cli.test.mjs index f0463a4..cac9e7a 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -401,6 +401,19 @@ test('--summaries surfaces mergeStateStatus behind for an up-to-date-required br assert.equal(report.deltas[0].summary.mergeStateStatus, 'behind'); }); +test('a mergeStateStatus-only transition fires an updated delta end-to-end', () => { + // Base branch advanced: the same PR goes CLEAN -> BEHIND with nothing else + // changed. gh-delta must emit a delta (exit 10) carrying the new summary, or a + // consumer never re-evaluates merge readiness. + const before = { ...basePr, mergeStateStatus: 'CLEAN' }; + const after = { ...basePr, mergeStateStatus: 'BEHIND' }; + const d = deps([[after]], { existing: { pr: { 42: prFingerprint(before) }, issue: {} } }); + const { code, report } = run(SUMMARIES_ARGS, d); + assert.equal(code, 10); + assert.deepEqual(report.deltas[0].classes, ['updated']); + assert.equal(report.deltas[0].summary.mergeStateStatus, 'behind'); +}); + const BASELINE_EMIT_ARGS = [ '--repo', 'o/r', diff --git a/test/detect.test.mjs b/test/detect.test.mjs index f077ea7..a1e7043 100644 --- a/test/detect.test.mjs +++ b/test/detect.test.mjs @@ -28,6 +28,40 @@ test('first run establishes a baseline with no deltas', () => { assert.ok(r.snapshot.pr['42']); }); +test('a mergeStateStatus-only transition (CLEAN->BEHIND) emits an updated delta', () => { + // The P1 scenario: base branch advances, PR goes CLEAN->BEHIND with no other + // change (still OPEN, still MERGEABLE, same head/updatedAt). It must surface, or + // a consumer stays at a stale "ready to merge". + const base = detectDeltas(null, { pr: [pr({ mergeStateStatus: 'CLEAN' })], issue: [] }); + const r = detectDeltas(base.snapshot, { + pr: [pr({ mergeStateStatus: 'BEHIND' })], + issue: [], + }); + assert.equal(r.deltas.length, 1); + assert.deepEqual(r.deltas[0].classes, ['updated']); + assert.equal(r.deltas[0].to.mergeStateStatus, 'BEHIND'); +}); + +test('an unchanged mergeStateStatus does not emit a delta', () => { + const base = detectDeltas(null, { pr: [pr({ mergeStateStatus: 'CLEAN' })], issue: [] }); + const r = detectDeltas(base.snapshot, { + pr: [pr({ mergeStateStatus: 'CLEAN' })], + issue: [], + }); + assert.deepEqual(r.deltas, []); +}); + +test('a snapshot predating mergeStateStatus does not burst on first observation', () => { + // Upgrade case (Codex P1): an older stored fingerprint lacks the field. Its + // first appearance must NOT be read as a change, or every open PR emits a + // spurious `updated` on the first post-upgrade tick. + const base = detectDeltas(null, { pr: [pr({ mergeStateStatus: 'CLEAN' })], issue: [] }); + const legacy = { ...base.snapshot }; + delete legacy.pr['42'].mergeStateStatus; // simulate a pre-field snapshot + const r = detectDeltas(legacy, { pr: [pr({ mergeStateStatus: 'CLEAN' })], issue: [] }); + assert.deepEqual(r.deltas, []); +}); + test('baseline-state is a registered closed-set class with a detail field map', () => { assert.ok(DELTA_CLASSES.includes('baseline-state')); assert.deepEqual(DELTA_DETAIL_FIELDS_BY_CLASS['baseline-state'], ['presence', 'state']); diff --git a/test/fingerprint.test.mjs b/test/fingerprint.test.mjs index 872888d..63bac39 100644 --- a/test/fingerprint.test.mjs +++ b/test/fingerprint.test.mjs @@ -94,7 +94,11 @@ test('comparableFingerprint drops the detail-only summaries', () => { assert.deepEqual(comparableFingerprint(legacy), comparable); }); -test('prFingerprint stores mergeStateStatus but comparableFingerprint strips it', () => { +test('prFingerprint stores mergeStateStatus and comparableFingerprint keeps it (a compared field)', () => { + // Unlike ciChecks/reviewSummary (detail mirrors of a compared digest), + // mergeStateStatus has no compared counterpart, so it must participate in + // comparison itself — otherwise a CLEAN->BEHIND-only transition is invisible. + // It is treated like `mergeable`: part of the change comparison and the id. const fp = prFingerprint({ state: 'OPEN', updatedAt: '2026-07-01T10:00:00Z', @@ -103,21 +107,7 @@ test('prFingerprint stores mergeStateStatus but comparableFingerprint strips it' latestReviews: [], }); assert.equal(fp.mergeStateStatus, 'BLOCKED'); - assert.equal('mergeStateStatus' in comparableFingerprint(fp), false); -}); - -test('a snapshot gaining mergeStateStatus does not compare as changed', () => { - // Mirrors the ciChecks/reviewSummary upgrade guarantee: an older snapshot that - // predates the field must diff to zero against a current one carrying it. - const withField = prFingerprint({ - state: 'OPEN', - updatedAt: '2026-07-01T10:00:00Z', - mergeStateStatus: 'CLEAN', - statusCheckRollup: [], - latestReviews: [], - }); - const { mergeStateStatus: _dropped, ...legacy } = withField; - assert.deepEqual(comparableFingerprint(legacy), comparableFingerprint(withField)); + assert.equal(comparableFingerprint(fp).mergeStateStatus, 'BLOCKED'); }); test('hashReviews is order-independent and reflects state', () => {