pierre-review 0.1.49 → 0.1.51

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,9 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { getAccessToken, getAccountUserId } from '../../auth/account.js';
3
3
  import { fetchActionsJobLog } from '../../github/actions-logs.js';
4
- import { getMentionCandidates, getPrDetail, getPrFilesContext, getPrWriteContext, markAllViewed, markPrViewed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
4
+ import { getMentionCandidates, getPrDetail, getPrFilesContext, getPrWriteContext, getReviewerLogins, markAllViewed, markPrViewed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
5
5
  import { buildFileAnchors, fallbackAnchor, isFindingAnchored, } from '../../github/diff-anchor.js';
6
- import { addIssueComment, fetchHeadShaFor, fetchPrFilesWithPatch, postInlineComment, rerunWorkflowRun, submitPrReview, } from '../../github/mutations.js';
6
+ import { addIssueComment, fetchHeadShaFor, fetchPrFilesWithPatch, postInlineComment, requestReviewers, rerunWorkflowRun, submitPrReview, } from '../../github/mutations.js';
7
7
  import { hydratePrDetail } from '../../sync/hydrate-detail.js';
8
8
  import { accountIdOf } from '../plugins/auth.js';
9
9
  // GitHub anchors a file in the PR "Files changed" diff by the SHA-256 of its
@@ -106,6 +106,22 @@ const reviewCommentSchema = {
106
106
  },
107
107
  },
108
108
  };
109
+ const requestReviewersSchema = {
110
+ ...idParamSchema,
111
+ body: {
112
+ type: 'object',
113
+ required: ['userIds'],
114
+ additionalProperties: false,
115
+ properties: {
116
+ userIds: {
117
+ type: 'array',
118
+ minItems: 1,
119
+ maxItems: 15,
120
+ items: { type: 'integer' },
121
+ },
122
+ },
123
+ },
124
+ };
109
125
  export async function prRoutes(app) {
110
126
  // Bulk "mark all seen": stamp every open PR (optionally scoped to repoIds) viewed
111
127
  // at its head, clearing all new-since badges at once. Static path — no :id — so it
@@ -405,5 +421,56 @@ export async function prRoutes(app) {
405
421
  };
406
422
  }
407
423
  });
424
+ // Request reviewers on a PR (powers the Insights "Assign reviewers" action). Server
425
+ // re-checks write access (WRITE/MAINTAIN/ADMIN — push-style, no author exclusion:
426
+ // an author may request reviewers on their own PR). The given user ids are resolved
427
+ // to GitHub logins (the PR author + bots + unknown ids dropped); GitHub itself gates
428
+ // that each login is a repo collaborator. The refreshed request state arrives on the
429
+ // next sync (reviewRequests are re-derived each pass).
430
+ app.post('/api/prs/:id/request-reviewers', { schema: requestReviewersSchema }, async (req, reply) => {
431
+ const { id } = req.params;
432
+ const { userIds } = req.body;
433
+ const accountId = accountIdOf(req);
434
+ const ctx = await getPrWriteContext(id, accountId);
435
+ if (!ctx) {
436
+ reply.status(404);
437
+ return { error: 'NotFound', message: `PR ${id} not found` };
438
+ }
439
+ const canRequest = ['WRITE', 'MAINTAIN', 'ADMIN'].includes(ctx.viewerPermission ?? '');
440
+ if (!canRequest) {
441
+ reply.status(403);
442
+ return {
443
+ error: 'NotPermitted',
444
+ message: 'You need write access to this repo to request reviewers.',
445
+ };
446
+ }
447
+ // Drop the PR author (GitHub rejects self-review requests), then resolve to
448
+ // logins (also drops bots + unknown ids).
449
+ const wanted = userIds.filter((uid) => uid !== ctx.authorId);
450
+ const logins = (await getReviewerLogins(wanted)).map((r) => r.login);
451
+ if (logins.length === 0) {
452
+ reply.status(400);
453
+ return {
454
+ error: 'NoReviewers',
455
+ message: 'None of the selected users can be requested as reviewers.',
456
+ };
457
+ }
458
+ try {
459
+ const token = await getAccessToken(accountId);
460
+ await requestReviewers(token, ctx.owner, ctx.name, ctx.number, logins);
461
+ const result = {
462
+ status: 'ok',
463
+ requestedLogins: logins,
464
+ };
465
+ return result;
466
+ }
467
+ catch (err) {
468
+ reply.status(502);
469
+ return {
470
+ error: 'GitHubError',
471
+ message: err instanceof Error ? err.message : String(err),
472
+ };
473
+ }
474
+ });
408
475
  }
409
476
  //# sourceMappingURL=prs.js.map
@@ -1,5 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { and, asc, count, desc, eq, exists, gt, gte, inArray, isNotNull, isNull, lte, ne, notInArray, or, sql, } from 'drizzle-orm';
2
+ import { and, asc, count, desc, eq, exists, gt, gte, inArray, isNotNull, isNull, lt, lte, ne, notInArray, or, sql, } from 'drizzle-orm';
3
3
  // Local copy of the shared `REASON_PRIORITY` value constant. `@pierre-review/shared`
4
4
  // is a types-only workspace package that is NOT shipped in the published tarball,
5
5
  // so the backend must only `import type` from it. Keep in sync with packages/shared.
@@ -18,6 +18,7 @@ import { runTransaction } from './client.js';
18
18
  import { config } from '../config.js';
19
19
  import { computeApprovalInfoByPr, computeTriage, } from './triage.js';
20
20
  import { getAccountUserId } from '../auth/account.js';
21
+ import { ensureRoutingPrFiles } from '../sync/routing-files.js';
21
22
  // Bind a JS Date into a raw-`sql` epoch comparison portably: Postgres columns are
22
23
  // timestamptz (drizzle binds the Date through the codec), whereas SQLite columns
23
24
  // are integer unix-epoch seconds (`mode:'timestamp'`), so we hand it the int.
@@ -1563,6 +1564,337 @@ export async function countNewMyTurnFeedItems(accountId, since) {
1563
1564
  }
1564
1565
  return n;
1565
1566
  }
1567
+ // ---- Team review-intelligence "Insights" (Pro; teamInsights) ----
1568
+ const INSIGHT_STALLED_REVIEW_HOURS = 24;
1569
+ const INSIGHT_UNTOUCHED_THREAD_HOURS = 24; // "> 1 day"
1570
+ const INSIGHT_SPRINT_DAYS = 14; // trailing 2 weeks
1571
+ const INSIGHT_ROUTING_MIN_AGE_HOURS = 4; // ignore brand-new PRs
1572
+ const INSIGHT_CARD_CAP = 15; // per-kind cap so the board stays digestible
1573
+ function topLevelDir(path) {
1574
+ const i = path.indexOf('/');
1575
+ return i === -1 ? '.' : path.slice(0, i);
1576
+ }
1577
+ // Compute the team review-intelligence cards from already-synced data — NO AI. WATCHED
1578
+ // repos (`inboxWatch`) are the team; "sprint" is the trailing 2 weeks. Runs on read (the
1579
+ // client refetches on the sync cadence); every query is account-scoped + bounded (watched
1580
+ // repos, open PRs, the sprint window, per-kind caps).
1581
+ export async function getTeamInsights(accountId) {
1582
+ const now = Date.now();
1583
+ const generatedAt = new Date(now);
1584
+ const sprintFrom = new Date(now - INSIGHT_SPRINT_DAYS * 86_400_000);
1585
+ const sprint = { from: sprintFrom.toISOString(), to: generatedAt.toISOString() };
1586
+ const cards = [];
1587
+ const userIdSet = new Set();
1588
+ const addUser = (id) => {
1589
+ if (id != null)
1590
+ userIdSet.add(id);
1591
+ };
1592
+ const watched = await db
1593
+ .select({ id: repos.id, owner: repos.owner, name: repos.name })
1594
+ .from(repos)
1595
+ .where(and(eq(repos.accountId, accountId), eq(repos.inboxWatch, true)))
1596
+ .execute();
1597
+ const repoName = new Map(watched.map((r) => [r.id, `${r.owner}/${r.name}`]));
1598
+ const repoIds = watched.map((r) => r.id);
1599
+ const finish = async () => {
1600
+ const kindRank = {
1601
+ stalled_review: 0,
1602
+ untouched_thread: 1,
1603
+ reviewer_load: 2,
1604
+ reviewer_routing: 3,
1605
+ };
1606
+ const sevRank = { high: 0, warn: 1, info: 2 };
1607
+ cards.sort((a, b) => sevRank[a.severity] - sevRank[b.severity] || kindRank[a.kind] - kindRank[b.kind]);
1608
+ const userRows = userIdSet.size > 0
1609
+ ? await db.select().from(users).where(inArray(users.id, [...userIdSet])).execute()
1610
+ : [];
1611
+ return {
1612
+ enabled: true,
1613
+ generatedAt: generatedAt.toISOString(),
1614
+ sprint,
1615
+ cards,
1616
+ users: userRows.map(mapUser),
1617
+ };
1618
+ };
1619
+ if (repoIds.length === 0)
1620
+ return finish();
1621
+ const ghUrl = (repoId, number) => `https://github.com/${repoName.get(repoId)}/pull/${number}`;
1622
+ // Open, non-draft PRs in the team's repos.
1623
+ const openPrs = await db
1624
+ .select({
1625
+ id: pullRequests.id,
1626
+ repoId: pullRequests.repoId,
1627
+ number: pullRequests.number,
1628
+ title: pullRequests.title,
1629
+ authorId: pullRequests.authorId,
1630
+ openedAt: pullRequests.openedAt,
1631
+ ciStatus: pullRequests.ciStatus,
1632
+ changedFiles: pullRequests.changedFiles,
1633
+ additions: pullRequests.additions,
1634
+ deletions: pullRequests.deletions,
1635
+ files: pullRequests.files,
1636
+ })
1637
+ .from(pullRequests)
1638
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds), eq(pullRequests.state, 'open'), eq(pullRequests.isDraft, false)))
1639
+ .execute();
1640
+ const openPrIds = openPrs.map((p) => p.id);
1641
+ const prById = new Map(openPrs.map((p) => [p.id, p]));
1642
+ if (openPrIds.length === 0)
1643
+ return finish();
1644
+ // Pending review requests (GitHub drops the request once a review lands → still-pending).
1645
+ const reqRows = await db
1646
+ .select({ prId: reviewRequests.prId, userId: reviewRequests.userId })
1647
+ .from(reviewRequests)
1648
+ .where(and(inArray(reviewRequests.prId, openPrIds), isNotNull(reviewRequests.userId)))
1649
+ .execute();
1650
+ const pendingByPr = new Map();
1651
+ const pendingByReviewer = new Map();
1652
+ for (const r of reqRows) {
1653
+ if (r.userId == null)
1654
+ continue;
1655
+ const a = pendingByPr.get(r.prId) ?? [];
1656
+ a.push(r.userId);
1657
+ pendingByPr.set(r.prId, a);
1658
+ const b = pendingByReviewer.get(r.userId) ?? [];
1659
+ b.push(r.prId);
1660
+ pendingByReviewer.set(r.userId, b);
1661
+ }
1662
+ // PRs that already have a submitted review (used by the routing "orphan" test).
1663
+ const reviewedPrIds = new Set();
1664
+ for (const r of await db
1665
+ .select({ prId: reviews.prId })
1666
+ .from(reviews)
1667
+ .where(inArray(reviews.prId, openPrIds))
1668
+ .execute())
1669
+ reviewedPrIds.add(r.prId);
1670
+ // Sprint review load per reviewer (reviews submitted on team PRs in the window).
1671
+ const reviewsThisSprint = new Map();
1672
+ for (const r of await db
1673
+ .select({ authorId: reviews.authorId })
1674
+ .from(reviews)
1675
+ .innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
1676
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds), gte(reviews.submittedAt, sprintFrom), isNotNull(reviews.authorId)))
1677
+ .execute()) {
1678
+ if (r.authorId != null)
1679
+ reviewsThisSprint.set(r.authorId, (reviewsThisSprint.get(r.authorId) ?? 0) + 1);
1680
+ }
1681
+ // (1) STALLED REVIEWS — open PRs with a still-pending reviewer, open past the threshold.
1682
+ const stalled = openPrs
1683
+ .filter((p) => (pendingByPr.get(p.id)?.length ?? 0) > 0 &&
1684
+ p.openedAt != null &&
1685
+ (now - p.openedAt.getTime()) / 3_600_000 > INSIGHT_STALLED_REVIEW_HOURS)
1686
+ .map((p) => ({ p, ageHours: Math.round((now - p.openedAt.getTime()) / 3_600_000) }))
1687
+ .sort((a, b) => b.ageHours - a.ageHours)
1688
+ .slice(0, INSIGHT_CARD_CAP);
1689
+ for (const { p, ageHours } of stalled) {
1690
+ const reviewers = pendingByPr.get(p.id) ?? [];
1691
+ addUser(p.authorId);
1692
+ reviewers.forEach(addUser);
1693
+ cards.push({
1694
+ id: `stalled:${p.id}`,
1695
+ kind: 'stalled_review',
1696
+ severity: ageHours >= 72 ? 'high' : ageHours >= 48 ? 'warn' : 'info',
1697
+ prId: p.id,
1698
+ repoId: p.repoId,
1699
+ repoFullName: repoName.get(p.repoId) ?? '',
1700
+ prNumber: p.number,
1701
+ prTitle: p.title,
1702
+ authorId: p.authorId,
1703
+ githubUrl: ghUrl(p.repoId, p.number),
1704
+ ciStatus: p.ciStatus,
1705
+ changedFiles: p.changedFiles,
1706
+ additions: p.additions,
1707
+ deletions: p.deletions,
1708
+ ageHours,
1709
+ requestedReviewerIds: reviewers,
1710
+ });
1711
+ }
1712
+ // (2) UNTOUCHED THREADS — derivedState 'untouched', older than a day, on open PRs.
1713
+ const threadRows = await db
1714
+ .select({
1715
+ threadId: reviewThreads.id,
1716
+ path: reviewThreads.path,
1717
+ createdAt: reviewThreads.createdAt,
1718
+ originalCommenterId: reviewThreads.originalCommenterId,
1719
+ prId: pullRequests.id,
1720
+ prNumber: pullRequests.number,
1721
+ prTitle: pullRequests.title,
1722
+ repoId: pullRequests.repoId,
1723
+ authorId: pullRequests.authorId,
1724
+ ciStatus: pullRequests.ciStatus,
1725
+ changedFiles: pullRequests.changedFiles,
1726
+ additions: pullRequests.additions,
1727
+ deletions: pullRequests.deletions,
1728
+ })
1729
+ .from(reviewThreads)
1730
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewThreads.prId))
1731
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds), eq(pullRequests.state, 'open'), eq(reviewThreads.derivedState, 'untouched'), lt(reviewThreads.createdAt, new Date(now - INSIGHT_UNTOUCHED_THREAD_HOURS * 3_600_000))))
1732
+ .execute();
1733
+ const threads = threadRows
1734
+ .map((t) => ({ t, ageHours: Math.round((now - t.createdAt.getTime()) / 3_600_000) }))
1735
+ .sort((a, b) => b.ageHours - a.ageHours)
1736
+ .slice(0, INSIGHT_CARD_CAP);
1737
+ for (const { t, ageHours } of threads) {
1738
+ addUser(t.originalCommenterId);
1739
+ addUser(t.authorId);
1740
+ cards.push({
1741
+ id: `thread:${t.threadId}`,
1742
+ kind: 'untouched_thread',
1743
+ severity: ageHours >= 96 ? 'high' : ageHours >= 48 ? 'warn' : 'info',
1744
+ prId: t.prId,
1745
+ repoId: t.repoId,
1746
+ repoFullName: repoName.get(t.repoId) ?? '',
1747
+ prNumber: t.prNumber,
1748
+ prTitle: t.prTitle,
1749
+ authorId: t.authorId,
1750
+ githubUrl: ghUrl(t.repoId, t.prNumber),
1751
+ ciStatus: t.ciStatus,
1752
+ changedFiles: t.changedFiles,
1753
+ additions: t.additions,
1754
+ deletions: t.deletions,
1755
+ threadId: t.threadId,
1756
+ path: t.path,
1757
+ ageHours,
1758
+ originalCommenterId: t.originalCommenterId,
1759
+ });
1760
+ }
1761
+ // (3) REVIEWER LOAD — ranked by pending-queue depth, with sprint load alongside.
1762
+ const loadCards = [...pendingByReviewer.keys()]
1763
+ .map((rid) => ({
1764
+ rid,
1765
+ pending: pendingByReviewer.get(rid)?.length ?? 0,
1766
+ sprint: reviewsThisSprint.get(rid) ?? 0,
1767
+ }))
1768
+ .filter((x) => x.pending >= 1)
1769
+ .sort((a, b) => b.pending - a.pending || b.sprint - a.sprint)
1770
+ .slice(0, 8);
1771
+ for (const x of loadCards) {
1772
+ addUser(x.rid);
1773
+ const pendingPrs = (pendingByReviewer.get(x.rid) ?? [])
1774
+ .map((id) => prById.get(id))
1775
+ .filter((p) => p != null)
1776
+ .slice(0, 8)
1777
+ .map((p) => ({
1778
+ prId: p.id,
1779
+ repoFullName: repoName.get(p.repoId) ?? '',
1780
+ prNumber: p.number,
1781
+ prTitle: p.title,
1782
+ }));
1783
+ cards.push({
1784
+ id: `load:${x.rid}`,
1785
+ kind: 'reviewer_load',
1786
+ severity: x.pending >= 4 ? 'high' : x.pending >= 2 ? 'warn' : 'info',
1787
+ reviewerId: x.rid,
1788
+ pendingCount: x.pending,
1789
+ reviewsThisSprint: x.sprint,
1790
+ pendingPrs,
1791
+ });
1792
+ }
1793
+ // (4) REVIEWER ROUTING — orphan PRs (nobody requested, nobody reviewed) + who should review.
1794
+ const orphans = openPrs
1795
+ .filter((p) => (pendingByPr.get(p.id)?.length ?? 0) === 0 &&
1796
+ !reviewedPrIds.has(p.id) &&
1797
+ p.openedAt != null &&
1798
+ (now - p.openedAt.getTime()) / 3_600_000 > INSIGHT_ROUTING_MIN_AGE_HOURS)
1799
+ .slice(0, INSIGHT_CARD_CAP);
1800
+ if (orphans.length > 0) {
1801
+ // Orphan PRs' changed paths come from the always-synced pull_requests.files. A few
1802
+ // stale orphans (old PRs predating the files column) are backfilled once via a
1803
+ // bounded GitHub fetch (cached onto the row), so routing works without depending on
1804
+ // the sparse per-commit commit_files cache.
1805
+ const orphanFiles = new Map(orphans.map((p) => [p.id, (p.files ?? []).map((f) => f.path)]));
1806
+ const missing = orphans.filter((p) => p.files == null).map((p) => p.id);
1807
+ if (missing.length > 0)
1808
+ for (const [prId, paths] of await ensureRoutingPrFiles(accountId, missing))
1809
+ orphanFiles.set(prId, paths);
1810
+ // Repo-wide "who recently worked where": every PR touched in the sprint → its author
1811
+ // × the top-level dirs its files span. Sourced from the always-stored
1812
+ // pull_requests.files (no commit-file dependency), so it reflects real recent
1813
+ // activity in each area of the codebase.
1814
+ const dirAuthors = new Map();
1815
+ for (const pr of await db
1816
+ .select({
1817
+ repoId: pullRequests.repoId,
1818
+ authorId: pullRequests.authorId,
1819
+ files: pullRequests.files,
1820
+ })
1821
+ .from(pullRequests)
1822
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds), isNotNull(pullRequests.authorId), isNotNull(pullRequests.files), gte(pullRequests.updatedAt, sprintFrom)))
1823
+ .execute()) {
1824
+ if (pr.authorId == null || pr.files == null)
1825
+ continue;
1826
+ for (const d of new Set(pr.files.map((f) => topLevelDir(f.path)))) {
1827
+ const key = `${pr.repoId} ${d}`;
1828
+ const m = dirAuthors.get(key) ?? new Map();
1829
+ m.set(pr.authorId, (m.get(pr.authorId) ?? 0) + 1);
1830
+ dirAuthors.set(key, m);
1831
+ }
1832
+ }
1833
+ const mergers = new Map((await getMergers(accountId)).map((m) => [m.repoId, new Set(m.userIds)]));
1834
+ for (const p of orphans) {
1835
+ const paths = orphanFiles.get(p.id) ?? [];
1836
+ const dirs = [...new Set(paths.map(topLevelDir))];
1837
+ const repoMergers = mergers.get(p.repoId) ?? new Set();
1838
+ // Candidates: mergers who recently worked in the same dirs. Track each one's
1839
+ // most-touched dir to phrase the rationale.
1840
+ const score = new Map();
1841
+ const topDir = new Map();
1842
+ for (const d of dirs) {
1843
+ const m = dirAuthors.get(`${p.repoId} ${d}`);
1844
+ if (!m)
1845
+ continue;
1846
+ for (const [uid, cnt] of m) {
1847
+ if (uid === p.authorId || !repoMergers.has(uid))
1848
+ continue;
1849
+ score.set(uid, (score.get(uid) ?? 0) + cnt);
1850
+ const cur = topDir.get(uid);
1851
+ if (!cur || cnt > cur.cnt)
1852
+ topDir.set(uid, { dir: d, cnt });
1853
+ }
1854
+ }
1855
+ const dirLabel = (d) => (d === '.' ? 'the repo root' : `${d}/`);
1856
+ let suggested = [...score.entries()]
1857
+ .sort((a, b) => b[1] - a[1])
1858
+ .slice(0, 3)
1859
+ .map(([uid]) => {
1860
+ const top = topDir.get(uid);
1861
+ return {
1862
+ userId: uid,
1863
+ reason: top ? `recently changed ${dirLabel(top.dir)}` : 'has merge rights here',
1864
+ };
1865
+ });
1866
+ // Fallback: any repo merger who isn't the author.
1867
+ if (suggested.length === 0)
1868
+ suggested = [...repoMergers]
1869
+ .filter((uid) => uid !== p.authorId)
1870
+ .slice(0, 3)
1871
+ .map((uid) => ({ userId: uid, reason: 'has merge rights here' }));
1872
+ if (suggested.length === 0)
1873
+ continue; // nothing useful to suggest
1874
+ addUser(p.authorId);
1875
+ suggested.forEach((s) => addUser(s.userId));
1876
+ cards.push({
1877
+ id: `route:${p.id}`,
1878
+ kind: 'reviewer_routing',
1879
+ severity: 'info',
1880
+ prId: p.id,
1881
+ repoId: p.repoId,
1882
+ repoFullName: repoName.get(p.repoId) ?? '',
1883
+ prNumber: p.number,
1884
+ prTitle: p.title,
1885
+ authorId: p.authorId,
1886
+ githubUrl: ghUrl(p.repoId, p.number),
1887
+ ciStatus: p.ciStatus,
1888
+ changedFiles: p.changedFiles,
1889
+ additions: p.additions,
1890
+ deletions: p.deletions,
1891
+ topPaths: paths.slice(0, 5),
1892
+ suggestedReviewers: suggested,
1893
+ });
1894
+ }
1895
+ }
1896
+ return finish();
1897
+ }
1566
1898
  export async function getConsolidatedFeed(accountId, opts = {}) {
1567
1899
  const { repoIds = null, userIds = null, limit = null, offset = 0, excludeBots = false, allowBotIds = null, } = opts;
1568
1900
  // Bot set (only loaded when the filter is on) — drops bot-authored activity, mirroring
@@ -3412,6 +3744,21 @@ export async function getPrWriteContext(prId, accountId) {
3412
3744
  prUrl: `https://github.com/${row.owner}/${row.name}/pull/${row.number}`,
3413
3745
  };
3414
3746
  }
3747
+ // Resolve a set of user ids to their GitHub logins for a reviewer request. Bots are
3748
+ // dropped (GitHub 422s on bot reviewers); the `users` table is global so no account
3749
+ // scope is needed (the caller already gated the PR by ownership + write access).
3750
+ export async function getReviewerLogins(userIds) {
3751
+ if (userIds.length === 0)
3752
+ return [];
3753
+ const rows = await db
3754
+ .select({ id: users.id, login: users.githubLogin, isBot: users.isBot })
3755
+ .from(users)
3756
+ .where(inArray(users.id, userIds))
3757
+ .execute();
3758
+ return rows
3759
+ .filter((r) => !r.isBot && r.login)
3760
+ .map((r) => ({ userId: r.id, login: r.login }));
3761
+ }
3415
3762
  // Subset of getPrWriteContext for the Changes-tab files fetch (owner/name/number
3416
3763
  // + the PR URL for building per-file deep links). Reuses getPrWriteContext so the
3417
3764
  // account-scoped ownership check stays in one place.
@@ -245,6 +245,15 @@ export async function createPullRequest(token, args) {
245
245
  });
246
246
  return { number: res.number, url: res.html_url };
247
247
  }
248
+ // ---- Request reviewers on a PR (REST) ----
249
+ // Request one or more reviewers on a PR (POST .../pulls/:n/requested_reviewers with
250
+ // { reviewers: [login…] }). Needs a token with write/triage access; GitHub 422s if a
251
+ // login isn't a collaborator or is the PR author (the caller filters the author out).
252
+ // Returns 201 with the updated PR body, which we don't need — the refreshed request
253
+ // state arrives on the next sync (reviewRequests are re-derived each sync).
254
+ export async function requestReviewers(token, owner, name, number, logins) {
255
+ await ghRestPostFor(token, `/repos/${owner}/${name}/pulls/${number}/requested_reviewers`, { reviewers: logins });
256
+ }
248
257
  // ---- Re-trigger a GitHub Actions workflow run ----
249
258
  // Re-run a workflow run (per-account). `mode: 'failed'` reruns only the failed jobs
250
259
  // (POST .../runs/:runId/rerun-failed-jobs); `mode: 'all'` reruns the whole run
package/dist/pro/bind.js CHANGED
@@ -41,7 +41,7 @@ export async function bindProPlugin(app) {
41
41
  if (!mod)
42
42
  return;
43
43
  const plugin = (mod.default ?? mod);
44
- if (plugin?.apiVersion !== 4 || typeof plugin.register !== 'function') {
44
+ if (plugin?.apiVersion !== 5 || typeof plugin.register !== 'function') {
45
45
  app.log.warn({ apiVersion: plugin?.apiVersion }, 'pro contract mismatch — skipped');
46
46
  return;
47
47
  }
@@ -77,6 +77,7 @@ export async function bindProPlugin(app) {
77
77
  userIds: null,
78
78
  }),
79
79
  getActivity: (accountId, repoIds) => hostQueries.getActivity(accountId, repoIds ?? null),
80
+ getTeamInsights: (accountId) => hostQueries.getTeamInsights(accountId),
80
81
  },
81
82
  reviewEvents,
82
83
  registerLearningsProvider,
@@ -6,6 +6,7 @@ const EMPTY = {
6
6
  reviewMemory: false,
7
7
  aiAnalysis: false,
8
8
  aiFix: false,
9
+ teamInsights: false,
9
10
  };
10
11
  let active = EMPTY;
11
12
  export function setProCapabilities(c) {
@@ -0,0 +1,58 @@
1
+ import { and, eq, inArray, isNull } from 'drizzle-orm';
2
+ import { getAccessToken } from '../auth/account.js';
3
+ import { db, schema } from '../db/client.js';
4
+ import { fetchPrFilesWithPatch } from '../github/mutations.js';
5
+ const { pullRequests, repos } = schema;
6
+ // Best-effort, BOUNDED backfill of `pull_requests.files` for reviewer-routing
7
+ // candidates that predate the files column (old open PRs never re-synced). Only PRs
8
+ // whose `files` IS NULL are fetched; the result — even `[]` on failure — is persisted
9
+ // so the same PR isn't refetched on every insights refresh (an [] sentinel means "we
10
+ // tried"). The sprint window's PRs already carry files from sync, so this only ever
11
+ // touches a handful of stale orphans. Returns the resolved paths by prId.
12
+ export async function ensureRoutingPrFiles(accountId, prIds) {
13
+ const out = new Map();
14
+ if (prIds.length === 0)
15
+ return out;
16
+ const rows = await db
17
+ .select({
18
+ id: pullRequests.id,
19
+ number: pullRequests.number,
20
+ owner: repos.owner,
21
+ name: repos.name,
22
+ })
23
+ .from(pullRequests)
24
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
25
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.id, prIds), isNull(pullRequests.files)))
26
+ .execute();
27
+ if (rows.length === 0)
28
+ return out;
29
+ let token;
30
+ try {
31
+ token = await getAccessToken(accountId);
32
+ }
33
+ catch {
34
+ return out; // no token (e.g. offline) → leave the fallback rationale in place
35
+ }
36
+ for (const r of rows) {
37
+ let stored = [];
38
+ try {
39
+ const { files } = await fetchPrFilesWithPatch(token, r.owner, r.name, r.number, 100);
40
+ stored = files.map((f) => ({
41
+ path: f.filename,
42
+ additions: f.additions,
43
+ deletions: f.deletions,
44
+ }));
45
+ }
46
+ catch {
47
+ stored = []; // sentinel — persist [] so we don't refetch this PR every refresh
48
+ }
49
+ await db
50
+ .update(pullRequests)
51
+ .set({ files: stored })
52
+ .where(and(eq(pullRequests.id, r.id), eq(pullRequests.accountId, accountId)))
53
+ .execute();
54
+ out.set(r.id, stored.map((s) => s.path));
55
+ }
56
+ return out;
57
+ }
58
+ //# sourceMappingURL=routing-files.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pierre-review",
3
- "version": "0.1.49",
3
+ "version": "0.1.51",
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",