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.
- package/dist/api/routes/activity.js +6 -6
- package/dist/api/routes/timeline.js +1 -0
- package/dist/db/queries.js +190 -103
- package/package.json +1 -1
- package/public/assets/index-D3B3ocm3.js +1390 -0
- package/public/assets/index-WLWeK-9-.css +10 -0
- package/public/index.html +2 -2
- package/public/assets/index-BfGXViGK.js +0 -1390
- package/public/assets/index-yvdhLCO_.css +0 -10
|
@@ -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
|
|
15
|
-
// narrow to the active FilterBar repo + member selection
|
|
16
|
-
//
|
|
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
|
|
23
|
-
//
|
|
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),
|
package/dist/db/queries.js
CHANGED
|
@@ -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
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
.
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
|
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).
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
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
|
|
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.
|
|
1561
|
-
//
|
|
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
|
-
//
|
|
1564
|
-
//
|
|
1565
|
-
//
|
|
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
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
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 -
|
|
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(
|
|
1593
|
-
const bi = (ms) => Math.max(0, Math.min(nBuckets - 1, Math.floor((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 >=
|
|
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,
|
|
1667
|
+
if (inWin(openedMs, curLo, curHi))
|
|
1624
1668
|
ttfrCur.push(ttfr);
|
|
1625
|
-
else if (inWin(openedMs, prevLo,
|
|
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 >=
|
|
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
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
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,
|
|
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 >=
|
|
1757
|
+
if (r.atMs >= chartWindowStartMs)
|
|
1710
1758
|
recoveryByBucket[bi(r.atMs)].push(r.hours);
|
|
1711
|
-
if (inWin(r.atMs, curLo,
|
|
1759
|
+
if (inWin(r.atMs, curLo, curHi))
|
|
1712
1760
|
recCur.push(r.hours);
|
|
1713
|
-
else if (inWin(r.atMs, prevLo,
|
|
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
|
-
|
|
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:
|
|
1725
|
-
leadTimeHours:
|
|
1726
|
-
timeToFirstReviewHours:
|
|
1727
|
-
mergeCiSuccessPct:
|
|
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:
|
|
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
|
-
//
|
|
1750
|
-
//
|
|
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
|
|
1997
|
-
//
|
|
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(
|
|
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,
|
|
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.
|
|
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",
|