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.
- package/dist/api/routes/prs.js +69 -2
- package/dist/db/queries.js +348 -1
- package/dist/github/mutations.js +9 -0
- package/dist/pro/bind.js +2 -1
- package/dist/pro/contract.js +1 -0
- package/dist/sync/routing-files.js +58 -0
- package/package.json +1 -1
- package/public/assets/index-C8mP8eMI.css +10 -0
- package/public/assets/{index-B5V5PQ74.js → index-D3OzCK5Z.js} +104 -103
- 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.
|
|
@@ -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.
|
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
|
@@ -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.
|
|
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",
|