pierre-review 0.1.60 → 0.1.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,17 +11,17 @@ function parseIntList(raw) {
11
11
  }
12
12
  export async function activityRoutes(app) {
13
13
  // The Activity aggregate: per repo, current-state stats + thread totals +
14
- // attention/unread flags + open PRs. Scoped to the account; `repoIds` + `userIds`
15
- // narrow to the active FilterBar repo + member selection (across ALL the account's
16
- // repos — watched-only was dropped). Pure DB read — no GitHub sync, no AI.
14
+ // attention/unread flags + open PRs. Scoped to the account's WATCHED repos (inboxWatch);
15
+ // `repoIds` + `userIds` narrow to the active FilterBar repo + member selection WITHIN
16
+ // watched. Pure DB read — no GitHub sync, no AI.
17
17
  app.get('/api/activity', async (req) => {
18
18
  const q = req.query;
19
19
  return getActivity(accountIdOf(req), parseIntList(q.repoIds), parseIntList(q.userIds));
20
20
  });
21
21
  // The consolidated Feed (the Activity "Feed" entry): one flat, chronological stream
22
- // merging My Turn actionables + the activity feed, deduped. Scoped by the FilterBar
23
- // repo + member selection across ALL the account's repos (watched-only dropped).
24
- // Pure DB read — no GitHub sync, no AI.
22
+ // merging My Turn actionables + the activity feed, deduped. Scoped to the account's
23
+ // WATCHED repos (inboxWatch); the FilterBar repo + member selection narrow WITHIN
24
+ // watched. Pure DB read — no GitHub sync, no AI.
25
25
  app.get('/api/activity/feed', async (req) => {
26
26
  const q = req.query;
27
27
  const limit = q.limit != null ? Number(q.limit) : null;
@@ -81,6 +81,7 @@ export async function timelineRoutes(app) {
81
81
  from: parseDate(q.from, new Date(now.getTime() - 14 * DAY_MS)),
82
82
  to: parseDate(q.to, now),
83
83
  repoIds: parseIntList(q.repoIds),
84
+ prIds: parseIntList(q.prIds),
84
85
  userIds: parseIntList(q.userIds),
85
86
  types: parseTypes(q.types),
86
87
  statuses: parseStatuses(q.statuses),
@@ -235,30 +235,38 @@ function mapTimelinePr(p, counts, tr) {
235
235
  }
236
236
  export async function getTimeline(filters) {
237
237
  const { accountId, from, to, repoIds, userIds, types, statuses, reviewStates, excludeBots, allowBotIds, excludeStale, } = filters;
238
- // ---- PRs that overlap the window ----
239
- const prConds = [
240
- eq(pullRequests.accountId, accountId),
241
- lte(pullRequests.openedAt, to),
242
- or(eq(pullRequests.state, 'open'), gte(sql `coalesce(${pullRequests.mergedAt}, ${pullRequests.closedAt}, ${pullRequests.openedAt})`, tsBound(from))),
243
- ];
244
- if (repoIds)
245
- prConds.push(inArray(pullRequests.repoId, repoIds));
246
- // PR-status filter: keep only PRs whose (state, isDraft) is a selected status.
247
- if (statuses)
248
- prConds.push(prStatusWhere(statuses));
249
- // Member filter: PRs the user authored OR acted on within the window.
250
- if (userIds && userIds.length > 0) {
251
- prConds.push(or(inArray(pullRequests.authorId, userIds), exists(db
252
- .select({ x: sql `1` })
253
- .from(events)
254
- .where(and(eq(events.prId, pullRequests.id), inArray(events.actorId, userIds))))));
238
+ // A pr-focus tab's isolated timeline: fetch EXACTLY these PRs + all their events, bypassing
239
+ // every board filter (the subject PR may be in a repo/date the board excludes). Always
240
+ // accountId-scoped.
241
+ const prIds = filters.prIds ?? null;
242
+ const prScoped = prIds != null && prIds.length > 0;
243
+ // ---- PRs that overlap the window (or, when pr-scoped, exactly the requested PRs) ----
244
+ const prConds = [eq(pullRequests.accountId, accountId)];
245
+ if (prScoped) {
246
+ prConds.push(inArray(pullRequests.id, prIds));
247
+ }
248
+ else {
249
+ prConds.push(lte(pullRequests.openedAt, to), or(eq(pullRequests.state, 'open'), gte(sql `coalesce(${pullRequests.mergedAt}, ${pullRequests.closedAt}, ${pullRequests.openedAt})`, tsBound(from))));
250
+ if (repoIds)
251
+ prConds.push(inArray(pullRequests.repoId, repoIds));
252
+ // PR-status filter: keep only PRs whose (state, isDraft) is a selected status.
253
+ if (statuses)
254
+ prConds.push(prStatusWhere(statuses));
255
+ // Member filter: PRs the user authored OR acted on within the window.
256
+ if (userIds && userIds.length > 0) {
257
+ prConds.push(or(inArray(pullRequests.authorId, userIds), exists(db
258
+ .select({ x: sql `1` })
259
+ .from(events)
260
+ .where(and(eq(events.prId, pullRequests.id), inArray(events.actorId, userIds))))));
261
+ }
255
262
  }
256
263
  // Resolve the bot set once (used by both the PR and events branches below). The
257
264
  // per-repo allow-list subtracts the "important" bots so their activity stays visible
258
- // even under excludeBots — every downstream predicate keys off this trimmed set.
265
+ // even under excludeBots — every downstream predicate keys off this trimmed set. Skipped
266
+ // entirely when pr-scoped (no bot filtering there).
259
267
  const allowBots = new Set(allowBotIds ?? []);
260
- const bots = excludeBots ? (await botUserIds()).filter((id) => !allowBots.has(id)) : [];
261
- if (excludeBots && bots.length > 0) {
268
+ const bots = !prScoped && excludeBots ? (await botUserIds()).filter((id) => !allowBots.has(id)) : [];
269
+ if (!prScoped && excludeBots && bots.length > 0) {
262
270
  prConds.push(or(sql `${pullRequests.authorId} is null`, sql `${pullRequests.authorId} not in (${sql.join(bots, sql `, `)})`));
263
271
  }
264
272
  let prRows = await db
@@ -267,29 +275,32 @@ export async function getTimeline(filters) {
267
275
  .where(and(...prConds))
268
276
  .execute();
269
277
  // Stale filter: drop open PRs with no activity in the window. Computed before
270
- // building the lean PRs so their events can be dropped too (below).
271
- const staleIds = excludeStale
278
+ // building the lean PRs so their events can be dropped too (below). Skipped when pr-scoped.
279
+ const staleIds = !prScoped && excludeStale
272
280
  ? await staleOpenPrIds(prRows, from, to)
273
281
  : new Set();
274
282
  if (staleIds.size > 0)
275
283
  prRows = prRows.filter((p) => !staleIds.has(p.id));
276
284
  const prs = await buildTimelinePrs(prRows, accountId);
277
- // ---- events in the window ----
278
- const evConds = [
279
- eq(events.accountId, accountId),
280
- gte(events.occurredAt, from),
281
- lte(events.occurredAt, to),
282
- ];
283
- if (repoIds)
284
- evConds.push(inArray(events.repoId, repoIds));
285
- if (types)
286
- evConds.push(inArray(events.type, types));
287
- if (userIds && userIds.length > 0) {
288
- evConds.push(inArray(events.actorId, userIds));
285
+ // ---- events (in the window, or — when pr-scoped — ALL events for the requested PRs) ----
286
+ const evConds = [eq(events.accountId, accountId)];
287
+ if (prScoped) {
288
+ evConds.push(inArray(events.prId, prIds));
289
+ }
290
+ else {
291
+ evConds.push(gte(events.occurredAt, from), lte(events.occurredAt, to));
292
+ if (repoIds)
293
+ evConds.push(inArray(events.repoId, repoIds));
294
+ if (types)
295
+ evConds.push(inArray(events.type, types));
296
+ if (userIds && userIds.length > 0) {
297
+ evConds.push(inArray(events.actorId, userIds));
298
+ }
289
299
  }
290
300
  // Drop events whose PR is filtered out by status — so a contributor with only
291
301
  // a (e.g.) closed PR keeps neither a bar nor any markers, and loses their row.
292
- if (statuses) {
302
+ // (All remaining event filters are bypassed when pr-scoped.)
303
+ if (!prScoped && statuses) {
293
304
  evConds.push(exists(db
294
305
  .select({ x: sql `1` })
295
306
  .from(pullRequests)
@@ -300,7 +311,7 @@ export async function getTimeline(filters) {
300
311
  // drops all review markers (the review row exists but no verdict matches). null =
301
312
  // no filter. Pure-reviewer rows vanish when their verdict is deselected because the
302
313
  // event is removed here (not just hidden client-side), so no empty row lingers.
303
- if (reviewStates) {
314
+ if (!prScoped && reviewStates) {
304
315
  evConds.push(reviewStates.length === 0
305
316
  ? ne(events.type, 'review_submitted')
306
317
  : or(ne(events.type, 'review_submitted'), exists(db
@@ -1055,21 +1066,28 @@ const ACTIVITY_ATTENTION_REASONS = new Set([
1055
1066
  'merge_conflicts',
1056
1067
  'untouched_threads',
1057
1068
  ]);
1058
- // The Activity aggregate: per watched repo, current-state stats + a per-repo thread
1069
+ // The Activity aggregate: per WATCHED repo, current-state stats + a per-repo thread
1059
1070
  // total + maintainer ids + attention/unread flags + the open PRs (caller groups by
1060
- // author). Composes EXISTING accountId-scoped readers getInsights / getOpenPrs /
1061
- // getMergers / listRepos so isolation + triage logic stay single-sourced. The
1062
- // one genuinely new aggregation is `threadTotals` (sum each open PR's threadCounts
1071
+ // author). Scoped to the account's WATCHED repos (inboxWatch); a passed `repoIds` further
1072
+ // narrows WITHIN watched. Composes EXISTING accountId-scoped readers getInsights /
1073
+ // getOpenPrs / getMergers / listRepos so isolation + triage logic stay single-sourced.
1074
+ // The one genuinely new aggregation is `threadTotals` (sum each open PR's threadCounts
1063
1075
  // per repo). Every watched repo is included (a quiet repo → empty prs, zeroed stats).
1064
1076
  export async function getActivity(accountId, repoIds, userIds = null) {
1065
1077
  const reposAll = await listRepos(accountId);
1066
- const reposScoped = repoIds ? reposAll.filter((r) => repoIds.includes(r.id)) : reposAll;
1078
+ const watched = reposAll.filter((r) => r.inboxWatch);
1079
+ const reposScoped = repoIds ? watched.filter((r) => repoIds.includes(r.id)) : watched;
1080
+ const scopedIds = reposScoped.map((r) => r.id);
1081
+ // No watched repos in scope → a valid empty response (also avoids an inArray([]) below).
1082
+ if (scopedIds.length === 0)
1083
+ return { repos: [], generatedAt: new Date().toISOString() };
1067
1084
  // The compact-header `stats` stay REPO-scoped (repo health: open/draft/merged/stalled/
1068
1085
  // median-first-review) — filtering them by member would misreport repo throughput. The
1069
- // PR cards + thread bar reflect the member filter via getOpenPrs' userIds.
1086
+ // PR cards + thread bar reflect the member filter via getOpenPrs' userIds. Pass the
1087
+ // watched-scoped ids (never the raw repoIds) so downstream readers stay watched-only.
1070
1088
  const [insights, openPrs, mergers] = await Promise.all([
1071
- getInsights({ accountId, repoIds }),
1072
- getOpenPrs({ accountId, repoIds, userIds }),
1089
+ getInsights({ accountId, repoIds: scopedIds }),
1090
+ getOpenPrs({ accountId, repoIds: scopedIds, userIds }),
1073
1091
  getMergers(accountId),
1074
1092
  ]);
1075
1093
  const insightsByRepo = new Map(insights.repos.map((r) => [r.repoId, r]));
@@ -1539,6 +1557,11 @@ const INSIGHT_STALLED_REVIEW_HOURS = 24;
1539
1557
  const INSIGHT_UNTOUCHED_THREAD_HOURS = 24; // "> 1 day"
1540
1558
  const INSIGHT_SPRINT_DAYS = 14; // trailing 2 weeks
1541
1559
  const INSIGHT_ROUTING_MIN_AGE_HOURS = 4; // ignore brand-new PRs
1560
+ // A PR with NO activity (GitHub updatedAt) in this many days is "ultra-stale": effectively
1561
+ // abandoned-but-unclosed, no longer being looked at. Insight cards (and, downstream, the AI
1562
+ // sprint report) exclude them so they don't clutter "what needs attention". 90d = the board's
1563
+ // max range — beyond it, a PR is off everyone's radar.
1564
+ const INSIGHT_MAX_STALE_DAYS = 90;
1542
1565
  const INSIGHT_CARD_CAP = 15; // per-kind cap so the board stays digestible
1543
1566
  function topLevelDir(path) {
1544
1567
  const i = path.indexOf('/');
@@ -1546,6 +1569,12 @@ function topLevelDir(path) {
1546
1569
  }
1547
1570
  const TEAM_METRICS_WINDOW_DAYS = 84; // 12 weeks of weekly chart history
1548
1571
  const TEAM_METRICS_WEEK_MS = 7 * 86_400_000;
1572
+ // A cur-vs-prev stat comparison needs at least this many items on BOTH sides to be worth a
1573
+ // trend read. Below it (typical early in a sprint — often a single carryover PR) the stat is
1574
+ // flagged low-confidence: the tile drops the delta arrow and the AI report states the raw "so
1575
+ // far" figure without a percentage / "cliff" / "spike". This is what stops a day-1 report from
1576
+ // claiming "merges collapsed 99%" off a 1-vs-N sample.
1577
+ const TEAM_METRIC_MIN_SAMPLE = 3;
1549
1578
  function medianOf(xs) {
1550
1579
  if (xs.length === 0)
1551
1580
  return null;
@@ -1557,20 +1586,37 @@ function medianOf(xs) {
1557
1586
  // mapping from synced PR/CI data: deploy frequency = merges; lead time = open→merge;
1558
1587
  // change-failure (inverted) = merged-PR CI success; time-to-restore is a PROXY off the
1559
1588
  // current snapshot (open PRs red on CI + how long they've sat) since no CI-state history
1560
- // is stored. Stat tiles compare the current 14-day sprint to the prior one; weekly series
1561
- // (aligned to `weekBuckets`) feed the same chart toolkit the per-repo analytics use.
1589
+ // is stored. The weekly CHART series are ALWAYS a fixed 12 weeks ending now (independent of
1590
+ // any sprint window); the stat TILES compare THIS sprint to the immediately-preceding
1591
+ // equal-length one, aligned to the configured sprint `window` (default trailing 14d). The
1592
+ // DB fetch spans max(12 weeks, both sprints) so the "previous" tiles aren't starved. Weekly
1593
+ // series (aligned to `weekBuckets`) feed the same chart toolkit the per-repo analytics use.
1562
1594
  export async function getTeamMetrics(accountId, repoIds, nowMs,
1563
- // Optional configured SPRINT window (epoch millis). When present, the series/lead/CI window
1564
- // starts at `window.fromMs` instead of the legacy fixed lookback. Undefined → identical to the
1565
- // prior behavior. (The current-vs-previous sprint delta below is generalized in the sprint
1566
- // feature; it stays the fixed 14d comparison here.)
1595
+ // The comparison window (epoch millis) + mode for the stat TILES: THIS window's elapsed slice vs
1596
+ // the SAME slice of the immediately-preceding one. Undefined → the legacy trailing-14d default.
1597
+ // The CHART window is a fixed 12 weeks regardless.
1567
1598
  window) {
1568
1599
  if (repoIds.length === 0)
1569
1600
  return null;
1570
- const windowStartMs = window
1571
- ? window.fromMs
1572
- : nowMs - TEAM_METRICS_WINDOW_DAYS * 86_400_000;
1573
- const windowStart = new Date(windowStartMs);
1601
+ // Charts: a fixed 12-week window ending now, INDEPENDENT of the sprint window.
1602
+ const chartWindowStartMs = nowMs - TEAM_METRICS_WINDOW_DAYS * 86_400_000;
1603
+ // Sprint tiles: this sprint SO FAR (curLo..curHi, never counting the future) vs the SAME
1604
+ // elapsed slice of the immediately-preceding sprint. ELAPSED-MATCHED: `prevHi` tracks how far
1605
+ // into the sprint we are (prevLo + elapsed), NOT the full prior sprint (prevLo..curLo). On day
1606
+ // 1 this compares day-1-so-far vs the previous sprint's first day — a fair, apples-to-apples
1607
+ // read — instead of a few hours against a complete 14-day sprint (which surfaced as merges
1608
+ // "down 99%" and lead time "spiked 37×" the moment a sprint rolled over). At sprint end
1609
+ // elapsedMs === sprintLenMs → prevHi === curLo, i.e. it degrades to the full-vs-full compare.
1610
+ const curLo = window ? window.fromMs : nowMs - INSIGHT_SPRINT_DAYS * 86_400_000;
1611
+ const curHi = Math.min(window ? window.toMs : nowMs, nowMs);
1612
+ const sprintLenMs = window ? window.toMs - window.fromMs : INSIGHT_SPRINT_DAYS * 86_400_000;
1613
+ const elapsedMs = Math.max(0, curHi - curLo);
1614
+ const prevLo = curLo - sprintLenMs;
1615
+ const prevHi = prevLo + elapsedMs;
1616
+ // Fetch must cover BOTH the chart window AND the previous sprint (which can predate it) —
1617
+ // this is what actually feeds the cur-vs-prev "previous" tiles (else they come back 0).
1618
+ const fetchStartMs = Math.min(chartWindowStartMs, prevLo);
1619
+ const fetchStart = new Date(fetchStartMs);
1574
1620
  const prs = await db
1575
1621
  .select({
1576
1622
  state: pullRequests.state,
@@ -1583,22 +1629,20 @@ window) {
1583
1629
  })
1584
1630
  .from(pullRequests)
1585
1631
  .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds),
1586
- // Everything currently open, plus anything opened or merged within the window.
1587
- or(eq(pullRequests.state, 'open'), gte(pullRequests.openedAt, windowStart), gte(pullRequests.mergedAt, windowStart))))
1632
+ // Everything currently open, plus anything opened or merged within the fetch window
1633
+ // (max of the 12-week chart span and the previous sprint).
1634
+ or(eq(pullRequests.state, 'open'), gte(pullRequests.openedAt, fetchStart), gte(pullRequests.mergedAt, fetchStart))))
1588
1635
  .execute();
1589
- const nBuckets = Math.max(1, Math.round((nowMs - windowStartMs) / TEAM_METRICS_WEEK_MS));
1636
+ const nBuckets = Math.max(1, Math.round((nowMs - chartWindowStartMs) / TEAM_METRICS_WEEK_MS));
1590
1637
  const weekBuckets = [];
1591
1638
  for (let i = 0; i < nBuckets; i++)
1592
- weekBuckets.push(new Date(windowStartMs + i * TEAM_METRICS_WEEK_MS).toISOString());
1593
- const bi = (ms) => Math.max(0, Math.min(nBuckets - 1, Math.floor((ms - windowStartMs) / TEAM_METRICS_WEEK_MS)));
1639
+ weekBuckets.push(new Date(chartWindowStartMs + i * TEAM_METRICS_WEEK_MS).toISOString());
1640
+ const bi = (ms) => Math.max(0, Math.min(nBuckets - 1, Math.floor((ms - chartWindowStartMs) / TEAM_METRICS_WEEK_MS)));
1594
1641
  const zeros = () => new Array(nBuckets).fill(0);
1595
1642
  const openedSeries = zeros();
1596
1643
  const mergedSeries = zeros();
1597
1644
  const leadByBucket = Array.from({ length: nBuckets }, () => []);
1598
1645
  const ciByBucket = Array.from({ length: nBuckets }, () => ({ succ: 0, total: 0 }));
1599
- const SPRINT_MS = 14 * 86_400_000;
1600
- const curLo = nowMs - SPRINT_MS;
1601
- const prevLo = nowMs - 2 * SPRINT_MS;
1602
1646
  const inWin = (ms, lo, hi) => ms >= lo && ms < hi;
1603
1647
  const leadCur = [];
1604
1648
  const leadPrev = [];
@@ -1613,22 +1657,22 @@ window) {
1613
1657
  let openPrsNow = 0;
1614
1658
  for (const p of prs) {
1615
1659
  const openedMs = p.openedAt.getTime();
1616
- if (openedMs >= windowStartMs)
1660
+ if (openedMs >= chartWindowStartMs)
1617
1661
  openedSeries[bi(openedMs)] += 1;
1618
1662
  if (p.state === 'open' && !p.isDraft)
1619
1663
  openPrsNow += 1;
1620
1664
  if (p.firstReviewAt != null) {
1621
1665
  const ttfr = (p.firstReviewAt.getTime() - openedMs) / 3_600_000;
1622
1666
  if (ttfr >= 0) {
1623
- if (inWin(openedMs, curLo, nowMs))
1667
+ if (inWin(openedMs, curLo, curHi))
1624
1668
  ttfrCur.push(ttfr);
1625
- else if (inWin(openedMs, prevLo, curLo))
1669
+ else if (inWin(openedMs, prevLo, prevHi))
1626
1670
  ttfrPrev.push(ttfr);
1627
1671
  }
1628
1672
  }
1629
1673
  if (p.state === 'merged' && p.mergedAt != null) {
1630
1674
  const mergedMs = p.mergedAt.getTime();
1631
- if (mergedMs >= windowStartMs) {
1675
+ if (mergedMs >= chartWindowStartMs) {
1632
1676
  mergedSeries[bi(mergedMs)] += 1;
1633
1677
  const lead = (mergedMs - openedMs) / 3_600_000;
1634
1678
  if (lead >= 0)
@@ -1638,22 +1682,26 @@ window) {
1638
1682
  cb.total += 1;
1639
1683
  if (green)
1640
1684
  cb.succ += 1;
1641
- if (inWin(mergedMs, curLo, nowMs)) {
1642
- mergesCur += 1;
1643
- if (lead >= 0)
1644
- leadCur.push(lead);
1645
- ciMergedCur.total += 1;
1646
- if (green)
1647
- ciMergedCur.succ += 1;
1648
- }
1649
- else if (inWin(mergedMs, prevLo, curLo)) {
1650
- mergesPrev += 1;
1651
- if (lead >= 0)
1652
- leadPrev.push(lead);
1653
- ciMergedPrev.total += 1;
1654
- if (green)
1655
- ciMergedPrev.succ += 1;
1656
- }
1685
+ }
1686
+ // Sprint tiles are independent of the chart window (a merge in the prev sprint but
1687
+ // before the 12-week chart still counts toward the "previous" tile).
1688
+ if (inWin(mergedMs, curLo, curHi)) {
1689
+ mergesCur += 1;
1690
+ const lead = (mergedMs - openedMs) / 3_600_000;
1691
+ if (lead >= 0)
1692
+ leadCur.push(lead);
1693
+ ciMergedCur.total += 1;
1694
+ if (p.ciStatus === 'success')
1695
+ ciMergedCur.succ += 1;
1696
+ }
1697
+ else if (inWin(mergedMs, prevLo, prevHi)) {
1698
+ mergesPrev += 1;
1699
+ const lead = (mergedMs - openedMs) / 3_600_000;
1700
+ if (lead >= 0)
1701
+ leadPrev.push(lead);
1702
+ ciMergedPrev.total += 1;
1703
+ if (p.ciStatus === 'success')
1704
+ ciMergedPrev.succ += 1;
1657
1705
  }
1658
1706
  }
1659
1707
  if (p.state === 'open' &&
@@ -1678,7 +1726,7 @@ window) {
1678
1726
  observedAt: ciStatusEvents.observedAt,
1679
1727
  })
1680
1728
  .from(ciStatusEvents)
1681
- .where(and(eq(ciStatusEvents.accountId, accountId), inArray(ciStatusEvents.repoId, repoIds), gte(ciStatusEvents.observedAt, windowStart)))
1729
+ .where(and(eq(ciStatusEvents.accountId, accountId), inArray(ciStatusEvents.repoId, repoIds), gte(ciStatusEvents.observedAt, fetchStart)))
1682
1730
  .orderBy(ciStatusEvents.prId, ciStatusEvents.observedAt)
1683
1731
  .execute();
1684
1732
  const recoveries = [];
@@ -1706,28 +1754,43 @@ window) {
1706
1754
  const recCur = [];
1707
1755
  const recPrev = [];
1708
1756
  for (const r of recoveries) {
1709
- if (r.atMs >= windowStartMs)
1757
+ if (r.atMs >= chartWindowStartMs)
1710
1758
  recoveryByBucket[bi(r.atMs)].push(r.hours);
1711
- if (inWin(r.atMs, curLo, nowMs))
1759
+ if (inWin(r.atMs, curLo, curHi))
1712
1760
  recCur.push(r.hours);
1713
- else if (inWin(r.atMs, prevLo, curLo))
1761
+ else if (inWin(r.atMs, prevLo, prevHi))
1714
1762
  recPrev.push(r.hours);
1715
1763
  }
1716
1764
  const ciFailureReasons = [...reasonCounts.entries()]
1717
1765
  .sort((a, b) => b[1] - a[1])
1718
1766
  .slice(0, 8)
1719
1767
  .map(([stage, count]) => ({ stage, count }));
1768
+ // Wrap each cur/prev pair with its sample sizes + a low-confidence flag (either side below
1769
+ // TEAM_METRIC_MIN_SAMPLE). For counts the sample IS the value; for medians/percentages it's
1770
+ // the number of items that fed the statistic.
1771
+ const stat = (value, previous, curN, prevN) => ({
1772
+ value,
1773
+ previous,
1774
+ sampleSize: curN,
1775
+ previousSampleSize: prevN,
1776
+ lowConfidence: curN < TEAM_METRIC_MIN_SAMPLE || prevN < TEAM_METRIC_MIN_SAMPLE,
1777
+ });
1778
+ const elapsedDays = elapsedMs / 86_400_000;
1779
+ const elapsedFraction = sprintLenMs > 0 ? Math.min(1, elapsedMs / sprintLenMs) : 1;
1720
1780
  return {
1721
- sprintDays: 14,
1781
+ comparisonMode: window?.mode ?? 'rolling_14',
1782
+ sprintDays: Math.round(sprintLenMs / 86_400_000),
1783
+ elapsedDays: Math.round(elapsedDays * 10) / 10,
1784
+ elapsedFraction,
1722
1785
  weekBuckets,
1723
1786
  openPrs: openPrsNow,
1724
- merges: { value: mergesCur, previous: mergesPrev },
1725
- leadTimeHours: { value: medianOf(leadCur), previous: medianOf(leadPrev) },
1726
- timeToFirstReviewHours: { value: medianOf(ttfrCur), previous: medianOf(ttfrPrev) },
1727
- mergeCiSuccessPct: { value: pct(ciMergedCur), previous: pct(ciMergedPrev) },
1787
+ merges: stat(mergesCur, mergesPrev, mergesCur, mergesPrev),
1788
+ leadTimeHours: stat(medianOf(leadCur), medianOf(leadPrev), leadCur.length, leadPrev.length),
1789
+ timeToFirstReviewHours: stat(medianOf(ttfrCur), medianOf(ttfrPrev), ttfrCur.length, ttfrPrev.length),
1790
+ mergeCiSuccessPct: stat(pct(ciMergedCur), pct(ciMergedPrev), ciMergedCur.total, ciMergedPrev.total),
1728
1791
  ciFailingNow,
1729
1792
  ciFailingMedianAgeHours: medianOf(failingAges),
1730
- ciRecoveryHours: { value: medianOf(recCur), previous: medianOf(recPrev) },
1793
+ ciRecoveryHours: stat(medianOf(recCur), medianOf(recPrev), recCur.length, recPrev.length),
1731
1794
  throughput: { opened: openedSeries, merged: mergedSeries },
1732
1795
  leadTimeTrend: leadByBucket.map(medianOf),
1733
1796
  ciSuccessTrend: ciByBucket.map(pct),
@@ -1746,8 +1809,9 @@ const METRIC_DETAIL_CAP = 500;
1746
1809
  // WATCHED repos + the current sprint. Returns the PRs behind each tile with the
1747
1810
  // metric-specific figures, so the user can see WHERE issues cluster.
1748
1811
  export async function getTeamMetricsDetail(accountId,
1749
- // Optional configured SPRINT window (epoch millis); undefined → legacy default lookback.
1750
- // The lower bound uses window.fromMs; `now` remains the upper bound for time-based facts.
1812
+ // The comparison window (epoch millis); undefined → legacy default lookback. The lower bound
1813
+ // uses window.fromMs; `now` remains the upper bound. `mode` is unused here (a current-window PR
1814
+ // list, no cur/prev split) but accepted so callers pass the same object as getTeamMetrics.
1751
1815
  window) {
1752
1816
  const now = Date.now();
1753
1817
  const sprintFromMs = window?.fromMs ?? now - INSIGHT_SPRINT_DAYS * 86_400_000;
@@ -1993,8 +2057,9 @@ export async function getTeamInsights(accountId, window) {
1993
2057
  const generatedAt = new Date(now);
1994
2058
  // The Insights window: the configured SPRINT when provided (its `to` may be in the future for
1995
2059
  // the in-progress sprint — metrics facts only exist up to `now`), else the legacy default.
1996
- // Insight CARDS iterate currently-open PRs regardless of age, so "open PRs always count for the
1997
- // sprint" holds independently of this window; the window only bounds the time-based flow metrics.
2060
+ // Insight CARDS iterate currently-open PRs that have had activity within the last
2061
+ // INSIGHT_MAX_STALE_DAYS (ultra-stale = abandoned, excluded), independently of this window;
2062
+ // the window only bounds the time-based flow metrics.
1998
2063
  const sprintFrom = new Date(window?.fromMs ?? now - INSIGHT_SPRINT_DAYS * 86_400_000);
1999
2064
  const sprintTo = new Date(window?.toMs ?? now);
2000
2065
  const sprint = { from: sprintFrom.toISOString(), to: sprintTo.toISOString() };
@@ -2036,7 +2101,13 @@ export async function getTeamInsights(accountId, window) {
2036
2101
  if (repoIds.length === 0)
2037
2102
  return finish();
2038
2103
  const ghUrl = (repoId, number) => `https://github.com/${repoName.get(repoId)}/pull/${number}`;
2039
- // Open, non-draft PRs in the team's repos.
2104
+ // Open, non-draft PRs in the team's repos that are NOT ultra-stale — i.e. have a real ACTIVITY
2105
+ // EVENT (open/commit/comment/review) within the last INSIGHT_MAX_STALE_DAYS. We key off the
2106
+ // events feed, NOT pullRequests.updatedAt: GitHub bumps updatedAt on non-substantive base/label
2107
+ // syncs, so an abandoned PR can still show a "today" updatedAt — the events feed is the honest
2108
+ // "is anyone actually working on this" signal. A PR with no event in-window is abandoned-but-
2109
+ // unclosed; it shouldn't surface as a stalled review / untouched thread (nor feed the sprint report).
2110
+ const staleCutoff = new Date(now - INSIGHT_MAX_STALE_DAYS * 86_400_000);
2040
2111
  const openPrs = await db
2041
2112
  .select({
2042
2113
  id: pullRequests.id,
@@ -2052,7 +2123,10 @@ export async function getTeamInsights(accountId, window) {
2052
2123
  files: pullRequests.files,
2053
2124
  })
2054
2125
  .from(pullRequests)
2055
- .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds), eq(pullRequests.state, 'open'), eq(pullRequests.isDraft, false)))
2126
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds), eq(pullRequests.state, 'open'), eq(pullRequests.isDraft, false), exists(db
2127
+ .select({ x: sql `1` })
2128
+ .from(events)
2129
+ .where(and(eq(events.prId, pullRequests.id), eq(events.accountId, accountId), gte(events.occurredAt, staleCutoff))))))
2056
2130
  .execute();
2057
2131
  const openPrIds = openPrs.map((p) => p.id);
2058
2132
  const prById = new Map(openPrs.map((p) => [p.id, p]));
@@ -2127,7 +2201,9 @@ export async function getTeamInsights(accountId, window) {
2127
2201
  requestedReviewerIds: reviewers,
2128
2202
  });
2129
2203
  }
2130
- // (2) UNTOUCHED THREADS — derivedState 'untouched', older than a day, on open PRs.
2204
+ // (2) UNTOUCHED THREADS — derivedState 'untouched', older than a day, on open PRs. Scoped to
2205
+ // `openPrIds` (the active, non-ultra-stale set built above) so an untouched thread on an
2206
+ // abandoned PR (no activity in 90d) doesn't resurrect it as "needs attention".
2131
2207
  const threadRows = await db
2132
2208
  .select({
2133
2209
  threadId: reviewThreads.id,
@@ -2147,7 +2223,7 @@ export async function getTeamInsights(accountId, window) {
2147
2223
  })
2148
2224
  .from(reviewThreads)
2149
2225
  .innerJoin(pullRequests, eq(pullRequests.id, reviewThreads.prId))
2150
- .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds), eq(pullRequests.state, 'open'), eq(reviewThreads.derivedState, 'untouched'), lt(reviewThreads.createdAt, new Date(now - INSIGHT_UNTOUCHED_THREAD_HOURS * 3_600_000))))
2226
+ .where(and(inArray(pullRequests.id, openPrIds), eq(reviewThreads.derivedState, 'untouched'), lt(reviewThreads.createdAt, new Date(now - INSIGHT_UNTOUCHED_THREAD_HOURS * 3_600_000))))
2151
2227
  .execute();
2152
2228
  const threads = threadRows
2153
2229
  .map((t) => ({ t, ageHours: Math.round((now - t.createdAt.getTime()) / 3_600_000) }))
@@ -2316,8 +2392,19 @@ export async function getTeamInsights(accountId, window) {
2316
2392
  }
2317
2393
  return finish();
2318
2394
  }
2395
+ // The consolidated Feed. Scoped to the account's WATCHED repos (inboxWatch); a passed
2396
+ // `repoIds` further narrows WITHIN watched (null → all watched). One flat, newest-first
2397
+ // stream of real activity, each row flagged isMyTurn by participation (Pro enricher).
2319
2398
  export async function getConsolidatedFeed(accountId, opts = {}) {
2320
2399
  const { repoIds = null, userIds = null, limit = null, offset = 0, excludeBots = false, allowBotIds = null, } = opts;
2400
+ // Restrict to the account's WATCHED repos; a passed repoIds narrows within them. An
2401
+ // out-of-scope / empty selection → a valid empty page (also avoids an inArray([]) below).
2402
+ const watchedIds = (await listRepos(accountId)).filter((r) => r.inboxWatch).map((r) => r.id);
2403
+ const effectiveRepoIds = repoIds
2404
+ ? repoIds.filter((id) => watchedIds.includes(id))
2405
+ : watchedIds;
2406
+ if (effectiveRepoIds.length === 0)
2407
+ return { items: [], users: [], total: 0, generatedAt: new Date().toISOString() };
2321
2408
  // Bot set (only loaded when the filter is on) — drops bot-authored activity, mirroring
2322
2409
  // the timeline's excludeBots. The per-repo allow-list subtracts the "important" bots so
2323
2410
  // their activity stays visible. getFeed doesn't filter bots, so we do it here; the commit
@@ -2329,12 +2416,12 @@ export async function getConsolidatedFeed(accountId, opts = {}) {
2329
2416
  const notBot = (id) => !excludeBots || id == null || !botIds.has(id);
2330
2417
  const feedSince = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
2331
2418
  const [feed, commitItems, claudeItems] = await Promise.all([
2332
- getFeed(accountId, { daysBefore: 14, repoIds, userIds }),
2419
+ getFeed(accountId, { daysBefore: 14, repoIds: effectiveRepoIds, userIds }),
2333
2420
  // Commit-push activity that ADDRESSED a review thread (the only commit rows we surface
2334
2421
  // — plain pushes are noise). Each carries the affected threads inline.
2335
- getCommitThreadItems(accountId, { repoIds, userIds, botIds, since: feedSince }),
2422
+ getCommitThreadItems(accountId, { repoIds: effectiveRepoIds, userIds, botIds, since: feedSince }),
2336
2423
  // Claude Review runs surfaced as their own feed item kind (local-only; empty in cloud).
2337
- getClaudeReviewFeedItems(accountId, repoIds, feedSince),
2424
+ getClaudeReviewFeedItems(accountId, effectiveRepoIds, feedSince),
2338
2425
  ]);
2339
2426
  // The FYI / "My Turn" participation flag (isMyTurn / myTurnReasons / reasonTag) is a PAID
2340
2427
  // capability computed by the @pierre/pro plugin's registered enricher (see fyi-provider.ts).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pierre-review",
3
- "version": "0.1.60",
3
+ "version": "0.1.61",
4
4
  "description": "Dashboard for tracking your team's GitHub PR activity across repos — local (SQLite + gh) or self-hosted multi-tenant cloud (Postgres + GitHub App).",
5
5
  "type": "module",
6
6
  "author": "Alex Wakeman",