pierre-review 0.1.0

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/dist/api/plugins/error-handler.js +40 -0
  4. package/dist/api/routes/health.js +7 -0
  5. package/dist/api/routes/me.js +34 -0
  6. package/dist/api/routes/mergers.js +7 -0
  7. package/dist/api/routes/open-prs.js +21 -0
  8. package/dist/api/routes/prs.js +50 -0
  9. package/dist/api/routes/repos.js +188 -0
  10. package/dist/api/routes/threads.js +20 -0
  11. package/dist/api/routes/timeline.js +73 -0
  12. package/dist/api/routes/users.js +28 -0
  13. package/dist/app.js +48 -0
  14. package/dist/ascii.js +12 -0
  15. package/dist/cli.js +138 -0
  16. package/dist/config.js +41 -0
  17. package/dist/db/cleanup.js +22 -0
  18. package/dist/db/client.js +13 -0
  19. package/dist/db/migrate.js +15 -0
  20. package/dist/db/migrations/0000_purple_ben_grimm.sql +155 -0
  21. package/dist/db/migrations/0001_colorful_ozymandias.sql +31 -0
  22. package/dist/db/migrations/0002_famous_sersi.sql +9 -0
  23. package/dist/db/migrations/0003_clever_shinobi_shaw.sql +3 -0
  24. package/dist/db/migrations/0004_pale_scalphunter.sql +1 -0
  25. package/dist/db/migrations/0005_daffy_guardian.sql +2 -0
  26. package/dist/db/migrations/meta/0000_snapshot.json +1116 -0
  27. package/dist/db/migrations/meta/0001_snapshot.json +1321 -0
  28. package/dist/db/migrations/meta/0002_snapshot.json +1375 -0
  29. package/dist/db/migrations/meta/0003_snapshot.json +1396 -0
  30. package/dist/db/migrations/meta/0004_snapshot.json +1416 -0
  31. package/dist/db/migrations/meta/0005_snapshot.json +1430 -0
  32. package/dist/db/migrations/meta/_journal.json +48 -0
  33. package/dist/db/queries.js +837 -0
  34. package/dist/db/run-migrations.js +10 -0
  35. package/dist/db/schema.js +248 -0
  36. package/dist/db/triage.js +168 -0
  37. package/dist/github/auth.js +19 -0
  38. package/dist/github/client.js +30 -0
  39. package/dist/github/local-user.js +92 -0
  40. package/dist/github/queries.js +249 -0
  41. package/dist/index.js +49 -0
  42. package/dist/sync/bot-detection.js +24 -0
  43. package/dist/sync/commit-files.js +50 -0
  44. package/dist/sync/derive-thread-state.js +38 -0
  45. package/dist/sync/scheduler.js +28 -0
  46. package/dist/sync/sync-manager.js +150 -0
  47. package/dist/sync/sync-repo.js +122 -0
  48. package/dist/sync/upsert.js +528 -0
  49. package/package.json +46 -0
  50. package/public/assets/index-6p3C9xk7.css +10 -0
  51. package/public/assets/index-C-CZcLLq.js +1360 -0
  52. package/public/index.html +25 -0
@@ -0,0 +1,837 @@
1
+ import { and, asc, count, eq, exists, gt, gte, inArray, isNotNull, isNull, lte, notInArray, or, sql, } from 'drizzle-orm';
2
+ // Local copy of the shared `REASON_PRIORITY` value constant. `@pierre-review/shared`
3
+ // is a types-only workspace package that is NOT shipped in the published tarball,
4
+ // so the backend must only `import type` from it. Keep in sync with packages/shared.
5
+ const REASON_PRIORITY = [
6
+ 'awaiting_your_review',
7
+ 'your_pr_new_comments',
8
+ 'ci_failing',
9
+ 'merge_conflicts',
10
+ 'approved_ready',
11
+ 'stalled',
12
+ 'untouched_threads',
13
+ 'in_progress',
14
+ ];
15
+ import { db, schema } from './client.js';
16
+ import { config } from '../config.js';
17
+ import { computeTriage } from './triage.js';
18
+ import { getLocalUserId } from '../github/local-user.js';
19
+ const { repos, users, pullRequests, reviewThreads, reviewComments, prComments, reviews, commits, events, syncState, prViews, myTurnDismissals, } = schema;
20
+ function iso(d) {
21
+ return d ? d.toISOString() : null;
22
+ }
23
+ function emptyCounts() {
24
+ return { resolved: 0, likely_addressed: 0, replied_unresolved: 0, untouched: 0 };
25
+ }
26
+ function mapUser(u) {
27
+ return {
28
+ id: u.id,
29
+ githubLogin: u.githubLogin,
30
+ displayName: u.displayName,
31
+ avatarUrl: u.avatarUrl,
32
+ isBot: u.isBot,
33
+ };
34
+ }
35
+ export function listRepos() {
36
+ const rows = db
37
+ .select()
38
+ .from(repos)
39
+ .leftJoin(syncState, eq(syncState.repoId, repos.id))
40
+ .orderBy(asc(repos.owner), asc(repos.name))
41
+ .all();
42
+ return rows.map((r) => ({
43
+ id: r.repos.id,
44
+ owner: r.repos.owner,
45
+ name: r.repos.name,
46
+ fullName: `${r.repos.owner}/${r.repos.name}`,
47
+ createdAt: r.repos.createdAt.toISOString(),
48
+ lastFullSyncAt: iso(r.sync_state?.lastFullSyncAt ?? null),
49
+ lastIncrementalSyncAt: iso(r.sync_state?.lastIncrementalSyncAt ?? null),
50
+ lastSyncStatus: r.sync_state?.lastSyncStatus ?? null,
51
+ lastSyncError: r.sync_state?.lastSyncError ?? null,
52
+ }));
53
+ }
54
+ export function getRepo(id) {
55
+ return listRepos().find((r) => r.id === id) ?? null;
56
+ }
57
+ // Node IDs of every watched repo. Used to drop already-tracked repos from live
58
+ // search results (a GitHub search hit exposes the same GraphQL `id`).
59
+ export function getWatchedRepoNodeIds() {
60
+ const rows = db.select({ nodeId: repos.githubNodeId }).from(repos).all();
61
+ return new Set(rows.map((r) => r.nodeId));
62
+ }
63
+ export function listUsers() {
64
+ return db
65
+ .select()
66
+ .from(users)
67
+ .orderBy(asc(users.githubLogin))
68
+ .all()
69
+ .map(mapUser);
70
+ }
71
+ export function setUserBot(id, isBot) {
72
+ const row = db
73
+ .update(users)
74
+ .set({ isBot, isBotOverridden: true })
75
+ .where(eq(users.id, id))
76
+ .returning()
77
+ .get();
78
+ return row ? mapUser(row) : null;
79
+ }
80
+ // Event types that count as "touching" a PR for the stale filter: code pushes and
81
+ // any human discussion (inline review comments, issue-level comments, reviews).
82
+ // Lifecycle events (opened/merged/…) are NOT activity — a quiet open PR that was
83
+ // merely opened long ago is exactly what "stale" targets.
84
+ const ACTIVITY_EVENT_TYPES = [
85
+ 'commit_pushed',
86
+ 'review_comment',
87
+ 'pr_comment',
88
+ 'review_submitted',
89
+ ];
90
+ // Open PRs (from `prRows`) with no activity event inside [from, to] — the "stale"
91
+ // set. Only open PRs are eligible (merged/closed are historical, never stale).
92
+ function staleOpenPrIds(prRows, from, to) {
93
+ const openIds = prRows.filter((p) => p.state === 'open').map((p) => p.id);
94
+ if (openIds.length === 0)
95
+ return new Set();
96
+ const activeRows = db
97
+ .select({ prId: events.prId })
98
+ .from(events)
99
+ .where(and(inArray(events.prId, openIds), inArray(events.type, ACTIVITY_EVENT_TYPES), gte(events.occurredAt, from), lte(events.occurredAt, to)))
100
+ .all();
101
+ const active = new Set();
102
+ for (const r of activeRows)
103
+ if (r.prId != null)
104
+ active.add(r.prId);
105
+ return new Set(openIds.filter((id) => !active.has(id)));
106
+ }
107
+ // SQL predicate (on the pullRequests table) for "the PR is one of these
108
+ // statuses". Reused directly on the PR query and inside an EXISTS on the events
109
+ // query, so events whose PR is filtered out drop too — letting a contributor's
110
+ // row disappear when their only PR is excluded. Empty selection → matches none.
111
+ function prStatusWhere(statuses) {
112
+ if (statuses.length === 0)
113
+ return sql `1 = 0`;
114
+ const parts = statuses.map((st) => st === 'draft'
115
+ ? and(eq(pullRequests.state, 'open'), eq(pullRequests.isDraft, true))
116
+ : st === 'open'
117
+ ? and(eq(pullRequests.state, 'open'), eq(pullRequests.isDraft, false))
118
+ : st === 'merged'
119
+ ? eq(pullRequests.state, 'merged')
120
+ : eq(pullRequests.state, 'closed'));
121
+ return or(...parts);
122
+ }
123
+ function botUserIds() {
124
+ return db
125
+ .select({ id: users.id })
126
+ .from(users)
127
+ .where(eq(users.isBot, true))
128
+ .all()
129
+ .map((r) => r.id);
130
+ }
131
+ function buildThreadCounts(prIds) {
132
+ const map = new Map();
133
+ if (prIds.length === 0)
134
+ return map;
135
+ const rows = db
136
+ .select({
137
+ prId: reviewThreads.prId,
138
+ state: reviewThreads.derivedState,
139
+ c: count(),
140
+ })
141
+ .from(reviewThreads)
142
+ .where(inArray(reviewThreads.prId, prIds))
143
+ .groupBy(reviewThreads.prId, reviewThreads.derivedState)
144
+ .all();
145
+ for (const r of rows) {
146
+ const entry = map.get(r.prId) ?? emptyCounts();
147
+ entry[r.state] = r.c;
148
+ map.set(r.prId, entry);
149
+ }
150
+ return map;
151
+ }
152
+ function isStalled(pr, counts) {
153
+ if (pr.state !== 'open')
154
+ return false;
155
+ const openThreads = counts.untouched + counts.replied_unresolved;
156
+ if (openThreads < 1)
157
+ return false;
158
+ const ref = pr.lastCommitAt ?? null;
159
+ if (!ref)
160
+ return false;
161
+ const ageMs = Date.now() - ref.getTime();
162
+ return ageMs > config.stallThresholdDays * 24 * 60 * 60 * 1000;
163
+ }
164
+ /** Build full TimelinePr objects (incl. triage fields) for a set of PR rows. */
165
+ function buildTimelinePrs(prRows) {
166
+ const counts = buildThreadCounts(prRows.map((p) => p.id));
167
+ const triage = computeTriage(prRows.map((p) => {
168
+ const c = counts.get(p.id) ?? emptyCounts();
169
+ return {
170
+ id: p.id,
171
+ state: p.state,
172
+ authorId: p.authorId,
173
+ ciStatus: (p.ciStatus ?? 'unknown'),
174
+ mergeable: (p.mergeable ?? 'unknown'),
175
+ mergeStateStatus: (p.mergeStateStatus ?? 'unknown'),
176
+ isStalled: isStalled(p, c),
177
+ threadCounts: c,
178
+ };
179
+ }));
180
+ return prRows.map((p) => {
181
+ const c = counts.get(p.id) ?? emptyCounts();
182
+ const tr = triage.get(p.id);
183
+ return mapTimelinePr(p, c, tr);
184
+ });
185
+ }
186
+ function mapTimelinePr(p, counts, tr) {
187
+ return {
188
+ id: p.id,
189
+ repoId: p.repoId,
190
+ number: p.number,
191
+ title: p.title,
192
+ authorId: p.authorId,
193
+ state: p.state,
194
+ isDraft: p.isDraft,
195
+ isStalled: isStalled(p, counts),
196
+ openedAt: p.openedAt.toISOString(),
197
+ firstReviewAt: iso(p.firstReviewAt),
198
+ lastCommitAt: iso(p.lastCommitAt),
199
+ mergedAt: iso(p.mergedAt),
200
+ closedAt: iso(p.closedAt),
201
+ updatedAt: p.updatedAt.toISOString(),
202
+ threadCounts: counts,
203
+ ciStatus: (p.ciStatus ?? 'unknown'),
204
+ mergeable: (p.mergeable ?? 'unknown'),
205
+ mergeStateStatus: (p.mergeStateStatus ?? 'unknown'),
206
+ labels: (p.labels ?? []),
207
+ reasonTag: tr?.reasonTag ?? 'in_progress',
208
+ reviewRequestedFromMe: tr?.reviewRequestedFromMe ?? false,
209
+ newSinceLastViewed: tr?.newSinceLastViewed ?? null,
210
+ };
211
+ }
212
+ export function getTimeline(filters) {
213
+ const { from, to, repoIds, userIds, types, statuses, excludeBots, excludeStale } = filters;
214
+ // ---- PRs that overlap the window ----
215
+ const prConds = [
216
+ lte(pullRequests.openedAt, to),
217
+ or(eq(pullRequests.state, 'open'), gte(sql `coalesce(${pullRequests.mergedAt}, ${pullRequests.closedAt}, ${pullRequests.openedAt})`, Math.floor(from.getTime() / 1000))),
218
+ ];
219
+ if (repoIds)
220
+ prConds.push(inArray(pullRequests.repoId, repoIds));
221
+ // PR-status filter: keep only PRs whose (state, isDraft) is a selected status.
222
+ if (statuses)
223
+ prConds.push(prStatusWhere(statuses));
224
+ // Member filter: PRs the user authored OR acted on within the window.
225
+ if (userIds && userIds.length > 0) {
226
+ prConds.push(or(inArray(pullRequests.authorId, userIds), exists(db
227
+ .select({ x: sql `1` })
228
+ .from(events)
229
+ .where(and(eq(events.prId, pullRequests.id), inArray(events.actorId, userIds))))));
230
+ }
231
+ if (excludeBots) {
232
+ const bots = botUserIds();
233
+ if (bots.length > 0) {
234
+ prConds.push(or(sql `${pullRequests.authorId} is null`, sql `${pullRequests.authorId} not in (${sql.join(bots, sql `, `)})`));
235
+ }
236
+ }
237
+ let prRows = db
238
+ .select()
239
+ .from(pullRequests)
240
+ .where(and(...prConds))
241
+ .all();
242
+ // Stale filter: drop open PRs with no activity in the window. Computed before
243
+ // building the lean PRs so their events can be dropped too (below).
244
+ const staleIds = excludeStale ? staleOpenPrIds(prRows, from, to) : new Set();
245
+ if (staleIds.size > 0)
246
+ prRows = prRows.filter((p) => !staleIds.has(p.id));
247
+ const prs = buildTimelinePrs(prRows);
248
+ // ---- events in the window ----
249
+ const evConds = [gte(events.occurredAt, from), lte(events.occurredAt, to)];
250
+ if (repoIds)
251
+ evConds.push(inArray(events.repoId, repoIds));
252
+ if (types)
253
+ evConds.push(inArray(events.type, types));
254
+ if (userIds && userIds.length > 0) {
255
+ evConds.push(inArray(events.actorId, userIds));
256
+ }
257
+ // Drop events whose PR is filtered out by status — so a contributor with only
258
+ // a (e.g.) closed PR keeps neither a bar nor any markers, and loses their row.
259
+ if (statuses) {
260
+ evConds.push(exists(db
261
+ .select({ x: sql `1` })
262
+ .from(pullRequests)
263
+ .where(and(eq(pullRequests.id, events.prId), prStatusWhere(statuses)))));
264
+ }
265
+ // Likewise drop a stale open PR's own events (only ever lifecycle markers, since
266
+ // by definition it has no activity events in-window) so its contributor row can
267
+ // disappear instead of lingering empty. Keep events with no PR (defensive).
268
+ if (staleIds.size > 0) {
269
+ evConds.push(or(isNull(events.prId), notInArray(events.prId, [...staleIds])));
270
+ }
271
+ if (excludeBots) {
272
+ const bots = botUserIds();
273
+ if (bots.length > 0) {
274
+ evConds.push(or(sql `${events.actorId} is null`, sql `${events.actorId} not in (${sql.join(bots, sql `, `)})`));
275
+ }
276
+ }
277
+ const evRows = db
278
+ .select()
279
+ .from(events)
280
+ .where(and(...evConds))
281
+ .orderBy(asc(events.occurredAt))
282
+ .all();
283
+ // Batch-load review outcomes for the review_submitted events in view, so
284
+ // markers can show approve/changes/comment without per-marker fetches.
285
+ const reviewRefIds = evRows
286
+ .filter((e) => e.type === 'review_submitted' && e.refTable === 'reviews' && e.refId != null)
287
+ .map((e) => e.refId);
288
+ const reviewStateById = new Map();
289
+ if (reviewRefIds.length > 0) {
290
+ const rows = db
291
+ .select({ id: reviews.id, state: reviews.state })
292
+ .from(reviews)
293
+ .where(inArray(reviews.id, reviewRefIds))
294
+ .all();
295
+ for (const r of rows)
296
+ reviewStateById.set(r.id, r.state);
297
+ }
298
+ const timelineEvents = evRows.map((e) => ({
299
+ id: e.id,
300
+ repoId: e.repoId,
301
+ actorId: e.actorId,
302
+ prId: e.prId,
303
+ type: e.type,
304
+ occurredAt: e.occurredAt.toISOString(),
305
+ threadId: e.type === 'review_comment' && e.refTable === 'review_threads'
306
+ ? e.refId
307
+ : null,
308
+ refId: e.refId,
309
+ reviewState: e.type === 'review_submitted' && e.refTable === 'reviews' && e.refId != null
310
+ ? (reviewStateById.get(e.refId) ?? null)
311
+ : null,
312
+ }));
313
+ return { prs, events: timelineEvents };
314
+ }
315
+ export function getOpenPrs(filters) {
316
+ const conds = [eq(pullRequests.state, 'open')];
317
+ if (filters.repoIds)
318
+ conds.push(inArray(pullRequests.repoId, filters.repoIds));
319
+ if (filters.userIds && filters.userIds.length > 0) {
320
+ conds.push(inArray(pullRequests.authorId, filters.userIds));
321
+ }
322
+ const prRows = db
323
+ .select()
324
+ .from(pullRequests)
325
+ .where(and(...conds))
326
+ .all();
327
+ const prs = buildTimelinePrs(prRows);
328
+ const rank = (t) => REASON_PRIORITY.indexOf(t.reasonTag);
329
+ return prs.sort((a, b) => {
330
+ const r = rank(a) - rank(b);
331
+ if (r !== 0)
332
+ return r;
333
+ return a.openedAt.localeCompare(b.openedAt); // oldest first
334
+ });
335
+ }
336
+ // ---- merge-rights inference ----
337
+ // Distinct users who have merged a PR INTO THE DEFAULT BRANCH per repo (across
338
+ // ALL synced history, not the timeline window). We treat "has merged into the
339
+ // repo's default branch" as a good-enough proxy for "is a maintainer" — merges
340
+ // into feature/integration branches don't count, since write access to a side
341
+ // branch isn't the same signal as landing changes on main.
342
+ //
343
+ // Backward-compat: mergedById / baseRefName / defaultBranch are only populated by
344
+ // syncs that ran after they were added, so older rows have nulls. We count a
345
+ // merge UNLESS we positively know it targeted a non-default branch (i.e. both the
346
+ // repo's default branch and the PR's base branch are known and differ). This
347
+ // keeps already-synced repos populated and tightens to default-only as they
348
+ // re-sync. Repos never (deep-)re-synced for mergedById stay empty regardless.
349
+ export function getMergers() {
350
+ const rows = db
351
+ .selectDistinct({ repoId: pullRequests.repoId, userId: pullRequests.mergedById })
352
+ .from(pullRequests)
353
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
354
+ .where(and(eq(pullRequests.state, 'merged'), isNotNull(pullRequests.mergedById), or(isNull(repos.defaultBranch), isNull(pullRequests.baseRefName), eq(pullRequests.baseRefName, repos.defaultBranch))))
355
+ .all();
356
+ const byRepo = new Map();
357
+ for (const r of rows) {
358
+ if (r.userId == null)
359
+ continue;
360
+ const arr = byRepo.get(r.repoId);
361
+ if (arr)
362
+ arr.push(r.userId);
363
+ else
364
+ byRepo.set(r.repoId, [r.userId]);
365
+ }
366
+ return [...byRepo.entries()].map(([repoId, userIds]) => ({ repoId, userIds }));
367
+ }
368
+ // ---- incremental review: pr_views ----
369
+ export function markPrViewed(prId, sha) {
370
+ const pr = db
371
+ .select({ id: pullRequests.id, headSha: pullRequests.headSha })
372
+ .from(pullRequests)
373
+ .where(eq(pullRequests.id, prId))
374
+ .get();
375
+ if (!pr)
376
+ return false;
377
+ const viewedSha = sha ?? pr.headSha ?? null;
378
+ const now = new Date();
379
+ db.insert(prViews)
380
+ .values({ prId, lastViewedSha: viewedSha, lastViewedAt: now })
381
+ .onConflictDoUpdate({
382
+ target: prViews.prId,
383
+ set: { lastViewedSha: viewedSha, lastViewedAt: now },
384
+ })
385
+ .run();
386
+ return true;
387
+ }
388
+ // ---- my turn ----
389
+ export function dismissMyTurn(kind, refId) {
390
+ const now = new Date();
391
+ db.insert(myTurnDismissals)
392
+ .values({ kind, refId, dismissedAt: now })
393
+ .onConflictDoUpdate({
394
+ target: [myTurnDismissals.kind, myTurnDismissals.refId],
395
+ set: { dismissedAt: now },
396
+ })
397
+ .run();
398
+ }
399
+ function truncate(s, n) {
400
+ const oneLine = s.replace(/\s+/g, ' ').trim();
401
+ return oneLine.length > n ? `${oneLine.slice(0, n - 1)}…` : oneLine;
402
+ }
403
+ function summariseNew(n) {
404
+ const parts = [];
405
+ if (n.comments > 0)
406
+ parts.push(`${n.comments} new comment${n.comments === 1 ? '' : 's'}`);
407
+ if (n.reviews > 0)
408
+ parts.push(`${n.reviews} new review${n.reviews === 1 ? '' : 's'}`);
409
+ if (n.commits > 0)
410
+ parts.push(`${n.commits} new commit${n.commits === 1 ? '' : 's'}`);
411
+ return parts.join(' · ');
412
+ }
413
+ export function getMyTurn() {
414
+ const localUserId = getLocalUserId();
415
+ const empty = {
416
+ awaitingReview: [],
417
+ yourPrs: [],
418
+ threadsAwaiting: [],
419
+ users: [],
420
+ };
421
+ if (localUserId == null)
422
+ return empty;
423
+ const referencedUsers = new Set();
424
+ // Open PRs, enriched with triage, are the basis for sections 1 & 2.
425
+ const openRows = db
426
+ .select()
427
+ .from(pullRequests)
428
+ .where(eq(pullRequests.state, 'open'))
429
+ .all();
430
+ const open = buildTimelinePrs(openRows);
431
+ const repoNameById = new Map();
432
+ for (const r of listRepos())
433
+ repoNameById.set(r.id, r.fullName);
434
+ // Manual dismissals, honoured only until newer activity supersedes them.
435
+ const dismissals = db.select().from(myTurnDismissals).all();
436
+ const reviewDismissedAt = new Map();
437
+ const threadDismissedAt = new Map();
438
+ for (const d of dismissals) {
439
+ if (d.kind === 'review_request')
440
+ reviewDismissedAt.set(d.refId, d.dismissedAt);
441
+ else if (d.kind === 'thread')
442
+ threadDismissedAt.set(d.refId, d.dismissedAt);
443
+ }
444
+ const meta = (prId) => openRows.find((p) => p.id === prId);
445
+ const toMyTurnPr = (t) => {
446
+ const m = meta(t.id);
447
+ const repoFullName = repoNameById.get(t.repoId) ?? `repo ${t.repoId}`;
448
+ const [owner, name] = repoFullName.split('/');
449
+ if (t.authorId != null)
450
+ referencedUsers.add(t.authorId);
451
+ return {
452
+ prId: t.id,
453
+ repoFullName,
454
+ number: t.number,
455
+ title: t.title,
456
+ authorId: t.authorId,
457
+ state: t.state,
458
+ openedAt: t.openedAt,
459
+ githubUrl: `https://github.com/${owner}/${name}/pull/${m.number}`,
460
+ };
461
+ };
462
+ // 1. Awaiting your review. A dismissal sticks until the PR is updated again
463
+ // (e.g. new commits → re-review warranted).
464
+ const awaitingReview = open
465
+ .filter((t) => t.reviewRequestedFromMe)
466
+ .filter((t) => {
467
+ const d = reviewDismissedAt.get(t.id);
468
+ return !d || meta(t.id).updatedAt.getTime() > d.getTime();
469
+ })
470
+ .map((t) => {
471
+ // otherReviewersRequested is recomputed via triage map; re-derive count.
472
+ const others = countOtherReviewers(t.id, localUserId);
473
+ return { ...toMyTurnPr(t), alsoRequested: others };
474
+ });
475
+ // 2. Your PRs with new activity since you last looked.
476
+ const yourPrs = open
477
+ .filter((t) => t.authorId === localUserId &&
478
+ t.newSinceLastViewed != null &&
479
+ (t.newSinceLastViewed.comments > 0 ||
480
+ t.newSinceLastViewed.reviews > 0 ||
481
+ t.newSinceLastViewed.commits > 0))
482
+ .map((t) => ({
483
+ ...toMyTurnPr(t),
484
+ newSinceLastViewed: t.newSinceLastViewed,
485
+ summary: summariseNew(t.newSinceLastViewed),
486
+ }));
487
+ // 3. Threads awaiting your response: you opened the thread, someone replied
488
+ // after you, and it isn't resolved. A dismissal sticks until a newer reply.
489
+ const threadsAwaiting = getThreadsAwaiting(localUserId, repoNameById).filter((ta) => {
490
+ const d = threadDismissedAt.get(ta.threadId);
491
+ return !d || Date.parse(ta.lastReplyAt) > d.getTime();
492
+ });
493
+ for (const ta of threadsAwaiting) {
494
+ if (ta.lastReplyAuthorId != null)
495
+ referencedUsers.add(ta.lastReplyAuthorId);
496
+ }
497
+ const users = referencedUsers.size > 0
498
+ ? db
499
+ .select()
500
+ .from(schema.users)
501
+ .where(inArray(schema.users.id, [...referencedUsers]))
502
+ .all()
503
+ .map(mapUser)
504
+ : [];
505
+ return { awaitingReview, yourPrs, threadsAwaiting, users };
506
+ }
507
+ function countOtherReviewers(prId, localUserId) {
508
+ return db
509
+ .select({ userId: schema.reviewRequests.userId })
510
+ .from(schema.reviewRequests)
511
+ .where(eq(schema.reviewRequests.prId, prId))
512
+ .all()
513
+ .filter((r) => r.userId != null && r.userId !== localUserId).length;
514
+ }
515
+ function getThreadsAwaiting(localUserId, repoNameById) {
516
+ const threads = db
517
+ .select({
518
+ id: reviewThreads.id,
519
+ prId: reviewThreads.prId,
520
+ path: reviewThreads.path,
521
+ line: reviewThreads.line,
522
+ derivedState: reviewThreads.derivedState,
523
+ })
524
+ .from(reviewThreads)
525
+ .where(and(eq(reviewThreads.originalCommenterId, localUserId), sql `${reviewThreads.derivedState} != 'resolved'`))
526
+ .all();
527
+ if (threads.length === 0)
528
+ return [];
529
+ const prIds = [...new Set(threads.map((t) => t.prId))];
530
+ const prRows = db
531
+ .select({ id: pullRequests.id, repoId: pullRequests.repoId, number: pullRequests.number })
532
+ .from(pullRequests)
533
+ .where(inArray(pullRequests.id, prIds))
534
+ .all();
535
+ const prById = new Map(prRows.map((p) => [p.id, p]));
536
+ const out = [];
537
+ for (const t of threads) {
538
+ const comments = db
539
+ .select()
540
+ .from(reviewComments)
541
+ .where(eq(reviewComments.threadId, t.id))
542
+ .orderBy(asc(reviewComments.createdAt))
543
+ .all();
544
+ const last = comments.at(-1);
545
+ if (!last)
546
+ continue;
547
+ // Someone other than you must have had the last word.
548
+ if (last.authorId === localUserId)
549
+ continue;
550
+ const pr = prById.get(t.prId);
551
+ if (!pr)
552
+ continue;
553
+ const repoFullName = repoNameById.get(pr.repoId) ?? `repo ${pr.repoId}`;
554
+ const [owner, name] = repoFullName.split('/');
555
+ out.push({
556
+ threadId: t.id,
557
+ prId: t.prId,
558
+ repoFullName,
559
+ prNumber: pr.number,
560
+ path: t.path,
561
+ line: t.line,
562
+ derivedState: t.derivedState,
563
+ lastReplyExcerpt: truncate(last.body, 140),
564
+ lastReplyAt: last.createdAt.toISOString(),
565
+ lastReplyAuthorId: last.authorId,
566
+ githubUrl: `https://github.com/${owner}/${name}/pull/${pr.number}`,
567
+ });
568
+ }
569
+ out.sort((a, b) => b.lastReplyAt.localeCompare(a.lastReplyAt));
570
+ return out;
571
+ }
572
+ export function getPrDetail(id) {
573
+ const row = db
574
+ .select()
575
+ .from(pullRequests)
576
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
577
+ .where(eq(pullRequests.id, id))
578
+ .get();
579
+ if (!row)
580
+ return null;
581
+ const pr = row.pull_requests;
582
+ const repo = row.repos;
583
+ // Base for activity deep links; per-item anchors are appended below.
584
+ const prUrl = `https://github.com/${repo.owner}/${repo.name}/pull/${pr.number}`;
585
+ const threadRows = db
586
+ .select()
587
+ .from(reviewThreads)
588
+ .where(eq(reviewThreads.prId, id))
589
+ .all();
590
+ const commentRows = db
591
+ .select()
592
+ .from(reviewComments)
593
+ .where(eq(reviewComments.prId, id))
594
+ .orderBy(asc(reviewComments.createdAt))
595
+ .all();
596
+ const reviewRows = db
597
+ .select()
598
+ .from(reviews)
599
+ .where(eq(reviews.prId, id))
600
+ .orderBy(asc(reviews.submittedAt))
601
+ .all();
602
+ const prCommentRows = db
603
+ .select()
604
+ .from(prComments)
605
+ .where(eq(prComments.prId, id))
606
+ .orderBy(asc(prComments.createdAt))
607
+ .all();
608
+ const commitRows = db
609
+ .select()
610
+ .from(commits)
611
+ .where(eq(commits.prId, id))
612
+ .orderBy(asc(commits.committedAt))
613
+ .all();
614
+ const commentsByThread = new Map();
615
+ for (const c of commentRows) {
616
+ const arr = commentsByThread.get(c.threadId) ?? [];
617
+ arr.push(c);
618
+ commentsByThread.set(c.threadId, arr);
619
+ }
620
+ const threads = threadRows.map((t) => {
621
+ const tComments = commentsByThread.get(t.id) ?? [];
622
+ return {
623
+ id: t.id,
624
+ prId: t.prId,
625
+ path: t.path,
626
+ line: t.line,
627
+ isResolved: t.isResolved,
628
+ isOutdated: t.isOutdated,
629
+ derivedState: t.derivedState,
630
+ originalCommenterId: t.originalCommenterId,
631
+ createdAt: t.createdAt.toISOString(),
632
+ comments: tComments.map((c) => ({
633
+ id: c.id,
634
+ authorId: c.authorId,
635
+ body: c.body,
636
+ diffHunk: c.diffHunk,
637
+ createdAt: c.createdAt.toISOString(),
638
+ url: c.databaseId ? `${prUrl}#discussion_r${c.databaseId}` : null,
639
+ })),
640
+ // Thread anchor = its first comment's #discussion_r.
641
+ url: tComments[0]?.databaseId
642
+ ? `${prUrl}#discussion_r${tComments[0].databaseId}`
643
+ : null,
644
+ };
645
+ });
646
+ const reviewsOut = reviewRows.map((r) => ({
647
+ id: r.id,
648
+ authorId: r.authorId,
649
+ state: r.state,
650
+ body: r.body,
651
+ submittedAt: r.submittedAt.toISOString(),
652
+ url: r.databaseId ? `${prUrl}#pullrequestreview-${r.databaseId}` : null,
653
+ }));
654
+ const commentsOut = prCommentRows.map((c) => ({
655
+ id: c.id,
656
+ authorId: c.authorId,
657
+ body: c.body,
658
+ createdAt: c.createdAt.toISOString(),
659
+ url: c.databaseId ? `${prUrl}#issuecomment-${c.databaseId}` : null,
660
+ }));
661
+ const commitsOut = commitRows.map((c) => ({
662
+ id: c.id,
663
+ sha: c.sha,
664
+ authorId: c.authorId,
665
+ committerId: c.committerId,
666
+ message: c.message,
667
+ committedAt: c.committedAt.toISOString(),
668
+ }));
669
+ // Outstanding review requests (for the Checks/Overview tab).
670
+ const reviewerRows = db
671
+ .select()
672
+ .from(schema.reviewRequests)
673
+ .where(eq(schema.reviewRequests.prId, id))
674
+ .all();
675
+ const requestedReviewers = reviewerRows.map((r) => ({
676
+ userId: r.userId,
677
+ teamName: r.teamName,
678
+ }));
679
+ // Gather referenced users for client-side lookup.
680
+ const userIds = new Set();
681
+ if (pr.authorId)
682
+ userIds.add(pr.authorId);
683
+ for (const t of threads)
684
+ if (t.originalCommenterId)
685
+ userIds.add(t.originalCommenterId);
686
+ for (const c of commentRows)
687
+ if (c.authorId)
688
+ userIds.add(c.authorId);
689
+ for (const r of reviewRows)
690
+ if (r.authorId)
691
+ userIds.add(r.authorId);
692
+ for (const c of prCommentRows)
693
+ if (c.authorId)
694
+ userIds.add(c.authorId);
695
+ for (const c of commitRows) {
696
+ if (c.authorId)
697
+ userIds.add(c.authorId);
698
+ if (c.committerId)
699
+ userIds.add(c.committerId);
700
+ }
701
+ for (const r of reviewerRows)
702
+ if (r.userId)
703
+ userIds.add(r.userId);
704
+ // A maintainer who only merged the PR (never authored/reviewed/commented) is
705
+ // otherwise absent from userList, leaving "Merged by" unresolved.
706
+ if (pr.mergedById)
707
+ userIds.add(pr.mergedById);
708
+ const userList = userIds.size > 0
709
+ ? db.select().from(users).where(inArray(users.id, [...userIds])).all().map(mapUser)
710
+ : [];
711
+ const counts = buildThreadCounts([id]).get(id) ?? emptyCounts();
712
+ // Incremental review: capture the last-viewed instant and what's happened
713
+ // since. No "new" once a PR is closed/merged.
714
+ const view = db.select().from(prViews).where(eq(prViews.prId, id)).get();
715
+ const lastViewedAt = view?.lastViewedAt ?? null;
716
+ let newSinceLastViewed = null;
717
+ if (pr.state === 'open' && lastViewedAt) {
718
+ const since = db
719
+ .select({ type: events.type, occurredAt: events.occurredAt })
720
+ .from(events)
721
+ .where(and(eq(events.prId, id), gt(events.occurredAt, lastViewedAt)))
722
+ .all();
723
+ const n = { commits: 0, comments: 0, reviews: 0 };
724
+ for (const e of since) {
725
+ if (e.type === 'commit_pushed')
726
+ n.commits += 1;
727
+ else if (e.type === 'pr_comment' || e.type === 'review_comment')
728
+ n.comments += 1;
729
+ else if (e.type === 'review_submitted')
730
+ n.reviews += 1;
731
+ }
732
+ newSinceLastViewed = n;
733
+ }
734
+ return {
735
+ id: pr.id,
736
+ repoId: pr.repoId,
737
+ repoFullName: `${repo.owner}/${repo.name}`,
738
+ number: pr.number,
739
+ title: pr.title,
740
+ body: pr.body,
741
+ authorId: pr.authorId,
742
+ state: pr.state,
743
+ isDraft: pr.isDraft,
744
+ isStalled: isStalled(pr, counts),
745
+ openedAt: pr.openedAt.toISOString(),
746
+ firstReviewAt: iso(pr.firstReviewAt),
747
+ lastCommitAt: iso(pr.lastCommitAt),
748
+ mergedAt: iso(pr.mergedAt),
749
+ mergedById: pr.mergedById,
750
+ closedAt: iso(pr.closedAt),
751
+ updatedAt: pr.updatedAt.toISOString(),
752
+ githubUrl: prUrl,
753
+ ciStatus: (pr.ciStatus ?? 'unknown'),
754
+ mergeable: (pr.mergeable ?? 'unknown'),
755
+ mergeStateStatus: (pr.mergeStateStatus ?? 'unknown'),
756
+ labels: (pr.labels ?? []),
757
+ checkRuns: (pr.checkRuns ?? []),
758
+ requestedReviewers,
759
+ threads,
760
+ reviews: reviewsOut,
761
+ comments: commentsOut,
762
+ commits: commitsOut,
763
+ users: userList,
764
+ lastViewedAt: iso(lastViewedAt),
765
+ newSinceLastViewed,
766
+ };
767
+ }
768
+ export function getThreadDetail(id) {
769
+ const row = db
770
+ .select()
771
+ .from(reviewThreads)
772
+ .innerJoin(pullRequests, eq(pullRequests.id, reviewThreads.prId))
773
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
774
+ .where(eq(reviewThreads.id, id))
775
+ .get();
776
+ if (!row)
777
+ return null;
778
+ const t = row.review_threads;
779
+ const prUrl = `https://github.com/${row.repos.owner}/${row.repos.name}/pull/${row.pull_requests.number}`;
780
+ const comments = db
781
+ .select()
782
+ .from(reviewComments)
783
+ .where(eq(reviewComments.threadId, id))
784
+ .orderBy(asc(reviewComments.createdAt))
785
+ .all();
786
+ return {
787
+ id: t.id,
788
+ prId: t.prId,
789
+ path: t.path,
790
+ line: t.line,
791
+ isResolved: t.isResolved,
792
+ isOutdated: t.isOutdated,
793
+ derivedState: t.derivedState,
794
+ originalCommenterId: t.originalCommenterId,
795
+ createdAt: t.createdAt.toISOString(),
796
+ comments: comments.map((c) => ({
797
+ id: c.id,
798
+ authorId: c.authorId,
799
+ body: c.body,
800
+ diffHunk: c.diffHunk,
801
+ createdAt: c.createdAt.toISOString(),
802
+ url: c.databaseId ? `${prUrl}#discussion_r${c.databaseId}` : null,
803
+ })),
804
+ url: comments[0]?.databaseId
805
+ ? `${prUrl}#discussion_r${comments[0].databaseId}`
806
+ : null,
807
+ };
808
+ }
809
+ export function deleteRepo(id) {
810
+ const repo = db.select().from(repos).where(eq(repos.id, id)).get();
811
+ if (!repo)
812
+ return false;
813
+ // Remove dependents in FK-safe order.
814
+ const prIds = db
815
+ .select({ id: pullRequests.id })
816
+ .from(pullRequests)
817
+ .where(eq(pullRequests.repoId, id))
818
+ .all()
819
+ .map((r) => r.id);
820
+ db.transaction(() => {
821
+ db.delete(events).where(eq(events.repoId, id)).run();
822
+ if (prIds.length > 0) {
823
+ db.delete(reviewComments).where(inArray(reviewComments.prId, prIds)).run();
824
+ db.delete(reviewThreads).where(inArray(reviewThreads.prId, prIds)).run();
825
+ db.delete(prComments).where(inArray(prComments.prId, prIds)).run();
826
+ db.delete(reviews).where(inArray(reviews.prId, prIds)).run();
827
+ db.delete(commits).where(inArray(commits.prId, prIds)).run();
828
+ db.delete(schema.reviewRequests).where(inArray(schema.reviewRequests.prId, prIds)).run();
829
+ db.delete(prViews).where(inArray(prViews.prId, prIds)).run();
830
+ db.delete(pullRequests).where(eq(pullRequests.repoId, id)).run();
831
+ }
832
+ db.delete(syncState).where(eq(syncState.repoId, id)).run();
833
+ db.delete(repos).where(eq(repos.id, id)).run();
834
+ });
835
+ return true;
836
+ }
837
+ //# sourceMappingURL=queries.js.map