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.
- package/dist/api/routes/prs.js +69 -2
- package/dist/db/queries.js +369 -1
- package/dist/github/mutations.js +9 -0
- package/dist/pro/bind.js +2 -1
- package/dist/pro/contract.js +1 -0
- package/package.json +1 -1
- package/public/assets/index-BWODO_hC.css +10 -0
- package/public/assets/{index-B5V5PQ74.js → index-CsC0bNMG.js} +98 -98
- package/public/index.html +2 -2
- package/public/assets/index-DoHJQ64p.css +0 -10
package/dist/api/routes/prs.js
CHANGED
|
@@ -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
|
package/dist/db/queries.js
CHANGED
|
@@ -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.
|
package/dist/github/mutations.js
CHANGED
|
@@ -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 !==
|
|
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,
|
package/dist/pro/contract.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pierre-review",
|
|
3
|
-
"version": "0.1.
|
|
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",
|