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.
- package/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/api/plugins/error-handler.js +40 -0
- package/dist/api/routes/health.js +7 -0
- package/dist/api/routes/me.js +34 -0
- package/dist/api/routes/mergers.js +7 -0
- package/dist/api/routes/open-prs.js +21 -0
- package/dist/api/routes/prs.js +50 -0
- package/dist/api/routes/repos.js +188 -0
- package/dist/api/routes/threads.js +20 -0
- package/dist/api/routes/timeline.js +73 -0
- package/dist/api/routes/users.js +28 -0
- package/dist/app.js +48 -0
- package/dist/ascii.js +12 -0
- package/dist/cli.js +138 -0
- package/dist/config.js +41 -0
- package/dist/db/cleanup.js +22 -0
- package/dist/db/client.js +13 -0
- package/dist/db/migrate.js +15 -0
- package/dist/db/migrations/0000_purple_ben_grimm.sql +155 -0
- package/dist/db/migrations/0001_colorful_ozymandias.sql +31 -0
- package/dist/db/migrations/0002_famous_sersi.sql +9 -0
- package/dist/db/migrations/0003_clever_shinobi_shaw.sql +3 -0
- package/dist/db/migrations/0004_pale_scalphunter.sql +1 -0
- package/dist/db/migrations/0005_daffy_guardian.sql +2 -0
- package/dist/db/migrations/meta/0000_snapshot.json +1116 -0
- package/dist/db/migrations/meta/0001_snapshot.json +1321 -0
- package/dist/db/migrations/meta/0002_snapshot.json +1375 -0
- package/dist/db/migrations/meta/0003_snapshot.json +1396 -0
- package/dist/db/migrations/meta/0004_snapshot.json +1416 -0
- package/dist/db/migrations/meta/0005_snapshot.json +1430 -0
- package/dist/db/migrations/meta/_journal.json +48 -0
- package/dist/db/queries.js +837 -0
- package/dist/db/run-migrations.js +10 -0
- package/dist/db/schema.js +248 -0
- package/dist/db/triage.js +168 -0
- package/dist/github/auth.js +19 -0
- package/dist/github/client.js +30 -0
- package/dist/github/local-user.js +92 -0
- package/dist/github/queries.js +249 -0
- package/dist/index.js +49 -0
- package/dist/sync/bot-detection.js +24 -0
- package/dist/sync/commit-files.js +50 -0
- package/dist/sync/derive-thread-state.js +38 -0
- package/dist/sync/scheduler.js +28 -0
- package/dist/sync/sync-manager.js +150 -0
- package/dist/sync/sync-repo.js +122 -0
- package/dist/sync/upsert.js +528 -0
- package/package.json +46 -0
- package/public/assets/index-6p3C9xk7.css +10 -0
- package/public/assets/index-C-CZcLLq.js +1360 -0
- 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
|