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.
@@ -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
@@ -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) => {
@@ -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 only the dismissal-backed kinds
668
- // (review_request + thread); "Your PRs" are cleared via mark-viewed, not a
669
- // restorable dismissal. Rebuilds each entry by joining the dismissal's refId back to
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
@@ -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', { enum: ['review_request', 'thread'] }).notNull(),
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,
@@ -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
- // or a review-thread id (thread). The dismissal is honoured only while no newer
169
- // activity has happened — getMyTurn compares dismissedAt against the PR's
170
- // updatedAt / the thread's last reply, so it auto-resurfaces. `accountId` scopes
171
- // the dismissal set per tenant.
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', { enum: ['review_request', 'thread'] }).notNull(),
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.23",
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",