pierre-review 0.1.24 → 0.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/routes/me.js +5 -1
- package/dist/api/routes/prs.js +247 -1
- package/dist/api/routes/repos.js +96 -8
- package/dist/api/routes/threads.js +89 -1
- package/dist/db/migrations/0015_repos_inbox_watch.sql +9 -0
- package/dist/db/migrations/0016_repo_viewer_permission.sql +7 -0
- package/dist/db/migrations/meta/_journal.json +14 -0
- package/dist/db/migrations-pg/0005_thick_blockbuster.sql +2 -0
- package/dist/db/migrations-pg/0006_majestic_dracula.sql +1 -0
- package/dist/db/migrations-pg/meta/0005_snapshot.json +2201 -0
- package/dist/db/migrations-pg/meta/0006_snapshot.json +2207 -0
- package/dist/db/migrations-pg/meta/_journal.json +14 -0
- package/dist/db/queries.js +453 -2
- package/dist/db/schema.pg.js +12 -1
- package/dist/db/schema.sqlite.js +14 -1
- package/dist/github/diff-anchor.js +227 -0
- package/dist/github/mutations.js +178 -0
- package/dist/github/queries.js +15 -3
- package/dist/review/post-review.js +6 -198
- package/dist/sync/sync-repo.js +1 -1
- package/dist/sync/upsert.js +17 -5
- package/package.json +1 -1
- package/public/assets/index-7mSD5tq7.css +10 -0
- package/public/assets/index-BMC2gKDh.js +1373 -0
- package/public/index.html +2 -2
- package/public/assets/index-CF5RjHlj.css +0 -10
- package/public/assets/index-_pvZqY4L.js +0 -1371
package/dist/api/routes/me.js
CHANGED
|
@@ -8,7 +8,10 @@ const dismissSchema = {
|
|
|
8
8
|
required: ['kind', 'refId'],
|
|
9
9
|
additionalProperties: false,
|
|
10
10
|
properties: {
|
|
11
|
-
kind: {
|
|
11
|
+
kind: {
|
|
12
|
+
type: 'string',
|
|
13
|
+
enum: ['review_request', 'thread', 'watched_repo_pr', 'claude_review'],
|
|
14
|
+
},
|
|
12
15
|
refId: { type: 'integer' },
|
|
13
16
|
},
|
|
14
17
|
},
|
|
@@ -23,6 +26,7 @@ export async function meRoutes(app) {
|
|
|
23
26
|
awaitingReview: myTurn.awaitingReview.length,
|
|
24
27
|
yourPrsActivity: myTurn.yourPrs.length,
|
|
25
28
|
threadsAwaiting: myTurn.threadsAwaiting.length,
|
|
29
|
+
watchedRepoPrs: myTurn.watchedRepoPrs.length,
|
|
26
30
|
claudeReviewsToAction: myTurn.claudeReviewsToAction.length,
|
|
27
31
|
},
|
|
28
32
|
claudeReviewEnabled: config.claudeReviewEnabled,
|
package/dist/api/routes/prs.js
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { getAccessToken, getAccountUserId } from '../../auth/account.js';
|
|
3
|
+
import { getPrDetail, getPrFilesContext, getPrWriteContext, markAllViewed, markPrViewed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
|
|
4
|
+
import { buildFileAnchors, fallbackAnchor, isFindingAnchored, } from '../../github/diff-anchor.js';
|
|
5
|
+
import { addIssueComment, fetchHeadShaFor, fetchPrFilesWithPatch, postInlineComment, submitPrReview, } from '../../github/mutations.js';
|
|
2
6
|
import { hydratePrDetail } from '../../sync/hydrate-detail.js';
|
|
3
7
|
import { accountIdOf } from '../plugins/auth.js';
|
|
8
|
+
// GitHub anchors a file in the PR "Files changed" diff by the SHA-256 of its
|
|
9
|
+
// path (matches db/queries.ts + hydrate-detail.ts's diffAnchorId).
|
|
10
|
+
function diffAnchorId(path) {
|
|
11
|
+
return createHash('sha256').update(path, 'utf8').digest('hex');
|
|
12
|
+
}
|
|
13
|
+
const PR_FILE_STATUSES = [
|
|
14
|
+
'added',
|
|
15
|
+
'modified',
|
|
16
|
+
'removed',
|
|
17
|
+
'renamed',
|
|
18
|
+
'changed',
|
|
19
|
+
'copied',
|
|
20
|
+
'unchanged',
|
|
21
|
+
];
|
|
22
|
+
// Pass GitHub's REST file status through verbatim when it's one we model, else
|
|
23
|
+
// fall back to 'changed' (the catch-all GitHub itself uses).
|
|
24
|
+
function normalizeStatus(status) {
|
|
25
|
+
return PR_FILE_STATUSES.includes(status)
|
|
26
|
+
? status
|
|
27
|
+
: 'changed';
|
|
28
|
+
}
|
|
4
29
|
const idParamSchema = {
|
|
5
30
|
params: {
|
|
6
31
|
type: 'object',
|
|
@@ -23,6 +48,37 @@ const markAllViewedSchema = {
|
|
|
23
48
|
properties: { repoIds: { type: 'array', items: { type: 'integer' } } },
|
|
24
49
|
},
|
|
25
50
|
};
|
|
51
|
+
const commentSchema = {
|
|
52
|
+
...idParamSchema,
|
|
53
|
+
body: {
|
|
54
|
+
type: 'object',
|
|
55
|
+
required: ['body'],
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
properties: { body: { type: 'string' } },
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const approveSchema = {
|
|
61
|
+
...idParamSchema,
|
|
62
|
+
body: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
additionalProperties: false,
|
|
65
|
+
properties: { body: { type: 'string' } },
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
const reviewCommentSchema = {
|
|
69
|
+
...idParamSchema,
|
|
70
|
+
body: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
required: ['path', 'line', 'body'],
|
|
73
|
+
additionalProperties: false,
|
|
74
|
+
properties: {
|
|
75
|
+
path: { type: 'string' },
|
|
76
|
+
line: { type: 'integer' },
|
|
77
|
+
side: { type: 'string', enum: ['LEFT', 'RIGHT'] },
|
|
78
|
+
body: { type: 'string' },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
};
|
|
26
82
|
export async function prRoutes(app) {
|
|
27
83
|
// Bulk "mark all seen": stamp every open PR (optionally scoped to repoIds) viewed
|
|
28
84
|
// at its head, clearing all new-since badges at once. Static path — no :id — so it
|
|
@@ -66,5 +122,195 @@ export async function prRoutes(app) {
|
|
|
66
122
|
}
|
|
67
123
|
return { status: 'ok' };
|
|
68
124
|
});
|
|
125
|
+
// Post a new issue-level (general) PR comment, then optimistically stamp it
|
|
126
|
+
// locally so it shows before the next sync.
|
|
127
|
+
app.post('/api/prs/:id/comment', { schema: commentSchema }, async (req, reply) => {
|
|
128
|
+
const { id } = req.params;
|
|
129
|
+
const { body } = req.body;
|
|
130
|
+
const accountId = accountIdOf(req);
|
|
131
|
+
const ctx = await getPrWriteContext(id, accountId);
|
|
132
|
+
if (!ctx) {
|
|
133
|
+
reply.status(404);
|
|
134
|
+
return { error: 'NotFound', message: `PR ${id} not found` };
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
const token = await getAccessToken(accountId);
|
|
138
|
+
const gh = await addIssueComment(token, ctx.owner, ctx.name, ctx.number, body);
|
|
139
|
+
const authorId = await getAccountUserId(accountId);
|
|
140
|
+
const rowId = await upsertLocalPrComment(ctx.prId, authorId, gh);
|
|
141
|
+
const result = {
|
|
142
|
+
id: rowId,
|
|
143
|
+
authorId,
|
|
144
|
+
body: gh.body,
|
|
145
|
+
createdAt: new Date(gh.createdAt).toISOString(),
|
|
146
|
+
url: gh.url,
|
|
147
|
+
};
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
reply.status(502);
|
|
152
|
+
return {
|
|
153
|
+
error: 'GitHubError',
|
|
154
|
+
message: err instanceof Error ? err.message : String(err),
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
// Approve the PR. Server re-checks (matching getPrDetail.viewerCanApprove) that
|
|
159
|
+
// the viewer has write+ permission and isn't the author; else 403. On success
|
|
160
|
+
// submits an APPROVE review and stamps it locally. A GitHub 422 (e.g. a
|
|
161
|
+
// self-approve race) bubbles to the 502 catch.
|
|
162
|
+
app.post('/api/prs/:id/approve', { schema: approveSchema }, async (req, reply) => {
|
|
163
|
+
const { id } = req.params;
|
|
164
|
+
const { body } = (req.body ?? {});
|
|
165
|
+
const accountId = accountIdOf(req);
|
|
166
|
+
const ctx = await getPrWriteContext(id, accountId);
|
|
167
|
+
if (!ctx) {
|
|
168
|
+
reply.status(404);
|
|
169
|
+
return { error: 'NotFound', message: `PR ${id} not found` };
|
|
170
|
+
}
|
|
171
|
+
const viewerUserId = await getAccountUserId(accountId);
|
|
172
|
+
const canApprove = viewerUserId != null &&
|
|
173
|
+
viewerUserId !== ctx.authorId &&
|
|
174
|
+
['WRITE', 'MAINTAIN', 'ADMIN'].includes(ctx.viewerPermission ?? '');
|
|
175
|
+
if (!canApprove) {
|
|
176
|
+
reply.status(403);
|
|
177
|
+
return {
|
|
178
|
+
error: 'NotPermitted',
|
|
179
|
+
message: 'You need write access to this repo and cannot approve your own PR.',
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const token = await getAccessToken(accountId);
|
|
184
|
+
const gh = await submitPrReview(token, ctx.owner, ctx.name, ctx.number, {
|
|
185
|
+
event: 'APPROVE',
|
|
186
|
+
body,
|
|
187
|
+
});
|
|
188
|
+
const rowId = await upsertLocalReview(ctx.prId, viewerUserId, gh);
|
|
189
|
+
const result = {
|
|
190
|
+
id: rowId,
|
|
191
|
+
authorId: viewerUserId,
|
|
192
|
+
state: 'approved',
|
|
193
|
+
body: gh.body,
|
|
194
|
+
submittedAt: new Date(gh.submittedAt).toISOString(),
|
|
195
|
+
url: gh.url,
|
|
196
|
+
};
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
reply.status(502);
|
|
201
|
+
return {
|
|
202
|
+
error: 'GitHubError',
|
|
203
|
+
message: err instanceof Error ? err.message : String(err),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
// Add ONE inline review comment, posted immediately. Validates the requested
|
|
208
|
+
// (path, line, side) lands on an addable diff line; if not, re-anchors to the
|
|
209
|
+
// file's first changed line; if the file has no changes at all, returns a
|
|
210
|
+
// not-anchored result without posting.
|
|
211
|
+
app.post('/api/prs/:id/review-comment', { schema: reviewCommentSchema }, async (req, reply) => {
|
|
212
|
+
const { id } = req.params;
|
|
213
|
+
const { path, line, body } = req.body;
|
|
214
|
+
const side = req.body.side ?? 'RIGHT';
|
|
215
|
+
const accountId = accountIdOf(req);
|
|
216
|
+
const ctx = await getPrWriteContext(id, accountId);
|
|
217
|
+
if (!ctx) {
|
|
218
|
+
reply.status(404);
|
|
219
|
+
return { error: 'NotFound', message: `PR ${id} not found` };
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
const token = await getAccessToken(accountId);
|
|
223
|
+
// Resolve the LIVE head (not the possibly-stale DB head): commit_id must
|
|
224
|
+
// pin to the same commit whose diff we validate the line against below,
|
|
225
|
+
// or GitHub 422s the line as "not part of the diff".
|
|
226
|
+
const head = await fetchHeadShaFor(token, ctx.owner, ctx.name, ctx.number);
|
|
227
|
+
// Find the requested file's REST patch (header-less) to validate anchoring.
|
|
228
|
+
const { files } = await fetchPrFilesWithPatch(token, ctx.owner, ctx.name, ctx.number);
|
|
229
|
+
const file = files.find((f) => f.filename === path);
|
|
230
|
+
const anchors = buildFileAnchors(path, file?.patch ?? null);
|
|
231
|
+
// A single-file AnchorIndex for the pure helpers.
|
|
232
|
+
const index = new Map([[path, anchors]]);
|
|
233
|
+
let finalLine = line;
|
|
234
|
+
let finalSide = side;
|
|
235
|
+
let anchored = true;
|
|
236
|
+
if (!isFindingAnchored(index, path, line, side)) {
|
|
237
|
+
const fb = fallbackAnchor(index, path);
|
|
238
|
+
if (!fb) {
|
|
239
|
+
// The file has no changes in the diff → can't post inline.
|
|
240
|
+
const result = {
|
|
241
|
+
commentId: null,
|
|
242
|
+
url: null,
|
|
243
|
+
line,
|
|
244
|
+
side,
|
|
245
|
+
anchored: false,
|
|
246
|
+
};
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
finalLine = fb.line;
|
|
250
|
+
finalSide = fb.side;
|
|
251
|
+
anchored = false;
|
|
252
|
+
}
|
|
253
|
+
const gh = await postInlineComment(token, ctx.owner, ctx.name, ctx.number, { commitId: head, path, line: finalLine, side: finalSide, body });
|
|
254
|
+
const result = {
|
|
255
|
+
commentId: gh.databaseId,
|
|
256
|
+
url: gh.url,
|
|
257
|
+
line: finalLine,
|
|
258
|
+
side: finalSide,
|
|
259
|
+
anchored,
|
|
260
|
+
};
|
|
261
|
+
return result;
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
265
|
+
// A 422 means GitHub rejected the line as not part of the diff (e.g. the
|
|
266
|
+
// head shifted between our diff fetch and the post). Surface it as the
|
|
267
|
+
// structured "couldn't place" result so the FE's recovery UX engages
|
|
268
|
+
// (open on GitHub) instead of a generic error toast.
|
|
269
|
+
if (/->\s*422\b/.test(message)) {
|
|
270
|
+
const result = {
|
|
271
|
+
commentId: null,
|
|
272
|
+
url: null,
|
|
273
|
+
line,
|
|
274
|
+
side,
|
|
275
|
+
anchored: false,
|
|
276
|
+
};
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
reply.status(502);
|
|
280
|
+
return { error: 'GitHubError', message };
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
// Changes tab: per-file diff patches, loaded on demand. Degrades to an empty
|
|
284
|
+
// list on a GitHub fetch error (never 500s) so the tab fails gracefully.
|
|
285
|
+
app.get('/api/prs/:id/files', { schema: idParamSchema }, async (req, reply) => {
|
|
286
|
+
const { id } = req.params;
|
|
287
|
+
const accountId = accountIdOf(req);
|
|
288
|
+
const ctx = await getPrFilesContext(id, accountId);
|
|
289
|
+
if (!ctx) {
|
|
290
|
+
reply.status(404);
|
|
291
|
+
return { error: 'NotFound', message: `PR ${id} not found` };
|
|
292
|
+
}
|
|
293
|
+
try {
|
|
294
|
+
const token = await getAccessToken(accountId);
|
|
295
|
+
const { files, truncated } = await fetchPrFilesWithPatch(token, ctx.owner, ctx.name, ctx.number);
|
|
296
|
+
const mapped = files.map((f) => ({
|
|
297
|
+
path: f.filename,
|
|
298
|
+
previousPath: f.previous_filename ?? null,
|
|
299
|
+
status: normalizeStatus(f.status),
|
|
300
|
+
additions: f.additions,
|
|
301
|
+
deletions: f.deletions,
|
|
302
|
+
patch: f.patch ?? null,
|
|
303
|
+
githubUrl: `${ctx.prUrl}/files#diff-${diffAnchorId(f.filename)}`,
|
|
304
|
+
blobUrl: f.blob_url,
|
|
305
|
+
}));
|
|
306
|
+
const result = { files: mapped, truncated };
|
|
307
|
+
return result;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
// Graceful degrade — the Changes tab shows "no files" rather than 500ing.
|
|
311
|
+
const result = { files: [], truncated: false };
|
|
312
|
+
return result;
|
|
313
|
+
}
|
|
314
|
+
});
|
|
69
315
|
}
|
|
70
316
|
//# sourceMappingURL=prs.js.map
|
package/dist/api/routes/repos.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { getGraphqlClientFor } from '../../github/client.js';
|
|
2
2
|
import { getAccessToken } from '../../auth/account.js';
|
|
3
|
-
import { REPO_ID_QUERY, REPO_SEARCH_QUERY, } from '../../github/queries.js';
|
|
3
|
+
import { OWNER_TYPE_QUERY, REPO_ID_QUERY, REPO_SEARCH_QUERY, } from '../../github/queries.js';
|
|
4
4
|
import { upsertRepo } from '../../sync/upsert.js';
|
|
5
5
|
import { getSyncStatus, isSyncRunning, requestSyncCancel, runSyncForRepo, waitForSyncToStop, } from '../../sync/sync-manager.js';
|
|
6
|
-
import { deleteRepo, getRepo, getWatchedRepoNodeIds, listRepos, } from '../../db/queries.js';
|
|
6
|
+
import { deleteRepo, getRepo, getWatchedRepoNodeIds, listRepos, setRepoInboxWatch, } from '../../db/queries.js';
|
|
7
7
|
import { accountIdOf } from '../plugins/auth.js';
|
|
8
8
|
// Local copy of the shared MAX_REPOS_PER_ACCOUNT value. `@pierre-review/shared` is
|
|
9
9
|
// a types-only package (not shipped in the published tarball), so the backend must
|
|
@@ -17,6 +17,9 @@ const createRepoSchema = {
|
|
|
17
17
|
properties: {
|
|
18
18
|
owner: { type: 'string', minLength: 1 },
|
|
19
19
|
name: { type: 'string', minLength: 1 },
|
|
20
|
+
// When true, also Watch the repo for the inbox on add (the picker passes
|
|
21
|
+
// true for "yours" repos). Optional; defaults to not-watched.
|
|
22
|
+
watch: { type: 'boolean' },
|
|
20
23
|
},
|
|
21
24
|
},
|
|
22
25
|
};
|
|
@@ -27,6 +30,15 @@ const idParamSchema = {
|
|
|
27
30
|
properties: { id: { type: 'integer' } },
|
|
28
31
|
},
|
|
29
32
|
};
|
|
33
|
+
const watchSchema = {
|
|
34
|
+
...idParamSchema,
|
|
35
|
+
body: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
required: ['inboxWatch'],
|
|
38
|
+
additionalProperties: false,
|
|
39
|
+
properties: { inboxWatch: { type: 'boolean' } },
|
|
40
|
+
},
|
|
41
|
+
};
|
|
30
42
|
const syncSchema = {
|
|
31
43
|
...idParamSchema,
|
|
32
44
|
querystring: {
|
|
@@ -60,13 +72,50 @@ export async function repoRoutes(app) {
|
|
|
60
72
|
return { error: 'BadRequest', message: 'Search query must not be empty' };
|
|
61
73
|
}
|
|
62
74
|
const accountId = accountIdOf(req);
|
|
75
|
+
// Translate the user term into a literal GitHub search query. An `owner/...`
|
|
76
|
+
// prefix scopes results to that owner (org:/user: qualifier — resolved by type);
|
|
77
|
+
// the remainder (or the whole term) is matched against the repo NAME only
|
|
78
|
+
// (`in:name`), and `needle` drives the literal re-rank below.
|
|
79
|
+
const client = getGraphqlClientFor(await getAccessToken(accountId));
|
|
80
|
+
const slash = term.indexOf('/');
|
|
81
|
+
const owner = slash >= 0 ? term.slice(0, slash).trim() : '';
|
|
82
|
+
const rest = slash >= 0 ? term.slice(slash + 1).trim() : term;
|
|
83
|
+
let searchQuery;
|
|
84
|
+
let needle;
|
|
85
|
+
if (owner) {
|
|
86
|
+
// `owner/...` → scope to that owner. Note a stray leading slash ("/foo") leaves
|
|
87
|
+
// owner empty and falls through to the plain branch (no malformed `user:` query).
|
|
88
|
+
needle = rest;
|
|
89
|
+
let qualifier = `user:${owner}`;
|
|
90
|
+
try {
|
|
91
|
+
const ownerResp = await client(OWNER_TYPE_QUERY, {
|
|
92
|
+
login: owner,
|
|
93
|
+
});
|
|
94
|
+
const kind = ownerResp.repositoryOwner?.__typename ?? null;
|
|
95
|
+
if (kind == null) {
|
|
96
|
+
// Owner login doesn't exist → no possible matches.
|
|
97
|
+
return { results: [], hasNextPage: false, cursor: null };
|
|
98
|
+
}
|
|
99
|
+
qualifier = kind === 'Organization' ? `org:${owner}` : `user:${owner}`;
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
// Lookup failed (network / rate limit) — default to user: scoping and run the
|
|
103
|
+
// search anyway. Logged so a wrong-scope result is diagnosable.
|
|
104
|
+
req.log.warn({ err, owner }, 'repo search: owner-type lookup failed; defaulting to user: scope');
|
|
105
|
+
}
|
|
106
|
+
searchQuery = rest ? `${qualifier} ${rest} in:name` : qualifier;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
// Plain term (or a stray leading slash) → literal repo-name search.
|
|
110
|
+
needle = rest;
|
|
111
|
+
searchQuery = rest ? `${rest} in:name` : term;
|
|
112
|
+
}
|
|
63
113
|
let resp;
|
|
64
114
|
try {
|
|
65
|
-
const client = getGraphqlClientFor(await getAccessToken(accountId));
|
|
66
115
|
// NB: the GraphQL variable is `searchQuery`, not `query` — @octokit/graphql
|
|
67
116
|
// reserves `query` for the document body and rejects it as a variable name.
|
|
68
117
|
resp = await client(REPO_SEARCH_QUERY, {
|
|
69
|
-
searchQuery
|
|
118
|
+
searchQuery,
|
|
70
119
|
first: limit,
|
|
71
120
|
cursor: cursor ?? null,
|
|
72
121
|
});
|
|
@@ -103,9 +152,31 @@ export async function repoRoutes(app) {
|
|
|
103
152
|
orgLogins.has(ownerLogin.toLowerCase()),
|
|
104
153
|
};
|
|
105
154
|
});
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
155
|
+
// Re-rank for literal matching: closest name match first (exact < prefix <
|
|
156
|
+
// substring < other), then your own/org repos, then stars. Array.prototype.sort
|
|
157
|
+
// is stable (Node ≥ 12), so GitHub's best-match order breaks remaining ties.
|
|
158
|
+
const lc = needle.toLowerCase();
|
|
159
|
+
const nameTier = (name) => {
|
|
160
|
+
if (!lc)
|
|
161
|
+
return 0;
|
|
162
|
+
const n = name.toLowerCase();
|
|
163
|
+
if (n === lc)
|
|
164
|
+
return 0;
|
|
165
|
+
if (n.startsWith(lc))
|
|
166
|
+
return 1;
|
|
167
|
+
if (n.includes(lc))
|
|
168
|
+
return 2;
|
|
169
|
+
return 3;
|
|
170
|
+
};
|
|
171
|
+
results.sort((a, b) => {
|
|
172
|
+
const t = nameTier(a.name) - nameTier(b.name);
|
|
173
|
+
if (t !== 0)
|
|
174
|
+
return t;
|
|
175
|
+
const own = Number(b.isOwnedOrMember) - Number(a.isOwnedOrMember);
|
|
176
|
+
if (own !== 0)
|
|
177
|
+
return own;
|
|
178
|
+
return b.stargazerCount - a.stargazerCount;
|
|
179
|
+
});
|
|
109
180
|
const body = {
|
|
110
181
|
results,
|
|
111
182
|
hasNextPage: resp.search.pageInfo.hasNextPage,
|
|
@@ -114,7 +185,7 @@ export async function repoRoutes(app) {
|
|
|
114
185
|
return body;
|
|
115
186
|
});
|
|
116
187
|
app.post('/api/repos', { schema: createRepoSchema }, async (req, reply) => {
|
|
117
|
-
const { owner, name } = req.body;
|
|
188
|
+
const { owner, name, watch } = req.body;
|
|
118
189
|
const accountId = accountIdOf(req);
|
|
119
190
|
let resp;
|
|
120
191
|
try {
|
|
@@ -150,11 +221,28 @@ export async function repoRoutes(app) {
|
|
|
150
221
|
const canonOwner = resp.repository.owner.login;
|
|
151
222
|
const canonName = resp.repository.name;
|
|
152
223
|
const repoId = await upsertRepo(canonOwner, canonName, resp.repository.id, null, accountId);
|
|
224
|
+
// Auto-watch "yours" repos for the inbox. Idempotent on re-add and preserves an
|
|
225
|
+
// existing watch-start (setRepoInboxWatch only stamps the start when unset).
|
|
226
|
+
if (watch === true)
|
|
227
|
+
await setRepoInboxWatch(accountId, repoId, true);
|
|
153
228
|
// Kick off the initial backfill in the background.
|
|
154
229
|
runSyncForRepo(repoId, app.log, { background: true });
|
|
155
230
|
reply.status(201);
|
|
156
231
|
return getRepo(repoId, accountId);
|
|
157
232
|
});
|
|
233
|
+
// Toggle "Watch for inbox" on a repo. Inbox-only: it does not affect timeline
|
|
234
|
+
// visibility or syncing. Ownership-scoped → 404 for a repo this account doesn't own.
|
|
235
|
+
app.patch('/api/repos/:id', { schema: watchSchema }, async (req, reply) => {
|
|
236
|
+
const { id } = req.params;
|
|
237
|
+
const { inboxWatch } = req.body;
|
|
238
|
+
const accountId = accountIdOf(req);
|
|
239
|
+
const ok = await setRepoInboxWatch(accountId, id, inboxWatch);
|
|
240
|
+
if (!ok) {
|
|
241
|
+
reply.status(404);
|
|
242
|
+
return { error: 'NotFound', message: `Repo ${id} not found` };
|
|
243
|
+
}
|
|
244
|
+
return getRepo(id, accountId);
|
|
245
|
+
});
|
|
158
246
|
app.delete('/api/repos/:id', { schema: idParamSchema }, async (req, reply) => {
|
|
159
247
|
const { id } = req.params;
|
|
160
248
|
const accountId = accountIdOf(req);
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getAccessToken, getAccountUserId } from '../../auth/account.js';
|
|
2
|
+
import { getThreadDetail, getThreadWriteContext, stampThreadRepliedState, stampThreadResolved, upsertLocalReply, } from '../../db/queries.js';
|
|
3
|
+
import { addReviewThreadReply, setReviewThreadResolved, } from '../../github/mutations.js';
|
|
2
4
|
import { hydrateThreadDetail } from '../../sync/hydrate-detail.js';
|
|
3
5
|
import { accountIdOf } from '../plugins/auth.js';
|
|
4
6
|
const idParamSchema = {
|
|
@@ -8,6 +10,24 @@ const idParamSchema = {
|
|
|
8
10
|
properties: { id: { type: 'integer' } },
|
|
9
11
|
},
|
|
10
12
|
};
|
|
13
|
+
const replySchema = {
|
|
14
|
+
...idParamSchema,
|
|
15
|
+
body: {
|
|
16
|
+
type: 'object',
|
|
17
|
+
required: ['body'],
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
properties: { body: { type: 'string' } },
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
const resolveSchema = {
|
|
23
|
+
...idParamSchema,
|
|
24
|
+
body: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
required: ['resolved'],
|
|
27
|
+
additionalProperties: false,
|
|
28
|
+
properties: { resolved: { type: 'boolean' } },
|
|
29
|
+
},
|
|
30
|
+
};
|
|
11
31
|
export async function threadRoutes(app) {
|
|
12
32
|
app.get('/api/threads/:id', { schema: idParamSchema }, async (req, reply) => {
|
|
13
33
|
const { id } = req.params;
|
|
@@ -19,5 +39,73 @@ export async function threadRoutes(app) {
|
|
|
19
39
|
}
|
|
20
40
|
return hydrateThreadDetail(thread, accountId);
|
|
21
41
|
});
|
|
42
|
+
// Reply to an existing review thread. GraphQL addPullRequestReviewThreadReply,
|
|
43
|
+
// then optimistically stamp the new comment locally so it shows before sync.
|
|
44
|
+
app.post('/api/threads/:id/reply', { schema: replySchema }, async (req, reply) => {
|
|
45
|
+
const { id } = req.params;
|
|
46
|
+
const { body } = req.body;
|
|
47
|
+
const accountId = accountIdOf(req);
|
|
48
|
+
const ctx = await getThreadWriteContext(id, accountId);
|
|
49
|
+
if (!ctx) {
|
|
50
|
+
reply.status(404);
|
|
51
|
+
return { error: 'NotFound', message: `Thread ${id} not found` };
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
const token = await getAccessToken(accountId);
|
|
55
|
+
const gh = await addReviewThreadReply(token, ctx.threadNodeId, body);
|
|
56
|
+
const authorId = await getAccountUserId(accountId);
|
|
57
|
+
const rowId = await upsertLocalReply(ctx.prId, id, authorId, gh);
|
|
58
|
+
// Bump the parent thread off 'untouched' so its badge reflects the reply
|
|
59
|
+
// before the next sync re-derives.
|
|
60
|
+
await stampThreadRepliedState(id);
|
|
61
|
+
const result = {
|
|
62
|
+
id: rowId,
|
|
63
|
+
authorId,
|
|
64
|
+
body: gh.body,
|
|
65
|
+
diffHunk: null,
|
|
66
|
+
createdAt: new Date(gh.createdAt).toISOString(),
|
|
67
|
+
url: gh.url,
|
|
68
|
+
};
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
reply.status(502);
|
|
73
|
+
return {
|
|
74
|
+
error: 'GitHubError',
|
|
75
|
+
message: err instanceof Error ? err.message : String(err),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
// Resolve (resolved=true) or unresolve (resolved=false) a review thread, then
|
|
80
|
+
// stamp the local derivedState so the UI reflects it before the next sync.
|
|
81
|
+
app.post('/api/threads/:id/resolve', { schema: resolveSchema }, async (req, reply) => {
|
|
82
|
+
const { id } = req.params;
|
|
83
|
+
const { resolved } = req.body;
|
|
84
|
+
const accountId = accountIdOf(req);
|
|
85
|
+
const ctx = await getThreadWriteContext(id, accountId);
|
|
86
|
+
if (!ctx) {
|
|
87
|
+
reply.status(404);
|
|
88
|
+
return { error: 'NotFound', message: `Thread ${id} not found` };
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
const token = await getAccessToken(accountId);
|
|
92
|
+
const gh = await setReviewThreadResolved(token, ctx.threadNodeId, resolved);
|
|
93
|
+
// Ownership already confirmed above, so the stamp is non-null.
|
|
94
|
+
const derivedState = await stampThreadResolved(id, resolved, accountId);
|
|
95
|
+
const result = {
|
|
96
|
+
threadId: id,
|
|
97
|
+
isResolved: gh.isResolved,
|
|
98
|
+
derivedState: derivedState ?? (resolved ? 'resolved' : 'untouched'),
|
|
99
|
+
};
|
|
100
|
+
return result;
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
reply.status(502);
|
|
104
|
+
return {
|
|
105
|
+
error: 'GitHubError',
|
|
106
|
+
message: err instanceof Error ? err.message : String(err),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
});
|
|
22
110
|
}
|
|
23
111
|
//# sourceMappingURL=threads.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
-- "Watch for inbox" per repo (additive). When `inbox_watch` is true, new open PRs by
|
|
2
|
+
-- others (opened on/after `inbox_watch_started_at`) surface in the My Turn inbox —
|
|
3
|
+
-- independent of timeline visibility and of removing the repo. `inbox_watch_started_at`
|
|
4
|
+
-- is set on the first watch and preserved across unwatch, so re-watching restores the
|
|
5
|
+
-- same window. Existing repos default to NOT watched (opt-in); new "yours" repos are
|
|
6
|
+
-- watched on add by the picker. Postgres baseline is regenerated separately via
|
|
7
|
+
-- db:generate:pg.
|
|
8
|
+
ALTER TABLE `repos` ADD `inbox_watch` integer DEFAULT false NOT NULL;--> statement-breakpoint
|
|
9
|
+
ALTER TABLE `repos` ADD `inbox_watch_started_at` integer;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- Viewer's repo permission (additive). `viewer_permission` records the GraphQL
|
|
2
|
+
-- Repository.viewerPermission enum (ADMIN/MAINTAIN/WRITE/TRIAGE/READ) captured each
|
|
3
|
+
-- activity sync. It drives whether the viewer may approve a PR (WRITE+ and not the
|
|
4
|
+
-- author) — surfaced as PrDetail.viewerCanApprove. Nullable; existing rows stay NULL
|
|
5
|
+
-- until the next sync repopulates them. Postgres baseline is regenerated separately
|
|
6
|
+
-- via db:generate:pg.
|
|
7
|
+
ALTER TABLE `repos` ADD `viewer_permission` text;
|
|
@@ -106,6 +106,20 @@
|
|
|
106
106
|
"when": 1780800000005,
|
|
107
107
|
"tag": "0014_account_last_active",
|
|
108
108
|
"breakpoints": true
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"idx": 15,
|
|
112
|
+
"version": "6",
|
|
113
|
+
"when": 1780800000006,
|
|
114
|
+
"tag": "0015_repos_inbox_watch",
|
|
115
|
+
"breakpoints": true
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"idx": 16,
|
|
119
|
+
"version": "6",
|
|
120
|
+
"when": 1780800000007,
|
|
121
|
+
"tag": "0016_repo_viewer_permission",
|
|
122
|
+
"breakpoints": true
|
|
109
123
|
}
|
|
110
124
|
]
|
|
111
125
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE "repos" ADD COLUMN "viewer_permission" text;
|