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
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ server/ # webhook server (GitHub App)
- **incremental processing**: only re-embeds new/changed items. crash-recoverable via sqlite
- **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
- **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)
- **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 Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

all notable changes to pr-prism are documented here.

## [unreleased]

### changed
- confirmed (identity) duplicate clusters now pick canonical by earliest-created instead of quality score: byte-identical duplicates are a which-was-first question, and a copied PR can outscore the original it was lifted from. fuzzy clusters keep the state/CI/score rule. starmap `canonical`/`contested` for confirmed clusters shift accordingly (value change, schema stays v1)

### added
- starmap items now carry `createdAt` alongside `updatedAt` (additive), so consumers can render and reason about which-was-first without re-fetching from github. NOTE for star-map: its importer rejects unknown fields, so it needs the coordinated importer patch before consuming a dataset with this field

## [3.1.0] — 2026-07-20

### added
Expand Down
20 changes: 20 additions & 0 deletions src/__tests__/identity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,23 @@ describe("diffFingerprint", () => {
expect(diffFingerprint(a)).not.toBe(diffFingerprint(c));
});
});

describe("identity canonical is which-was-first", () => {
it("earliest-created member wins the bestPick even when a later copy outscores it", () => {
const original = pr(100, {
headRefOid: "same-oid",
createdAt: "2026-01-01T00:00:00Z",
ciStatus: "unknown",
});
const copy = pr(200, {
headRefOid: "same-oid",
createdAt: "2026-03-01T00:00:00Z",
ciStatus: "success",
hasTests: true,
reviewCount: 3,
});
const clusters = findConfirmedDuplicates([copy, original]);
expect(clusters).toHaveLength(1);
expect(clusters[0].bestPick.number).toBe(100);
});
});
30 changes: 30 additions & 0 deletions src/__tests__/starmap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,33 @@ describe("buildStarmapPayload relational classification", () => {
expect(p.schemaVersion).toBe(1);
});
});

describe("confirmed clusters in the starmap payload", () => {
it("canonical follows earliest-created for identity clusters and items carry createdAt", () => {
const original = item(100, "pr", 0.2, { createdAt: "2026-01-01T00:00:00Z" });
const copy = item(200, "pr", 0.9, { createdAt: "2026-03-01T00:00:00Z" });
const confirmed: Cluster = {
...cluster(1, [original, copy], 1, 1),
kind: "identity",
identity: { basis: "head-oid", key: "oid" },
};
const p = buildStarmapPayload([], META, { confirmed: [confirmed] });
expect(p.clusters[0].canonical.number).toBe(100);
expect(p.clusters[0].items.every((i) => i.createdAt !== undefined)).toBe(true);
});

it("fuzzy PR clusters keep the quality-score canonical rule", () => {
const clusters = [
cluster(
1,
[
item(10, "pr", 0.9, { createdAt: "2026-03-01T00:00:00Z" }),
item(11, "pr", 0.2, { createdAt: "2026-01-01T00:00:00Z" }),
],
0.9,
0.88,
),
];
expect(buildStarmapPayload(clusters, META).clusters[0].canonical.number).toBe(10);
});
});
6 changes: 5 additions & 1 deletion src/identity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,11 @@ function pushGroup(map: Map<string, PRItem[]>, key: string, item: PRItem): void

function makeIdentityCluster(members: PRItem[], identity: { basis: "head-oid" | "patch-id"; key: string }): Cluster {
const items = members.map(scoreClusterItem).sort((a, b) => b.score - a.score);
const bestPick = selectCanonical(items);
// Confirmed byte-identical duplicates resolve by WHICH-WAS-FIRST, not quality:
// a copied PR can easily outscore the original it was lifted from, and naming
// the copy canonical rewards the theft. mode "issue" is the earliest-created
// rule (with its 24h near-tie window for genuinely simultaneous pushes).
const bestPick = selectCanonical(items, { mode: "issue" });
return {
id: 0, // reassigned by the caller
items,
Expand Down
7 changes: 6 additions & 1 deletion src/starmap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface StarmapItemRef {
export interface StarmapItem extends StarmapItemRef {
title: string;
author: string;
createdAt: string;
updatedAt: string;
score: number;
/** PRs only: same-repo issue numbers this PR closes. Omitted when the item
Expand Down Expand Up @@ -148,7 +149,10 @@ export function buildStarmapPayload(
// contested / runnerUp come from decideCanonical so they reflect the actual
// canonical rule (earliest-report for issue clusters), not a score sort.
const runnerUpMargin = ranked.length >= 2 ? ranked[0].score - ranked[1].score : null;
const decision = decideCanonical(c.items);
// Identity (confirmed byte-identical) clusters resolve canonical by
// which-was-first, matching identity.ts's bestPick — mode "issue" is the
// earliest-created rule. Fuzzy clusters keep the derived state/CI/score rule.
const decision = decideCanonical(c.items, c.kind === "identity" ? { mode: "issue" } : undefined);
const trackerDecision = selectTracker(c.items);
const tracker: StarmapTracker = {
needsTracker: trackerDecision.needsTracker,
Expand All @@ -160,6 +164,7 @@ export function buildStarmapPayload(
...ref(it),
title: sanitizeTitle(it.title),
author: it.author,
createdAt: it.createdAt,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Coordinate the strict importer before emitting createdAt

The existing star-map importer rejects unknown fields (as this commit's CHANGELOG notes), so every newly generated --starmap payload will fail to load in that consumer while still advertising schema version 1. Ship the importer change in lockstep, defer this field, or version the contract before emitting it.

Useful? React with 👍 / 👎.

updatedAt: it.updatedAt,
score: it.score,
};
Expand Down
Loading