pierre-review 0.1.49 → 0.1.50

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.
@@ -1563,6 +1563,359 @@ export async function countNewMyTurnFeedItems(accountId, since) {
1563
1563
  }
1564
1564
  return n;
1565
1565
  }
1566
+ // ---- Team review-intelligence "Insights" (Pro; teamInsights) ----
1567
+ const INSIGHT_STALLED_REVIEW_HOURS = 24;
1568
+ const INSIGHT_UNTOUCHED_THREAD_HOURS = 24; // "> 1 day"
1569
+ const INSIGHT_SPRINT_DAYS = 14; // trailing 2 weeks
1570
+ const INSIGHT_ROUTING_MIN_AGE_HOURS = 4; // ignore brand-new PRs
1571
+ const INSIGHT_CARD_CAP = 15; // per-kind cap so the board stays digestible
1572
+ function topLevelDir(path) {
1573
+ const i = path.indexOf('/');
1574
+ return i === -1 ? '.' : path.slice(0, i);
1575
+ }
1576
+ // Compute the team review-intelligence cards from already-synced data — NO AI. WATCHED
1577
+ // repos (`inboxWatch`) are the team; "sprint" is the trailing 2 weeks. Runs on read (the
1578
+ // client refetches on the sync cadence); every query is account-scoped + bounded (watched
1579
+ // repos, open PRs, the sprint window, per-kind caps).
1580
+ export async function getTeamInsights(accountId) {
1581
+ const now = Date.now();
1582
+ const generatedAt = new Date(now);
1583
+ const sprintFrom = new Date(now - INSIGHT_SPRINT_DAYS * 86_400_000);
1584
+ const sprint = { from: sprintFrom.toISOString(), to: generatedAt.toISOString() };
1585
+ const cards = [];
1586
+ const userIdSet = new Set();
1587
+ const addUser = (id) => {
1588
+ if (id != null)
1589
+ userIdSet.add(id);
1590
+ };
1591
+ const watched = await db
1592
+ .select({ id: repos.id, owner: repos.owner, name: repos.name })
1593
+ .from(repos)
1594
+ .where(and(eq(repos.accountId, accountId), eq(repos.inboxWatch, true)))
1595
+ .execute();
1596
+ const repoName = new Map(watched.map((r) => [r.id, `${r.owner}/${r.name}`]));
1597
+ const repoIds = watched.map((r) => r.id);
1598
+ const finish = async () => {
1599
+ const kindRank = {
1600
+ stalled_review: 0,
1601
+ untouched_thread: 1,
1602
+ reviewer_load: 2,
1603
+ reviewer_routing: 3,
1604
+ };
1605
+ const sevRank = { high: 0, warn: 1, info: 2 };
1606
+ cards.sort((a, b) => sevRank[a.severity] - sevRank[b.severity] || kindRank[a.kind] - kindRank[b.kind]);
1607
+ const userRows = userIdSet.size > 0
1608
+ ? await db.select().from(users).where(inArray(users.id, [...userIdSet])).execute()
1609
+ : [];
1610
+ return {
1611
+ enabled: true,
1612
+ generatedAt: generatedAt.toISOString(),
1613
+ sprint,
1614
+ cards,
1615
+ users: userRows.map(mapUser),
1616
+ };
1617
+ };
1618
+ if (repoIds.length === 0)
1619
+ return finish();
1620
+ const ghUrl = (repoId, number) => `https://github.com/${repoName.get(repoId)}/pull/${number}`;
1621
+ // Open, non-draft PRs in the team's repos.
1622
+ const openPrs = await db
1623
+ .select({
1624
+ id: pullRequests.id,
1625
+ repoId: pullRequests.repoId,
1626
+ number: pullRequests.number,
1627
+ title: pullRequests.title,
1628
+ authorId: pullRequests.authorId,
1629
+ openedAt: pullRequests.openedAt,
1630
+ ciStatus: pullRequests.ciStatus,
1631
+ changedFiles: pullRequests.changedFiles,
1632
+ additions: pullRequests.additions,
1633
+ deletions: pullRequests.deletions,
1634
+ })
1635
+ .from(pullRequests)
1636
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds), eq(pullRequests.state, 'open'), eq(pullRequests.isDraft, false)))
1637
+ .execute();
1638
+ const openPrIds = openPrs.map((p) => p.id);
1639
+ const prById = new Map(openPrs.map((p) => [p.id, p]));
1640
+ if (openPrIds.length === 0)
1641
+ return finish();
1642
+ // Pending review requests (GitHub drops the request once a review lands → still-pending).
1643
+ const reqRows = await db
1644
+ .select({ prId: reviewRequests.prId, userId: reviewRequests.userId })
1645
+ .from(reviewRequests)
1646
+ .where(and(inArray(reviewRequests.prId, openPrIds), isNotNull(reviewRequests.userId)))
1647
+ .execute();
1648
+ const pendingByPr = new Map();
1649
+ const pendingByReviewer = new Map();
1650
+ for (const r of reqRows) {
1651
+ if (r.userId == null)
1652
+ continue;
1653
+ const a = pendingByPr.get(r.prId) ?? [];
1654
+ a.push(r.userId);
1655
+ pendingByPr.set(r.prId, a);
1656
+ const b = pendingByReviewer.get(r.userId) ?? [];
1657
+ b.push(r.prId);
1658
+ pendingByReviewer.set(r.userId, b);
1659
+ }
1660
+ // PRs that already have a submitted review (used by the routing "orphan" test).
1661
+ const reviewedPrIds = new Set();
1662
+ for (const r of await db
1663
+ .select({ prId: reviews.prId })
1664
+ .from(reviews)
1665
+ .where(inArray(reviews.prId, openPrIds))
1666
+ .execute())
1667
+ reviewedPrIds.add(r.prId);
1668
+ // Sprint review load per reviewer (reviews submitted on team PRs in the window).
1669
+ const reviewsThisSprint = new Map();
1670
+ for (const r of await db
1671
+ .select({ authorId: reviews.authorId })
1672
+ .from(reviews)
1673
+ .innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
1674
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds), gte(reviews.submittedAt, sprintFrom), isNotNull(reviews.authorId)))
1675
+ .execute()) {
1676
+ if (r.authorId != null)
1677
+ reviewsThisSprint.set(r.authorId, (reviewsThisSprint.get(r.authorId) ?? 0) + 1);
1678
+ }
1679
+ // (1) STALLED REVIEWS — open PRs with a still-pending reviewer, open past the threshold.
1680
+ const stalled = openPrs
1681
+ .filter((p) => (pendingByPr.get(p.id)?.length ?? 0) > 0 &&
1682
+ p.openedAt != null &&
1683
+ (now - p.openedAt.getTime()) / 3_600_000 > INSIGHT_STALLED_REVIEW_HOURS)
1684
+ .map((p) => ({ p, ageHours: Math.round((now - p.openedAt.getTime()) / 3_600_000) }))
1685
+ .sort((a, b) => b.ageHours - a.ageHours)
1686
+ .slice(0, INSIGHT_CARD_CAP);
1687
+ for (const { p, ageHours } of stalled) {
1688
+ const reviewers = pendingByPr.get(p.id) ?? [];
1689
+ addUser(p.authorId);
1690
+ reviewers.forEach(addUser);
1691
+ cards.push({
1692
+ id: `stalled:${p.id}`,
1693
+ kind: 'stalled_review',
1694
+ severity: ageHours >= 72 ? 'high' : ageHours >= 48 ? 'warn' : 'info',
1695
+ prId: p.id,
1696
+ repoId: p.repoId,
1697
+ repoFullName: repoName.get(p.repoId) ?? '',
1698
+ prNumber: p.number,
1699
+ prTitle: p.title,
1700
+ authorId: p.authorId,
1701
+ githubUrl: ghUrl(p.repoId, p.number),
1702
+ ciStatus: p.ciStatus,
1703
+ changedFiles: p.changedFiles,
1704
+ additions: p.additions,
1705
+ deletions: p.deletions,
1706
+ ageHours,
1707
+ requestedReviewerIds: reviewers,
1708
+ });
1709
+ }
1710
+ // (2) UNTOUCHED THREADS — derivedState 'untouched', older than a day, on open PRs.
1711
+ const threadRows = await db
1712
+ .select({
1713
+ threadId: reviewThreads.id,
1714
+ path: reviewThreads.path,
1715
+ createdAt: reviewThreads.createdAt,
1716
+ originalCommenterId: reviewThreads.originalCommenterId,
1717
+ prId: pullRequests.id,
1718
+ prNumber: pullRequests.number,
1719
+ prTitle: pullRequests.title,
1720
+ repoId: pullRequests.repoId,
1721
+ authorId: pullRequests.authorId,
1722
+ ciStatus: pullRequests.ciStatus,
1723
+ changedFiles: pullRequests.changedFiles,
1724
+ additions: pullRequests.additions,
1725
+ deletions: pullRequests.deletions,
1726
+ })
1727
+ .from(reviewThreads)
1728
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewThreads.prId))
1729
+ .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))))
1730
+ .execute();
1731
+ const threads = threadRows
1732
+ .map((t) => ({ t, ageHours: Math.round((now - t.createdAt.getTime()) / 3_600_000) }))
1733
+ .sort((a, b) => b.ageHours - a.ageHours)
1734
+ .slice(0, INSIGHT_CARD_CAP);
1735
+ for (const { t, ageHours } of threads) {
1736
+ addUser(t.originalCommenterId);
1737
+ addUser(t.authorId);
1738
+ cards.push({
1739
+ id: `thread:${t.threadId}`,
1740
+ kind: 'untouched_thread',
1741
+ severity: ageHours >= 96 ? 'high' : ageHours >= 48 ? 'warn' : 'info',
1742
+ prId: t.prId,
1743
+ repoId: t.repoId,
1744
+ repoFullName: repoName.get(t.repoId) ?? '',
1745
+ prNumber: t.prNumber,
1746
+ prTitle: t.prTitle,
1747
+ authorId: t.authorId,
1748
+ githubUrl: ghUrl(t.repoId, t.prNumber),
1749
+ ciStatus: t.ciStatus,
1750
+ changedFiles: t.changedFiles,
1751
+ additions: t.additions,
1752
+ deletions: t.deletions,
1753
+ threadId: t.threadId,
1754
+ path: t.path,
1755
+ ageHours,
1756
+ originalCommenterId: t.originalCommenterId,
1757
+ });
1758
+ }
1759
+ // (3) REVIEWER LOAD — ranked by pending-queue depth, with sprint load alongside.
1760
+ const loadCards = [...pendingByReviewer.keys()]
1761
+ .map((rid) => ({
1762
+ rid,
1763
+ pending: pendingByReviewer.get(rid)?.length ?? 0,
1764
+ sprint: reviewsThisSprint.get(rid) ?? 0,
1765
+ }))
1766
+ .filter((x) => x.pending >= 1)
1767
+ .sort((a, b) => b.pending - a.pending || b.sprint - a.sprint)
1768
+ .slice(0, 8);
1769
+ for (const x of loadCards) {
1770
+ addUser(x.rid);
1771
+ const pendingPrs = (pendingByReviewer.get(x.rid) ?? [])
1772
+ .map((id) => prById.get(id))
1773
+ .filter((p) => p != null)
1774
+ .slice(0, 8)
1775
+ .map((p) => ({
1776
+ prId: p.id,
1777
+ repoFullName: repoName.get(p.repoId) ?? '',
1778
+ prNumber: p.number,
1779
+ prTitle: p.title,
1780
+ }));
1781
+ cards.push({
1782
+ id: `load:${x.rid}`,
1783
+ kind: 'reviewer_load',
1784
+ severity: x.pending >= 4 ? 'high' : x.pending >= 2 ? 'warn' : 'info',
1785
+ reviewerId: x.rid,
1786
+ pendingCount: x.pending,
1787
+ reviewsThisSprint: x.sprint,
1788
+ pendingPrs,
1789
+ });
1790
+ }
1791
+ // (4) REVIEWER ROUTING — orphan PRs (nobody requested, nobody reviewed) + who should review.
1792
+ const orphans = openPrs
1793
+ .filter((p) => (pendingByPr.get(p.id)?.length ?? 0) === 0 &&
1794
+ !reviewedPrIds.has(p.id) &&
1795
+ p.openedAt != null &&
1796
+ (now - p.openedAt.getTime()) / 3_600_000 > INSIGHT_ROUTING_MIN_AGE_HOURS)
1797
+ .slice(0, INSIGHT_CARD_CAP);
1798
+ if (orphans.length > 0) {
1799
+ const orphanIds = orphans.map((p) => p.id);
1800
+ // The orphan PRs' changed paths (their commits → files).
1801
+ const shasByPr = new Map();
1802
+ const orphanShas = new Set();
1803
+ for (const c of await db
1804
+ .select({ prId: commits.prId, sha: commits.sha })
1805
+ .from(commits)
1806
+ .where(inArray(commits.prId, orphanIds))
1807
+ .execute()) {
1808
+ if (c.prId == null)
1809
+ continue;
1810
+ const a = shasByPr.get(c.prId) ?? [];
1811
+ a.push(c.sha);
1812
+ shasByPr.set(c.prId, a);
1813
+ orphanShas.add(c.sha);
1814
+ }
1815
+ const pathsBySha = new Map();
1816
+ if (orphanShas.size > 0)
1817
+ for (const f of await db
1818
+ .select({ sha: commitFiles.sha, paths: commitFiles.paths })
1819
+ .from(commitFiles)
1820
+ .where(inArray(commitFiles.sha, [...orphanShas]))
1821
+ .execute())
1822
+ pathsBySha.set(f.sha, f.paths);
1823
+ // Repo-wide "who commits where" over the sprint → (repo, top-level dir) → author counts.
1824
+ const sprintShaAuthor = new Map();
1825
+ for (const c of await db
1826
+ .select({ repoId: pullRequests.repoId, authorId: commits.authorId, sha: commits.sha })
1827
+ .from(commits)
1828
+ .innerJoin(pullRequests, eq(pullRequests.id, commits.prId))
1829
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.repoId, repoIds), gte(commits.committedAt, sprintFrom), isNotNull(commits.authorId)))
1830
+ .execute()) {
1831
+ if (c.authorId != null)
1832
+ sprintShaAuthor.set(c.sha, { repoId: c.repoId, authorId: c.authorId });
1833
+ }
1834
+ const dirAuthors = new Map();
1835
+ if (sprintShaAuthor.size > 0)
1836
+ for (const f of await db
1837
+ .select({ sha: commitFiles.sha, paths: commitFiles.paths })
1838
+ .from(commitFiles)
1839
+ .where(inArray(commitFiles.sha, [...sprintShaAuthor.keys()]))
1840
+ .execute()) {
1841
+ const meta = sprintShaAuthor.get(f.sha);
1842
+ if (!meta)
1843
+ continue;
1844
+ for (const d of new Set(f.paths.map(topLevelDir))) {
1845
+ const key = `${meta.repoId}${d}`;
1846
+ const m = dirAuthors.get(key) ?? new Map();
1847
+ m.set(meta.authorId, (m.get(meta.authorId) ?? 0) + 1);
1848
+ dirAuthors.set(key, m);
1849
+ }
1850
+ }
1851
+ const mergers = new Map((await getMergers(accountId)).map((m) => [m.repoId, new Set(m.userIds)]));
1852
+ for (const p of orphans) {
1853
+ const shas = shasByPr.get(p.id) ?? [];
1854
+ const paths = [...new Set(shas.flatMap((s) => pathsBySha.get(s) ?? []))];
1855
+ const dirs = [...new Set(paths.map(topLevelDir))];
1856
+ const repoMergers = mergers.get(p.repoId) ?? new Set();
1857
+ // Candidates: authors who touched the same dirs this sprint AND have merge rights.
1858
+ // Track the single dir each candidate touched most, to phrase the rationale.
1859
+ const score = new Map();
1860
+ const topDir = new Map();
1861
+ for (const d of dirs) {
1862
+ const m = dirAuthors.get(`${p.repoId}${d}`);
1863
+ if (!m)
1864
+ continue;
1865
+ for (const [uid, cnt] of m) {
1866
+ if (uid === p.authorId || !repoMergers.has(uid))
1867
+ continue;
1868
+ score.set(uid, (score.get(uid) ?? 0) + cnt);
1869
+ const cur = topDir.get(uid);
1870
+ if (!cur || cnt > cur.cnt)
1871
+ topDir.set(uid, { dir: d, cnt });
1872
+ }
1873
+ }
1874
+ const dirLabel = (d) => (d === '.' ? 'the repo root' : `${d}/`);
1875
+ let suggested = [...score.entries()]
1876
+ .sort((a, b) => b[1] - a[1])
1877
+ .slice(0, 3)
1878
+ .map(([uid]) => {
1879
+ const top = topDir.get(uid);
1880
+ return {
1881
+ userId: uid,
1882
+ reason: top
1883
+ ? `committed to ${dirLabel(top.dir)} this sprint`
1884
+ : 'has merge rights here',
1885
+ };
1886
+ });
1887
+ // Fallback: any repo merger who isn't the author.
1888
+ if (suggested.length === 0)
1889
+ suggested = [...repoMergers]
1890
+ .filter((uid) => uid !== p.authorId)
1891
+ .slice(0, 3)
1892
+ .map((uid) => ({ userId: uid, reason: 'has merge rights here' }));
1893
+ if (suggested.length === 0)
1894
+ continue; // nothing useful to suggest
1895
+ addUser(p.authorId);
1896
+ suggested.forEach((s) => addUser(s.userId));
1897
+ cards.push({
1898
+ id: `route:${p.id}`,
1899
+ kind: 'reviewer_routing',
1900
+ severity: 'info',
1901
+ prId: p.id,
1902
+ repoId: p.repoId,
1903
+ repoFullName: repoName.get(p.repoId) ?? '',
1904
+ prNumber: p.number,
1905
+ prTitle: p.title,
1906
+ authorId: p.authorId,
1907
+ githubUrl: ghUrl(p.repoId, p.number),
1908
+ ciStatus: p.ciStatus,
1909
+ changedFiles: p.changedFiles,
1910
+ additions: p.additions,
1911
+ deletions: p.deletions,
1912
+ topPaths: paths.slice(0, 5),
1913
+ suggestedReviewers: suggested,
1914
+ });
1915
+ }
1916
+ }
1917
+ return finish();
1918
+ }
1566
1919
  export async function getConsolidatedFeed(accountId, opts = {}) {
1567
1920
  const { repoIds = null, userIds = null, limit = null, offset = 0, excludeBots = false, allowBotIds = null, } = opts;
1568
1921
  // Bot set (only loaded when the filter is on) — drops bot-authored activity, mirroring
@@ -3412,6 +3765,21 @@ export async function getPrWriteContext(prId, accountId) {
3412
3765
  prUrl: `https://github.com/${row.owner}/${row.name}/pull/${row.number}`,
3413
3766
  };
3414
3767
  }
3768
+ // Resolve a set of user ids to their GitHub logins for a reviewer request. Bots are
3769
+ // dropped (GitHub 422s on bot reviewers); the `users` table is global so no account
3770
+ // scope is needed (the caller already gated the PR by ownership + write access).
3771
+ export async function getReviewerLogins(userIds) {
3772
+ if (userIds.length === 0)
3773
+ return [];
3774
+ const rows = await db
3775
+ .select({ id: users.id, login: users.githubLogin, isBot: users.isBot })
3776
+ .from(users)
3777
+ .where(inArray(users.id, userIds))
3778
+ .execute();
3779
+ return rows
3780
+ .filter((r) => !r.isBot && r.login)
3781
+ .map((r) => ({ userId: r.id, login: r.login }));
3782
+ }
3415
3783
  // Subset of getPrWriteContext for the Changes-tab files fetch (owner/name/number
3416
3784
  // + the PR URL for building per-file deep links). Reuses getPrWriteContext so the
3417
3785
  // 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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pierre-review",
3
- "version": "0.1.49",
3
+ "version": "0.1.50",
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",