pierre-review 0.1.23 → 0.1.24
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/insights.js +13 -1
- package/dist/api/routes/me.js +3 -2
- package/dist/db/queries.js +380 -5
- package/dist/db/schema.pg.js +3 -1
- package/dist/db/schema.sqlite.js +9 -6
- package/package.json +1 -1
- package/public/assets/index-CF5RjHlj.css +10 -0
- package/public/assets/index-_pvZqY4L.js +1371 -0
- package/public/index.html +2 -2
- package/public/assets/index-CbNTBha6.js +0 -1371
- package/public/assets/index-CjBmEO6s.css +0 -10
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getInsights } from '../../db/queries.js';
|
|
1
|
+
import { getInsights, getRepoAnalytics } from '../../db/queries.js';
|
|
2
2
|
import { accountIdOf } from '../plugins/auth.js';
|
|
3
3
|
function parseIntList(raw) {
|
|
4
4
|
if (!raw)
|
|
@@ -19,5 +19,17 @@ export async function insightsRoutes(app) {
|
|
|
19
19
|
repoIds: parseIntList(q.repoIds),
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
|
+
// Heavier per-repo analytics for the drill-down chart panel — loaded on demand.
|
|
23
|
+
// Ownership-scoped: a repo not owned by the account 404s.
|
|
24
|
+
app.get('/api/insights/:repoId/analytics', async (req, reply) => {
|
|
25
|
+
const { repoId } = req.params;
|
|
26
|
+
const id = Number.parseInt(repoId, 10);
|
|
27
|
+
const data = Number.isFinite(id)
|
|
28
|
+
? await getRepoAnalytics(accountIdOf(req), id)
|
|
29
|
+
: null;
|
|
30
|
+
if (!data)
|
|
31
|
+
return reply.code(404).send({ error: 'repo not found' });
|
|
32
|
+
return data;
|
|
33
|
+
});
|
|
22
34
|
}
|
|
23
35
|
//# sourceMappingURL=insights.js.map
|
package/dist/api/routes/me.js
CHANGED
|
@@ -8,7 +8,7 @@ const dismissSchema = {
|
|
|
8
8
|
required: ['kind', 'refId'],
|
|
9
9
|
additionalProperties: false,
|
|
10
10
|
properties: {
|
|
11
|
-
kind: { type: 'string', enum: ['review_request', 'thread'] },
|
|
11
|
+
kind: { type: 'string', enum: ['review_request', 'thread', 'claude_review'] },
|
|
12
12
|
refId: { type: 'integer' },
|
|
13
13
|
},
|
|
14
14
|
},
|
|
@@ -35,7 +35,8 @@ export async function meRoutes(app) {
|
|
|
35
35
|
await dismissMyTurn(accountIdOf(req), kind, refId);
|
|
36
36
|
return { status: 'ok' };
|
|
37
37
|
});
|
|
38
|
-
// The "Done" tab: entries dismissed in the past 90 days (review_request + thread
|
|
38
|
+
// The "Done" tab: entries dismissed in the past 90 days (review_request + thread
|
|
39
|
+
// + claude_review).
|
|
39
40
|
app.get('/api/my-turn/done', async (req) => getCompletedDismissals(accountIdOf(req), 90));
|
|
40
41
|
// Un-dismiss: move a completed entry back to the inbox.
|
|
41
42
|
app.post('/api/my-turn/undismiss', { schema: dismissSchema }, async (req) => {
|
package/dist/db/queries.js
CHANGED
|
@@ -385,6 +385,10 @@ export async function getOpenPrs(filters) {
|
|
|
385
385
|
}
|
|
386
386
|
const INSIGHTS_MERGED_WINDOW_DAYS = 7;
|
|
387
387
|
const INSIGHTS_REVIEW_WINDOW_DAYS = 30;
|
|
388
|
+
// The "avg time a PR stays open" trend spans this many days back from now, in
|
|
389
|
+
// weekly buckets (84 / 7 = 12 points).
|
|
390
|
+
const INSIGHTS_CHART_WINDOW_DAYS = 84;
|
|
391
|
+
const WEEK_MS = 7 * 86_400_000;
|
|
388
392
|
function median(xs) {
|
|
389
393
|
if (xs.length === 0)
|
|
390
394
|
return null;
|
|
@@ -392,6 +396,34 @@ function median(xs) {
|
|
|
392
396
|
const mid = Math.floor(s.length / 2);
|
|
393
397
|
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
394
398
|
}
|
|
399
|
+
// Bucket PR cycle times (openMs→closeMs) into weekly points over the chart window,
|
|
400
|
+
// oldest first. Each point is the MEAN open-hours of the PRs that CLOSED that week;
|
|
401
|
+
// a week with no closed PRs yields a null average (a gap in the trend line).
|
|
402
|
+
function buildOpenDurationTrend(rows, windowStartMs, nowMs) {
|
|
403
|
+
const buckets = Math.round((nowMs - windowStartMs) / WEEK_MS);
|
|
404
|
+
const sums = new Array(buckets).fill(0);
|
|
405
|
+
const counts = new Array(buckets).fill(0);
|
|
406
|
+
for (const r of rows) {
|
|
407
|
+
if (r.closeMs < windowStartMs || r.closeMs > nowMs)
|
|
408
|
+
continue;
|
|
409
|
+
const hrs = (r.closeMs - r.openMs) / 3_600_000;
|
|
410
|
+
if (hrs < 0)
|
|
411
|
+
continue;
|
|
412
|
+
const idx = Math.min(buckets - 1, Math.floor((r.closeMs - windowStartMs) / WEEK_MS));
|
|
413
|
+
sums[idx] += hrs;
|
|
414
|
+
counts[idx] += 1;
|
|
415
|
+
}
|
|
416
|
+
const out = [];
|
|
417
|
+
for (let i = 0; i < buckets; i++) {
|
|
418
|
+
const c = counts[i];
|
|
419
|
+
out.push({
|
|
420
|
+
bucketStart: new Date(windowStartMs + i * WEEK_MS).toISOString(),
|
|
421
|
+
avgOpenHours: c > 0 ? Math.round((sums[i] / c) * 10) / 10 : null,
|
|
422
|
+
count: c,
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
return out;
|
|
426
|
+
}
|
|
395
427
|
/**
|
|
396
428
|
* Per-repo snapshot for the Insights panel. Counts (open/draft/stalled) are current
|
|
397
429
|
* state; merged is a 7-day window; time-to-first-review is a median over PRs opened
|
|
@@ -403,10 +435,12 @@ export async function getInsights(filters) {
|
|
|
403
435
|
const now = Date.now();
|
|
404
436
|
const mergedCutoff = new Date(now - INSIGHTS_MERGED_WINDOW_DAYS * 86_400_000);
|
|
405
437
|
const reviewCutoff = new Date(now - INSIGHTS_REVIEW_WINDOW_DAYS * 86_400_000);
|
|
438
|
+
const chartWindowStartMs = now - INSIGHTS_CHART_WINDOW_DAYS * 86_400_000;
|
|
406
439
|
const base = {
|
|
407
440
|
mergedWindowDays: INSIGHTS_MERGED_WINDOW_DAYS,
|
|
408
441
|
reviewWindowDays: INSIGHTS_REVIEW_WINDOW_DAYS,
|
|
409
442
|
stallThresholdDays: config.stallThresholdDays,
|
|
443
|
+
chartWindowDays: INSIGHTS_CHART_WINDOW_DAYS,
|
|
410
444
|
generatedAt: new Date().toISOString(),
|
|
411
445
|
};
|
|
412
446
|
const reposAll = await listRepos(accountId);
|
|
@@ -436,6 +470,35 @@ export async function getInsights(filters) {
|
|
|
436
470
|
const mergedByRepo = new Map();
|
|
437
471
|
for (const r of mergedRows)
|
|
438
472
|
mergedByRepo.set(r.repoId, (mergedByRepo.get(r.repoId) ?? 0) + 1);
|
|
473
|
+
// PRs closed/merged within the chart window, for the per-repo "avg time open"
|
|
474
|
+
// trend. A merged PR's close instant is mergedAt; a plain-closed one's is closedAt.
|
|
475
|
+
const chartCutoff = new Date(chartWindowStartMs);
|
|
476
|
+
const closedConds = [
|
|
477
|
+
eq(pullRequests.accountId, accountId),
|
|
478
|
+
inArray(pullRequests.state, ['merged', 'closed']),
|
|
479
|
+
or(gte(pullRequests.mergedAt, chartCutoff), gte(pullRequests.closedAt, chartCutoff)),
|
|
480
|
+
];
|
|
481
|
+
if (repoIds)
|
|
482
|
+
closedConds.push(inArray(pullRequests.repoId, repoIds));
|
|
483
|
+
const closedRows = await db
|
|
484
|
+
.select({
|
|
485
|
+
repoId: pullRequests.repoId,
|
|
486
|
+
openedAt: pullRequests.openedAt,
|
|
487
|
+
mergedAt: pullRequests.mergedAt,
|
|
488
|
+
closedAt: pullRequests.closedAt,
|
|
489
|
+
})
|
|
490
|
+
.from(pullRequests)
|
|
491
|
+
.where(and(...closedConds))
|
|
492
|
+
.execute();
|
|
493
|
+
const closedByRepo = new Map();
|
|
494
|
+
for (const r of closedRows) {
|
|
495
|
+
const close = r.mergedAt ?? r.closedAt;
|
|
496
|
+
if (!close)
|
|
497
|
+
continue;
|
|
498
|
+
const arr = closedByRepo.get(r.repoId) ?? [];
|
|
499
|
+
arr.push({ openMs: r.openedAt.getTime(), closeMs: close.getTime() });
|
|
500
|
+
closedByRepo.set(r.repoId, arr);
|
|
501
|
+
}
|
|
439
502
|
// Time-to-first-review samples: PRs opened in the review window with a first review.
|
|
440
503
|
const ttfrConds = [
|
|
441
504
|
eq(pullRequests.accountId, accountId),
|
|
@@ -527,10 +590,269 @@ export async function getInsights(filters) {
|
|
|
527
590
|
: null,
|
|
528
591
|
reviewLoad,
|
|
529
592
|
openPrList,
|
|
593
|
+
openDurationTrend: buildOpenDurationTrend(closedByRepo.get(repo.id) ?? [], chartWindowStartMs, now),
|
|
530
594
|
};
|
|
531
595
|
});
|
|
532
596
|
return { ...base, repos: repoInsights };
|
|
533
597
|
}
|
|
598
|
+
// Heavier per-repo analytics for the drill-down panel — computed on demand (only
|
|
599
|
+
// when the panel opens). All series cover the last INSIGHTS_CHART_WINDOW_DAYS in
|
|
600
|
+
// weekly buckets, except the distributions (categorical), the size/cycle scatter,
|
|
601
|
+
// and the weekday×hour heatmap. Scoped to the account; returns null when the repo
|
|
602
|
+
// isn't owned by the account (→ 404 at the route).
|
|
603
|
+
export async function getRepoAnalytics(accountId, repoId) {
|
|
604
|
+
const repo = await getRepo(repoId, accountId);
|
|
605
|
+
if (!repo)
|
|
606
|
+
return null;
|
|
607
|
+
const now = Date.now();
|
|
608
|
+
const windowDays = INSIGHTS_CHART_WINDOW_DAYS;
|
|
609
|
+
const windowStartMs = now - windowDays * 86_400_000;
|
|
610
|
+
const windowStart = new Date(windowStartMs);
|
|
611
|
+
const nBuckets = Math.round((now - windowStartMs) / WEEK_MS);
|
|
612
|
+
const weekBuckets = [];
|
|
613
|
+
for (let i = 0; i < nBuckets; i++) {
|
|
614
|
+
weekBuckets.push(new Date(windowStartMs + i * WEEK_MS).toISOString());
|
|
615
|
+
}
|
|
616
|
+
const zeros = () => new Array(nBuckets).fill(0);
|
|
617
|
+
const bi = (ms) => Math.max(0, Math.min(nBuckets - 1, Math.floor((ms - windowStartMs) / WEEK_MS)));
|
|
618
|
+
const inWin = (ms) => ms >= windowStartMs && ms <= now;
|
|
619
|
+
const inc = (arr, i) => {
|
|
620
|
+
arr[i] = (arr[i] ?? 0) + 1;
|
|
621
|
+
};
|
|
622
|
+
const addv = (arr, i, v) => {
|
|
623
|
+
arr[i] = (arr[i] ?? 0) + v;
|
|
624
|
+
};
|
|
625
|
+
// ---- PRs relevant to the window: opened/closed in window, or still open ----
|
|
626
|
+
const prRows = await db
|
|
627
|
+
.select({
|
|
628
|
+
number: pullRequests.number,
|
|
629
|
+
openedAt: pullRequests.openedAt,
|
|
630
|
+
firstReviewAt: pullRequests.firstReviewAt,
|
|
631
|
+
mergedAt: pullRequests.mergedAt,
|
|
632
|
+
closedAt: pullRequests.closedAt,
|
|
633
|
+
lastCommitAt: pullRequests.lastCommitAt,
|
|
634
|
+
additions: pullRequests.additions,
|
|
635
|
+
deletions: pullRequests.deletions,
|
|
636
|
+
})
|
|
637
|
+
.from(pullRequests)
|
|
638
|
+
.where(and(eq(pullRequests.accountId, accountId), eq(pullRequests.repoId, repoId), or(and(isNull(pullRequests.mergedAt), isNull(pullRequests.closedAt)), gte(pullRequests.openedAt, windowStart), gte(pullRequests.mergedAt, windowStart), gte(pullRequests.closedAt, windowStart))))
|
|
639
|
+
.execute();
|
|
640
|
+
const opened = zeros();
|
|
641
|
+
const mergedSeries = zeros();
|
|
642
|
+
const closedSeries = zeros();
|
|
643
|
+
const open = zeros();
|
|
644
|
+
const stalled = zeros();
|
|
645
|
+
const stallMs = config.stallThresholdDays * 86_400_000;
|
|
646
|
+
const ttfrByBucket = Array.from({ length: nBuckets }, () => []);
|
|
647
|
+
const cbA = zeros();
|
|
648
|
+
const cbB = zeros();
|
|
649
|
+
const cbN = zeros();
|
|
650
|
+
const LAT_BINS = [
|
|
651
|
+
{ label: '<1h', max: 1 },
|
|
652
|
+
{ label: '1–4h', max: 4 },
|
|
653
|
+
{ label: '4–24h', max: 24 },
|
|
654
|
+
{ label: '1–3d', max: 72 },
|
|
655
|
+
{ label: '>3d', max: Infinity },
|
|
656
|
+
];
|
|
657
|
+
const latCounts = new Array(LAT_BINS.length).fill(0);
|
|
658
|
+
const SIZE_BINS = [
|
|
659
|
+
{ label: 'XS <10', max: 10 },
|
|
660
|
+
{ label: 'S <50', max: 50 },
|
|
661
|
+
{ label: 'M <200', max: 200 },
|
|
662
|
+
{ label: 'L <500', max: 500 },
|
|
663
|
+
{ label: 'XL 500+', max: Infinity },
|
|
664
|
+
];
|
|
665
|
+
const sizeCounts = new Array(SIZE_BINS.length).fill(0);
|
|
666
|
+
const binOf = (bins, v) => {
|
|
667
|
+
for (let b = 0; b < bins.length; b++)
|
|
668
|
+
if (v < bins[b].max)
|
|
669
|
+
return b;
|
|
670
|
+
return bins.length - 1;
|
|
671
|
+
};
|
|
672
|
+
const sizeVsCycle = [];
|
|
673
|
+
// Time-open samples per LOC bucket, over ALL PRs closed in the window (uncapped,
|
|
674
|
+
// unlike sizeVsCycle), for the median-by-size view.
|
|
675
|
+
const sizeBucketDur = SIZE_BINS.map(() => []);
|
|
676
|
+
for (const p of prRows) {
|
|
677
|
+
const oMs = p.openedAt.getTime();
|
|
678
|
+
const closeDate = p.mergedAt ?? p.closedAt;
|
|
679
|
+
const cMs = closeDate ? closeDate.getTime() : null;
|
|
680
|
+
if (inWin(oMs))
|
|
681
|
+
inc(opened, bi(oMs));
|
|
682
|
+
if (p.mergedAt && inWin(p.mergedAt.getTime()))
|
|
683
|
+
inc(mergedSeries, bi(p.mergedAt.getTime()));
|
|
684
|
+
if (!p.mergedAt && p.closedAt && inWin(p.closedAt.getTime())) {
|
|
685
|
+
inc(closedSeries, bi(p.closedAt.getTime()));
|
|
686
|
+
}
|
|
687
|
+
// Backlog: was this PR open at each week's end, and stalled (no recent commit)?
|
|
688
|
+
for (let i = 0; i < nBuckets; i++) {
|
|
689
|
+
const snap = Math.min(windowStartMs + (i + 1) * WEEK_MS, now);
|
|
690
|
+
if (oMs <= snap && (cMs == null || cMs > snap)) {
|
|
691
|
+
inc(open, i);
|
|
692
|
+
const lastAct = p.lastCommitAt ? p.lastCommitAt.getTime() : oMs;
|
|
693
|
+
if (lastAct < snap - stallMs)
|
|
694
|
+
inc(stalled, i);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
if (p.firstReviewAt && inWin(oMs)) {
|
|
698
|
+
const hrs = (p.firstReviewAt.getTime() - oMs) / 3_600_000;
|
|
699
|
+
if (hrs >= 0)
|
|
700
|
+
ttfrByBucket[bi(oMs)].push(hrs);
|
|
701
|
+
}
|
|
702
|
+
if (p.firstReviewAt && inWin(p.firstReviewAt.getTime())) {
|
|
703
|
+
const hrs = (p.firstReviewAt.getTime() - oMs) / 3_600_000;
|
|
704
|
+
if (hrs >= 0)
|
|
705
|
+
latCounts[binOf(LAT_BINS, hrs)]++;
|
|
706
|
+
}
|
|
707
|
+
if (cMs != null && inWin(cMs)) {
|
|
708
|
+
const total = (cMs - oMs) / 3_600_000;
|
|
709
|
+
if (total >= 0) {
|
|
710
|
+
const idx = bi(cMs);
|
|
711
|
+
inc(cbN, idx);
|
|
712
|
+
if (p.firstReviewAt) {
|
|
713
|
+
addv(cbA, idx, Math.max(0, (p.firstReviewAt.getTime() - oMs) / 3_600_000));
|
|
714
|
+
addv(cbB, idx, Math.max(0, (cMs - p.firstReviewAt.getTime()) / 3_600_000));
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
addv(cbA, idx, total);
|
|
718
|
+
}
|
|
719
|
+
const loc = p.additions + p.deletions;
|
|
720
|
+
sizeVsCycle.push({
|
|
721
|
+
prNumber: p.number,
|
|
722
|
+
loc,
|
|
723
|
+
hoursOpen: Math.round(total * 10) / 10,
|
|
724
|
+
merged: p.mergedAt != null,
|
|
725
|
+
});
|
|
726
|
+
sizeBucketDur[binOf(SIZE_BINS, loc)].push(total);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
if (inWin(oMs))
|
|
730
|
+
sizeCounts[binOf(SIZE_BINS, p.additions + p.deletions)]++;
|
|
731
|
+
}
|
|
732
|
+
// ---- reviews in window: verdict mix + per-reviewer load ----
|
|
733
|
+
const reviewRows = await db
|
|
734
|
+
.select({
|
|
735
|
+
reviewerId: reviews.authorId,
|
|
736
|
+
state: reviews.state,
|
|
737
|
+
submittedAt: reviews.submittedAt,
|
|
738
|
+
})
|
|
739
|
+
.from(reviews)
|
|
740
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
|
|
741
|
+
.where(and(eq(pullRequests.accountId, accountId), eq(pullRequests.repoId, repoId), gte(reviews.submittedAt, windowStart)))
|
|
742
|
+
.execute();
|
|
743
|
+
const verdicts = {
|
|
744
|
+
approved: zeros(),
|
|
745
|
+
changes_requested: zeros(),
|
|
746
|
+
commented: zeros(),
|
|
747
|
+
dismissed: zeros(),
|
|
748
|
+
};
|
|
749
|
+
const reviewerWeekly = new Map();
|
|
750
|
+
for (const r of reviewRows) {
|
|
751
|
+
const ms = r.submittedAt.getTime();
|
|
752
|
+
if (!inWin(ms))
|
|
753
|
+
continue;
|
|
754
|
+
const idx = bi(ms);
|
|
755
|
+
if (r.state === 'approved')
|
|
756
|
+
inc(verdicts.approved, idx);
|
|
757
|
+
else if (r.state === 'changes_requested')
|
|
758
|
+
inc(verdicts.changes_requested, idx);
|
|
759
|
+
else if (r.state === 'commented')
|
|
760
|
+
inc(verdicts.commented, idx);
|
|
761
|
+
else if (r.state === 'dismissed')
|
|
762
|
+
inc(verdicts.dismissed, idx);
|
|
763
|
+
if (r.reviewerId != null) {
|
|
764
|
+
const arr = reviewerWeekly.get(r.reviewerId) ?? zeros();
|
|
765
|
+
inc(arr, idx);
|
|
766
|
+
reviewerWeekly.set(r.reviewerId, arr);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const reviewerEntries = [...reviewerWeekly.entries()]
|
|
770
|
+
.map(([userId, weekly]) => ({ userId, weekly, total: weekly.reduce((a, b) => a + b, 0) }))
|
|
771
|
+
.sort((a, b) => b.total - a.total);
|
|
772
|
+
const TOP_REVIEWERS = 6;
|
|
773
|
+
const reviewerLoad = reviewerEntries
|
|
774
|
+
.slice(0, TOP_REVIEWERS)
|
|
775
|
+
.map((e) => ({ userId: e.userId, total: e.total, weekly: e.weekly }));
|
|
776
|
+
const rest = reviewerEntries.slice(TOP_REVIEWERS);
|
|
777
|
+
if (rest.length > 0) {
|
|
778
|
+
const otherWeekly = zeros();
|
|
779
|
+
for (const e of rest)
|
|
780
|
+
for (let i = 0; i < nBuckets; i++)
|
|
781
|
+
addv(otherWeekly, i, e.weekly[i] ?? 0);
|
|
782
|
+
reviewerLoad.push({
|
|
783
|
+
userId: -1,
|
|
784
|
+
total: otherWeekly.reduce((a, b) => a + b, 0),
|
|
785
|
+
weekly: otherWeekly,
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
// ---- review threads in window: derived-state mix by createdAt week ----
|
|
789
|
+
const threadRows = await db
|
|
790
|
+
.select({ state: reviewThreads.derivedState, createdAt: reviewThreads.createdAt })
|
|
791
|
+
.from(reviewThreads)
|
|
792
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviewThreads.prId))
|
|
793
|
+
.where(and(eq(pullRequests.accountId, accountId), eq(pullRequests.repoId, repoId), gte(reviewThreads.createdAt, windowStart)))
|
|
794
|
+
.execute();
|
|
795
|
+
const threadMix = {
|
|
796
|
+
resolved: zeros(),
|
|
797
|
+
likely_addressed: zeros(),
|
|
798
|
+
replied_unresolved: zeros(),
|
|
799
|
+
untouched: zeros(),
|
|
800
|
+
};
|
|
801
|
+
for (const t of threadRows) {
|
|
802
|
+
const ms = t.createdAt.getTime();
|
|
803
|
+
if (!inWin(ms))
|
|
804
|
+
continue;
|
|
805
|
+
inc(threadMix[t.state], bi(ms));
|
|
806
|
+
}
|
|
807
|
+
// ---- activity heatmap: events by weekday×hour (UTC) ----
|
|
808
|
+
const eventRows = await db
|
|
809
|
+
.select({ occurredAt: events.occurredAt })
|
|
810
|
+
.from(events)
|
|
811
|
+
.where(and(eq(events.accountId, accountId), eq(events.repoId, repoId), gte(events.occurredAt, windowStart)))
|
|
812
|
+
.execute();
|
|
813
|
+
const activityHeatmap = new Array(168).fill(0);
|
|
814
|
+
for (const e of eventRows) {
|
|
815
|
+
const d = e.occurredAt;
|
|
816
|
+
if (!inWin(d.getTime()))
|
|
817
|
+
continue;
|
|
818
|
+
activityHeatmap[d.getUTCDay() * 24 + d.getUTCHours()]++;
|
|
819
|
+
}
|
|
820
|
+
const round1 = (x) => Math.round(x * 10) / 10;
|
|
821
|
+
return {
|
|
822
|
+
repoId: repo.id,
|
|
823
|
+
repoFullName: repo.fullName,
|
|
824
|
+
windowDays,
|
|
825
|
+
stallThresholdDays: config.stallThresholdDays,
|
|
826
|
+
generatedAt: new Date().toISOString(),
|
|
827
|
+
weekBuckets,
|
|
828
|
+
throughput: { opened, merged: mergedSeries, closed: closedSeries },
|
|
829
|
+
backlog: { open, stalled },
|
|
830
|
+
reviewLatencyTrend: {
|
|
831
|
+
medianHours: ttfrByBucket.map((a) => {
|
|
832
|
+
const m = median(a);
|
|
833
|
+
return m == null ? null : round1(m);
|
|
834
|
+
}),
|
|
835
|
+
count: ttfrByBucket.map((a) => a.length),
|
|
836
|
+
},
|
|
837
|
+
cycleBreakdown: {
|
|
838
|
+
toFirstReview: cbA.map((s, i) => (cbN[i] ? round1(s / cbN[i]) : 0)),
|
|
839
|
+
reviewToMerge: cbB.map((s, i) => (cbN[i] ? round1(s / cbN[i]) : 0)),
|
|
840
|
+
count: cbN,
|
|
841
|
+
},
|
|
842
|
+
reviewLatencyDist: LAT_BINS.map((b, i) => ({ label: b.label, count: latCounts[i] })),
|
|
843
|
+
threadMix,
|
|
844
|
+
reviewVerdicts: verdicts,
|
|
845
|
+
reviewerLoad,
|
|
846
|
+
sizeDist: SIZE_BINS.map((b, i) => ({ label: b.label, count: sizeCounts[i] })),
|
|
847
|
+
sizeVsCycle: sizeVsCycle.slice(0, 500),
|
|
848
|
+
sizeCycleByBucket: SIZE_BINS.map((b, i) => {
|
|
849
|
+
const arr = sizeBucketDur[i];
|
|
850
|
+
const m = median(arr);
|
|
851
|
+
return { label: b.label, medianHours: m == null ? null : round1(m), count: arr.length };
|
|
852
|
+
}),
|
|
853
|
+
activityHeatmap,
|
|
854
|
+
};
|
|
855
|
+
}
|
|
534
856
|
// ---- merge-rights inference ----
|
|
535
857
|
// Distinct users who have merged a PR INTO THE DEFAULT BRANCH per repo (across
|
|
536
858
|
// ALL synced history, not the timeline window). We treat "has merged into the
|
|
@@ -632,6 +954,15 @@ async function ownsDismissRef(accountId, kind, refId) {
|
|
|
632
954
|
.execute();
|
|
633
955
|
return rows.length > 0;
|
|
634
956
|
}
|
|
957
|
+
if (kind === 'claude_review') {
|
|
958
|
+
const rows = await db
|
|
959
|
+
.select({ id: claudeReviews.id })
|
|
960
|
+
.from(claudeReviews)
|
|
961
|
+
.where(and(eq(claudeReviews.id, refId), eq(claudeReviews.accountId, accountId)))
|
|
962
|
+
.limit(1)
|
|
963
|
+
.execute();
|
|
964
|
+
return rows.length > 0;
|
|
965
|
+
}
|
|
635
966
|
const rows = await db
|
|
636
967
|
.select({ id: reviewThreads.id })
|
|
637
968
|
.from(reviewThreads)
|
|
@@ -664,10 +995,10 @@ export async function undismissMyTurn(accountId, kind, refId) {
|
|
|
664
995
|
.execute();
|
|
665
996
|
}
|
|
666
997
|
// The My Turn "Done" tab: entries the user dismissed within the past `daysBefore`
|
|
667
|
-
// days (default 90), newest-dismissed first. Covers
|
|
668
|
-
// (review_request + thread); "Your PRs" are cleared via mark-viewed,
|
|
669
|
-
// restorable dismissal. Rebuilds each entry by joining the dismissal's refId
|
|
670
|
-
// its PR / thread. accountId-scoped throughout.
|
|
998
|
+
// days (default 90), newest-dismissed first. Covers the dismissal-backed kinds
|
|
999
|
+
// (review_request + thread + claude_review); "Your PRs" are cleared via mark-viewed,
|
|
1000
|
+
// not a restorable dismissal. Rebuilds each entry by joining the dismissal's refId
|
|
1001
|
+
// back to its PR / thread / Claude-review run. accountId-scoped throughout.
|
|
671
1002
|
export async function getCompletedDismissals(accountId, daysBefore = 90) {
|
|
672
1003
|
const cutoff = new Date(Date.now() - daysBefore * 24 * 60 * 60 * 1000);
|
|
673
1004
|
const dismissals = await db
|
|
@@ -679,6 +1010,7 @@ export async function getCompletedDismissals(accountId, daysBefore = 90) {
|
|
|
679
1010
|
return { items: [], users: [] };
|
|
680
1011
|
const reviewDismissals = dismissals.filter((d) => d.kind === 'review_request');
|
|
681
1012
|
const threadDismissals = dismissals.filter((d) => d.kind === 'thread');
|
|
1013
|
+
const claudeDismissals = dismissals.filter((d) => d.kind === 'claude_review');
|
|
682
1014
|
const items = [];
|
|
683
1015
|
const referencedUsers = new Set();
|
|
684
1016
|
// review_request dismissals → their PRs (account-scoped).
|
|
@@ -756,6 +1088,43 @@ export async function getCompletedDismissals(accountId, daysBefore = 90) {
|
|
|
756
1088
|
});
|
|
757
1089
|
}
|
|
758
1090
|
}
|
|
1091
|
+
// claude_review dismissals → their run + parent PR (account-scoped). History is
|
|
1092
|
+
// kept, so an old run still resolves even after a newer run superseded it.
|
|
1093
|
+
if (claudeDismissals.length > 0) {
|
|
1094
|
+
const reviewIds = claudeDismissals.map((d) => d.refId);
|
|
1095
|
+
const runRows = await db
|
|
1096
|
+
.select({
|
|
1097
|
+
reviewId: claudeReviews.id,
|
|
1098
|
+
prId: claudeReviews.prId,
|
|
1099
|
+
owner: repos.owner,
|
|
1100
|
+
name: repos.name,
|
|
1101
|
+
prNumber: pullRequests.number,
|
|
1102
|
+
prTitle: pullRequests.title,
|
|
1103
|
+
verdict: claudeReviews.verdict,
|
|
1104
|
+
})
|
|
1105
|
+
.from(claudeReviews)
|
|
1106
|
+
.innerJoin(pullRequests, eq(pullRequests.id, claudeReviews.prId))
|
|
1107
|
+
.innerJoin(repos, eq(repos.id, pullRequests.repoId))
|
|
1108
|
+
.where(and(eq(claudeReviews.accountId, accountId), inArray(claudeReviews.id, reviewIds)))
|
|
1109
|
+
.execute();
|
|
1110
|
+
const byId = new Map(runRows.map((r) => [r.reviewId, r]));
|
|
1111
|
+
for (const d of claudeDismissals) {
|
|
1112
|
+
const r = byId.get(d.refId);
|
|
1113
|
+
if (!r)
|
|
1114
|
+
continue;
|
|
1115
|
+
items.push({
|
|
1116
|
+
kind: 'claude_review',
|
|
1117
|
+
reviewId: r.reviewId,
|
|
1118
|
+
prId: r.prId,
|
|
1119
|
+
repoFullName: `${r.owner}/${r.name}`,
|
|
1120
|
+
prNumber: r.prNumber,
|
|
1121
|
+
prTitle: r.prTitle,
|
|
1122
|
+
verdict: r.verdict,
|
|
1123
|
+
githubUrl: `https://github.com/${r.owner}/${r.name}/pull/${r.prNumber}`,
|
|
1124
|
+
dismissedAt: d.dismissedAt.toISOString(),
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
759
1128
|
// Newest-dismissed first.
|
|
760
1129
|
items.sort((a, b) => b.dismissedAt.localeCompare(a.dismissedAt));
|
|
761
1130
|
const usersList = referencedUsers.size > 0
|
|
@@ -811,11 +1180,16 @@ export async function getMyTurn(accountId) {
|
|
|
811
1180
|
.execute();
|
|
812
1181
|
const reviewDismissedAt = new Map();
|
|
813
1182
|
const threadDismissedAt = new Map();
|
|
1183
|
+
// Dismissed Claude-review run ids. Keyed by run id (not PR id): a fresh run gets
|
|
1184
|
+
// a new id, so it naturally re-appears without a timestamp comparison.
|
|
1185
|
+
const claudeDismissedIds = new Set();
|
|
814
1186
|
for (const d of dismissals) {
|
|
815
1187
|
if (d.kind === 'review_request')
|
|
816
1188
|
reviewDismissedAt.set(d.refId, d.dismissedAt);
|
|
817
1189
|
else if (d.kind === 'thread')
|
|
818
1190
|
threadDismissedAt.set(d.refId, d.dismissedAt);
|
|
1191
|
+
else if (d.kind === 'claude_review')
|
|
1192
|
+
claudeDismissedIds.add(d.refId);
|
|
819
1193
|
}
|
|
820
1194
|
const meta = (prId) => openRows.find((p) => p.id === prId);
|
|
821
1195
|
const toMyTurnPr = (t) => {
|
|
@@ -871,8 +1245,9 @@ export async function getMyTurn(accountId) {
|
|
|
871
1245
|
referencedUsers.add(ta.lastReplyAuthorId);
|
|
872
1246
|
}
|
|
873
1247
|
// Completed-but-unactioned Claude reviews (local-only feature; empty otherwise).
|
|
1248
|
+
// A manual "Done" hides the run until a newer run finishes (see claudeDismissedIds).
|
|
874
1249
|
const claudeReviewsToAction = config.claudeReviewEnabled
|
|
875
|
-
? await getUnactionedClaudeReviews(accountId)
|
|
1250
|
+
? (await getUnactionedClaudeReviews(accountId)).filter((c) => !claudeDismissedIds.has(c.reviewId))
|
|
876
1251
|
: [];
|
|
877
1252
|
const users = referencedUsers.size > 0
|
|
878
1253
|
? (await db
|
package/dist/db/schema.pg.js
CHANGED
|
@@ -151,7 +151,9 @@ export const myTurnDismissals = pgTable('my_turn_dismissals', {
|
|
|
151
151
|
accountId: integer('account_id')
|
|
152
152
|
.notNull()
|
|
153
153
|
.references(() => accounts.id),
|
|
154
|
-
kind: text('kind', {
|
|
154
|
+
kind: text('kind', {
|
|
155
|
+
enum: ['review_request', 'thread', 'claude_review'],
|
|
156
|
+
}).notNull(),
|
|
155
157
|
refId: integer('ref_id').notNull(),
|
|
156
158
|
dismissedAt: timestamp('dismissed_at', {
|
|
157
159
|
withTimezone: true,
|
package/dist/db/schema.sqlite.js
CHANGED
|
@@ -164,17 +164,20 @@ export const prViews = sqliteTable('pr_views', {
|
|
|
164
164
|
lastViewedSha: text('last_viewed_sha'),
|
|
165
165
|
lastViewedAt: integer('last_viewed_at', { mode: 'timestamp' }).notNull(),
|
|
166
166
|
});
|
|
167
|
-
// Manual dismissals of "my turn" entries. `refId` is a PR id (review_request)
|
|
168
|
-
//
|
|
169
|
-
// activity has happened — getMyTurn
|
|
170
|
-
// updatedAt / the thread's last reply,
|
|
171
|
-
//
|
|
167
|
+
// Manual dismissals of "my turn" entries. `refId` is a PR id (review_request),
|
|
168
|
+
// a review-thread id (thread), or a Claude-review run id (claude_review). The
|
|
169
|
+
// dismissal is honoured only while no newer activity has happened — getMyTurn
|
|
170
|
+
// compares dismissedAt against the PR's updatedAt / the thread's last reply, and a
|
|
171
|
+
// claude_review is keyed by run id so a fresh run is a new (undismissed) entry.
|
|
172
|
+
// `accountId` scopes the dismissal set per tenant.
|
|
172
173
|
export const myTurnDismissals = sqliteTable('my_turn_dismissals', {
|
|
173
174
|
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
174
175
|
accountId: integer('account_id')
|
|
175
176
|
.notNull()
|
|
176
177
|
.references(() => accounts.id),
|
|
177
|
-
kind: text('kind', {
|
|
178
|
+
kind: text('kind', {
|
|
179
|
+
enum: ['review_request', 'thread', 'claude_review'],
|
|
180
|
+
}).notNull(),
|
|
178
181
|
refId: integer('ref_id').notNull(),
|
|
179
182
|
dismissedAt: integer('dismissed_at', { mode: 'timestamp' }).notNull(),
|
|
180
183
|
}, (t) => ({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pierre-review",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.24",
|
|
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",
|