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,10 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
|
3
|
+
import { db } from './client.js';
|
|
4
|
+
const migrationsFolder = resolve(import.meta.dirname, 'migrations');
|
|
5
|
+
// Apply pending migrations against the shared connection. Safe to call at
|
|
6
|
+
// server startup -- does not close the connection.
|
|
7
|
+
export function runMigrations() {
|
|
8
|
+
migrate(db, { migrationsFolder });
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=run-migrations.js.map
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { sqliteTable, text, integer, index, uniqueIndex, } from 'drizzle-orm/sqlite-core';
|
|
2
|
+
import { sql } from 'drizzle-orm';
|
|
3
|
+
export const repos = sqliteTable('repos', {
|
|
4
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
5
|
+
owner: text('owner').notNull(),
|
|
6
|
+
name: text('name').notNull(),
|
|
7
|
+
githubNodeId: text('github_node_id').notNull().unique(),
|
|
8
|
+
// The repo's default branch (GraphQL defaultBranchRef.name), captured each
|
|
9
|
+
// activity sync. Used to scope the "maintainer" inference to PRs merged into
|
|
10
|
+
// the default branch. Null until a sync populates it.
|
|
11
|
+
defaultBranch: text('default_branch'),
|
|
12
|
+
backfillUntil: integer('backfill_until', { mode: 'timestamp' }),
|
|
13
|
+
createdAt: integer('created_at', { mode: 'timestamp' })
|
|
14
|
+
.notNull()
|
|
15
|
+
.default(sql `(unixepoch())`),
|
|
16
|
+
}, (t) => ({ ownerNameUx: uniqueIndex('repos_owner_name').on(t.owner, t.name) }));
|
|
17
|
+
export const users = sqliteTable('users', {
|
|
18
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
19
|
+
githubLogin: text('github_login').notNull().unique(),
|
|
20
|
+
githubNodeId: text('github_node_id').unique(),
|
|
21
|
+
displayName: text('display_name'),
|
|
22
|
+
avatarUrl: text('avatar_url'),
|
|
23
|
+
isBot: integer('is_bot', { mode: 'boolean' }).notNull().default(false),
|
|
24
|
+
// Set when a user toggles is_bot by hand; auto-detection won't override it.
|
|
25
|
+
isBotOverridden: integer('is_bot_overridden', { mode: 'boolean' })
|
|
26
|
+
.notNull()
|
|
27
|
+
.default(false),
|
|
28
|
+
});
|
|
29
|
+
export const pullRequests = sqliteTable('pull_requests', {
|
|
30
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
31
|
+
githubNodeId: text('github_node_id').notNull().unique(),
|
|
32
|
+
repoId: integer('repo_id')
|
|
33
|
+
.notNull()
|
|
34
|
+
.references(() => repos.id),
|
|
35
|
+
number: integer('number').notNull(),
|
|
36
|
+
title: text('title').notNull(),
|
|
37
|
+
body: text('body'),
|
|
38
|
+
authorId: integer('author_id').references(() => users.id),
|
|
39
|
+
// Who actually merged the PR (GraphQL `mergedBy`), distinct from the author.
|
|
40
|
+
// Drives the "has merge rights / maintainer" inference. Null for non-merged
|
|
41
|
+
// PRs and until a (deep) sync backfills it on already-synced merged PRs.
|
|
42
|
+
mergedById: integer('merged_by_id').references(() => users.id),
|
|
43
|
+
// The branch this PR targets (GraphQL `baseRefName`). The maintainer
|
|
44
|
+
// inference only counts merges into the repo's default branch, so a merge
|
|
45
|
+
// into a feature/integration branch doesn't elevate the merger. Null until a
|
|
46
|
+
// (deep) sync backfills it on already-synced PRs.
|
|
47
|
+
baseRefName: text('base_ref_name'),
|
|
48
|
+
state: text('state', { enum: ['open', 'merged', 'closed'] }).notNull(),
|
|
49
|
+
isDraft: integer('is_draft', { mode: 'boolean' }).notNull().default(false),
|
|
50
|
+
openedAt: integer('opened_at', { mode: 'timestamp' }).notNull(),
|
|
51
|
+
firstReviewAt: integer('first_review_at', { mode: 'timestamp' }),
|
|
52
|
+
lastCommitAt: integer('last_commit_at', { mode: 'timestamp' }),
|
|
53
|
+
mergedAt: integer('merged_at', { mode: 'timestamp' }),
|
|
54
|
+
closedAt: integer('closed_at', { mode: 'timestamp' }),
|
|
55
|
+
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
|
56
|
+
// ---- v1.1: CI / mergeability / labels (head-commit derived) ----
|
|
57
|
+
headSha: text('head_sha'),
|
|
58
|
+
ciStatus: text('ci_status', {
|
|
59
|
+
enum: ['success', 'failure', 'pending', 'error', 'expected', 'unknown'],
|
|
60
|
+
}),
|
|
61
|
+
mergeable: text('mergeable', {
|
|
62
|
+
enum: ['mergeable', 'conflicting', 'unknown'],
|
|
63
|
+
}),
|
|
64
|
+
mergeStateStatus: text('merge_state_status', {
|
|
65
|
+
enum: [
|
|
66
|
+
'clean',
|
|
67
|
+
'dirty',
|
|
68
|
+
'unstable',
|
|
69
|
+
'blocked',
|
|
70
|
+
'behind',
|
|
71
|
+
'has_hooks',
|
|
72
|
+
'unknown',
|
|
73
|
+
],
|
|
74
|
+
}),
|
|
75
|
+
labels: text('labels', { mode: 'json' }).$type(),
|
|
76
|
+
// Per-job CI checks on the head commit (CheckRuns + StatusContexts).
|
|
77
|
+
checkRuns: text('check_runs', { mode: 'json' }).$type(),
|
|
78
|
+
}, (t) => ({
|
|
79
|
+
repoIdx: index('pr_repo_idx').on(t.repoId),
|
|
80
|
+
openedIdx: index('pr_opened_idx').on(t.openedAt),
|
|
81
|
+
}));
|
|
82
|
+
// Outstanding review requests on a PR. GitHub removes a request once the
|
|
83
|
+
// reviewer submits, so presence here == still awaiting. Re-derived each sync
|
|
84
|
+
// (delete + reinsert per PR). `userId` null for team requests (teamName set).
|
|
85
|
+
export const reviewRequests = sqliteTable('review_requests', {
|
|
86
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
87
|
+
prId: integer('pr_id')
|
|
88
|
+
.notNull()
|
|
89
|
+
.references(() => pullRequests.id),
|
|
90
|
+
userId: integer('user_id').references(() => users.id),
|
|
91
|
+
teamName: text('team_name'),
|
|
92
|
+
}, (t) => ({
|
|
93
|
+
prIdx: index('rr_pr_idx').on(t.prId),
|
|
94
|
+
userIdx: index('rr_user_idx').on(t.userId),
|
|
95
|
+
}));
|
|
96
|
+
// Per-PR "last viewed" state for incremental review. One row per PR.
|
|
97
|
+
export const prViews = sqliteTable('pr_views', {
|
|
98
|
+
prId: integer('pr_id')
|
|
99
|
+
.primaryKey()
|
|
100
|
+
.references(() => pullRequests.id),
|
|
101
|
+
lastViewedSha: text('last_viewed_sha'),
|
|
102
|
+
lastViewedAt: integer('last_viewed_at', { mode: 'timestamp' }).notNull(),
|
|
103
|
+
});
|
|
104
|
+
// Manual dismissals of "my turn" entries. `refId` is a PR id (review_request)
|
|
105
|
+
// or a review-thread id (thread). The dismissal is honoured only while no newer
|
|
106
|
+
// activity has happened — getMyTurn compares dismissedAt against the PR's
|
|
107
|
+
// updatedAt / the thread's last reply, so it auto-resurfaces.
|
|
108
|
+
export const myTurnDismissals = sqliteTable('my_turn_dismissals', {
|
|
109
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
110
|
+
kind: text('kind', { enum: ['review_request', 'thread'] }).notNull(),
|
|
111
|
+
refId: integer('ref_id').notNull(),
|
|
112
|
+
dismissedAt: integer('dismissed_at', { mode: 'timestamp' }).notNull(),
|
|
113
|
+
}, (t) => ({ kindRefUx: uniqueIndex('mtd_kind_ref_ux').on(t.kind, t.refId) }));
|
|
114
|
+
// Singleton (id always 1): the locally-authenticated GitHub user, cached from
|
|
115
|
+
// `gh api user` so triage ("my turn") knows who "you" are.
|
|
116
|
+
export const localUser = sqliteTable('local_user', {
|
|
117
|
+
id: integer('id').primaryKey(),
|
|
118
|
+
githubLogin: text('github_login').notNull(),
|
|
119
|
+
githubId: text('github_id').notNull(),
|
|
120
|
+
avatarUrl: text('avatar_url'),
|
|
121
|
+
cachedAt: integer('cached_at', { mode: 'timestamp' }).notNull(),
|
|
122
|
+
});
|
|
123
|
+
export const reviewThreads = sqliteTable('review_threads', {
|
|
124
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
125
|
+
githubNodeId: text('github_node_id').notNull().unique(),
|
|
126
|
+
prId: integer('pr_id')
|
|
127
|
+
.notNull()
|
|
128
|
+
.references(() => pullRequests.id),
|
|
129
|
+
path: text('path').notNull(),
|
|
130
|
+
line: integer('line'),
|
|
131
|
+
isResolved: integer('is_resolved', { mode: 'boolean' }).notNull(),
|
|
132
|
+
isOutdated: integer('is_outdated', { mode: 'boolean' })
|
|
133
|
+
.notNull()
|
|
134
|
+
.default(false),
|
|
135
|
+
derivedState: text('derived_state', {
|
|
136
|
+
enum: ['resolved', 'likely_addressed', 'replied_unresolved', 'untouched'],
|
|
137
|
+
}).notNull(),
|
|
138
|
+
originalCommenterId: integer('original_commenter_id').references(() => users.id),
|
|
139
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
140
|
+
}, (t) => ({ prIdx: index('thread_pr_idx').on(t.prId) }));
|
|
141
|
+
export const reviewComments = sqliteTable('review_comments', {
|
|
142
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
143
|
+
githubNodeId: text('github_node_id').notNull().unique(),
|
|
144
|
+
threadId: integer('thread_id')
|
|
145
|
+
.notNull()
|
|
146
|
+
.references(() => reviewThreads.id),
|
|
147
|
+
prId: integer('pr_id')
|
|
148
|
+
.notNull()
|
|
149
|
+
.references(() => pullRequests.id),
|
|
150
|
+
authorId: integer('author_id').references(() => users.id),
|
|
151
|
+
body: text('body').notNull(),
|
|
152
|
+
diffHunk: text('diff_hunk'),
|
|
153
|
+
// GitHub numeric id (fullDatabaseId) for the #discussion_r<id> deep link.
|
|
154
|
+
databaseId: text('database_id'),
|
|
155
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
156
|
+
}, (t) => ({ threadIdx: index('rc_thread_idx').on(t.threadId) }));
|
|
157
|
+
export const prComments = sqliteTable('pr_comments', {
|
|
158
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
159
|
+
githubNodeId: text('github_node_id').notNull().unique(),
|
|
160
|
+
prId: integer('pr_id')
|
|
161
|
+
.notNull()
|
|
162
|
+
.references(() => pullRequests.id),
|
|
163
|
+
authorId: integer('author_id').references(() => users.id),
|
|
164
|
+
body: text('body').notNull(),
|
|
165
|
+
// GitHub numeric id (fullDatabaseId) for the #issuecomment-<id> deep link.
|
|
166
|
+
databaseId: text('database_id'),
|
|
167
|
+
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
|
168
|
+
}, (t) => ({ prIdx: index('prc_pr_idx').on(t.prId) }));
|
|
169
|
+
export const reviews = sqliteTable('reviews', {
|
|
170
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
171
|
+
githubNodeId: text('github_node_id').notNull().unique(),
|
|
172
|
+
prId: integer('pr_id')
|
|
173
|
+
.notNull()
|
|
174
|
+
.references(() => pullRequests.id),
|
|
175
|
+
authorId: integer('author_id').references(() => users.id),
|
|
176
|
+
state: text('state', {
|
|
177
|
+
enum: ['approved', 'changes_requested', 'commented', 'dismissed', 'pending'],
|
|
178
|
+
}).notNull(),
|
|
179
|
+
body: text('body'),
|
|
180
|
+
// GitHub numeric id (fullDatabaseId) for the #pullrequestreview-<id> deep link.
|
|
181
|
+
databaseId: text('database_id'),
|
|
182
|
+
submittedAt: integer('submitted_at', { mode: 'timestamp' }).notNull(),
|
|
183
|
+
}, (t) => ({ prIdx: index('rv_pr_idx').on(t.prId) }));
|
|
184
|
+
export const commits = sqliteTable('commits', {
|
|
185
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
186
|
+
sha: text('sha').notNull(),
|
|
187
|
+
prId: integer('pr_id')
|
|
188
|
+
.notNull()
|
|
189
|
+
.references(() => pullRequests.id),
|
|
190
|
+
authorId: integer('author_id').references(() => users.id),
|
|
191
|
+
committerId: integer('committer_id').references(() => users.id),
|
|
192
|
+
message: text('message'),
|
|
193
|
+
committedAt: integer('committed_at', { mode: 'timestamp' }).notNull(),
|
|
194
|
+
}, (t) => ({
|
|
195
|
+
prIdx: index('commit_pr_idx').on(t.prId),
|
|
196
|
+
shaPrUx: uniqueIndex('commit_sha_pr_ux').on(t.sha, t.prId),
|
|
197
|
+
}));
|
|
198
|
+
// SHA -> string[] of changed paths. Cached forever (SHAs are immutable).
|
|
199
|
+
export const commitFiles = sqliteTable('commit_files', {
|
|
200
|
+
sha: text('sha').primaryKey(),
|
|
201
|
+
paths: text('paths', { mode: 'json' }).$type().notNull(),
|
|
202
|
+
fetchedAt: integer('fetched_at', { mode: 'timestamp' })
|
|
203
|
+
.notNull()
|
|
204
|
+
.default(sql `(unixepoch())`),
|
|
205
|
+
});
|
|
206
|
+
// Unified events log -- what the timeline reads.
|
|
207
|
+
export const events = sqliteTable('events', {
|
|
208
|
+
id: integer('id').primaryKey({ autoIncrement: true }),
|
|
209
|
+
repoId: integer('repo_id')
|
|
210
|
+
.notNull()
|
|
211
|
+
.references(() => repos.id),
|
|
212
|
+
actorId: integer('actor_id').references(() => users.id),
|
|
213
|
+
prId: integer('pr_id').references(() => pullRequests.id),
|
|
214
|
+
type: text('type', {
|
|
215
|
+
enum: [
|
|
216
|
+
'pr_opened',
|
|
217
|
+
'pr_merged',
|
|
218
|
+
'pr_closed',
|
|
219
|
+
'pr_reopened',
|
|
220
|
+
'pr_ready_for_review',
|
|
221
|
+
'review_submitted',
|
|
222
|
+
'review_comment',
|
|
223
|
+
'pr_comment',
|
|
224
|
+
'commit_pushed',
|
|
225
|
+
],
|
|
226
|
+
}).notNull(),
|
|
227
|
+
occurredAt: integer('occurred_at', { mode: 'timestamp' }).notNull(),
|
|
228
|
+
refTable: text('ref_table'),
|
|
229
|
+
refId: integer('ref_id'),
|
|
230
|
+
// Stable identity for idempotent upserts: e.g. "pr_opened:<prNodeId>".
|
|
231
|
+
dedupeKey: text('dedupe_key').notNull().unique(),
|
|
232
|
+
}, (t) => ({
|
|
233
|
+
timeIdx: index('events_time_idx').on(t.occurredAt),
|
|
234
|
+
repoTimeIdx: index('events_repo_time_idx').on(t.repoId, t.occurredAt),
|
|
235
|
+
actorIdx: index('events_actor_idx').on(t.actorId),
|
|
236
|
+
}));
|
|
237
|
+
export const syncState = sqliteTable('sync_state', {
|
|
238
|
+
repoId: integer('repo_id')
|
|
239
|
+
.primaryKey()
|
|
240
|
+
.references(() => repos.id),
|
|
241
|
+
lastFullSyncAt: integer('last_full_sync_at', { mode: 'timestamp' }),
|
|
242
|
+
lastIncrementalSyncAt: integer('last_incremental_sync_at', {
|
|
243
|
+
mode: 'timestamp',
|
|
244
|
+
}),
|
|
245
|
+
lastSyncStatus: text('last_sync_status'),
|
|
246
|
+
lastSyncError: text('last_sync_error'),
|
|
247
|
+
});
|
|
248
|
+
//# sourceMappingURL=schema.js.map
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { inArray } from 'drizzle-orm';
|
|
2
|
+
import { db, schema } from './client.js';
|
|
3
|
+
import { getLocalUserId } from '../github/local-user.js';
|
|
4
|
+
const { reviewRequests, prViews, events, reviews } = schema;
|
|
5
|
+
function emptyNew() {
|
|
6
|
+
return { commits: 0, comments: 0, reviews: 0 };
|
|
7
|
+
}
|
|
8
|
+
/** Per-author latest review state → is the PR approved with no blocking review? */
|
|
9
|
+
function computeApprovedByPr(prIds) {
|
|
10
|
+
const approved = new Set();
|
|
11
|
+
if (prIds.length === 0)
|
|
12
|
+
return approved;
|
|
13
|
+
const rows = db
|
|
14
|
+
.select({
|
|
15
|
+
prId: reviews.prId,
|
|
16
|
+
authorId: reviews.authorId,
|
|
17
|
+
state: reviews.state,
|
|
18
|
+
submittedAt: reviews.submittedAt,
|
|
19
|
+
})
|
|
20
|
+
.from(reviews)
|
|
21
|
+
.where(inArray(reviews.prId, prIds))
|
|
22
|
+
.all();
|
|
23
|
+
// latest review state per (pr, author), ignoring pure "commented" reviews.
|
|
24
|
+
const latest = new Map();
|
|
25
|
+
for (const r of rows) {
|
|
26
|
+
if (r.state !== 'approved' && r.state !== 'changes_requested')
|
|
27
|
+
continue;
|
|
28
|
+
const key = `${r.prId}:${r.authorId}`;
|
|
29
|
+
const at = r.submittedAt.getTime();
|
|
30
|
+
const prev = latest.get(key);
|
|
31
|
+
if (!prev || at > prev.at)
|
|
32
|
+
latest.set(key, { state: r.state, at });
|
|
33
|
+
}
|
|
34
|
+
const byPr = new Map();
|
|
35
|
+
for (const [key, v] of latest) {
|
|
36
|
+
const prId = Number.parseInt(key.split(':')[0], 10);
|
|
37
|
+
const entry = byPr.get(prId) ?? { approvals: 0, blocks: 0 };
|
|
38
|
+
if (v.state === 'approved')
|
|
39
|
+
entry.approvals += 1;
|
|
40
|
+
else
|
|
41
|
+
entry.blocks += 1;
|
|
42
|
+
byPr.set(prId, entry);
|
|
43
|
+
}
|
|
44
|
+
for (const [prId, e] of byPr) {
|
|
45
|
+
if (e.approvals > 0 && e.blocks === 0)
|
|
46
|
+
approved.add(prId);
|
|
47
|
+
}
|
|
48
|
+
return approved;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Compute triage fields (reason tag, review-requested-from-me, new-since
|
|
52
|
+
* counts) for a batch of PRs. All supporting data is loaded in a handful of
|
|
53
|
+
* batched queries — safe to call on the hot timeline path.
|
|
54
|
+
*/
|
|
55
|
+
export function computeTriage(prs) {
|
|
56
|
+
const out = new Map();
|
|
57
|
+
const prIds = prs.map((p) => p.id);
|
|
58
|
+
const localUserId = getLocalUserId();
|
|
59
|
+
// ---- review requests (user-type) by PR ----
|
|
60
|
+
const reqByPr = new Map();
|
|
61
|
+
if (prIds.length > 0) {
|
|
62
|
+
const rows = db
|
|
63
|
+
.select({ prId: reviewRequests.prId, userId: reviewRequests.userId })
|
|
64
|
+
.from(reviewRequests)
|
|
65
|
+
.where(inArray(reviewRequests.prId, prIds))
|
|
66
|
+
.all();
|
|
67
|
+
for (const r of rows) {
|
|
68
|
+
if (r.userId == null)
|
|
69
|
+
continue; // team requests don't map to "me"
|
|
70
|
+
const entry = reqByPr.get(r.prId) ?? { mine: false, others: 0 };
|
|
71
|
+
if (localUserId != null && r.userId === localUserId)
|
|
72
|
+
entry.mine = true;
|
|
73
|
+
else
|
|
74
|
+
entry.others += 1;
|
|
75
|
+
reqByPr.set(r.prId, entry);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// ---- last-viewed per PR ----
|
|
79
|
+
const viewedAtByPr = new Map();
|
|
80
|
+
if (prIds.length > 0) {
|
|
81
|
+
const rows = db
|
|
82
|
+
.select({ prId: prViews.prId, lastViewedAt: prViews.lastViewedAt })
|
|
83
|
+
.from(prViews)
|
|
84
|
+
.where(inArray(prViews.prId, prIds))
|
|
85
|
+
.all();
|
|
86
|
+
for (const r of rows)
|
|
87
|
+
viewedAtByPr.set(r.prId, r.lastViewedAt.getTime());
|
|
88
|
+
}
|
|
89
|
+
// ---- events per PR (for new-since counts) ----
|
|
90
|
+
const newByPr = new Map();
|
|
91
|
+
if (prIds.length > 0 && viewedAtByPr.size > 0) {
|
|
92
|
+
const rows = db
|
|
93
|
+
.select({
|
|
94
|
+
prId: events.prId,
|
|
95
|
+
type: events.type,
|
|
96
|
+
occurredAt: events.occurredAt,
|
|
97
|
+
})
|
|
98
|
+
.from(events)
|
|
99
|
+
.where(inArray(events.prId, prIds))
|
|
100
|
+
.all();
|
|
101
|
+
for (const r of rows) {
|
|
102
|
+
if (r.prId == null)
|
|
103
|
+
continue;
|
|
104
|
+
const threshold = viewedAtByPr.get(r.prId);
|
|
105
|
+
if (threshold == null)
|
|
106
|
+
continue;
|
|
107
|
+
if (r.occurredAt.getTime() <= threshold)
|
|
108
|
+
continue;
|
|
109
|
+
const n = newByPr.get(r.prId) ?? emptyNew();
|
|
110
|
+
if (r.type === 'commit_pushed')
|
|
111
|
+
n.commits += 1;
|
|
112
|
+
else if (r.type === 'pr_comment' || r.type === 'review_comment')
|
|
113
|
+
n.comments += 1;
|
|
114
|
+
else if (r.type === 'review_submitted')
|
|
115
|
+
n.reviews += 1;
|
|
116
|
+
newByPr.set(r.prId, n);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const approvedByPr = computeApprovedByPr(prIds);
|
|
120
|
+
for (const pr of prs) {
|
|
121
|
+
const req = reqByPr.get(pr.id);
|
|
122
|
+
const reviewRequestedFromMe = req?.mine ?? false;
|
|
123
|
+
const otherReviewersRequested = req?.others ?? 0;
|
|
124
|
+
// No "new" badges on closed/merged PRs — done is done.
|
|
125
|
+
const newSinceLastViewed = pr.state === 'open' && viewedAtByPr.has(pr.id)
|
|
126
|
+
? (newByPr.get(pr.id) ?? emptyNew())
|
|
127
|
+
: null;
|
|
128
|
+
out.set(pr.id, {
|
|
129
|
+
reviewRequestedFromMe,
|
|
130
|
+
otherReviewersRequested,
|
|
131
|
+
newSinceLastViewed,
|
|
132
|
+
reasonTag: deriveReasonTag(pr, {
|
|
133
|
+
reviewRequestedFromMe,
|
|
134
|
+
localUserId,
|
|
135
|
+
newComments: newSinceLastViewed?.comments ?? 0,
|
|
136
|
+
approvedReady: approvedByPr.has(pr.id),
|
|
137
|
+
}),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
function deriveReasonTag(pr, ctx) {
|
|
143
|
+
// The cascade only really applies to open PRs; closed/merged fall through to
|
|
144
|
+
// the default.
|
|
145
|
+
if (pr.state === 'open') {
|
|
146
|
+
// Actionable-by-you reasons win — "awaiting your review" beats CI failing
|
|
147
|
+
// because only you can clear it.
|
|
148
|
+
if (ctx.reviewRequestedFromMe)
|
|
149
|
+
return 'awaiting_your_review';
|
|
150
|
+
if (ctx.localUserId != null &&
|
|
151
|
+
pr.authorId === ctx.localUserId &&
|
|
152
|
+
ctx.newComments > 0)
|
|
153
|
+
return 'your_pr_new_comments';
|
|
154
|
+
if (pr.ciStatus === 'failure' || pr.ciStatus === 'error')
|
|
155
|
+
return 'ci_failing';
|
|
156
|
+
if (pr.mergeable === 'conflicting' || pr.mergeStateStatus === 'dirty')
|
|
157
|
+
return 'merge_conflicts';
|
|
158
|
+
// CI is known-not-failing here (failing CI returned above).
|
|
159
|
+
if (ctx.approvedReady && pr.mergeable === 'mergeable')
|
|
160
|
+
return 'approved_ready';
|
|
161
|
+
if (pr.isStalled)
|
|
162
|
+
return 'stalled';
|
|
163
|
+
if (pr.threadCounts.untouched > 0)
|
|
164
|
+
return 'untouched_threads';
|
|
165
|
+
}
|
|
166
|
+
return 'in_progress';
|
|
167
|
+
}
|
|
168
|
+
//# sourceMappingURL=triage.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
// One-shot at startup. Shell out to the gh CLI so we inherit its SSO/keyring
|
|
3
|
+
// handling rather than managing a PAT in-app. Fail loud if gh isn't set up.
|
|
4
|
+
export function getGithubToken() {
|
|
5
|
+
try {
|
|
6
|
+
const token = execFileSync('gh', ['auth', 'token'], {
|
|
7
|
+
encoding: 'utf-8',
|
|
8
|
+
}).trim();
|
|
9
|
+
if (!token)
|
|
10
|
+
throw new Error('empty token');
|
|
11
|
+
return token;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
throw new Error('gh CLI not authenticated. Run `gh auth login` first, then restart the app.\n' +
|
|
15
|
+
'For org repos behind SSO you may also need:\n' +
|
|
16
|
+
' gh auth refresh -h github.com -s read:org');
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { graphql } from '@octokit/graphql';
|
|
2
|
+
import { getGithubToken } from './auth.js';
|
|
3
|
+
let token = null;
|
|
4
|
+
function authToken() {
|
|
5
|
+
token ??= getGithubToken();
|
|
6
|
+
return token;
|
|
7
|
+
}
|
|
8
|
+
let graphqlClient = null;
|
|
9
|
+
export function getGraphqlClient() {
|
|
10
|
+
graphqlClient ??= graphql.defaults({
|
|
11
|
+
headers: { authorization: `token ${authToken()}` },
|
|
12
|
+
});
|
|
13
|
+
return graphqlClient;
|
|
14
|
+
}
|
|
15
|
+
// REST GET helper, used for per-commit changed-files (no GraphQL path for it).
|
|
16
|
+
export async function ghRestGet(path) {
|
|
17
|
+
const res = await fetch(`https://api.github.com${path}`, {
|
|
18
|
+
headers: {
|
|
19
|
+
authorization: `token ${authToken()}`,
|
|
20
|
+
accept: 'application/vnd.github+json',
|
|
21
|
+
'x-github-api-version': '2022-11-28',
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
const body = await res.text().catch(() => '');
|
|
26
|
+
throw new Error(`GitHub REST ${path} -> ${res.status}: ${body.slice(0, 200)}`);
|
|
27
|
+
}
|
|
28
|
+
return res.json();
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=client.js.map
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { eq } from 'drizzle-orm';
|
|
3
|
+
import { db, schema } from '../db/client.js';
|
|
4
|
+
const { localUser, users } = schema;
|
|
5
|
+
const STALE_MS = 24 * 60 * 60 * 1000; // re-fetch once a day
|
|
6
|
+
function fetchFromGh() {
|
|
7
|
+
try {
|
|
8
|
+
const out = execFileSync('gh', ['api', 'user'], { encoding: 'utf-8' });
|
|
9
|
+
const parsed = JSON.parse(out);
|
|
10
|
+
if (!parsed.login || !parsed.node_id)
|
|
11
|
+
return null;
|
|
12
|
+
return {
|
|
13
|
+
login: parsed.login,
|
|
14
|
+
node_id: parsed.node_id,
|
|
15
|
+
avatar_url: parsed.avatar_url ?? null,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
// gh missing / not authed / offline — non-fatal; my-turn just stays empty.
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Ensure the locally-authenticated GitHub user is cached. Refetches via
|
|
25
|
+
* `gh api user` on first run and once per day; otherwise returns the cached
|
|
26
|
+
* row. Never throws — failure leaves "you" unknown and triage degrades
|
|
27
|
+
* gracefully to empty.
|
|
28
|
+
*/
|
|
29
|
+
export function ensureLocalUser() {
|
|
30
|
+
const existing = db.select().from(localUser).where(eq(localUser.id, 1)).get();
|
|
31
|
+
const fresh = existing && Date.now() - existing.cachedAt.getTime() < STALE_MS;
|
|
32
|
+
if (existing && fresh) {
|
|
33
|
+
return {
|
|
34
|
+
login: existing.githubLogin,
|
|
35
|
+
githubId: existing.githubId,
|
|
36
|
+
avatarUrl: existing.avatarUrl,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const gh = fetchFromGh();
|
|
40
|
+
if (!gh) {
|
|
41
|
+
// Fall back to a stale cached value if we have one.
|
|
42
|
+
return existing
|
|
43
|
+
? {
|
|
44
|
+
login: existing.githubLogin,
|
|
45
|
+
githubId: existing.githubId,
|
|
46
|
+
avatarUrl: existing.avatarUrl,
|
|
47
|
+
}
|
|
48
|
+
: null;
|
|
49
|
+
}
|
|
50
|
+
db.insert(localUser)
|
|
51
|
+
.values({
|
|
52
|
+
id: 1,
|
|
53
|
+
githubLogin: gh.login,
|
|
54
|
+
githubId: gh.node_id,
|
|
55
|
+
avatarUrl: gh.avatar_url,
|
|
56
|
+
cachedAt: new Date(),
|
|
57
|
+
})
|
|
58
|
+
.onConflictDoUpdate({
|
|
59
|
+
target: localUser.id,
|
|
60
|
+
set: {
|
|
61
|
+
githubLogin: gh.login,
|
|
62
|
+
githubId: gh.node_id,
|
|
63
|
+
avatarUrl: gh.avatar_url,
|
|
64
|
+
cachedAt: new Date(),
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
.run();
|
|
68
|
+
return { login: gh.login, githubId: gh.node_id, avatarUrl: gh.avatar_url };
|
|
69
|
+
}
|
|
70
|
+
/** Cached local user without triggering a network fetch. */
|
|
71
|
+
export function getLocalUser() {
|
|
72
|
+
const row = db.select().from(localUser).where(eq(localUser.id, 1)).get();
|
|
73
|
+
return row
|
|
74
|
+
? { login: row.githubLogin, githubId: row.githubId, avatarUrl: row.avatarUrl }
|
|
75
|
+
: null;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the local user to a row in `users` (by login) if they've appeared in
|
|
79
|
+
* any synced repo. Returns null when "you" haven't authored/acted anywhere yet.
|
|
80
|
+
*/
|
|
81
|
+
export function getLocalUserId() {
|
|
82
|
+
const local = getLocalUser();
|
|
83
|
+
if (!local)
|
|
84
|
+
return null;
|
|
85
|
+
const row = db
|
|
86
|
+
.select({ id: users.id })
|
|
87
|
+
.from(users)
|
|
88
|
+
.where(eq(users.githubLogin, local.login))
|
|
89
|
+
.get();
|
|
90
|
+
return row?.id ?? null;
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=local-user.js.map
|