pierre-review 0.1.40 → 0.1.41

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.
@@ -32,6 +32,7 @@ export async function activityRoutes(app) {
32
32
  limit: Number.isFinite(limit) && limit != null && limit > 0 ? limit : null,
33
33
  offset: Number.isFinite(offset) && offset > 0 ? offset : 0,
34
34
  excludeBots: q.excludeBots === 'true',
35
+ allowBotIds: parseIntList(q.allowBotIds),
35
36
  });
36
37
  });
37
38
  // Repo-scoped Claude-review history for the Activity single-repo console. Ownership +
@@ -1,7 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { getAccessToken, getAccountUserId } from '../../auth/account.js';
3
3
  import { ghRestGetText } from '../../github/client.js';
4
- import { getPrDetail, getPrFilesContext, getPrWriteContext, markAllViewed, markPrViewed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
4
+ import { getMentionCandidates, getPrDetail, getPrFilesContext, getPrWriteContext, markAllViewed, markPrViewed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
5
5
  import { buildFileAnchors, fallbackAnchor, isFindingAnchored, } from '../../github/diff-anchor.js';
6
6
  import { addIssueComment, fetchHeadShaFor, fetchPrFilesWithPatch, postInlineComment, submitPrReview, } from '../../github/mutations.js';
7
7
  import { hydratePrDetail } from '../../sync/hydrate-detail.js';
@@ -115,6 +115,18 @@ export async function prRoutes(app) {
115
115
  // caches the result in IndexedDB keyed by updatedAt so unchanged PRs don't refetch.
116
116
  return hydratePrDetail(pr, accountId);
117
117
  });
118
+ // Candidates for an @mention autocomplete, ranked by proximity to this PR
119
+ // (participants first, then repo people), self + bots excluded. Account-scoped:
120
+ // 404 when the PR isn't the caller's.
121
+ app.get('/api/prs/:id/mention-candidates', { schema: idParamSchema }, async (req, reply) => {
122
+ const { id } = req.params;
123
+ const candidates = await getMentionCandidates(id, accountIdOf(req));
124
+ if (!candidates) {
125
+ reply.status(404);
126
+ return { error: 'NotFound', message: `PR ${id} not found` };
127
+ }
128
+ return candidates;
129
+ });
118
130
  // Record that the local user has seen this PR up to `sha` (defaults to the
119
131
  // current head). Clears "new since last viewed" badges.
120
132
  app.post('/api/prs/:id/mark-viewed', { schema: markViewedSchema }, async (req, reply) => {
@@ -86,6 +86,7 @@ export async function timelineRoutes(app) {
86
86
  statuses: parseStatuses(q.statuses),
87
87
  reviewStates: parseReviewStates(q.reviewStates),
88
88
  excludeBots: q.excludeBots === 'true',
89
+ allowBotIds: parseIntList(q.allowBotIds),
89
90
  excludeStale: q.excludeStale === 'true',
90
91
  };
91
92
  return getTimeline(filters);
@@ -225,7 +225,7 @@ function mapTimelinePr(p, counts, tr) {
225
225
  };
226
226
  }
227
227
  export async function getTimeline(filters) {
228
- const { accountId, from, to, repoIds, userIds, types, statuses, reviewStates, excludeBots, excludeStale, } = filters;
228
+ const { accountId, from, to, repoIds, userIds, types, statuses, reviewStates, excludeBots, allowBotIds, excludeStale, } = filters;
229
229
  // ---- PRs that overlap the window ----
230
230
  const prConds = [
231
231
  eq(pullRequests.accountId, accountId),
@@ -244,8 +244,11 @@ export async function getTimeline(filters) {
244
244
  .from(events)
245
245
  .where(and(eq(events.prId, pullRequests.id), inArray(events.actorId, userIds))))));
246
246
  }
247
- // Resolve the bot set once (used by both the PR and events branches below).
248
- const bots = excludeBots ? await botUserIds() : [];
247
+ // Resolve the bot set once (used by both the PR and events branches below). The
248
+ // per-repo allow-list subtracts the "important" bots so their activity stays visible
249
+ // even under excludeBots — every downstream predicate keys off this trimmed set.
250
+ const allowBots = new Set(allowBotIds ?? []);
251
+ const bots = excludeBots ? (await botUserIds()).filter((id) => !allowBots.has(id)) : [];
249
252
  if (excludeBots && bots.length > 0) {
250
253
  prConds.push(or(sql `${pullRequests.authorId} is null`, sql `${pullRequests.authorId} not in (${sql.join(bots, sql `, `)})`));
251
254
  }
@@ -1318,6 +1321,7 @@ async function getCommitThreadItems(accountId, opts) {
1318
1321
  id: `feed:commitrun:${run.prId}:${run.latestEventId}`,
1319
1322
  // Flagged by the caller (getConsolidatedFeed) once participation is resolved.
1320
1323
  isMyTurn: false,
1324
+ myTurnReasons: [],
1321
1325
  kind: 'commit_pushed',
1322
1326
  occurredAt: run.latestOccurredAt.toISOString(),
1323
1327
  repoId: run.repoId,
@@ -1329,6 +1333,7 @@ async function getCommitThreadItems(accountId, opts) {
1329
1333
  actorId: run.actorId,
1330
1334
  content: null,
1331
1335
  threadId: null,
1336
+ commentId: null,
1332
1337
  path: null,
1333
1338
  line: null,
1334
1339
  reasonTag: null,
@@ -1338,27 +1343,27 @@ async function getCommitThreadItems(accountId, opts) {
1338
1343
  : null,
1339
1344
  mergedById: null,
1340
1345
  reviewers: null,
1346
+ ciStatus: null,
1347
+ changedFilesCount: null,
1341
1348
  affectedThreads: affected,
1342
1349
  commitCount: run.commitCount,
1343
1350
  changeSummary: `pushed ${run.commitCount} ${commitWord} · addressed ${affected.length} ${threadWord}`,
1351
+ claudeReviewId: null,
1352
+ claudeVerdict: null,
1344
1353
  });
1345
1354
  }
1346
1355
  return out;
1347
1356
  }
1348
- // Participation ("is this PR mine?") for the consolidated feed's isMyTurn flag. Given a
1349
- // candidate set of PR ids (all account-owned — they come from the account-scoped feed),
1350
- // returns the subset the viewer participates in: PRs they authored, are a requested
1351
- // reviewer on, or previously reviewed / commented on (issue comment OR inline review
1352
- // comment). Isolation rides on prId ∈ the account-owned candidate set, so the un-scoped
1353
- // child tables (reviews/reviewRequests/prComments/reviewComments) need no accountId
1354
- // predicate here. `authored`/`requested` are broken out so the caller can colour the badge.
1355
- // Empty when the viewer is unknown (offline / not yet synced) → isMyTurn defaults false.
1356
1357
  async function getParticipatingPrIds(accountId, localUserId, prIds) {
1357
1358
  const authored = new Set();
1358
1359
  const requested = new Set();
1360
+ const reviewed = new Set();
1361
+ const commented = new Set();
1362
+ const merged = new Set();
1359
1363
  const all = new Set();
1364
+ const empty = { all, authored, requested, reviewed, commented, merged };
1360
1365
  if (localUserId == null || prIds.length === 0)
1361
- return { all, authored, requested };
1366
+ return empty;
1362
1367
  for (const row of await db
1363
1368
  .select({ id: pullRequests.id })
1364
1369
  .from(pullRequests)
@@ -1367,6 +1372,16 @@ async function getParticipatingPrIds(accountId, localUserId, prIds) {
1367
1372
  authored.add(row.id);
1368
1373
  all.add(row.id);
1369
1374
  }
1375
+ // PRs the viewer merged (even if they never reviewed) count as participation too — a
1376
+ // merge is a strong "I own the outcome of this" signal, so later activity is FYI.
1377
+ for (const row of await db
1378
+ .select({ id: pullRequests.id })
1379
+ .from(pullRequests)
1380
+ .where(and(eq(pullRequests.accountId, accountId), eq(pullRequests.mergedById, localUserId), inArray(pullRequests.id, prIds)))
1381
+ .execute()) {
1382
+ merged.add(row.id);
1383
+ all.add(row.id);
1384
+ }
1370
1385
  for (const row of await db
1371
1386
  .select({ prId: reviewRequests.prId })
1372
1387
  .from(reviewRequests)
@@ -1379,35 +1394,144 @@ async function getParticipatingPrIds(accountId, localUserId, prIds) {
1379
1394
  .select({ prId: reviews.prId })
1380
1395
  .from(reviews)
1381
1396
  .where(and(eq(reviews.authorId, localUserId), inArray(reviews.prId, prIds)))
1382
- .execute())
1397
+ .execute()) {
1398
+ reviewed.add(row.prId);
1383
1399
  all.add(row.prId);
1400
+ }
1384
1401
  for (const row of await db
1385
1402
  .select({ prId: prComments.prId })
1386
1403
  .from(prComments)
1387
1404
  .where(and(eq(prComments.authorId, localUserId), inArray(prComments.prId, prIds)))
1388
- .execute())
1405
+ .execute()) {
1406
+ commented.add(row.prId);
1389
1407
  all.add(row.prId);
1408
+ }
1390
1409
  for (const row of await db
1391
1410
  .select({ prId: reviewComments.prId })
1392
1411
  .from(reviewComments)
1393
1412
  .where(and(eq(reviewComments.authorId, localUserId), inArray(reviewComments.prId, prIds)))
1394
- .execute())
1413
+ .execute()) {
1414
+ commented.add(row.prId);
1395
1415
  all.add(row.prId);
1396
- return { all, authored, requested };
1416
+ }
1417
+ return { all, authored, requested, reviewed, commented, merged };
1418
+ }
1419
+ // Map a viewer's participation in a PR to the ordered reason pills for its FYI card.
1420
+ // Most-relevant first; the UI shows the primary. Empty when the viewer doesn't
1421
+ // participate (non-my-turn row).
1422
+ function myTurnReasonsFor(participation, prId) {
1423
+ if (prId == null)
1424
+ return [];
1425
+ const reasons = [];
1426
+ if (participation.requested.has(prId))
1427
+ reasons.push('requested');
1428
+ if (participation.authored.has(prId))
1429
+ reasons.push('authored');
1430
+ if (participation.merged.has(prId))
1431
+ reasons.push('merged');
1432
+ if (participation.reviewed.has(prId))
1433
+ reasons.push('reviewed');
1434
+ if (participation.commented.has(prId))
1435
+ reasons.push('commented');
1436
+ return reasons;
1437
+ }
1438
+ // Claude Review runs surfaced in the consolidated feed as their own item kind. One item
1439
+ // per PR = that PR's most-recent SUCCEEDED run finished within the feed window, repo-scoped.
1440
+ // Gated on the feature flag (force-off in cloud) so no items appear where Claude Review
1441
+ // doesn't exist. Never member-/bot-filtered (a run has no member author).
1442
+ async function getClaudeReviewFeedItems(accountId, repoIds, since) {
1443
+ if (!config.claudeReviewEnabled)
1444
+ return [];
1445
+ const conds = [
1446
+ eq(repos.accountId, accountId),
1447
+ eq(claudeReviews.status, 'succeeded'),
1448
+ gte(sql `coalesce(${claudeReviews.finishedAt}, ${claudeReviews.createdAt})`, tsBound(since)),
1449
+ ];
1450
+ if (repoIds)
1451
+ conds.push(inArray(pullRequests.repoId, repoIds));
1452
+ const rows = await db
1453
+ .select({
1454
+ reviewId: claudeReviews.id,
1455
+ prId: claudeReviews.prId,
1456
+ repoId: pullRequests.repoId,
1457
+ owner: repos.owner,
1458
+ name: repos.name,
1459
+ prNumber: pullRequests.number,
1460
+ prTitle: pullRequests.title,
1461
+ prState: pullRequests.state,
1462
+ summary: claudeReviews.summary,
1463
+ verdict: claudeReviews.verdict,
1464
+ userVerdict: claudeReviews.userVerdict,
1465
+ finishedAt: claudeReviews.finishedAt,
1466
+ createdAt: claudeReviews.createdAt,
1467
+ })
1468
+ .from(claudeReviews)
1469
+ .innerJoin(pullRequests, eq(pullRequests.id, claudeReviews.prId))
1470
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
1471
+ .where(and(...conds))
1472
+ .orderBy(desc(claudeReviews.finishedAt), desc(claudeReviews.createdAt))
1473
+ .execute();
1474
+ // Keep the most-recent succeeded run per PR (rows are newest-first).
1475
+ const seen = new Set();
1476
+ const out = [];
1477
+ for (const r of rows) {
1478
+ if (seen.has(r.prId))
1479
+ continue;
1480
+ seen.add(r.prId);
1481
+ out.push({
1482
+ id: `feed:claude:${r.reviewId}`,
1483
+ isMyTurn: false,
1484
+ myTurnReasons: [],
1485
+ kind: 'claude_review',
1486
+ occurredAt: (r.finishedAt ?? r.createdAt).toISOString(),
1487
+ repoId: r.repoId,
1488
+ repoFullName: `${r.owner}/${r.name}`,
1489
+ prId: r.prId,
1490
+ prNumber: r.prNumber,
1491
+ prTitle: r.prTitle,
1492
+ prState: r.prState,
1493
+ actorId: null,
1494
+ content: r.summary,
1495
+ threadId: null,
1496
+ commentId: null,
1497
+ path: null,
1498
+ line: null,
1499
+ reasonTag: null,
1500
+ reviewState: null,
1501
+ githubUrl: r.prNumber != null ? `https://github.com/${r.owner}/${r.name}/pull/${r.prNumber}` : null,
1502
+ mergedById: null,
1503
+ reviewers: null,
1504
+ ciStatus: null,
1505
+ changedFilesCount: null,
1506
+ affectedThreads: null,
1507
+ commitCount: null,
1508
+ changeSummary: null,
1509
+ claudeReviewId: r.reviewId,
1510
+ // Prefer the user's decision when they've set one, else Claude's read-only verdict.
1511
+ claudeVerdict: r.userVerdict ?? r.verdict,
1512
+ });
1513
+ }
1514
+ return out;
1397
1515
  }
1398
1516
  export async function getConsolidatedFeed(accountId, opts = {}) {
1399
- const { repoIds = null, userIds = null, limit = null, offset = 0, excludeBots = false } = opts;
1517
+ const { repoIds = null, userIds = null, limit = null, offset = 0, excludeBots = false, allowBotIds = null, } = opts;
1400
1518
  // Bot set (only loaded when the filter is on) — drops bot-authored activity, mirroring
1401
- // the timeline's excludeBots. getFeed doesn't filter bots, so we do it here; the commit
1519
+ // the timeline's excludeBots. The per-repo allow-list subtracts the "important" bots so
1520
+ // their activity stays visible. getFeed doesn't filter bots, so we do it here; the commit
1402
1521
  // helper filters in its own SQL.
1403
- const botIds = excludeBots ? new Set(await botUserIds()) : new Set();
1522
+ const allowBots = new Set(allowBotIds ?? []);
1523
+ const botIds = excludeBots
1524
+ ? new Set((await botUserIds()).filter((id) => !allowBots.has(id)))
1525
+ : new Set();
1404
1526
  const notBot = (id) => !excludeBots || id == null || !botIds.has(id);
1405
1527
  const feedSince = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000);
1406
- const [feed, commitItems, localUserId] = await Promise.all([
1528
+ const [feed, commitItems, claudeItems, localUserId] = await Promise.all([
1407
1529
  getFeed(accountId, { daysBefore: 14, repoIds, userIds }),
1408
1530
  // Commit-push activity that ADDRESSED a review thread (the only commit rows we surface
1409
1531
  // — plain pushes are noise). Each carries the affected threads inline.
1410
1532
  getCommitThreadItems(accountId, { repoIds, userIds, botIds, since: feedSince }),
1533
+ // Claude Review runs surfaced as their own feed item kind (local-only; empty in cloud).
1534
+ getClaudeReviewFeedItems(accountId, repoIds, feedSince),
1411
1535
  getAccountUserId(accountId),
1412
1536
  ]);
1413
1537
  // Participation ("is this PR mine?") drives the isMyTurn flag: which of the PRs the feed
@@ -1453,6 +1577,7 @@ export async function getConsolidatedFeed(accountId, opts = {}) {
1453
1577
  push({
1454
1578
  id: `feed:${f.id}`,
1455
1579
  isMyTurn: mine,
1580
+ myTurnReasons: mine ? myTurnReasonsFor(participation, f.prId) : [],
1456
1581
  kind: f.type,
1457
1582
  occurredAt: f.occurredAt,
1458
1583
  repoId: f.repoId,
@@ -1465,6 +1590,7 @@ export async function getConsolidatedFeed(accountId, opts = {}) {
1465
1590
  // Full markdown body (fallback to the short preview on pre-persistence lean rows).
1466
1591
  content: f.content ?? f.excerpt,
1467
1592
  threadId: f.type === 'review_comment' ? f.refId : null,
1593
+ commentId: f.type === 'pr_comment' ? f.refId : null,
1468
1594
  path: null,
1469
1595
  line: null,
1470
1596
  reasonTag: mine ? myTurnReason(f.prId) : null,
@@ -1472,27 +1598,38 @@ export async function getConsolidatedFeed(accountId, opts = {}) {
1472
1598
  githubUrl: f.prNumber != null ? `https://github.com/${f.repoFullName}/pull/${f.prNumber}` : null,
1473
1599
  mergedById: null,
1474
1600
  reviewers: null,
1601
+ ciStatus: null,
1602
+ changedFilesCount: null,
1475
1603
  affectedThreads: null,
1476
1604
  commitCount: null,
1477
1605
  changeSummary: null,
1606
+ claudeReviewId: null,
1607
+ claudeVerdict: null,
1478
1608
  });
1479
1609
  }
1480
1610
  // Commit-push items (already repo/member/bot-scoped + thread-enriched in the SQL helper).
1481
1611
  // Flag them the same way, then append.
1482
1612
  for (const it of commitItems) {
1483
1613
  it.isMyTurn = isMine(it.prId, it.actorId);
1484
- if (it.isMyTurn)
1614
+ if (it.isMyTurn) {
1485
1615
  it.reasonTag = myTurnReason(it.prId);
1616
+ it.myTurnReasons = myTurnReasonsFor(participation, it.prId);
1617
+ }
1486
1618
  push(it);
1487
1619
  }
1488
- // Pure chronologicalnewest first. Keep every My Turn item; cap the plain activity rows
1489
- // so a busy multi-repo account doesn't render thousands of them.
1490
- const myTurnRows = items.filter((i) => i.isMyTurn);
1620
+ // Claude Review items a distinct kind (never bot/member-scoped). Kept out of the FYI
1621
+ // flow but always retained (see the caps below) so the "Claude Reviews" pill finds them.
1622
+ for (const it of claudeItems)
1623
+ push(it);
1624
+ // Pure chronological — newest first. Keep every My Turn item AND every Claude-review item
1625
+ // (both are always relevant); cap the plain activity rows so a busy multi-repo account
1626
+ // doesn't render thousands of them.
1627
+ const alwaysRows = items.filter((i) => i.isMyTurn || i.kind === 'claude_review');
1491
1628
  const feedRows = items
1492
- .filter((i) => !i.isMyTurn)
1629
+ .filter((i) => !i.isMyTurn && i.kind !== 'claude_review')
1493
1630
  .sort((a, b) => b.occurredAt.localeCompare(a.occurredAt))
1494
1631
  .slice(0, FEED_EVENT_CAP);
1495
- const ordered = [...myTurnRows, ...feedRows].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
1632
+ const ordered = [...alwaysRows, ...feedRows].sort((a, b) => b.occurredAt.localeCompare(a.occurredAt));
1496
1633
  // Paginate: `total` is the full stream length; `page` is the requested window. Only
1497
1634
  // the page is enriched + has its users backfilled, so hidden items cost nothing.
1498
1635
  const total = ordered.length;
@@ -1504,16 +1641,27 @@ export async function getConsolidatedFeed(accountId, opts = {}) {
1504
1641
  if (it.prId != null)
1505
1642
  prIdsForCtx.add(it.prId);
1506
1643
  const mergedByPr = new Map();
1644
+ const ciByPr = new Map();
1645
+ const filesByPr = new Map();
1507
1646
  const reviewersByPr = new Map();
1508
1647
  if (prIdsForCtx.size > 0) {
1509
1648
  const prIdList = [...prIdsForCtx];
1510
- // mergedById — account-scoped via pullRequests.accountId.
1649
+ // mergedById + CI rollup + changed-file count — account-scoped via
1650
+ // pullRequests.accountId. CI/files surface on pr_opened cards (item 2).
1511
1651
  for (const row of await db
1512
- .select({ id: pullRequests.id, mergedById: pullRequests.mergedById })
1652
+ .select({
1653
+ id: pullRequests.id,
1654
+ mergedById: pullRequests.mergedById,
1655
+ ciStatus: pullRequests.ciStatus,
1656
+ changedFiles: pullRequests.changedFiles,
1657
+ })
1513
1658
  .from(pullRequests)
1514
1659
  .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.id, prIdList)))
1515
- .execute())
1660
+ .execute()) {
1516
1661
  mergedByPr.set(row.id, row.mergedById);
1662
+ ciByPr.set(row.id, (row.ciStatus ?? 'unknown'));
1663
+ filesByPr.set(row.id, row.changedFiles);
1664
+ }
1517
1665
  // reviewers — `reviews` has NO accountId, so isolation MUST come from the join to
1518
1666
  // pullRequests (eq accountId). Ascending order → the last write per (prId, userId)
1519
1667
  // is that reviewer's standing state; non-'pending' only.
@@ -1545,6 +1693,8 @@ export async function getConsolidatedFeed(accountId, opts = {}) {
1545
1693
  continue;
1546
1694
  it.mergedById = mergedByPr.get(it.prId) ?? null;
1547
1695
  it.reviewers = reviewersByPr.get(it.prId) ?? null;
1696
+ it.ciStatus = ciByPr.get(it.prId) ?? null;
1697
+ it.changedFilesCount = filesByPr.get(it.prId) ?? null;
1548
1698
  }
1549
1699
  // Backfill any referenced users on the page not already loaded by getMyTurn / getFeed —
1550
1700
  // including merger + reviewer ids so the SPA resolves their login/avatar.
@@ -2550,6 +2700,110 @@ export async function getPrDetail(id, accountId) {
2550
2700
  newSinceLastViewed,
2551
2701
  };
2552
2702
  }
2703
+ // Candidates for an "@mention" autocomplete on a PR, ranked by PROXIMITY to the PR
2704
+ // (closest first) and account-scoped. Excludes the viewer (they can't @ themselves)
2705
+ // and bots. Ownership is verified via the account-scoped PR/repo join → null (→ 404)
2706
+ // when the PR isn't the caller's. Ranks: author(0) > requested reviewer(1) >
2707
+ // reviewer(2) > comment/thread author(3) > commit author(4) > repo maintainer(5) >
2708
+ // other repo PR author(6). Repo-people (5/6) are a bounded proxy for "active in this
2709
+ // repo" (authors + those who've merged here) — cheap and never cross-tenant because a
2710
+ // repo row belongs to exactly one account.
2711
+ export async function getMentionCandidates(prId, accountId) {
2712
+ const prRows = await db
2713
+ .select({ repoId: pullRequests.repoId, authorId: pullRequests.authorId })
2714
+ .from(pullRequests)
2715
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
2716
+ .where(and(eq(pullRequests.id, prId), eq(repos.accountId, accountId)))
2717
+ .limit(1)
2718
+ .execute();
2719
+ const pr = prRows[0];
2720
+ if (!pr)
2721
+ return null;
2722
+ const repoId = pr.repoId;
2723
+ // Best (lowest) rank wins if a user shows up in more than one bucket.
2724
+ const rank = new Map();
2725
+ const bump = (id, r) => {
2726
+ if (id == null)
2727
+ return;
2728
+ const cur = rank.get(id);
2729
+ if (cur == null || r < cur)
2730
+ rank.set(id, r);
2731
+ };
2732
+ bump(pr.authorId, 0);
2733
+ const [reqRows, revRows, rcRows, thRows, pcRows, cmRows] = await Promise.all([
2734
+ db
2735
+ .select({ userId: schema.reviewRequests.userId })
2736
+ .from(schema.reviewRequests)
2737
+ .where(eq(schema.reviewRequests.prId, prId))
2738
+ .execute(),
2739
+ db.select({ authorId: reviews.authorId }).from(reviews).where(eq(reviews.prId, prId)).execute(),
2740
+ db
2741
+ .select({ authorId: reviewComments.authorId })
2742
+ .from(reviewComments)
2743
+ .where(eq(reviewComments.prId, prId))
2744
+ .execute(),
2745
+ db
2746
+ .select({ id: reviewThreads.originalCommenterId })
2747
+ .from(reviewThreads)
2748
+ .where(eq(reviewThreads.prId, prId))
2749
+ .execute(),
2750
+ db
2751
+ .select({ authorId: prComments.authorId })
2752
+ .from(prComments)
2753
+ .where(eq(prComments.prId, prId))
2754
+ .execute(),
2755
+ db
2756
+ .select({ authorId: commits.authorId, committerId: commits.committerId })
2757
+ .from(commits)
2758
+ .where(eq(commits.prId, prId))
2759
+ .execute(),
2760
+ ]);
2761
+ for (const r of reqRows)
2762
+ bump(r.userId, 1);
2763
+ for (const r of revRows)
2764
+ bump(r.authorId, 2);
2765
+ for (const r of rcRows)
2766
+ bump(r.authorId, 3);
2767
+ for (const r of thRows)
2768
+ bump(r.id, 3);
2769
+ for (const r of pcRows)
2770
+ bump(r.authorId, 3);
2771
+ for (const r of cmRows) {
2772
+ bump(r.authorId, 4);
2773
+ bump(r.committerId, 4);
2774
+ }
2775
+ // Repo people: whoever has MERGED here (maintainers, rank 5) + anyone who has
2776
+ // OPENED a PR here (rank 6). Bounded to this repo's PR rows.
2777
+ const [mergerRows, repoAuthorRows] = await Promise.all([
2778
+ db
2779
+ .selectDistinct({ userId: pullRequests.mergedById })
2780
+ .from(pullRequests)
2781
+ .where(and(eq(pullRequests.repoId, repoId), eq(pullRequests.state, 'merged')))
2782
+ .execute(),
2783
+ db
2784
+ .selectDistinct({ authorId: pullRequests.authorId })
2785
+ .from(pullRequests)
2786
+ .where(eq(pullRequests.repoId, repoId))
2787
+ .execute(),
2788
+ ]);
2789
+ for (const r of mergerRows)
2790
+ bump(r.userId, 5);
2791
+ for (const r of repoAuthorRows)
2792
+ bump(r.authorId, 6);
2793
+ // The viewer can't @ themselves.
2794
+ const viewerUserId = await getAccountUserId(accountId);
2795
+ if (viewerUserId != null)
2796
+ rank.delete(viewerUserId);
2797
+ const ids = [...rank.keys()];
2798
+ if (ids.length === 0)
2799
+ return [];
2800
+ const rows = await db.select().from(users).where(inArray(users.id, ids)).execute();
2801
+ return rows
2802
+ .filter((u) => !u.isBot)
2803
+ .map((u) => ({ user: mapUser(u), rank: rank.get(u.id) ?? 99 }))
2804
+ .sort((a, b) => a.rank - b.rank || a.user.githubLogin.localeCompare(b.user.githubLogin))
2805
+ .map((x) => x.user);
2806
+ }
2553
2807
  export async function getThreadDetail(id, accountId) {
2554
2808
  const rows = await db
2555
2809
  .select()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pierre-review",
3
- "version": "0.1.40",
3
+ "version": "0.1.41",
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",