Skip to content

feat(rank): treat incident-closed PRs as open, not rejected - #28

Merged
StressTestor merged 5 commits into
mainfrom
feat/incident-aware-ranking
Jul 29, 2026
Merged

feat(rank): treat incident-closed PRs as open, not rejected#28
StressTestor merged 5 commits into
mainfrom
feat/incident-aware-ranking

Conversation

@StressTestor

Copy link
Copy Markdown
Owner

A repository-wide event closes PRs for reasons that have nothing to do with their quality: a visibility flip, a bulk close, a migration. selectCanonical() ranks lifecycle state before score, so every one of those PRs sinks below genuinely-closed siblings.

That inverts triage order for exactly the backlog you would want to look at first.

what changes

prism.config.yaml accepts incident windows:

incidents:
  - start: "2026-07-23T09:00:00Z"
    end: "2026-07-23T11:00:00Z"
    reason: "repo visibility flip auto-closed all open PRs"

A PR closed inside a window ranks as open. closedAt is what gets persisted; incidentClosed is derived at read time, so correcting a mis-set window is a config edit rather than a rescan.

Window bounds must carry an explicit UTC offset. Date.parse("2026-07-23T00:00:00") resolves in the host timezone, so an offset-less bound would select a different set of PRs on a laptop than in CI, and "2026-07-23" parses as UTC while "2026-07-23T00:00:00" parses local. Two spellings of one day disagreeing is not a thing a window can survive. Bounds are compiled once at load: unparseable or inverted throws, naming the offending window by its reason.

two things to know before configuring one

Re-scan after each incident, not once: a default scan fetches open items only, so after a bulk close the affected PRs stop being returned at all and their stored rows keep state: open with no closedAt. prism scan --state all is needed per incident or the window matches nothing.

Pin windows tightly. A day-wide window sweeps up PRs that were closed deliberately that day.

scope

CLI only. The App path applies no windows: ServerConfig has no incidents field and the scheduler fetches open items only, so closedAt is never populated there. Tracked in #27, along with server/triage.ts's hand-rolled metadata literal, which has drifted three fields behind itemMetadata(). scheduler.ts moves to the shared helper here.

Worth being precise about, since i got this wrong once already in review: that path is not otherwise frozen. It calls findDuplicateClusters, so the built-in bot list applies. The cluster.* config does not, because ServerConfig has no cluster field either.

starmap

Items and every reference to them carry incidentClosed: true, omitted when false. Six positions: canonical, runnerUp, partition.prs, partition.issues, tracker.ref, tracker.candidates. A consumer contract has to accept it across all six positions. star-map's side is https://github.com/RaresKeY/star-map-odysseus/pull/11.

review found six things worth naming

An adversarial pass and a taste pass ran over this. What they caught:

  • a comment claiming windows were the only App-path gap, when cluster.* config is also missing there
  • "one-time backfill" in the docs, which is per-incident
  • 2026-07-23 00:00:00+00:00 passing the offset rule with a space where the T belongs, landing in Date.parse's implementation-defined fallback
  • an unparseable bound also reporting "end must be after start", because NaN > NaN is false
  • isIncidentClosed silently skipping a malformed window, the same no-op the config schema rejects loudly
  • closedAt tested only in isolation, so a dropped query field or mapper call would have killed the feature with every test green

The last one now has guards for both failure modes, checked by breaking each in turn.

506 tests, lint, typecheck and build clean.

StressTestor and others added 5 commits July 29, 2026 12:56
A repository-wide event (visibility flip, bulk close, migration) can close
hundreds of PRs for reasons unrelated to their quality. selectCanonical()
ranks lifecycle state before score, so those items ranked as rejections and
sank below genuinely-closed siblings -- exactly inverting the triage order
for the set a maintainer most needs to review.

prism.config.yaml now accepts an `incidents:` list of {start, end, reason}
windows. closedAt is captured from both the GraphQL and REST paths, stored in
metadata_json (no schema migration), and store.ts stamps `incidentClosed`
onto each item at hydration. statePriority() ranks an incident-closed PR as
open.

Only the raw closedAt is persisted; incidentClosed is derived at read time,
so correcting a mis-set window is a config edit rather than a rescan of the
whole backlog. The starmap payload carries `incidentClosed: true` (omitted
when false, keeping the consumer contract additive) so a consumer can bucket
these for re-triage instead of treating them as rejected.
Review found two real bugs in the first cut.

Timezone: window bounds were bare strings fed to Date.parse, so an
offset-less ISO literal resolved in the HOST timezone. The same config
selected a different set of PRs under TZ=America/Denver than under UTC in
CI, and "2026-07-23" parsed as UTC while "2026-07-23T00:00:00" parsed local
-- two spellings of one day disagreeing. Bounds now require an explicit
offset and are rejected at load. Verified across UTC, America/Denver and
Asia/Kolkata: every accepted form yields a byte-identical instant.

Silent misconfiguration: unparseable ("last tuesday"), inverted, and blank
windows were accepted and then matched nothing, forever, with no diagnostic.
A single typo made the whole feature a no-op. Now fails at load.

Scope: incident-aware ranking is CLI-only. An earlier version of this commit
claimed to wire the server/GitHub-App path; it did not. openRepoDB gained a
parameter no caller could supply -- ServerConfig has no `incidents` field --
and the scheduler fetches open items only, so closedAt is never populated
there. The whole server change was removable with the suite still green.
That parameter is gone and the limitation is stated in server/db.ts,
ARCHITECTURE.md and CHANGELOG. Wiring the App path needs a config field, a
scheduler that fetches closed items, and server/triage.ts's hand-rolled
metadata literal replaced; that is its own change.

Kept from that attempt: server/scheduler.ts now builds metadata via
itemMetadata() instead of a hand-rolled literal that had drifted from it,
silently dropping bodyLength, nodeId, headRefOid and closesIssues on every
scheduled sync. Unrelated to incidents, but a real bug and a one-line fix.

Also: the "config edit, not a rescan" claim holds only for rows scanned
since the feature landed; earlier rows need a one-time `prism scan
--state all`, now documented. Extracted the duplicated hydration expression
so the two sites cannot drift, documented `incidents:` in the config
template so init surfaces it, and fixed a stale doc comment.
Three loose ends from the incident-window review.

ARCHITECTURE said the server path "ranks exactly as it did before", which is
now wrong twice over: it overstates a narrow gap, and bot filtering does reach
that path, since scheduler.ts calls findDuplicateClusters and picks up the
default. Scoped to what is actually missing there, which is incident windows.

"Tracked separately" tracked nothing. Opened #27 for wiring the server path,
and pointed both the doc and the db.ts comment at it. The issue also carries
the triage.ts hand-rolled metadata literal, which is worth fixing on its own:
it has already drifted behind itemMetadata() by three fields and nothing
catches it. scheduler.ts moving to the shared helper is now in the changelog,
which it was not.

Adds config tests for the incident window rules. Checked they discriminate:
relaxing `reason` to a bare `z.string()` fails the blank-reason case rather
than passing silently.
Adversarial review follow-ups.

The comment I added to server/db.ts said incident windows were the only thing
missing on the App path. They are not: ServerConfig has no `cluster` field
either, so `cluster.bot_authors` and `cluster.include_bot_authors` are honoured
by the CLI and ignored by the App. Only the built-in bot list reaches that
path. ARCHITECTURE had the same overstatement. A maintainer whose repo-specific
bot login is filtered locally and not in the digest would have gone hunting for
a clustering bug.

The changelog called `prism scan --state all` a one-time backfill. It is
per-incident: a default scan fetches open items only, so after a bulk close the
affected PRs stop being returned and their rows keep `state: open` with no
`closedAt`. Configure a window without re-scanning and it matches nothing.

`incidentClosed` is emitted on every reference to an item, not just on items,
so a consumer contract has to accept it in six positions. The changelog said
"items", which understates what a coordinated importer patch needs.

Validation:

- the offset rule accepted `2026-07-23 00:00:00+00:00`, which has an offset but
  a space where the T belongs, putting Date.parse into its
  implementation-defined fallback. Now requires the full ISO-8601 shape.
- an unparseable bound also reported "end must be after start", because
  NaN > NaN is false. That points the reader at the wrong field, so the
  ordering check now defers when either bound failed to parse.

Tests:

- `closedAt` was verified only through itemClosedAt in isolation. Both ways it
  can silently die (a query stops requesting the field, a mapper stops calling
  it) now fail a test; checked by breaking each in turn.
- the incident hydration test covered getAllItems only. getAllItemsMulti
  duplicates that literal and is what cross-repo dupes uses.
- the "unparseable timestamp" test fed input that tripped the offset rule
  first, so it passed with the parseability rule deleted. Split into cases that
  isolate each rule.
Review follow-ups.

isIncidentClosed skipped a window whose bounds would not parse and returned
false, so a malformed window matched nothing and said nothing. That is the
same silent no-op the config schema rejects at load, and windows reach a
VectorStore from callers that never went through that schema. Bounds are now
resolved once by compileIncidentWindows, which throws and names the offending
window by its reason so a long list is searchable.

Compiling once also stops re-parsing two constants for every item in the
backlog, which was work with no result.

The incident windows were a fourth positional constructor parameter. They are
now a named options object, so the tail stays one argument as more knobs
arrive. The first three positionals are pre-existing and left alone.

itemMetadata moves out of pipeline.ts into src/metadata.ts. server/scheduler.ts
needs it, and importing it from the CLI pipeline dragged a spinner, a GitHub
client and an embedder factory into the server for one object literal.

The flag has two spellings on purpose, now stated where they are declared:
`boolean` internally, `true`-only on the starmap wire format, because the
payload omits the field when false and a consumer contract should reject
anything else.

Also corrects ARCHITECTURE's "one-time backfill" the same way the changelog was
already corrected: `--state all` is needed after each incident.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 51d91e715c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/pipeline.ts
Comment on lines +69 to +71
const store = new VectorStore(undefined, embedder.dimensions, env.EMBEDDING_MODEL, {
incidentWindows: config.incidents ?? [],
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Scope incident windows to the selected repository

When the config uses repos or the user supplies --repo, createPipelineContext is invoked for each selected repository but forwards the same unscoped config.incidents list every time. An incident recorded for one repository therefore promotes any PR in another repository whose closedAt happens to fall in the same window, producing incorrect canonical selections and starmap data. Associate each window with a repository or otherwise filter the windows before constructing this store.

Useful? React with 👍 / 👎.

Comment thread src/store.ts
Comment on lines 299 to +301
state: metadata.state,
closedAt: metadata.closedAt,
incidentClosed: this.incidentFlag(metadata),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude issues from PR incident classification

When a closed issue happens to have a closedAt inside an incident window, this hydration path marks it incidentClosed because incidentFlag receives only metadata and never checks row.type. In a PR-majority mixed cluster, statePriority then treats that issue as open, so it can outrank deliberately closed PRs and even become the canonical item; the starmap also incorrectly advertises the issue as incident-closed. Pass the item type into the predicate and limit this override to PRs.

Useful? React with 👍 / 👎.

@StressTestor
StressTestor merged commit b208519 into main Jul 29, 2026
2 checks passed
@StressTestor
StressTestor deleted the feat/incident-aware-ranking branch July 29, 2026 19:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant