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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ src/ # CLI tool (published to npm as prism-triage)
write-gate.ts # one dry-run-by-default gate every GitHub mutation funnels through (read-only ethos)
benchmark.ts # embedding provider benchmark tool (--out per-run results + cluster membership)
bots.ts # bot-author detection; excluded from clustering by default
incident.ts # incident windows: compile once, match closedAt at read time
metadata.ts # the stored-metadata shape, shared by the CLI and App paths
config.ts # Zod-validated YAML + env config
errors.ts # typed error classes
types.ts # shared interfaces
Expand Down Expand Up @@ -73,6 +75,8 @@ server/ # webhook server (GitHub App)
- **read-only default**: every GitHub mutation (labels, comments, closes, issue creation) funnels through one `write-gate.ts` gate that defaults to dry-run. CLI writes only under `--apply-labels`; the webhook server writes only when `PRISM_APPLY=1`. `--dry-run` always wins. (Fixes the prior leak where `ensureLabelsExist` created labels even under `--dry-run`, and the server writing unconditionally.)
- **cross-repo**: config accepts multiple repos, dupe detection works across repo boundaries
- **canonical selection**: one `selectCanonical()` (src/canonical.ts) picks each cluster's source of truth for the report, the starmap payload, and the live triage bot alike. issue-majority clusters resolve to the earliest report (the original bug); PR-majority ranks by lifecycle state (merged > open > closed), then a CI veto (a known-red build never outranks a same-state green sibling, before score), then quality score. the veto stops a high-scored PR with failing checks from becoming bestPick over the green fix that actually landed; only `ciStatus === "failure"` demotes, so a not-yet-reported PR is never penalized. fully deterministic - every tie bottoms out at item number - so re-runs name the same canonical. confirmed (identity) clusters override to the earliest-created rule: byte-identical dupes resolve by which-was-first, never score (a copy can outscore its original)
- **incident awareness**: a repository-wide event (visibility flip, bulk close, migration) can close hundreds of PRs for reasons unrelated to their quality. because `selectCanonical()` ranks lifecycle state before score, those items would otherwise rank as rejections and sink below genuinely-closed siblings. `prism.config.yaml` accepts an `incidents:` list of `{start, end, reason}` windows; `store.ts` stamps `incidentClosed` onto each item at hydration via `isIncidentClosed()` (src/incident.ts), and `statePriority()` ranks an incident-closed PR as open. the raw `closedAt` is what gets persisted, never the derived flag, so correcting a mis-set window is a config edit rather than a rescan, for rows scanned since the feature landed. rows stored earlier carry no `closedAt`, and a default scan fetches open items only, so after a bulk close the affected PRs are no longer returned at all and their stored rows keep `state: open`. `prism scan --state all` is needed after each incident, not once. window bounds require an explicit UTC offset and are rejected at load if unparseable or inverted: an offset-less timestamp resolves in the host timezone, so the same config would select different items on a laptop than in CI. **CLI-only.** the server / GitHub-App path (`server/db.ts`, `server/scheduler.ts`) does not apply windows: `ServerConfig` has no `incidents` field and the scheduler fetches open items only, so `closedAt` is never populated there. that path is not otherwise frozen: `scheduler.ts` calls `findDuplicateClusters`, so the *built-in* bot list applies there. the `cluster.*` config does not, because `ServerConfig` has no `cluster` field, so a repo-specific `cluster.bot_authors` is honoured by the CLI and ignored by the App. wiring incident awareness there needs a `ServerConfig.incidents` field, a scheduler that fetches closed items, and `server/triage.ts`'s hand-rolled `metadata: { author, state }` literal replaced with `itemMetadata()` the way `scheduler.ts` now is: tracked in #27. the starmap payload carries `incidentClosed: true` (omitted when false, keeping the contract additive) so a consumer can bucket them for re-triage rather than treating them as rejected

- **cluster confidence**: clustering is single-linkage (BFS over pairs >= threshold) with a centroid-refinement pass to break chained mega-clusters. because single-linkage can still chain in loosely-related members, each cluster reports both `avgSimilarity` and `minSimilarity` (lowest pairwise). the report/dupes output surfaces min as a confidence tier (high >= 90%, solid >= 80%, loose < 80%) so a low-min "loose" cluster gets eyeballed before anything is closed. avg and min are computed exactly over all pairs (no sampling), so the tier a maintainer sees is reproducible run to run

## database
Expand All @@ -81,7 +85,7 @@ single SQLite database per project, managed by `store.ts`.

| table | purpose |
|-------|---------|
| items | PRs and issues with metadata |
| items | PRs and issues with metadata (incl. `closedAt` in `metadata_json`; no migration needed) |
| embeddings | vector embeddings (sqlite-vec) |
| prism_meta | config versioning, model mismatch detection |

Expand All @@ -105,4 +109,4 @@ npm run server # start webhook server
- the npm package excludes compiled tests (`tsconfig.build.json`); `npm run build` cleans `dist/` first so stale artifacts can't leak into a publish (the 2.0.1 orphan-file gotcha)
- brew tap bump is manual: update `Formula/prism-triage.rb` (tarball url + sha256) in StressTestor/homebrew-tap after the GitHub Release exists

last updated: 2026-07-13
last updated: 2026-07-28
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ all notable changes to pr-prism are documented here.
- `prism benchmark --out <path>` writes a run's results to a chosen file. every run previously wrote `data/benchmark-results.json`, so a second run silently destroyed the first one's numbers - an hour of embedding lost to starting the next comparison. an empty or directory-shaped value is rejected rather than falling back to the default
- benchmark results now record `clusterMembership` per model per threshold. cluster counts cannot tell you whether a model finding more clusters is catching real duplicates or chaining unrelated items, and re-deriving membership means re-embedding the whole corpus
- starmap items now carry `createdAt` alongside `updatedAt` (additive), so consumers can render and reason about which-was-first without re-fetching from github. star-map's importer rejects unknown fields; the coordinated patch is star-map PR #11, which must land before it consumes a dataset carrying this field
- incident-aware ranking: `prism.config.yaml` accepts an `incidents:` list of `{start, end, reason}` windows. a repository-wide event (visibility flip, bulk close, migration) closes items for reasons unrelated to their quality, and because `selectCanonical()` ranks lifecycle state before score those items sank below genuinely-closed siblings, inverting triage order for exactly the backlog a maintainer needs. PRs closed inside a window now rank as open. `closedAt` is captured from both API paths and stored in `metadata_json` (no schema migration); `incidentClosed` is derived at read time, so correcting a window is a config edit rather than a rescan. window bounds require an explicit UTC offset and are rejected at load if unparseable or inverted. an offset-less timestamp parses in the host timezone and would select different PRs on a laptop than in CI. starmap carries `incidentClosed: true` on items *and* on every reference to them (canonical, runnerUp, partition, tracker ref and candidates), omitted when false. a consumer contract has to accept it in all of those positions, not just on items. **CLI only**: the server/GitHub-App path applies no incident windows, because `ServerConfig` has no `incidents` field and the scheduler fetches open items only (#27). NOTE for star-map: same coordinated importer patch as `createdAt` below, since its importer rejects unknown fields
- `server/scheduler.ts` now builds stored metadata with the shared `itemMetadata()` instead of its own literal. the hand-rolled copy had already drifted behind the real one, so items scanned by the App path were missing fields the CLI path stored. `server/triage.ts` still hand-rolls its own and is tracked in #27
- NOTE: rows scanned before this release carry no `closedAt`, and a default scan only fetches open items. run `prism scan --state all` to pick them up. this is per-incident, not a one-time backfill: a default scan fetches open items only, so after a bulk close the affected PRs are no longer returned at all and their stored rows keep `state: open` with no `closedAt`. re-scan with `--state all` after each incident, or the window matches nothing

## [3.1.0] — 2026-07-20

Expand Down
15 changes: 15 additions & 0 deletions prism.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ vision_doc: ./VISION.md
# owner/repo1: ./VISION_1.md
# owner/repo2: ./VISION_2.md

# repository-wide events that closed items for reasons unrelated to their
# quality (a visibility flip, a bulk close, a migration). PRs closed inside a
# window rank as open instead of rejected, so a mass auto-close does not bury
# the exact backlog you need to triage.
#
# start/end MUST carry an explicit UTC offset - an offset-less timestamp is
# parsed in the host timezone and would select a different set of PRs on your
# laptop than in CI. Pin the window tightly: a day-wide window sweeps up items
# that were closed deliberately on the same day.
#
# incidents:
# - start: "2026-07-23T09:00:00Z"
# end: "2026-07-23T11:00:00Z"
# reason: "repo visibility flip auto-closed all open PRs"

thresholds:
duplicate_similarity: 0.85
aligned: 0.65
Expand Down
7 changes: 7 additions & 0 deletions server/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,13 @@ export function openRepoDB(
model?: string,
): VectorStore {
const dbPath = getRepoDBPath(dataDir, owner, repo);
// NOTE: no incident windows. Incident-aware ranking is CLI-only for now:
// ServerConfig has no `incidents` field and the scheduler fetches open items
// only, so `closedAt` is never populated on this path. Wiring the App path
// needs a config field, a scheduler that fetches closed items, and the
// triage.ts metadata literal fixed. Tracked in #27. ServerConfig also has no
// `cluster` field, so `cluster.bot_authors` and `cluster.include_bot_authors`
// do not reach this path either; only the built-in bot list in bots.ts applies.
return new VectorStore(dbPath, dimensions, model);
}

Expand Down
16 changes: 5 additions & 11 deletions server/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import cron from "node-cron";
import { findDuplicateClusters } from "../src/cluster.js";
import { createEmbeddingProvider, prepareEmbeddingText } from "../src/embeddings.js";
import { GitHubClient } from "../src/github.js";
import { itemMetadata } from "../src/metadata.js";
import { escapeTableCell } from "../src/sanitize.js";
import type { Cluster, PRItem, StoreItem } from "../src/types.js";
import {
Expand Down Expand Up @@ -165,17 +166,10 @@ export async function runBacklogScan(
title: item.title,
bodySnippet: (item.body || "").slice(0, 2000),
embedding,
metadata: {
author: item.author,
state: item.state,
labels: item.labels,
additions: item.additions,
deletions: item.deletions,
changedFiles: item.changedFiles,
ciStatus: item.ciStatus,
reviewCount: item.reviewCount,
hasTests: item.hasTests,
},
// Single source for stored metadata. The hand-rolled literal that
// was here had drifted from it, silently dropping bodyLength,
// nodeId, headRefOid and closesIssues on every scheduled sync.
metadata: itemMetadata(item),
createdAt: item.createdAt,
updatedAt: item.updatedAt,
};
Expand Down
17 changes: 17 additions & 0 deletions src/__tests__/canonical.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,3 +359,20 @@ describe("selectTracker (tracker substrate: original bug + role-tagged candidate
expect(items.map((i) => i.number)).toEqual(before);
});
});

describe("incident-closed lifecycle ranking", () => {
it("ranks an incident-closed PR as open, so a bulk auto-close cannot bury it under a deliberately closed sibling", () => {
const items = [
c({ number: 5559, state: "closed", incidentClosed: true, score: 0.1 }),
c({ number: 5560, state: "closed", score: 0.99 }),
];

expect(selectCanonical(items).number).toBe(5559);
});

it("leaves a deliberately closed PR ranked below an open one", () => {
const items = [c({ number: 30, state: "closed", score: 0.99 }), c({ number: 31, state: "open", score: 0.1 })];

expect(selectCanonical(items).number).toBe(31);
});
});
80 changes: 80 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,86 @@ describe("loadEnvConfig", () => {
});
});

describe("incident window config", () => {
function load(yaml: string) {
const dir = mkdtempSync(join(tmpdir(), "prism-incident-"));
const path = join(dir, "prism.config.yaml");
writeFileSync(path, yaml);
try {
return loadConfig(path);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

const window = (extra: string) =>
`repo: owner/name\nincidents:\n - start: 2026-07-23T00:00:00Z\n end: 2026-07-24T00:00:00Z\n${extra}`;

it("accepts a well-formed window", () => {
const cfg = load(window(" reason: bulk close\n"));
expect(cfg.incidents?.[0].reason).toBe("bulk close");
});

it("rejects a blank reason", () => {
// A window is a manual override of lifecycle state. Without a reason the
// next person cannot tell a deliberate correction from a stray edit.
expect(() => load(window(" reason: ''\n"))).toThrow();
expect(() => load(window(" reason: ' '\n"))).toThrow();
});

it("rejects a missing reason", () => {
expect(() => load(window(""))).toThrow();
});

it("rejects bounds with no UTC offset", () => {
expect(() =>
load(
"repo: owner/name\nincidents:\n - start: 2026-07-23T00:00:00\n end: 2026-07-24T00:00:00Z\n reason: x\n",
),
).toThrow(/offset/);
});

it("rejects a bound that has an offset but is not a real instant", () => {
// Isolates the parseability rule: the offset rule passes, Date.parse does
// not. "last tuesday" trips the offset rule first and proves nothing.
expect(() =>
load(
"repo: owner/name\nincidents:\n - start: 2026-13-45T00:00:00Z\n end: 2026-07-24T00:00:00Z\n reason: x\n",
),
).toThrow(/parseable/);
});

it("rejects the space-separated form, which parses per-engine", () => {
// `2026-07-23 00:00:00+00:00` is not ISO-8601; Date.parse falls back to
// implementation-defined behaviour, the ambiguity this schema exists to reject.
expect(() =>
load(
"repo: owner/name\nincidents:\n - start: '2026-07-23 00:00:00+00:00'\n end: 2026-07-24T00:00:00Z\n reason: x\n",
),
).toThrow();
});

it("does not blame ordering when a bound is unparseable", () => {
// Reporting "end must be after start" for a bound that never parsed sends
// the reader to the wrong field.
let message = "";
try {
load("repo: owner/name\nincidents:\n - start: last tuesday\n end: 2026-07-24T00:00:00Z\n reason: x\n");
} catch (e) {
message = (e as Error).message;
}
expect(message).not.toMatch(/after start/);
});

it("rejects an inverted window", () => {
expect(() =>
load(
"repo: owner/name\nincidents:\n - start: 2026-07-24T00:00:00Z\n end: 2026-07-23T00:00:00Z\n reason: x\n",
),
).toThrow(/after start/);
});
});

describe("bot author clustering config", () => {
function load(yaml: string) {
const dir = mkdtempSync(join(tmpdir(), "prism-bots-"));
Expand Down
38 changes: 37 additions & 1 deletion src/__tests__/github.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import { accountIsBot, GitHubClient, restPRState } from "../github.js";
import { accountIsBot, GitHubClient, itemClosedAt, restPRState } from "../github.js";
import { createWriteGate } from "../write-gate.js";

function fakeOctokit() {
Expand Down Expand Up @@ -219,3 +220,38 @@ describe("accountIsBot", () => {
expect(accountIsBot({})).toBeUndefined();
});
});

describe("itemClosedAt", () => {
it("reads the REST closed_at field", () => {
expect(itemClosedAt({ closed_at: "2026-07-23T10:18:00Z" })).toBe("2026-07-23T10:18:00Z");
});

it("reads the GraphQL closedAt field", () => {
expect(itemClosedAt({ closedAt: "2026-07-23T10:18:00Z" })).toBe("2026-07-23T10:18:00Z");
});

it("returns undefined for an item that is still open", () => {
expect(itemClosedAt({ closed_at: null })).toBeUndefined();
});
});

describe("closedAt wiring", () => {
// itemClosedAt being correct is worth nothing if a query stops asking for the
// field or a mapper stops calling it. Both failures are silent: every item
// hydrates with closedAt undefined, isIncidentClosed returns false for
// everything, and the whole incident feature dies with all tests green.
const source = readFileSync(new URL("../github.ts", import.meta.url), "utf-8");

it("both GraphQL queries still request closedAt", () => {
const queryBlocks = source.split("author { login __typename }").length - 1;
expect(queryBlocks).toBe(2);
expect(source.match(/^\s+closedAt$/gm)?.length).toBe(2);
});

it("every PRItem construction site populates closedAt", () => {
// Five constructors: 2 GraphQL (pr, issue), 3 REST (pr list, issue list, single pr).
const constructions = source.match(/author: \w+\??\.\w+\?\.login \|\| "unknown"/g) ?? [];
const closedAtCalls = source.match(/closedAt: itemClosedAt\(/g) ?? [];
expect(closedAtCalls.length).toBe(constructions.length);
});
});
Loading
Loading