diff --git a/README.md b/README.md
index 4609d8f..9ad6f5b 100644
--- a/README.md
+++ b/README.md
@@ -12,9 +12,11 @@ with a machine-readable code. Scheduling belongs to cron, an automation system,
or the caller.
The tool does not decide what to do. It only detects changes such as new PRs,
-merged PRs, CI status changes, review decision changes, unresolved review
-threads, new comments, relabeling, missing objects, and catch-all updates. Your
-orchestrator, script, or agent owns the action.
+merged PRs, CI status changes, review decision changes, requested-reviewer and
+assignee changes, unresolved review threads, new or deleted comments,
+relabeling, base-branch retargets, draft transitions, mergeability flips,
+missing objects, and catch-all updates. Your orchestrator, script, or agent owns
+the action.
`gh-delta` is not a dashboard, inbox, or PR bot. It is a deterministic GitHub
delta detector for schedulers, scripts, and agent loops.
@@ -138,7 +140,7 @@ Text output is designed for scheduled logs:
JSON output is designed for programs and agents:
-
+
Use `--summary-line` when an agent only needs a display sentence. Use `--detail`
diff --git a/RUNBOOK.md b/RUNBOOK.md
index 4df528c..33eea6d 100644
--- a/RUNBOOK.md
+++ b/RUNBOOK.md
@@ -128,6 +128,12 @@ Heartbeat format:
Use `--format json` when another program needs the raw structured report.
+**Cadence and rate limit:** a typical tick costs ~18 GraphQL points (7 per PR
+page + 2 per issue page, × two fetch phases) against GitHub's 5,000
+points/hour-per-token budget — generous for one monitor, shared across all
+monitors on the same token. Numbers and how to spend less:
+[Fetch limits](docs/contract.md#fetch-limits-page-caps).
+
## Outpost Mode
`--outpost-url` must be an `http:` or `https:` URL. Invalid configuration exits
@@ -230,17 +236,24 @@ developer polling loops or webhook-driven automation.
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `new` (PR) | a worker opened a PR; read it and queue review |
| `first-seen` | first observed non-open item; inspect it before treating it as newly created |
+| `baseline-state` | only under `--baseline-emit-state`, on the seeding run: pre-existing open-item state (e.g. already conflicting); inspect, do not treat as newly created |
| `ci-changed` | CI green: consider merge path; CI red: nudge worker with the failure (with `--format json --detail`, the delta's `ci` detail names the exact checks that changed) |
| `review-changed` | approved: merge candidate; changes requested: relay to worker (with `--format json --detail`, the `reviews` detail names the reviewers and state transitions) |
| `became-mergeable` | conflicts resolved; merge candidate |
+| `became-conflicting` | PR now conflicts with its base; rebase or resolve before merge |
| `draft-ready` | PR left draft and is ready for review; queue it for review or dispatch |
+| `converted-to-draft` | PR went back to draft; hold review and merge actions until it is ready again |
| `merged` / `closed` | slice done; advance build order or sync spawn base |
| `reopened` | item reopened; re-enter it into the active work queue |
| `new-comments` | read PR threads; fold review comments before merge |
+| `comments-removed` | comments were deleted; re-read the thread — prior context may be gone |
| `unresolved-threads-added` | unresolved review threads appeared; resolve before merge |
| `unresolved-threads-resolved` | review threads resolved; re-check CI and review state |
| `review-threads-changed` | review thread activity changed; inspect before acting |
-| `relabeled` | scope or state change on an issue; reassess dispatch |
+| `relabeled` | labels changed (PR or issue — route on `entity`); reassess dispatch |
+| `assignees-changed` | ownership changed (with `--detail`, the `assignees` detail names added/removed logins); check who owns the item before dispatching |
+| `review-requests-changed` | requested reviewers changed (with `--detail`, added/removed logins; teams as `org/slug`); check who is now expected to review |
+| `base-changed` | PR base branch changed; prior CI/mergeability context refers to the old base — re-check both |
| `missing` | open item disappeared from fetch; check pagination, permissions, or scope |
| `still-missing` | open item remains absent (tick 2); unresolved operational issue, not a fresh delta |
| `presumed-deleted` | absent for 3 consecutive ticks; treat as gone; verify on GitHub if unexpected; no further ticks will mention it unless it reappears |
diff --git a/docs/contract.md b/docs/contract.md
index eb15e17..4b25547 100644
--- a/docs/contract.md
+++ b/docs/contract.md
@@ -292,6 +292,9 @@ the array is not significant and not guaranteed stable.
| `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. |
+| `comments-removed` | pr, issue | Comment count decreased (comment deleted, or deletions outnumbering additions within one tick). Prior context an agent read may be gone; re-read before acting. |
+| `relabeled` | pr, issue | Labels changed. Detail names the added/removed labels. **Was issue-only before 0.5.0** — route on `entity`, not on the class. |
+| `assignees-changed` | pr, issue | Assignees changed. Detail names the added/removed logins. Neutral state transition: gh-delta reports who owns the item now, never who _should_. |
| `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`. |
@@ -299,13 +302,16 @@ the array is not significant and not guaranteed stable.
| `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. |
+| `converted-to-draft` | pr only | PR moved back from ready to draft. The inverse of `draft-ready`. |
| `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). |
+| `became-mergeable` | pr only | PR moved from `CONFLICTING` to `MERGEABLE` (an `UNKNOWN` mid-recompute placeholder does not count). Best-effort edge trigger: a transition sampled through the `UNKNOWN` window surfaces as `updated` instead — gate decisions on the observed `mergeable` state, not on this class. |
+| `became-conflicting` | pr only | PR moved from `MERGEABLE` to `CONFLICTING` (an `UNKNOWN` mid-recompute placeholder does not count). The inverse of `became-mergeable`, with the same best-effort caveat: sampling through `UNKNOWN` yields `updated` instead. |
| `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.) |
+| `review-requests-changed` | pr only | Requested reviewers changed (review requested or a request withdrawn/satisfied). Detail names the added/removed logins (teams as `org/slug`). Note GitHub removes a user from `reviewRequests` once they submit a review, so a submitted review usually fires this together with `review-changed`. |
+| `base-changed` | pr only | Base branch changed (PR retargeted). Prior CI and mergeability context refer to the old base; expect `mergeable: UNKNOWN` churn while GitHub recomputes. |
**Forward compatibility:** new classes may be added in a later minor version.
Consumers must treat an unrecognized class as "something changed, inspect,"
@@ -423,7 +429,12 @@ Each delta:
deliberately **excludes `monitorId`**, the report `at`, `title`, and every
derived display field — so two monitors observing the same current GitHub
state emit the same `id`. Use it as the idempotency key for dedupe. Added
- additively; it does not bump `schemaVersion`.
+ additively; it does not bump `schemaVersion`. Ids are stable **per gh-delta
+ version**: a release that adds compared fingerprint fields (e.g. 0.5.0's
+ `base`/`labels`/`assignees`/`reviewRequests`) shifts each item's id once on
+ its first post-upgrade delta, and monitors on different versions emit
+ different ids for the same observation — upgrade co-posting monitors
+ together to keep cross-monitor dedupe intact.
- `entity` (`"pr"` | `"issue"`): the **only** discriminator between an issue and
a PR. GitHub numbers are shared across issues and PRs, so `number` alone is
ambiguous — always key on `(entity, number)`.
@@ -441,10 +452,12 @@ Each delta:
all issue deltas. Lets a consumer route or group by branch without a second
GitHub round-trip.
- > Note: gh-delta does **not** signal branch deletion — deletion changes neither
- > `headRefName` nor `headRefOid` (both retained by GitHub) and is not
- > fingerprinted, so it produces no delta. The field is purely "which branch",
- > never "does the branch still exist".
+ > Note: gh-delta does **not** identify branch deletion as such — deletion
+ > changes neither `headRefName` nor `headRefOid` (both retained by GitHub).
+ > GitHub does bump the PR's `updatedAt` when the head branch is deleted, so
+ > within the horizon window the deletion surfaces as an opaque `updated`
+ > delta; query `headRef` (null once deleted) to attribute it. The field is
+ > purely "which branch", never "does the branch still exist".
- `classes` (string[]): non-empty set of [classes](#delta-classes).
- `from`, `to`: entity [fingerprints](#fingerprint-fields-from--to), or `null`. `from` is
@@ -468,7 +481,7 @@ Detail entries use `class` to name the class being explained. Common shapes:
- field transition: `{ "class": "closed", "field": "state", "from": "OPEN", "to": "CLOSED" }`;
- numeric transition: `{ "class": "new-comments", "field": "comments", "from": 1, "to": 3, "delta": 2 }`;
-- label transition: `{ "class": "relabeled", "field": "labels", "added": ["urgent"], "removed": [] }`;
+- set transition (labels, assignees, requested reviewers): `{ "class": "relabeled", "field": "labels", "added": ["urgent"], "removed": [] }` — same `added`/`removed` shape for `assignees-changed` (`field: "assignees"`) and `review-requests-changed` (`field: "reviewRequests"`);
- presence transition: `{ "class": "missing", "field": "presence", "from": "present", "to": "missing", "missingTicks": 1 }`.
`ci-changed` and `review-changed` details name the exact entries that changed
@@ -586,11 +599,16 @@ PR fingerprint:
| `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. |
+| `base` | yes | base branch name (`baseRefName`). A compared field; a transition emits `base-changed`. Snapshots predating it do not fire on its first appearance (same upgrade rule as `mergeStateStatus`). |
+| `labels` | yes | string[], sorted label names. A compared field; a transition emits `relabeled`. Same additive upgrade rule. |
+| `assignees` | yes | string[], sorted assignee logins. A compared field; a transition emits `assignees-changed`. Same additive upgrade rule. |
+| `reviewRequests` | yes | string[], sorted requested-reviewer names (user/bot logins; teams as `org/slug`). A reviewer the token cannot resolve (e.g. a private team) is recorded as the literal placeholder `?` — deterministic per token, but two monitors with different token scopes can fingerprint the same PR differently there. A compared field; a transition emits `review-requests-changed`. Same additive upgrade rule. |
| `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`).
+`assignees` (string[], sorted logins; same additive upgrade rule as the PR
+fields above), `comments` (exact integer total from GraphQL `totalCount`).
When an item is missing, `missing: true` and `missingTicks` are written into the
stored fingerprint so the lifecycle (`missing` → `still-missing` →
@@ -657,6 +675,18 @@ family with more than 1000 currently-open items, or with more than 3000 items
updated since the last horizon, needs a shorter tick interval or a narrower
`--entities` selection to stay under these caps.
+**Rate-limit budget.** GitHub charges GraphQL by _requested_ page shape, not by
+rows returned: as of 0.5.0 a PR page costs **7 points** and an issue page **2
+points** (measured live via `rateLimit { cost }`; the PR page was 4 points
+before the 0.5.0 fields). A typical steady-state tick — both entity families,
+one page each, open + updated phases — costs **~18 points** against the
+GraphQL budget of **5,000 points/hour per token**; a baseline tick skips the
+updated phase and costs half. That leaves headroom for hundreds of ticks per
+hour, but the budget is shared by every monitor (and every other GraphQL use)
+on the same token. To spend less: drop an entity family with `--entities pr`
+or `--entities issue`, lengthen the tick cadence (cost scales linearly), or
+split dense fleets across tokens.
+
Snapshot shape: `{ "pr": object, "issue": object, "meta"?: object }`.
`meta.horizon` is stamped with the run timestamp on every successful write.
Legacy snapshots without `meta` fall back to the newest `updatedAt` across all
@@ -699,7 +729,7 @@ their derived filename or a registry entry.
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`,
- `mergeStateStatus`). The
+ `mergeStateStatus`, `base`, PR `labels`, `assignees`, `reviewRequests`). 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.
@@ -736,6 +766,16 @@ On **Windows** the behavior degrades explicitly, never silently:
keys case-fold the resolved snapshot path on Windows, so `C:\State\x.json`
and `c:\state\x.json` are one monitor, not two. On POSIX, paths that differ
by case are genuinely different files and are kept distinct.
+- **GraphQL schema age (GitHub Enterprise).** The PR query's `reviewRequests`
+ selection spreads an inline fragment on `Bot` and selects
+ `Team.combinedSlug` — both newer schema members, verified against
+ github.com. On a GHES version whose `RequestedReviewer` union predates
+ `Bot` (or lacks `combinedSlug`), GraphQL **validation rejects the whole
+ query** and every tick fails with a `github` error. If you run against an
+ older GHES and hit this, report it — the fix would ship as an **opt-in
+ compatibility selection** for those hosts (e.g. an env knob switching to
+ `slug`, no `Bot` fragment), never as a degradation of the github.com
+ default, which would lose Copilot-reviewer detection for everyone.
## Outpost Payload (schema v1)
diff --git a/docs/img/demo.svg b/docs/img/demo.svg
index ff8a32d..aded821 100644
--- a/docs/img/demo.svg
+++ b/docs/img/demo.svg
@@ -1 +1 @@
-
\ No newline at end of file
+❯❯while❯while:;❯while:;do❯while:;dogh-delta❯while:;dogh-delta--repo❯while:;dogh-delta--repoowner/repo;❯while:;dogh-delta--repoowner/repo;sleep❯while:;dogh-delta--repoowner/repo;sleep300;❯while:;dogh-delta--repoowner/repo;sleep300;done2026-07-01T12:00:00.000Z|0delta(s)Baselineseededforowner/repo(monitor:host-9c1f7b2a4e83).Noactiontaken.sleep300#inbetween,someoneworksontherepo2026-07-01T12:05:00.000Z|2delta(s)PR#42"Addbillingwebhook":ci-changed,review-changed,review-requests-changedclasses:ci-changed,review-changed,review-requests-changedsuggestedaction:CI/reviewchanged.Readchecksandreviewthreadsbeforemerge.ISSUE#17"Backfillcustomerimports":relabeledclasses:relabeledsuggestedaction:scope/statechanged.Reassessdispatch.❯w❯wh❯whi❯whil❯while:❯while:;d❯while:;dog❯while:;dogh❯while:;dogh-❯while:;dogh-d❯while:;dogh-de❯while:;dogh-del❯while:;dogh-delt❯while:;dogh-delta-❯while:;dogh-delta--❯while:;dogh-delta--r❯while:;dogh-delta--re❯while:;dogh-delta--rep❯while:;dogh-delta--repoo❯while:;dogh-delta--repoow❯while:;dogh-delta--repoown❯while:;dogh-delta--repoowne❯while:;dogh-delta--repoowner❯while:;dogh-delta--repoowner/❯while:;dogh-delta--repoowner/r❯while:;dogh-delta--repoowner/re❯while:;dogh-delta--repoowner/rep❯while:;dogh-delta--repoowner/repo❯while:;dogh-delta--repoowner/repo;s❯while:;dogh-delta--repoowner/repo;sl❯while:;dogh-delta--repoowner/repo;sle❯while:;dogh-delta--repoowner/repo;slee❯while:;dogh-delta--repoowner/repo;sleep3❯while:;dogh-delta--repoowner/repo;sleep30❯while:;dogh-delta--repoowner/repo;sleep300❯while:;dogh-delta--repoowner/repo;sleep300;d❯while:;dogh-delta--repoowner/repo;sleep300;do❯while:;dogh-delta--repoowner/repo;sleep300;don…seedingbaselinefromGitHub…fetchingGitHubstate
\ No newline at end of file
diff --git a/docs/img/json-output.svg b/docs/img/json-output.svg
index 0571451..a88bb55 100644
--- a/docs/img/json-output.svg
+++ b/docs/img/json-output.svg
@@ -1 +1 @@
-],"state":"OPEN",},{"opaque":true},"class":"review-changed",❯gh-delta--repoowner/repo--formatjson--detail|jq{"schemaVersion":1,"baseline":false,"repo":"owner/repo","monitorId":"host-9c1f7b2a4e83","entities":["pr","issue""stateFile":"/tmp/gh-delta-user/repo-owner%2Frepo__monitor-host-9c1f7b2a4e83__pr-issue.json","at":"2026-07-01T12:05:00.000Z","deltas":[{"id":"f9e831442932e012f9928543f9eaad12749a32cd99abce8f3844438216096e96","entity":"pr","number":42,"title":"Addbillingwebhook","headRefName":"feature/billing-webhook","classes":["ci-changed","review-changed"],"from":{"ci":"a1b2c3","review":"CHANGES_REQUESTED","reviews":"r-9f8e""to":{"ci":"d4e5f6","review":"APPROVED","reviews":"r-2c1d""summaryLine":"PR#42\"Addbillingwebhook\":ci-changed,review-changed","line":"PR#42\"Addbillingwebhook\":ci-changed,review-changed","details":["class":"ci-changed","field":"ci","from":"a1b2c3","to":"d4e5f6","field":"review","from":"CHANGES_REQUESTED","to":"APPROVED""field":"reviews","from":"r-9f8e","to":"r-2c1d",}]}"summary":"1delta(s)"}❯
\ No newline at end of file
+],"state":"OPEN",},{"opaque":true},"class":"review-changed",❯gh-delta--repoowner/repo--formatjson--detail|jq{"schemaVersion":1,"baseline":false,"repo":"owner/repo","repoSource":"flag","monitorId":"host-9c1f7b2a4e83","entities":["pr","issue""stateFile":"/tmp/gh-delta-user/repo-owner%2Frepo__monitor-host-9c1f7b2a4e83__pr-issue.json","at":"2026-07-01T12:05:00.000Z","deltas":[{"id":"c86792c9ccb2682586d31095e3207e8afba2664942c760a1ed6bf73b46e78bd5","entity":"pr","number":42,"title":"Addbillingwebhook","headRefName":"feature/billing-webhook","classes":["ci-changed","review-changed","review-requests-changed"],"from":{"ci":"a1b2c3","review":"CHANGES_REQUESTED","reviews":"r-9f8e","reviewRequests":["alice"]"to":{"ci":"d4e5f6","review":"APPROVED","reviews":"r-2c1d","reviewRequests":[]"summaryLine":"PR#42\"Addbillingwebhook\":ci-changed,review-changed,review-requests-changed","line":"PR#42\"Addbillingwebhook\":ci-changed,review-changed,review-requests-changed","details":["class":"ci-changed","field":"ci","from":"a1b2c3","to":"d4e5f6","field":"review","from":"CHANGES_REQUESTED","to":"APPROVED""field":"reviews","from":"r-9f8e","to":"r-2c1d","class":"review-requests-changed","field":"reviewRequests","added":[],"removed":["alice"]}]}"summary":"1delta(s)"}❯
\ No newline at end of file
diff --git a/docs/img/text-output.svg b/docs/img/text-output.svg
index 7119c11..2d4d346 100644
--- a/docs/img/text-output.svg
+++ b/docs/img/text-output.svg
@@ -1 +1 @@
-❯gh-delta--repoowner/repo2026-07-01T12:05:00.000Z|2delta(s)PR#42"Addbillingwebhook":ci-changed,review-changedclasses:ci-changed,review-changedsuggestedaction:CI/reviewchanged.Readchecksandreviewthreadsbeforemerge.ISSUE#17"Backfillcustomerimports":relabeledclasses:relabeledsuggestedaction:scope/statechanged.Reassessdispatch.❯
\ No newline at end of file
+❯gh-delta--repoowner/repo2026-07-01T12:05:00.000Z|2delta(s)PR#42"Addbillingwebhook":ci-changed,review-changed,review-requests-changedclasses:ci-changed,review-changed,review-requests-changedsuggestedaction:CI/reviewchanged.Readchecksandreviewthreadsbeforemerge.ISSUE#17"Backfillcustomerimports":relabeledclasses:relabeledsuggestedaction:scope/statechanged.Reassessdispatch.❯
\ No newline at end of file
diff --git a/docs/watch-loop-prompt.md b/docs/watch-loop-prompt.md
index 4577e2d..e89f4e4 100644
--- a/docs/watch-loop-prompt.md
+++ b/docs/watch-loop-prompt.md
@@ -46,26 +46,41 @@ Run the GitHub delta detector for `` and act on what it reports.
- new (PR): a worker opened a PR. Read it; queue it for review.
- first-seen: first observed non-open item. Inspect it before treating it as
newly created.
+ - baseline-state: only under --baseline-emit-state, on the seeding run.
+ Pre-existing open-item state (e.g. already conflicting); inspect, do not
+ treat as newly created.
- ci-changed: CI green -> move toward merge. CI red -> nudge the worker with
the failure.
- review-changed: APPROVED -> merge candidate. CHANGES_REQUESTED -> relay the
changes to the worker.
- became-mergeable: conflicts resolved -> merge candidate.
+ - became-conflicting: PR now conflicts with its base. Rebase or resolve
+ before merge.
- draft-ready: PR left draft and is ready for review. Queue it for review or
dispatch.
+ - converted-to-draft: PR went back to draft. Hold review and merge actions
+ until it is ready again.
- merged / closed: a slice is done. Advance the build order or sync the spawn
base.
- reopened: item reopened. Re-enter it into the active work queue.
- new-comments: read the PR/issue threads; fold in any review comments before
merging.
+ - comments-removed: comments were deleted. Re-read the thread; prior context
+ may be gone.
- unresolved-threads-added: unresolved PR review threads appeared. Read and
resolve them before merging.
- unresolved-threads-resolved: unresolved PR review threads were resolved.
Re-check CI and review state before merging.
- review-threads-changed: PR review thread activity changed. Inspect review
threads before acting.
- - relabeled: an issue's scope/state changed. Reassess whether or what to
- dispatch.
+ - relabeled: labels changed on a PR or issue (route on entity). Reassess
+ whether or what to dispatch.
+ - assignees-changed: ownership changed. Check who owns the item before
+ dispatching.
+ - review-requests-changed: requested reviewers changed. Check who is now
+ expected to review.
+ - base-changed: the PR base branch changed. Prior CI/mergeability context
+ refers to the old base; re-check both.
- missing: an open item disappeared from the fetch. Check pagination,
permissions, or scope before trusting the snapshot.
- still-missing: the same open item is still absent (tick 2). Treat it as
diff --git a/lib/cli.mjs b/lib/cli.mjs
index 8a6e877..017fbd9 100644
--- a/lib/cli.mjs
+++ b/lib/cli.mjs
@@ -6,7 +6,7 @@ import { parseArgs } from 'node:util';
import { mkdirSync, statSync } from 'node:fs';
import { fetchPRs as ghPRs, fetchIssues as ghIssues } from './gh.mjs';
import { detectDeltas } from './detect.mjs';
-import { deltaId, deltaIdentity } from './fingerprint.mjs';
+import { ADDITIVE_COMPARED_FIELDS, deltaId, deltaIdentity } from './fingerprint.mjs';
import { deltaSummary } from './summary.mjs';
import {
defaultStateDir,
@@ -71,16 +71,18 @@ function pushNumericDelta(details, klass, delta, field) {
details.push(fieldDetail(klass, field, from, to, { delta: to - from }));
}
-function pushLabelDelta(details, klass, delta) {
- const from = hasField(delta.from, 'labels') ? delta.from.labels : [];
- const to = hasField(delta.to, 'labels') ? delta.to.labels : [];
- const oldLabels = new Set(from);
- const newLabels = new Set(to);
+// Set-style detail for sorted string-list fingerprint fields (labels,
+// assignees, reviewRequests): name what entered and what left.
+function pushSetDelta(details, klass, delta, field) {
+ const from = hasField(delta.from, field) ? delta.from[field] : [];
+ const to = hasField(delta.to, field) ? delta.to[field] : [];
+ const oldEntries = new Set(from);
+ const newEntries = new Set(to);
details.push({
class: klass,
- field: 'labels',
- added: to.filter((label) => !oldLabels.has(label)),
- removed: from.filter((label) => !newLabels.has(label)),
+ field,
+ added: to.filter((entry) => !oldEntries.has(entry)),
+ removed: from.filter((entry) => !newEntries.has(entry)),
});
}
@@ -95,6 +97,12 @@ function changedFingerprintFields(delta) {
// pre-summary snapshot side simply lacks the field).
!['missing', 'missingTicks', 'commentsOverflow', 'ciChecks', 'reviewSummary'].includes(key),
)
+ .filter(
+ // Same additive-field suppression as the detector's change comparison: a
+ // compared field the old snapshot predates is not a transition, so an
+ // upgrade tick must not report a phantom `null -> current` detail row.
+ (key) => !(ADDITIVE_COMPARED_FIELDS.includes(key) && !Object.hasOwn(delta.from, key)),
+ )
.filter((key) => JSON.stringify(delta.from[key]) !== JSON.stringify(delta.to[key]))
.sort();
}
@@ -178,6 +186,7 @@ function detailForClass(delta, klass) {
pushFieldDetail(details, klass, delta, 'state');
break;
case 'draft-ready':
+ case 'converted-to-draft':
pushFieldDetail(details, klass, delta, 'isDraft');
break;
case 'ci-changed':
@@ -194,9 +203,14 @@ function detailForClass(delta, klass) {
);
break;
case 'became-mergeable':
+ case 'became-conflicting':
pushFieldDetail(details, klass, delta, 'mergeable');
break;
+ case 'base-changed':
+ pushFieldDetail(details, klass, delta, 'base');
+ break;
case 'new-comments':
+ case 'comments-removed':
pushNumericDelta(details, klass, delta, 'comments');
break;
case 'unresolved-threads-added':
@@ -207,7 +221,13 @@ function detailForClass(delta, klass) {
pushNumericDelta(details, klass, delta, 'reviewThreads');
break;
case 'relabeled':
- pushLabelDelta(details, klass, delta);
+ pushSetDelta(details, klass, delta, 'labels');
+ break;
+ case 'assignees-changed':
+ pushSetDelta(details, klass, delta, 'assignees');
+ break;
+ case 'review-requests-changed':
+ pushSetDelta(details, klass, delta, 'reviewRequests');
break;
case 'updated':
for (const field of changedFingerprintFields(delta)) {
diff --git a/lib/contract.mjs b/lib/contract.mjs
index c370329..252c72d 100644
--- a/lib/contract.mjs
+++ b/lib/contract.mjs
@@ -130,13 +130,19 @@ export const DELTA_CLASSES = Object.freeze([
'reappeared',
'merged',
'draft-ready',
+ 'converted-to-draft',
'ci-changed',
'review-changed',
'became-mergeable',
+ 'became-conflicting',
'unresolved-threads-added',
'unresolved-threads-resolved',
'review-threads-changed',
'relabeled',
+ 'comments-removed',
+ 'assignees-changed',
+ 'review-requests-changed',
+ 'base-changed',
]);
export const DELTA_DETAIL_FIELDS_BY_CLASS = Object.freeze({
@@ -146,7 +152,10 @@ export const DELTA_DETAIL_FIELDS_BY_CLASS = Object.freeze({
closed: Object.freeze(['state']),
reopened: Object.freeze(['state']),
'new-comments': Object.freeze(['comments']),
+ 'comments-removed': Object.freeze(['comments']),
updated: Object.freeze([
+ 'assignees',
+ 'base',
'ci',
'comments',
'head',
@@ -155,6 +164,7 @@ export const DELTA_DETAIL_FIELDS_BY_CLASS = Object.freeze({
'mergeable',
'mergeStateStatus',
'review',
+ 'reviewRequests',
'reviewThreads',
'reviews',
'state',
@@ -167,11 +177,16 @@ export const DELTA_DETAIL_FIELDS_BY_CLASS = Object.freeze({
reappeared: Object.freeze(['presence']),
merged: Object.freeze(['state']),
'draft-ready': Object.freeze(['isDraft']),
+ 'converted-to-draft': Object.freeze(['isDraft']),
'ci-changed': Object.freeze(['ci']),
'review-changed': Object.freeze(['review', 'reviews']),
'became-mergeable': Object.freeze(['mergeable']),
+ 'became-conflicting': Object.freeze(['mergeable']),
'unresolved-threads-added': Object.freeze(['unresolvedReviewThreads']),
'unresolved-threads-resolved': Object.freeze(['unresolvedReviewThreads']),
'review-threads-changed': Object.freeze(['reviewThreads']),
relabeled: Object.freeze(['labels']),
+ 'assignees-changed': Object.freeze(['assignees']),
+ 'review-requests-changed': Object.freeze(['reviewRequests']),
+ 'base-changed': Object.freeze(['base']),
});
diff --git a/lib/detect.mjs b/lib/detect.mjs
index 7d845ec..12000e2 100644
--- a/lib/detect.mjs
+++ b/lib/detect.mjs
@@ -5,6 +5,7 @@ import {
issueFingerprint,
comparableFingerprint,
stableValue,
+ ADDITIVE_COMPARED_FIELDS,
} from './fingerprint.mjs';
const MISSING_DEMOTE_TICKS = 3;
@@ -18,6 +19,14 @@ function currentObjectMeta(kind, obj) {
return kind === 'pr' ? { headRefName: obj.headRefName ?? null } : {};
}
+// A JSON.stringify inequality between two sorted string arrays. Guarded on the
+// OLD side being an array: a snapshot written before the field existed must not
+// classify the field's first appearance as a change (the additive-field upgrade
+// case; fingerprintChanged applies the same suppression to the whole delta).
+function sortedListChanged(oldList, newList) {
+ return Array.isArray(oldList) && JSON.stringify(oldList) !== JSON.stringify(newList);
+}
+
function classifyPr(oldFp, fp) {
const c = [];
if (oldFp.state !== fp.state) {
@@ -26,11 +35,21 @@ function classifyPr(oldFp, fp) {
else if (fp.state === 'OPEN') c.push('reopened');
}
if (oldFp.isDraft === true && fp.isDraft === false) c.push('draft-ready');
+ if (oldFp.isDraft === false && fp.isDraft === true) c.push('converted-to-draft');
if (oldFp.ci !== fp.ci) c.push('ci-changed');
if (oldFp.review !== fp.review || oldFp.reviews !== fp.reviews) c.push('review-changed');
// Only a real mergeability resolution counts; UNKNOWN is a mid-recompute placeholder.
if (fp.mergeable === 'MERGEABLE' && oldFp.mergeable === 'CONFLICTING') c.push('became-mergeable');
+ if (fp.mergeable === 'CONFLICTING' && oldFp.mergeable === 'MERGEABLE')
+ c.push('became-conflicting');
if (fp.comments > oldFp.comments) c.push('new-comments');
+ if (fp.comments < oldFp.comments) c.push('comments-removed');
+ // Old-side typeof/Array guards: snapshots predating these compared fields must
+ // not read the field's first appearance as a transition.
+ if (typeof oldFp.base === 'string' && oldFp.base !== fp.base) c.push('base-changed');
+ if (sortedListChanged(oldFp.labels, fp.labels)) c.push('relabeled');
+ if (sortedListChanged(oldFp.assignees, fp.assignees)) c.push('assignees-changed');
+ if (sortedListChanged(oldFp.reviewRequests, fp.reviewRequests)) c.push('review-requests-changed');
if (oldFp.state === 'OPEN' && fp.state === 'OPEN') {
const oldUnresolved = oldFp.unresolvedReviewThreads ?? 0;
const newUnresolved = fp.unresolvedReviewThreads ?? 0;
@@ -50,22 +69,27 @@ function classifyIssue(oldFp, fp) {
else if (fp.state === 'OPEN') c.push('reopened');
}
if (JSON.stringify(oldFp.labels) !== JSON.stringify(fp.labels)) c.push('relabeled');
+ if (sortedListChanged(oldFp.assignees, fp.assignees)) c.push('assignees-changed');
if (fp.comments > oldFp.comments) c.push('new-comments');
+ if (fp.comments < oldFp.comments) c.push('comments-removed');
return c;
}
function fingerprintChanged(a, 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;
+ // Additive-field upgrade: when the prior side (a) predates a compared field,
+ // 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 each
+ // field — the fingerprint builders default absent input to ''/[]/UNKNOWN.)
+ if (ca && cb) {
+ for (const field of ADDITIVE_COMPARED_FIELDS) {
+ if (!(field in ca) && field in cb) {
+ const { [field]: _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 1f6ca95..a3011f6 100644
--- a/lib/fingerprint.mjs
+++ b/lib/fingerprint.mjs
@@ -100,6 +100,10 @@ export function prFingerprint(pr) {
reviewThreads: pr.reviewThreads ?? 0,
unresolvedReviewThreads: pr.unresolvedReviewThreads ?? 0,
head: pr.headRefOid ?? '',
+ base: pr.baseRefName ?? '',
+ labels: (pr.labels ?? []).map((l) => l.name).sort(),
+ assignees: [...(pr.assignees ?? [])].sort(),
+ reviewRequests: [...(pr.reviewRequests ?? [])].sort(),
};
}
@@ -114,10 +118,25 @@ export function issueFingerprint(issue) {
state: issue.state,
updatedAt: issue.updatedAt,
labels: (issue.labels ?? []).map((l) => l.name).sort(),
+ assignees: [...(issue.assignees ?? [])].sort(),
comments: issue.comments ?? 0,
};
}
+// Compared fields added after schema v1 shipped. A snapshot written before one
+// of these existed lacks the key, and its first appearance is not a real change:
+// the detector suppresses it pairwise in change comparison, and the CLI applies
+// the same suppression when enumerating `updated` detail rows so upgrade ticks
+// never report a phantom `null -> current` transition for these fields.
+// (Issue snapshots have always carried `labels`, so that entry is inert there.)
+export const ADDITIVE_COMPARED_FIELDS = Object.freeze([
+ 'mergeStateStatus',
+ 'base',
+ 'labels',
+ 'assignees',
+ 'reviewRequests',
+]);
+
/**
* Strip detector-internal churn from a fingerprint so identity is comparable.
*
diff --git a/lib/gh.mjs b/lib/gh.mjs
index 708dcbc..f4a8d0f 100644
--- a/lib/gh.mjs
+++ b/lib/gh.mjs
@@ -13,13 +13,18 @@ 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 mergeStateStatus reviewDecision totalCommentsCount headRefOid headRefName
+ number title state updatedAt isDraft mergeable mergeStateStatus reviewDecision totalCommentsCount headRefOid headRefName baseRefName
commits(last: 1) { nodes { commit { statusCheckRollup { contexts(first: ${PAGE_SIZE}) {
nodes { __typename ... on CheckRun { name status conclusion } ... on StatusContext { context state } }
pageInfo { hasNextPage }
} } } } }
latestReviews(first: ${PAGE_SIZE}) { nodes { id submittedAt state author { login } commit { oid } } pageInfo { hasNextPage } }
reviewThreads(first: ${PAGE_SIZE}) { totalCount nodes { isResolved } pageInfo { hasNextPage } }
+ labels(first: ${PAGE_SIZE}) { nodes { name } pageInfo { hasNextPage } }
+ assignees(first: ${PAGE_SIZE}) { nodes { login } pageInfo { hasNextPage } }
+ reviewRequests(first: ${PAGE_SIZE}) { nodes { requestedReviewer {
+ ... on User { login } ... on Bot { login } ... on Mannequin { login } ... on Team { combinedSlug }
+ } } pageInfo { hasNextPage } }
}
pageInfo { hasNextPage endCursor }
}
@@ -33,6 +38,7 @@ query($owner: String!, $name: String!, $states: [IssueState!], $endCursor: Strin
nodes {
number title state updatedAt
labels(first: ${PAGE_SIZE}) { nodes { name } pageInfo { hasNextPage } }
+ assignees(first: ${PAGE_SIZE}) { nodes { login } pageInfo { hasNextPage } }
comments { totalCount }
}
pageInfo { hasNextPage endCursor }
@@ -120,11 +126,26 @@ function assertComplete(connection, label, repo, number) {
}
}
+// GraphQL `RequestedReviewer` is a union (User | Bot | Mannequin | Team); the
+// selection aliases each variant to one name-ish scalar. Teams surface as
+// `combinedSlug` (org/team), everything else as `login`.
+function requestedReviewerName(node) {
+ const reviewer = node?.requestedReviewer;
+ return reviewer?.login ?? reviewer?.combinedSlug ?? '?';
+}
+
+function assigneeLogins(assignees) {
+ return (assignees?.nodes ?? []).filter(Boolean).map((a) => a.login ?? '?');
+}
+
function normalizePr(node, repo) {
const contexts = node.commits?.nodes?.[0]?.commit?.statusCheckRollup?.contexts;
assertComplete(contexts, 'statusCheckRollup', repo, node.number);
assertComplete(node.latestReviews, 'latestReviews', repo, node.number);
assertComplete(node.reviewThreads, 'reviewThreads', repo, node.number);
+ assertComplete(node.labels, 'labels', repo, node.number);
+ assertComplete(node.assignees, 'assignees', repo, node.number);
+ assertComplete(node.reviewRequests, 'reviewRequests', repo, node.number);
return {
number: node.number,
title: node.title,
@@ -149,17 +170,24 @@ function normalizePr(node, repo) {
unresolvedReviewThreads: (node.reviewThreads?.nodes ?? []).filter(
(thread) => thread?.isResolved === false,
).length,
+ // GitHub retains `baseRefName` (String!) even after base deletion; '' is defensive.
+ baseRefName: node.baseRefName ?? '',
+ labels: (node.labels?.nodes ?? []).filter(Boolean),
+ assignees: assigneeLogins(node.assignees),
+ reviewRequests: (node.reviewRequests?.nodes ?? []).filter(Boolean).map(requestedReviewerName),
};
}
function normalizeIssue(node, repo) {
assertComplete(node.labels, 'labels', repo, node.number);
+ assertComplete(node.assignees, 'assignees', repo, node.number);
return {
number: node.number,
title: node.title,
state: node.state,
updatedAt: node.updatedAt,
labels: (node.labels?.nodes ?? []).filter(Boolean),
+ assignees: assigneeLogins(node.assignees),
comments: node.comments?.totalCount ?? 0,
};
}
diff --git a/lib/help.mjs b/lib/help.mjs
index c5138f4..e495723 100644
--- a/lib/help.mjs
+++ b/lib/help.mjs
@@ -219,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), 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.",
+ "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). relabeled, assignees-changed, and review-requests-changed details carry added/removed arrays of names (teams as org/slug). --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/text-output.mjs b/lib/text-output.mjs
index fbe7528..105f283 100644
--- a/lib/text-output.mjs
+++ b/lib/text-output.mjs
@@ -12,6 +12,22 @@ const SUGGESTIONS = [
matches: ['new-comments'],
text: 'new comments. Read the PR/issue thread before taking action.',
},
+ {
+ matches: ['comments-removed'],
+ text: 'comments were deleted. Re-read the thread; prior context may be gone.',
+ },
+ {
+ matches: ['review-requests-changed'],
+ text: 'requested reviewers changed. Check who is now expected to review.',
+ },
+ {
+ matches: ['assignees-changed'],
+ text: 'assignees changed. Check who now owns the item before dispatching.',
+ },
+ {
+ matches: ['base-changed'],
+ text: 'base branch changed. Re-check CI and mergeability against the new base.',
+ },
{
matches: ['unresolved-threads-added'],
text: 'unresolved review threads. Read and resolve them before merge.',
@@ -40,10 +56,18 @@ const SUGGESTIONS = [
matches: ['became-mergeable'],
text: 'conflicts resolved. Consider the merge path after review.',
},
+ {
+ matches: ['became-conflicting'],
+ text: 'PR now conflicts with its base. Rebase or resolve before merge.',
+ },
{
matches: ['draft-ready'],
text: 'PR left draft and is ready for review. Queue it for review or dispatch.',
},
+ {
+ matches: ['converted-to-draft'],
+ text: 'PR went back to draft. Hold review and merge actions until it is ready again.',
+ },
{
matches: ['reopened'],
text: 'item reopened. Re-enter it into the active work queue.',
diff --git a/test/cli.test.mjs b/test/cli.test.mjs
index cac9e7a..00b840b 100644
--- a/test/cli.test.mjs
+++ b/test/cli.test.mjs
@@ -218,6 +218,107 @@ test('--detail keeps line compatibility and adds structured class details', () =
]);
});
+test('--detail suppresses additive-field rows the old snapshot predates (no phantom transitions)', () => {
+ // Upgrade path: the stored fingerprint predates base/labels/assignees/
+ // reviewRequests (and mergeStateStatus). A same-tick catch-all change (head
+ // bump) fires `updated`; its details must not report `null -> current` rows
+ // for fields whose first appearance the detector itself suppressed.
+ const legacy = prFingerprint({ ...basePr, totalCommentsCount: 0 });
+ for (const field of ['base', 'labels', 'assignees', 'reviewRequests', 'mergeStateStatus']) {
+ delete legacy[field];
+ }
+ const after = {
+ ...basePr,
+ updatedAt: '2026-07-01T11:00:00Z',
+ headRefOid: 'sha2',
+ baseRefName: 'main',
+ labels: [{ name: 'bug' }],
+ assignees: ['alice'],
+ reviewRequests: ['bob'],
+ };
+ const d = deps([[after]], { existing: { pr: { 42: legacy }, issue: {} } });
+ const { report } = run(
+ ['--repo', 'o/r', '--monitor-id', 'main', '--state-file', '/tmp/x.json', '--detail'],
+ d,
+ );
+ const delta = report.deltas[0];
+ assert.deepEqual(delta.classes, ['updated']);
+ const detailFields = delta.details.map((row) => row.field).sort();
+ assert.deepEqual(detailFields, ['head', 'updatedAt']);
+});
+
+test('--detail explains the audit-driven classes: set diffs, base transition, comment removal', () => {
+ const before = {
+ ...basePr,
+ totalCommentsCount: 3,
+ baseRefName: 'main',
+ assignees: ['alice'],
+ reviewRequests: [],
+ };
+ const after = {
+ ...basePr,
+ updatedAt: '2026-07-01T11:00:00Z',
+ totalCommentsCount: 2,
+ baseRefName: 'release/2.0',
+ assignees: ['bob'],
+ reviewRequests: ['carol', 'org/platform-team'],
+ };
+ const d = deps([[after]], {
+ existing: { pr: { 42: prFingerprint(before) }, issue: {} },
+ });
+ const { report } = run(
+ ['--repo', 'o/r', '--monitor-id', 'main', '--state-file', '/tmp/x.json', '--detail'],
+ d,
+ );
+ const delta = report.deltas[0];
+ for (const klass of [
+ 'comments-removed',
+ 'base-changed',
+ 'assignees-changed',
+ 'review-requests-changed',
+ ]) {
+ assert.ok(delta.classes.includes(klass), `expected class ${klass}`);
+ }
+ const details = delta.details;
+ assert.deepEqual(
+ details.find((row) => row.class === 'comments-removed'),
+ {
+ class: 'comments-removed',
+ field: 'comments',
+ from: 3,
+ to: 2,
+ delta: -1,
+ },
+ );
+ assert.deepEqual(
+ details.find((row) => row.class === 'base-changed'),
+ {
+ class: 'base-changed',
+ field: 'base',
+ from: 'main',
+ to: 'release/2.0',
+ },
+ );
+ assert.deepEqual(
+ details.find((row) => row.class === 'assignees-changed'),
+ {
+ class: 'assignees-changed',
+ field: 'assignees',
+ added: ['bob'],
+ removed: ['alice'],
+ },
+ );
+ assert.deepEqual(
+ details.find((row) => row.class === 'review-requests-changed'),
+ {
+ class: 'review-requests-changed',
+ field: 'reviewRequests',
+ added: ['carol', 'org/platform-team'],
+ removed: [],
+ },
+ );
+});
+
test('--detail names the exact checks and reviews that changed when the snapshot carries summaries', () => {
const before = {
...basePr,
diff --git a/test/detect.test.mjs b/test/detect.test.mjs
index a1e7043..0953a6c 100644
--- a/test/detect.test.mjs
+++ b/test/detect.test.mjs
@@ -195,6 +195,141 @@ test('an identical PR (only array reorder) emits NO delta', () => {
assert.deepEqual(r.deltas, []);
});
+test('ready → draft emits converted-to-draft', () => {
+ const base = detectDeltas(null, { pr: [pr({ isDraft: false })], issue: [] });
+ const r = detectDeltas(base.snapshot, {
+ pr: [pr({ isDraft: true, updatedAt: '2026-07-01T11:00:00Z' })],
+ issue: [],
+ });
+ assert.ok(r.deltas[0].classes.includes('converted-to-draft'));
+});
+
+test('mergeable MERGEABLE → CONFLICTING emits became-conflicting', () => {
+ const base = detectDeltas(null, { pr: [pr({ mergeable: 'MERGEABLE' })], issue: [] });
+ const r = detectDeltas(base.snapshot, {
+ pr: [pr({ mergeable: 'CONFLICTING', updatedAt: '2026-07-01T11:00:00Z' })],
+ issue: [],
+ });
+ assert.ok(r.deltas[0].classes.includes('became-conflicting'));
+});
+
+test('mergeable UNKNOWN → CONFLICTING does NOT emit became-conflicting', () => {
+ const base = detectDeltas(null, { pr: [pr({ mergeable: 'UNKNOWN' })], issue: [] });
+ const r = detectDeltas(base.snapshot, {
+ pr: [pr({ mergeable: 'CONFLICTING', updatedAt: '2026-07-01T11:00:00Z' })],
+ issue: [],
+ });
+ assert.ok(!r.deltas[0]?.classes.includes('became-conflicting'));
+});
+
+test('a base branch change emits base-changed', () => {
+ const base = detectDeltas(null, { pr: [pr({ baseRefName: 'main' })], issue: [] });
+ const r = detectDeltas(base.snapshot, {
+ pr: [pr({ baseRefName: 'release/2.0', updatedAt: '2026-07-01T11:00:00Z' })],
+ issue: [],
+ });
+ assert.ok(r.deltas[0].classes.includes('base-changed'));
+});
+
+test('a PR label change emits relabeled', () => {
+ const base = detectDeltas(null, { pr: [pr({ labels: [{ name: 'bug' }] })], issue: [] });
+ const r = detectDeltas(base.snapshot, {
+ pr: [pr({ labels: [{ name: 'bug' }, { name: 'urgent' }], updatedAt: '2026-07-01T11:00:00Z' })],
+ issue: [],
+ });
+ assert.ok(r.deltas[0].classes.includes('relabeled'));
+});
+
+test('a PR assignee change emits assignees-changed', () => {
+ const base = detectDeltas(null, { pr: [pr({ assignees: ['alice'] })], issue: [] });
+ const r = detectDeltas(base.snapshot, {
+ pr: [pr({ assignees: ['alice', 'bob'], updatedAt: '2026-07-01T11:00:00Z' })],
+ issue: [],
+ });
+ assert.ok(r.deltas[0].classes.includes('assignees-changed'));
+});
+
+test('an issue assignee change emits assignees-changed', () => {
+ const issue = {
+ number: 7,
+ title: 'bug',
+ state: 'OPEN',
+ updatedAt: '2026-07-01T10:00:00Z',
+ labels: [],
+ assignees: ['alice'],
+ comments: 0,
+ };
+ const base = detectDeltas(null, { pr: [], issue: [issue] });
+ const r = detectDeltas(base.snapshot, {
+ pr: [],
+ issue: [{ ...issue, updatedAt: '2026-07-01T11:00:00Z', assignees: [] }],
+ });
+ assert.ok(r.deltas[0].classes.includes('assignees-changed'));
+});
+
+test('a review request emits review-requests-changed', () => {
+ const base = detectDeltas(null, { pr: [pr({ reviewRequests: [] })], issue: [] });
+ const r = detectDeltas(base.snapshot, {
+ pr: [pr({ reviewRequests: ['carol'], updatedAt: '2026-07-01T11:00:00Z' })],
+ issue: [],
+ });
+ assert.ok(r.deltas[0].classes.includes('review-requests-changed'));
+});
+
+test('a comment total decrease emits comments-removed', () => {
+ const base = detectDeltas(null, { pr: [pr({ totalCommentsCount: 3 })], issue: [] });
+ const r = detectDeltas(base.snapshot, {
+ pr: [pr({ totalCommentsCount: 2, updatedAt: '2026-07-01T11:00:00Z' })],
+ issue: [],
+ });
+ assert.deepEqual(r.deltas[0].classes, ['comments-removed']);
+});
+
+test('a snapshot predating the new compared fields does not burst on first observation', () => {
+ // Same upgrade rule as mergeStateStatus: the first appearance of base/labels/
+ // assignees/reviewRequests in a fresh fingerprint is not a change.
+ const base = detectDeltas(null, {
+ pr: [pr({ baseRefName: 'main', labels: [{ name: 'bug' }], assignees: ['alice'] })],
+ issue: [],
+ });
+ const legacy = { ...base.snapshot };
+ for (const field of ['base', 'labels', 'assignees', 'reviewRequests']) {
+ delete legacy.pr['42'][field];
+ }
+ const r = detectDeltas(legacy, {
+ pr: [pr({ baseRefName: 'main', labels: [{ name: 'bug' }], assignees: ['alice'] })],
+ issue: [],
+ });
+ assert.deepEqual(r.deltas, []);
+});
+
+test('a legacy snapshot with a real concurrent change does not misfire the new classes', () => {
+ // When a pre-upgrade snapshot sees a genuine change (a new comment), the delta
+ // must not also claim relabeled/assignees-changed/base-changed just because the
+ // old fingerprint lacked those keys.
+ const base = detectDeltas(null, {
+ pr: [pr({ baseRefName: 'main', labels: [{ name: 'bug' }], assignees: ['alice'] })],
+ issue: [],
+ });
+ const legacy = { ...base.snapshot };
+ for (const field of ['base', 'labels', 'assignees', 'reviewRequests']) {
+ delete legacy.pr['42'][field];
+ }
+ const r = detectDeltas(legacy, {
+ pr: [
+ pr({
+ baseRefName: 'main',
+ labels: [{ name: 'bug' }],
+ assignees: ['alice'],
+ totalCommentsCount: 1,
+ updatedAt: '2026-07-01T11:00:00Z',
+ }),
+ ],
+ issue: [],
+ });
+ assert.deepEqual(r.deltas[0].classes, ['new-comments']);
+});
+
test('draft → ready emits draft-ready', () => {
const base = detectDeltas(null, { pr: [pr({ isDraft: true })], issue: [] });
const r = detectDeltas(base.snapshot, {
diff --git a/test/fingerprint.test.mjs b/test/fingerprint.test.mjs
index 63bac39..1177537 100644
--- a/test/fingerprint.test.mjs
+++ b/test/fingerprint.test.mjs
@@ -176,6 +176,40 @@ test('prFingerprint extracts the tracked fields', () => {
]);
});
+test('prFingerprint sorts labels, assignees, and reviewRequests, and stores the base ref', () => {
+ const fp = prFingerprint({
+ state: 'OPEN',
+ updatedAt: '2026-07-01T10:00:00Z',
+ baseRefName: 'main',
+ labels: [{ name: 'worker' }, { name: 'backend' }],
+ assignees: ['zoe', 'alice'],
+ reviewRequests: ['org/platform-team', 'bob'],
+ });
+ assert.equal(fp.base, 'main');
+ assert.deepEqual(fp.labels, ['backend', 'worker']);
+ assert.deepEqual(fp.assignees, ['alice', 'zoe']);
+ assert.deepEqual(fp.reviewRequests, ['bob', 'org/platform-team']);
+});
+
+test('prFingerprint defaults the new compared fields when input predates them', () => {
+ const fp = prFingerprint({ state: 'OPEN', updatedAt: '2026-07-01T10:00:00Z' });
+ assert.equal(fp.base, '');
+ assert.deepEqual(fp.labels, []);
+ assert.deepEqual(fp.assignees, []);
+ assert.deepEqual(fp.reviewRequests, []);
+});
+
+test('issueFingerprint sorts assignees', () => {
+ const fp = issueFingerprint({
+ state: 'OPEN',
+ updatedAt: '2026-07-01T10:00:00Z',
+ labels: [],
+ assignees: ['zoe', 'alice'],
+ comments: 0,
+ });
+ assert.deepEqual(fp.assignees, ['alice', 'zoe']);
+});
+
test('prFingerprint reads exact totals beyond the old 100 cap', () => {
const fp = prFingerprint({
state: 'OPEN',
diff --git a/test/gh.test.mjs b/test/gh.test.mjs
index 45d7d3e..7f79020 100644
--- a/test/gh.test.mjs
+++ b/test/gh.test.mjs
@@ -246,6 +246,75 @@ test('the PR query requests mergeStateStatus and normalizePr defaults it to UNKN
assert.equal(rows[0].mergeStateStatus, 'UNKNOWN');
});
+test('the PR query requests base ref, labels, assignees, and review requests and normalizePr carries them', () => {
+ let sentQuery = '';
+ const exec = (_cmd, args) => {
+ sentQuery = args.find((a) => a.startsWith('query=')) ?? '';
+ return page([
+ prNode({
+ baseRefName: 'main',
+ labels: { nodes: [null, { name: 'bug' }], pageInfo: { hasNextPage: false } },
+ assignees: { nodes: [{ login: 'alice' }, null], pageInfo: { hasNextPage: false } },
+ reviewRequests: {
+ nodes: [
+ { requestedReviewer: { login: 'bob' } },
+ { requestedReviewer: { combinedSlug: 'org/platform-team' } },
+ null,
+ ],
+ pageInfo: { hasNextPage: false },
+ },
+ }),
+ ]);
+ };
+ const rows = fetchPRs('o/r', { exec, horizonCutoff: null });
+ for (const field of ['baseRefName', 'labels', 'assignees', 'reviewRequests']) {
+ assert.ok(sentQuery.includes(field), `PR GraphQL selection must request ${field}`);
+ }
+ assert.equal(rows[0].baseRefName, 'main');
+ assert.deepEqual(rows[0].labels, [{ name: 'bug' }]);
+ assert.deepEqual(rows[0].assignees, ['alice']);
+ assert.deepEqual(rows[0].reviewRequests, ['bob', 'org/platform-team']);
+});
+
+test('normalizePr defaults the new fields when a node predates them', () => {
+ const rows = fetchPRs('o/r', { exec: () => page([prNode()]), horizonCutoff: null });
+ assert.equal(rows[0].baseRefName, '');
+ assert.deepEqual(rows[0].labels, []);
+ assert.deepEqual(rows[0].assignees, []);
+ assert.deepEqual(rows[0].reviewRequests, []);
+});
+
+test('fails closed on paginated PR assignees', () => {
+ const overflow = prNode({
+ assignees: { nodes: [{ login: 'alice' }], pageInfo: { hasNextPage: true } },
+ });
+ assert.throws(() => fetchPRs('o/r', { exec: () => page([overflow]) }), /paginated assignees/);
+});
+
+test('the issue query requests assignees and fetchIssues normalizes them', () => {
+ let sentQuery = '';
+ const exec = (_cmd, args) => {
+ sentQuery = args.find((a) => a.startsWith('query=')) ?? '';
+ return page([
+ {
+ number: 7,
+ title: 'bug',
+ state: 'OPEN',
+ updatedAt: '2026-07-01T10:00:00Z',
+ labels: { nodes: [], pageInfo: { hasNextPage: false } },
+ assignees: {
+ nodes: [{ login: 'zoe' }, { login: 'alice' }],
+ pageInfo: { hasNextPage: false },
+ },
+ comments: { totalCount: 0 },
+ },
+ ]);
+ };
+ const rows = fetchIssues('o/r', { exec, horizonCutoff: null });
+ assert.ok(sentQuery.includes('assignees'), 'issue GraphQL selection must request assignees');
+ assert.deepEqual(rows[0].assignees, ['zoe', 'alice']);
+});
+
test('normalizePr passes through a present mergeStateStatus verbatim', () => {
const rows = fetchPRs('o/r', {
exec: () => page([prNode({ mergeStateStatus: 'BEHIND' })]),
diff --git a/tools/examples/fixtures.mjs b/tools/examples/fixtures.mjs
index 1ec7560..84d982c 100644
--- a/tools/examples/fixtures.mjs
+++ b/tools/examples/fixtures.mjs
@@ -23,27 +23,32 @@ const AT = '2026-07-01T12:05:00.000Z';
const AT_BASELINE = '2026-07-01T12:00:00.000Z';
// The PR #42 delta: a single item that exercises three distinct detail field
-// groups (`ci`, `review`, and label add/remove) so `--detail` is shown off in
-// one place. The fingerprints deliberately omit the ciChecks/reviewSummary
-// summaries, so the ci/reviews details render the `opaque: true` fallback (the
-// output of a first tick over a pre-summary snapshot).
+// groups (`ci`, `review`, and the reviewRequests add/remove set diff) so
+// `--detail` is shown off in one place. The reviewer approving satisfies their
+// pending request, so `review-changed` and `review-requests-changed` co-occur —
+// the exact interplay the contract documents. The fingerprints deliberately
+// omit the ciChecks/reviewSummary summaries, so the ci/reviews details render
+// the `opaque: true` fallback (the output of a first tick over a pre-summary
+// snapshot).
const pr42 = withId({
entity: 'pr',
number: 42,
title: 'Add billing webhook',
headRefName: 'feature/billing-webhook',
- classes: ['ci-changed', 'review-changed'],
+ classes: ['ci-changed', 'review-changed', 'review-requests-changed'],
from: {
state: 'OPEN',
ci: 'a1b2c3',
review: 'CHANGES_REQUESTED',
reviews: 'r-9f8e',
+ reviewRequests: ['alice'],
},
to: {
state: 'OPEN',
ci: 'd4e5f6',
review: 'APPROVED',
reviews: 'r-2c1d',
+ reviewRequests: [],
},
});