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