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,122 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
|
2
|
+
import { db, schema } from '../db/client.js';
|
|
3
|
+
import { getGraphqlClient } from '../github/client.js';
|
|
4
|
+
import { REPO_ACTIVITY_QUERY } from '../github/queries.js';
|
|
5
|
+
import { ensureCommitFiles } from './commit-files.js';
|
|
6
|
+
import { createUserResolver, persistPr, upsertRepo } from './upsert.js';
|
|
7
|
+
const { syncState } = schema;
|
|
8
|
+
const consoleLogger = {
|
|
9
|
+
info: (m, ...a) => console.log(m, ...a),
|
|
10
|
+
warn: (m, ...a) => console.warn(m, ...a),
|
|
11
|
+
error: (m, ...a) => console.error(m, ...a),
|
|
12
|
+
};
|
|
13
|
+
function clamp01(n) {
|
|
14
|
+
return n < 0 ? 0 : n > 1 ? 1 : n;
|
|
15
|
+
}
|
|
16
|
+
export async function syncRepo(opts) {
|
|
17
|
+
const { owner, name, mode, since, onProgress } = opts;
|
|
18
|
+
const log = opts.log ?? consoleLogger;
|
|
19
|
+
const client = getGraphqlClient();
|
|
20
|
+
const resolver = createUserResolver();
|
|
21
|
+
let cursor = null;
|
|
22
|
+
let repoId = null;
|
|
23
|
+
let prCount = 0;
|
|
24
|
+
let pages = 0;
|
|
25
|
+
let totalCost = 0;
|
|
26
|
+
let lastRemaining = 0;
|
|
27
|
+
// Time-walked progress: PRs arrive newest-first and we stop at `since`, so the
|
|
28
|
+
// span [since .. newest] is the work and the current PR's updatedAt marks how
|
|
29
|
+
// far through it we are.
|
|
30
|
+
let newestMs = null;
|
|
31
|
+
const sinceMs = since?.getTime() ?? null;
|
|
32
|
+
const reportProgress = (currentMs) => {
|
|
33
|
+
if (!onProgress)
|
|
34
|
+
return;
|
|
35
|
+
let percent = 0;
|
|
36
|
+
if (newestMs != null && sinceMs != null && newestMs > sinceMs && currentMs != null) {
|
|
37
|
+
percent = clamp01((newestMs - currentMs) / (newestMs - sinceMs));
|
|
38
|
+
}
|
|
39
|
+
onProgress({ percent, prsProcessed: prCount, pages });
|
|
40
|
+
};
|
|
41
|
+
try {
|
|
42
|
+
let stop = false;
|
|
43
|
+
do {
|
|
44
|
+
const resp = await client(REPO_ACTIVITY_QUERY, {
|
|
45
|
+
owner,
|
|
46
|
+
name,
|
|
47
|
+
cursor,
|
|
48
|
+
});
|
|
49
|
+
pages += 1;
|
|
50
|
+
totalCost += resp.rateLimit.cost;
|
|
51
|
+
lastRemaining = resp.rateLimit.remaining;
|
|
52
|
+
if (!resp.repository) {
|
|
53
|
+
const err = new Error(`Repository ${owner}/${name} not found or inaccessible`);
|
|
54
|
+
err.statusCode = 404;
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
repoId ??= upsertRepo(owner, name, resp.repository.id, resp.repository.defaultBranchRef?.name ?? null);
|
|
58
|
+
const { nodes, pageInfo } = resp.repository.pullRequests;
|
|
59
|
+
for (const pr of nodes) {
|
|
60
|
+
const updatedMs = new Date(pr.updatedAt).getTime();
|
|
61
|
+
if (since && updatedMs < since.getTime()) {
|
|
62
|
+
stop = true;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
newestMs ??= updatedMs;
|
|
66
|
+
// Only fetch changed-files for commits that could plausibly have
|
|
67
|
+
// addressed an open thread (after its last comment).
|
|
68
|
+
const unresolved = pr.reviewThreads.nodes.filter((t) => !t.isResolved && t.comments.nodes.length > 0);
|
|
69
|
+
let shas = [];
|
|
70
|
+
if (unresolved.length > 0) {
|
|
71
|
+
const threshold = Math.min(...unresolved.map((t) => Date.parse(t.comments.nodes.at(-1).createdAt)));
|
|
72
|
+
shas = pr.commits.nodes
|
|
73
|
+
.filter((c) => Date.parse(c.commit.committedDate) > threshold)
|
|
74
|
+
.map((c) => c.commit.oid);
|
|
75
|
+
}
|
|
76
|
+
const commitFilesBySha = await ensureCommitFiles(owner, name, shas);
|
|
77
|
+
persistPr(pr, repoId, resolver, commitFilesBySha);
|
|
78
|
+
prCount += 1;
|
|
79
|
+
reportProgress(updatedMs);
|
|
80
|
+
}
|
|
81
|
+
cursor = pageInfo.endCursor;
|
|
82
|
+
if (stop || !pageInfo.hasNextPage)
|
|
83
|
+
break;
|
|
84
|
+
} while (cursor);
|
|
85
|
+
// Reached the cutoff / last page — the walk is complete.
|
|
86
|
+
reportProgress(sinceMs);
|
|
87
|
+
if (repoId === null) {
|
|
88
|
+
throw new Error(`Repository ${owner}/${name} returned no data`);
|
|
89
|
+
}
|
|
90
|
+
const now = new Date();
|
|
91
|
+
const statePatch = mode === 'full'
|
|
92
|
+
? { lastFullSyncAt: now, lastIncrementalSyncAt: now }
|
|
93
|
+
: { lastIncrementalSyncAt: now };
|
|
94
|
+
db.insert(syncState)
|
|
95
|
+
.values({ repoId, ...statePatch, lastSyncStatus: 'ok', lastSyncError: null })
|
|
96
|
+
.onConflictDoUpdate({
|
|
97
|
+
target: syncState.repoId,
|
|
98
|
+
set: { ...statePatch, lastSyncStatus: 'ok', lastSyncError: null },
|
|
99
|
+
})
|
|
100
|
+
.run();
|
|
101
|
+
log.info(`sync ${owner}/${name} [${mode}] done: ${prCount} PRs over ${pages} page(s), cost ${totalCost}, ${lastRemaining} remaining`);
|
|
102
|
+
return {
|
|
103
|
+
repoId,
|
|
104
|
+
prCount,
|
|
105
|
+
pages,
|
|
106
|
+
rateLimitRemaining: lastRemaining,
|
|
107
|
+
rateLimitCost: totalCost,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
112
|
+
if (repoId !== null) {
|
|
113
|
+
db.update(syncState)
|
|
114
|
+
.set({ lastSyncStatus: 'error', lastSyncError: message })
|
|
115
|
+
.where(eq(syncState.repoId, repoId))
|
|
116
|
+
.run();
|
|
117
|
+
}
|
|
118
|
+
log.error(`sync ${owner}/${name} failed: ${message}`);
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=sync-repo.js.map
|
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
import { eq, sql } from 'drizzle-orm';
|
|
2
|
+
import { db, schema } from '../db/client.js';
|
|
3
|
+
import { isLikelyBot } from './bot-detection.js';
|
|
4
|
+
import { deriveThreadState, } from './derive-thread-state.js';
|
|
5
|
+
const { repos, users, pullRequests, reviews, reviewThreads, reviewComments, prComments, commits, events, reviewRequests, } = schema;
|
|
6
|
+
function toDate(iso) {
|
|
7
|
+
return iso ? new Date(iso) : null;
|
|
8
|
+
}
|
|
9
|
+
function maxDate(dates) {
|
|
10
|
+
let max = null;
|
|
11
|
+
for (const d of dates) {
|
|
12
|
+
if (d && (!max || d > max))
|
|
13
|
+
max = d;
|
|
14
|
+
}
|
|
15
|
+
return max;
|
|
16
|
+
}
|
|
17
|
+
function minDate(dates) {
|
|
18
|
+
let min = null;
|
|
19
|
+
for (const d of dates) {
|
|
20
|
+
if (d && (!min || d < min))
|
|
21
|
+
min = d;
|
|
22
|
+
}
|
|
23
|
+
return min;
|
|
24
|
+
}
|
|
25
|
+
/** Resolves GraphQL actors to local user ids, caching by login within a run. */
|
|
26
|
+
export function createUserResolver() {
|
|
27
|
+
const cache = new Map();
|
|
28
|
+
return {
|
|
29
|
+
resolve(actor) {
|
|
30
|
+
const login = actor?.login;
|
|
31
|
+
if (!login)
|
|
32
|
+
return null;
|
|
33
|
+
const cached = cache.get(login);
|
|
34
|
+
if (cached !== undefined)
|
|
35
|
+
return cached;
|
|
36
|
+
const row = db
|
|
37
|
+
.insert(users)
|
|
38
|
+
.values({
|
|
39
|
+
githubLogin: login,
|
|
40
|
+
githubNodeId: actor?.id ?? null,
|
|
41
|
+
displayName: actor?.name ?? null,
|
|
42
|
+
avatarUrl: actor?.avatarUrl ?? null,
|
|
43
|
+
isBot: isLikelyBot(login),
|
|
44
|
+
})
|
|
45
|
+
.onConflictDoUpdate({
|
|
46
|
+
target: users.githubLogin,
|
|
47
|
+
set: {
|
|
48
|
+
githubNodeId: sql `coalesce(excluded.github_node_id, ${users.githubNodeId})`,
|
|
49
|
+
displayName: sql `coalesce(excluded.display_name, ${users.displayName})`,
|
|
50
|
+
avatarUrl: sql `coalesce(excluded.avatar_url, ${users.avatarUrl})`,
|
|
51
|
+
// Never clobber a manual is_bot override.
|
|
52
|
+
isBot: sql `case when ${users.isBotOverridden} = 1 then ${users.isBot} else excluded.is_bot end`,
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
.returning({ id: users.id })
|
|
56
|
+
.get();
|
|
57
|
+
cache.set(login, row.id);
|
|
58
|
+
return row.id;
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Upsert a repo by its GitHub node id; returns the local repo id. */
|
|
63
|
+
export function upsertRepo(owner, name, githubNodeId, defaultBranch) {
|
|
64
|
+
// Only overwrite default_branch when we actually know it (the add-repo path
|
|
65
|
+
// calls without it via the lightweight REPO_ID_QUERY) so we never null out a
|
|
66
|
+
// value a prior sync populated.
|
|
67
|
+
const set = { owner, name };
|
|
68
|
+
if (defaultBranch != null)
|
|
69
|
+
set.defaultBranch = defaultBranch;
|
|
70
|
+
const row = db
|
|
71
|
+
.insert(repos)
|
|
72
|
+
.values({ owner, name, githubNodeId, defaultBranch: defaultBranch ?? null })
|
|
73
|
+
.onConflictDoUpdate({
|
|
74
|
+
target: repos.githubNodeId,
|
|
75
|
+
set,
|
|
76
|
+
})
|
|
77
|
+
.returning({ id: repos.id })
|
|
78
|
+
.get();
|
|
79
|
+
return row.id;
|
|
80
|
+
}
|
|
81
|
+
function prState(s) {
|
|
82
|
+
if (s === 'MERGED')
|
|
83
|
+
return 'merged';
|
|
84
|
+
if (s === 'CLOSED')
|
|
85
|
+
return 'closed';
|
|
86
|
+
return 'open';
|
|
87
|
+
}
|
|
88
|
+
function ciStatusFrom(state) {
|
|
89
|
+
switch ((state ?? '').toUpperCase()) {
|
|
90
|
+
case 'SUCCESS':
|
|
91
|
+
return 'success';
|
|
92
|
+
case 'FAILURE':
|
|
93
|
+
return 'failure';
|
|
94
|
+
case 'PENDING':
|
|
95
|
+
return 'pending';
|
|
96
|
+
case 'ERROR':
|
|
97
|
+
return 'error';
|
|
98
|
+
case 'EXPECTED':
|
|
99
|
+
return 'expected';
|
|
100
|
+
default:
|
|
101
|
+
return 'unknown';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function mergeableFrom(state) {
|
|
105
|
+
switch ((state ?? '').toUpperCase()) {
|
|
106
|
+
case 'MERGEABLE':
|
|
107
|
+
return 'mergeable';
|
|
108
|
+
case 'CONFLICTING':
|
|
109
|
+
return 'conflicting';
|
|
110
|
+
default:
|
|
111
|
+
return 'unknown';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const MERGE_STATE_STATUSES = new Set([
|
|
115
|
+
'clean',
|
|
116
|
+
'dirty',
|
|
117
|
+
'unstable',
|
|
118
|
+
'blocked',
|
|
119
|
+
'behind',
|
|
120
|
+
'has_hooks',
|
|
121
|
+
'unknown',
|
|
122
|
+
]);
|
|
123
|
+
function mergeStateStatusFrom(state) {
|
|
124
|
+
const lower = (state ?? '').toLowerCase();
|
|
125
|
+
return (MERGE_STATE_STATUSES.has(lower) ? lower : 'unknown');
|
|
126
|
+
}
|
|
127
|
+
function checkContextState(c) {
|
|
128
|
+
if (c.__typename === 'StatusContext') {
|
|
129
|
+
switch ((c.state ?? '').toUpperCase()) {
|
|
130
|
+
case 'SUCCESS':
|
|
131
|
+
return 'success';
|
|
132
|
+
case 'FAILURE':
|
|
133
|
+
return 'failure';
|
|
134
|
+
case 'ERROR':
|
|
135
|
+
return 'error';
|
|
136
|
+
case 'PENDING':
|
|
137
|
+
case 'EXPECTED':
|
|
138
|
+
return 'pending';
|
|
139
|
+
default:
|
|
140
|
+
return 'unknown';
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// CheckRun: outcome is meaningful only once completed.
|
|
144
|
+
if ((c.status ?? '').toUpperCase() !== 'COMPLETED')
|
|
145
|
+
return 'pending';
|
|
146
|
+
switch ((c.conclusion ?? '').toUpperCase()) {
|
|
147
|
+
case 'SUCCESS':
|
|
148
|
+
return 'success';
|
|
149
|
+
case 'FAILURE':
|
|
150
|
+
case 'TIMED_OUT':
|
|
151
|
+
case 'STARTUP_FAILURE':
|
|
152
|
+
case 'ACTION_REQUIRED':
|
|
153
|
+
return 'failure';
|
|
154
|
+
case 'CANCELLED':
|
|
155
|
+
case 'STALE':
|
|
156
|
+
case 'NEUTRAL':
|
|
157
|
+
return 'neutral';
|
|
158
|
+
case 'SKIPPED':
|
|
159
|
+
return 'skipped';
|
|
160
|
+
default:
|
|
161
|
+
return 'unknown';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function checkRunsFrom(head) {
|
|
165
|
+
const nodes = head?.statusCheckRollup?.contexts?.nodes ?? [];
|
|
166
|
+
return nodes.map((c) => ({
|
|
167
|
+
name: c.__typename === 'CheckRun' ? c.name : c.context,
|
|
168
|
+
state: checkContextState(c),
|
|
169
|
+
url: c.__typename === 'CheckRun' ? c.detailsUrl : c.targetUrl,
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
const REVIEW_STATES = new Set([
|
|
173
|
+
'approved',
|
|
174
|
+
'changes_requested',
|
|
175
|
+
'commented',
|
|
176
|
+
'dismissed',
|
|
177
|
+
'pending',
|
|
178
|
+
]);
|
|
179
|
+
function reviewState(s) {
|
|
180
|
+
const lower = s.toLowerCase();
|
|
181
|
+
return (REVIEW_STATES.has(lower) ? lower : 'commented');
|
|
182
|
+
}
|
|
183
|
+
// A review warrants its own timeline marker only when it's substantive: a
|
|
184
|
+
// decision (approved/changes_requested/dismissed) or one carrying a summary
|
|
185
|
+
// body. An empty "commented" review is just GitHub's wrapper around inline
|
|
186
|
+
// comments — those already show as review_comment markers, so the wrapper would
|
|
187
|
+
// duplicate them. See db/cleanup.ts for backfilling existing rows.
|
|
188
|
+
export function isSubstantiveReview(state, body) {
|
|
189
|
+
return state !== 'commented' || !!body?.trim();
|
|
190
|
+
}
|
|
191
|
+
function upsertEvent(row) {
|
|
192
|
+
db.insert(events)
|
|
193
|
+
.values(row)
|
|
194
|
+
.onConflictDoUpdate({
|
|
195
|
+
target: events.dedupeKey,
|
|
196
|
+
set: {
|
|
197
|
+
actorId: row.actorId,
|
|
198
|
+
occurredAt: row.occurredAt,
|
|
199
|
+
refTable: row.refTable,
|
|
200
|
+
refId: row.refId,
|
|
201
|
+
},
|
|
202
|
+
})
|
|
203
|
+
.run();
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Persist a single PR and all its nested entities idempotently, derive thread
|
|
207
|
+
* states, and emit timeline events. `commitFilesBySha` must already be
|
|
208
|
+
* populated for any commits relevant to unresolved-thread derivation.
|
|
209
|
+
*/
|
|
210
|
+
export function persistPr(pr, repoId, resolver, commitFilesBySha) {
|
|
211
|
+
db.transaction(() => {
|
|
212
|
+
const authorId = resolver.resolve(pr.author);
|
|
213
|
+
// The actual merger (null for non-merged PRs / when GitHub omits the actor).
|
|
214
|
+
const mergedById = resolver.resolve(pr.mergedBy);
|
|
215
|
+
const openedAt = new Date(pr.createdAt);
|
|
216
|
+
const mergedAt = toDate(pr.mergedAt);
|
|
217
|
+
const closedAt = toDate(pr.closedAt);
|
|
218
|
+
const commitDates = pr.commits.nodes.map((c) => new Date(c.commit.committedDate));
|
|
219
|
+
const lastCommitAt = maxDate(commitDates);
|
|
220
|
+
const firstReviewAt = minDate(pr.reviews.nodes.map((r) => toDate(r.submittedAt)));
|
|
221
|
+
const head = pr.headCommit?.nodes[0]?.commit;
|
|
222
|
+
const headSha = head?.oid ?? null;
|
|
223
|
+
const ciStatus = ciStatusFrom(head?.statusCheckRollup?.state);
|
|
224
|
+
const mergeable = mergeableFrom(pr.mergeable);
|
|
225
|
+
const mergeStateStatus = mergeStateStatusFrom(pr.mergeStateStatus);
|
|
226
|
+
const labels = (pr.labels?.nodes ?? []).map((l) => ({
|
|
227
|
+
name: l.name,
|
|
228
|
+
color: l.color,
|
|
229
|
+
}));
|
|
230
|
+
const checkRuns = checkRunsFrom(head);
|
|
231
|
+
const prRow = db
|
|
232
|
+
.insert(pullRequests)
|
|
233
|
+
.values({
|
|
234
|
+
githubNodeId: pr.id,
|
|
235
|
+
repoId,
|
|
236
|
+
number: pr.number,
|
|
237
|
+
title: pr.title,
|
|
238
|
+
body: pr.body ?? null,
|
|
239
|
+
authorId,
|
|
240
|
+
mergedById,
|
|
241
|
+
baseRefName: pr.baseRefName ?? null,
|
|
242
|
+
state: prState(pr.state),
|
|
243
|
+
isDraft: pr.isDraft,
|
|
244
|
+
openedAt,
|
|
245
|
+
firstReviewAt,
|
|
246
|
+
lastCommitAt,
|
|
247
|
+
mergedAt,
|
|
248
|
+
closedAt,
|
|
249
|
+
updatedAt: new Date(pr.updatedAt),
|
|
250
|
+
headSha,
|
|
251
|
+
ciStatus,
|
|
252
|
+
mergeable,
|
|
253
|
+
mergeStateStatus,
|
|
254
|
+
labels,
|
|
255
|
+
checkRuns,
|
|
256
|
+
})
|
|
257
|
+
.onConflictDoUpdate({
|
|
258
|
+
target: pullRequests.githubNodeId,
|
|
259
|
+
set: {
|
|
260
|
+
title: pr.title,
|
|
261
|
+
body: pr.body ?? null,
|
|
262
|
+
authorId,
|
|
263
|
+
mergedById,
|
|
264
|
+
baseRefName: pr.baseRefName ?? null,
|
|
265
|
+
state: prState(pr.state),
|
|
266
|
+
isDraft: pr.isDraft,
|
|
267
|
+
firstReviewAt,
|
|
268
|
+
lastCommitAt,
|
|
269
|
+
mergedAt,
|
|
270
|
+
closedAt,
|
|
271
|
+
updatedAt: new Date(pr.updatedAt),
|
|
272
|
+
headSha,
|
|
273
|
+
ciStatus,
|
|
274
|
+
mergeable,
|
|
275
|
+
mergeStateStatus,
|
|
276
|
+
labels,
|
|
277
|
+
checkRuns,
|
|
278
|
+
},
|
|
279
|
+
})
|
|
280
|
+
.returning({ id: pullRequests.id })
|
|
281
|
+
.get();
|
|
282
|
+
const prId = prRow.id;
|
|
283
|
+
// ---- review requests (outstanding) — reconcile by delete + reinsert ----
|
|
284
|
+
db.delete(reviewRequests).where(eq(reviewRequests.prId, prId)).run();
|
|
285
|
+
for (const rr of pr.reviewRequests?.nodes ?? []) {
|
|
286
|
+
const reviewer = rr.requestedReviewer;
|
|
287
|
+
if (!reviewer)
|
|
288
|
+
continue;
|
|
289
|
+
if (reviewer.__typename === 'User') {
|
|
290
|
+
const userId = resolver.resolve({
|
|
291
|
+
login: reviewer.login,
|
|
292
|
+
id: reviewer.id,
|
|
293
|
+
});
|
|
294
|
+
db.insert(reviewRequests).values({ prId, userId, teamName: null }).run();
|
|
295
|
+
}
|
|
296
|
+
else if (reviewer.__typename === 'Team') {
|
|
297
|
+
db.insert(reviewRequests)
|
|
298
|
+
.values({ prId, userId: null, teamName: reviewer.name })
|
|
299
|
+
.run();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// ---- lifecycle events ----
|
|
303
|
+
upsertEvent({
|
|
304
|
+
repoId,
|
|
305
|
+
actorId: authorId,
|
|
306
|
+
prId,
|
|
307
|
+
type: 'pr_opened',
|
|
308
|
+
occurredAt: openedAt,
|
|
309
|
+
refTable: 'pull_requests',
|
|
310
|
+
refId: prId,
|
|
311
|
+
dedupeKey: `pr_opened:${pr.id}`,
|
|
312
|
+
});
|
|
313
|
+
if (pr.state === 'MERGED' && mergedAt) {
|
|
314
|
+
upsertEvent({
|
|
315
|
+
repoId,
|
|
316
|
+
actorId: authorId,
|
|
317
|
+
prId,
|
|
318
|
+
type: 'pr_merged',
|
|
319
|
+
occurredAt: mergedAt,
|
|
320
|
+
refTable: 'pull_requests',
|
|
321
|
+
refId: prId,
|
|
322
|
+
dedupeKey: `pr_merged:${pr.id}`,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
else if (pr.state === 'CLOSED' && closedAt) {
|
|
326
|
+
upsertEvent({
|
|
327
|
+
repoId,
|
|
328
|
+
actorId: authorId,
|
|
329
|
+
prId,
|
|
330
|
+
type: 'pr_closed',
|
|
331
|
+
occurredAt: closedAt,
|
|
332
|
+
refTable: 'pull_requests',
|
|
333
|
+
refId: prId,
|
|
334
|
+
dedupeKey: `pr_closed:${pr.id}`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
// ---- reviews ----
|
|
338
|
+
for (const r of pr.reviews.nodes) {
|
|
339
|
+
const reviewerId = resolver.resolve(r.author);
|
|
340
|
+
const submittedAt = toDate(r.submittedAt);
|
|
341
|
+
if (!submittedAt)
|
|
342
|
+
continue; // pending reviews have no timestamp
|
|
343
|
+
const reviewRow = db
|
|
344
|
+
.insert(reviews)
|
|
345
|
+
.values({
|
|
346
|
+
githubNodeId: r.id,
|
|
347
|
+
prId,
|
|
348
|
+
authorId: reviewerId,
|
|
349
|
+
state: reviewState(r.state),
|
|
350
|
+
body: r.body ?? null,
|
|
351
|
+
databaseId: r.fullDatabaseId ?? null,
|
|
352
|
+
submittedAt,
|
|
353
|
+
})
|
|
354
|
+
.onConflictDoUpdate({
|
|
355
|
+
target: reviews.githubNodeId,
|
|
356
|
+
set: {
|
|
357
|
+
state: reviewState(r.state),
|
|
358
|
+
body: r.body ?? null,
|
|
359
|
+
databaseId: r.fullDatabaseId ?? null,
|
|
360
|
+
submittedAt,
|
|
361
|
+
},
|
|
362
|
+
})
|
|
363
|
+
.returning({ id: reviews.id })
|
|
364
|
+
.get();
|
|
365
|
+
if (isSubstantiveReview(reviewState(r.state), r.body)) {
|
|
366
|
+
upsertEvent({
|
|
367
|
+
repoId,
|
|
368
|
+
actorId: reviewerId,
|
|
369
|
+
prId,
|
|
370
|
+
type: 'review_submitted',
|
|
371
|
+
occurredAt: submittedAt,
|
|
372
|
+
refTable: 'reviews',
|
|
373
|
+
refId: reviewRow.id,
|
|
374
|
+
dedupeKey: `review_submitted:${r.id}`,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// ---- review threads + comments ----
|
|
379
|
+
const commitInputs = pr.commits.nodes.map((c) => ({
|
|
380
|
+
oid: c.commit.oid,
|
|
381
|
+
committedDate: c.commit.committedDate,
|
|
382
|
+
}));
|
|
383
|
+
for (const t of pr.reviewThreads.nodes) {
|
|
384
|
+
const commentNodes = t.comments.nodes;
|
|
385
|
+
const originalCommenterId = resolver.resolve(commentNodes[0]?.author);
|
|
386
|
+
const derivedState = deriveThreadState({
|
|
387
|
+
isResolved: t.isResolved,
|
|
388
|
+
path: t.path,
|
|
389
|
+
comments: commentNodes.map((c) => ({
|
|
390
|
+
author: c.author ? { login: c.author.login } : null,
|
|
391
|
+
createdAt: c.createdAt,
|
|
392
|
+
})),
|
|
393
|
+
}, commitInputs, commitFilesBySha);
|
|
394
|
+
const threadRow = db
|
|
395
|
+
.insert(reviewThreads)
|
|
396
|
+
.values({
|
|
397
|
+
githubNodeId: t.id,
|
|
398
|
+
prId,
|
|
399
|
+
path: t.path,
|
|
400
|
+
line: t.line,
|
|
401
|
+
isResolved: t.isResolved,
|
|
402
|
+
isOutdated: t.isOutdated,
|
|
403
|
+
derivedState,
|
|
404
|
+
originalCommenterId,
|
|
405
|
+
createdAt: commentNodes[0]
|
|
406
|
+
? new Date(commentNodes[0].createdAt)
|
|
407
|
+
: new Date(pr.createdAt),
|
|
408
|
+
})
|
|
409
|
+
.onConflictDoUpdate({
|
|
410
|
+
target: reviewThreads.githubNodeId,
|
|
411
|
+
set: {
|
|
412
|
+
isResolved: t.isResolved,
|
|
413
|
+
isOutdated: t.isOutdated,
|
|
414
|
+
derivedState,
|
|
415
|
+
line: t.line,
|
|
416
|
+
},
|
|
417
|
+
})
|
|
418
|
+
.returning({ id: reviewThreads.id })
|
|
419
|
+
.get();
|
|
420
|
+
for (const c of commentNodes) {
|
|
421
|
+
const commenterId = resolver.resolve(c.author);
|
|
422
|
+
const createdAt = new Date(c.createdAt);
|
|
423
|
+
db.insert(reviewComments)
|
|
424
|
+
.values({
|
|
425
|
+
githubNodeId: c.id,
|
|
426
|
+
threadId: threadRow.id,
|
|
427
|
+
prId,
|
|
428
|
+
authorId: commenterId,
|
|
429
|
+
body: c.body,
|
|
430
|
+
diffHunk: c.diffHunk ?? null,
|
|
431
|
+
databaseId: c.fullDatabaseId ?? null,
|
|
432
|
+
createdAt,
|
|
433
|
+
})
|
|
434
|
+
.onConflictDoUpdate({
|
|
435
|
+
target: reviewComments.githubNodeId,
|
|
436
|
+
set: {
|
|
437
|
+
body: c.body,
|
|
438
|
+
diffHunk: c.diffHunk ?? null,
|
|
439
|
+
databaseId: c.fullDatabaseId ?? null,
|
|
440
|
+
},
|
|
441
|
+
})
|
|
442
|
+
.run();
|
|
443
|
+
upsertEvent({
|
|
444
|
+
repoId,
|
|
445
|
+
actorId: commenterId,
|
|
446
|
+
prId,
|
|
447
|
+
type: 'review_comment',
|
|
448
|
+
occurredAt: createdAt,
|
|
449
|
+
// Point at the thread for in-app navigation.
|
|
450
|
+
refTable: 'review_threads',
|
|
451
|
+
refId: threadRow.id,
|
|
452
|
+
dedupeKey: `review_comment:${c.id}`,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
// ---- general PR comments ----
|
|
457
|
+
for (const c of pr.comments.nodes) {
|
|
458
|
+
const commenterId = resolver.resolve(c.author);
|
|
459
|
+
const createdAt = new Date(c.createdAt);
|
|
460
|
+
const commentRow = db
|
|
461
|
+
.insert(prComments)
|
|
462
|
+
.values({
|
|
463
|
+
githubNodeId: c.id,
|
|
464
|
+
prId,
|
|
465
|
+
authorId: commenterId,
|
|
466
|
+
body: c.body,
|
|
467
|
+
databaseId: c.fullDatabaseId ?? null,
|
|
468
|
+
createdAt,
|
|
469
|
+
})
|
|
470
|
+
.onConflictDoUpdate({
|
|
471
|
+
target: prComments.githubNodeId,
|
|
472
|
+
set: { body: c.body, databaseId: c.fullDatabaseId ?? null },
|
|
473
|
+
})
|
|
474
|
+
.returning({ id: prComments.id })
|
|
475
|
+
.get();
|
|
476
|
+
upsertEvent({
|
|
477
|
+
repoId,
|
|
478
|
+
actorId: commenterId,
|
|
479
|
+
prId,
|
|
480
|
+
type: 'pr_comment',
|
|
481
|
+
occurredAt: createdAt,
|
|
482
|
+
refTable: 'pr_comments',
|
|
483
|
+
refId: commentRow.id,
|
|
484
|
+
dedupeKey: `pr_comment:${c.id}`,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
// ---- commits ----
|
|
488
|
+
for (const node of pr.commits.nodes) {
|
|
489
|
+
const c = node.commit;
|
|
490
|
+
const commitAuthorId = resolver.resolve(c.author?.user
|
|
491
|
+
? { login: c.author.user.login, id: c.author.user.id }
|
|
492
|
+
: null);
|
|
493
|
+
const committerId = resolver.resolve(c.committer?.user
|
|
494
|
+
? { login: c.committer.user.login, id: c.committer.user.id }
|
|
495
|
+
: null);
|
|
496
|
+
const committedAt = new Date(c.committedDate);
|
|
497
|
+
// Upsert (not DoNothing) so we always get the row id back to point the
|
|
498
|
+
// timeline event at — the marker modal resolves the commit via ref_id.
|
|
499
|
+
const commitRow = db
|
|
500
|
+
.insert(commits)
|
|
501
|
+
.values({
|
|
502
|
+
sha: c.oid,
|
|
503
|
+
prId,
|
|
504
|
+
authorId: commitAuthorId,
|
|
505
|
+
committerId,
|
|
506
|
+
message: c.message,
|
|
507
|
+
committedAt,
|
|
508
|
+
})
|
|
509
|
+
.onConflictDoUpdate({
|
|
510
|
+
target: [commits.sha, commits.prId],
|
|
511
|
+
set: { message: c.message, committedAt },
|
|
512
|
+
})
|
|
513
|
+
.returning({ id: commits.id })
|
|
514
|
+
.get();
|
|
515
|
+
upsertEvent({
|
|
516
|
+
repoId,
|
|
517
|
+
actorId: commitAuthorId ?? committerId,
|
|
518
|
+
prId,
|
|
519
|
+
type: 'commit_pushed',
|
|
520
|
+
occurredAt: committedAt,
|
|
521
|
+
refTable: 'commits',
|
|
522
|
+
refId: commitRow.id,
|
|
523
|
+
dedupeKey: `commit_pushed:${pr.id}:${c.oid}`,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
//# sourceMappingURL=upsert.js.map
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pierre-review",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local-only dashboard for tracking your team's GitHub PR activity across repos.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Alex Wakeman",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/alexwakeman/pierre-review.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/alexwakeman/pierre-review#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/alexwakeman/pierre-review/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"pierre": "dist/cli.js",
|
|
17
|
+
"pierre-review": "dist/cli.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"public",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@fastify/cors": "^10.0.1",
|
|
30
|
+
"@fastify/static": "^9.1.3",
|
|
31
|
+
"@octokit/graphql": "^8.1.2",
|
|
32
|
+
"better-sqlite3": "^11.8.0",
|
|
33
|
+
"drizzle-orm": "^0.38.3",
|
|
34
|
+
"fastify": "^5.2.1",
|
|
35
|
+
"node-cron": "^3.0.3"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"github",
|
|
39
|
+
"pull-requests",
|
|
40
|
+
"code-review",
|
|
41
|
+
"dashboard",
|
|
42
|
+
"cli",
|
|
43
|
+
"team"
|
|
44
|
+
],
|
|
45
|
+
"license": "MIT"
|
|
46
|
+
}
|