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.
@@ -8,7 +8,10 @@ const dismissSchema = {
8
8
  required: ['kind', 'refId'],
9
9
  additionalProperties: false,
10
10
  properties: {
11
- kind: { type: 'string', enum: ['review_request', 'thread', 'claude_review'] },
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,
@@ -1,6 +1,31 @@
1
- import { getPrDetail, markAllViewed, markPrViewed } from '../../db/queries.js';
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
@@ -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: term,
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
- // Float owned/member repos to the top, preserving GitHub's best-match order
107
- // within each group (Array.prototype.sort is stable on Node 12).
108
- results.sort((a, b) => Number(b.isOwnedOrMember) - Number(a.isOwnedOrMember));
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 { getThreadDetail } from '../../db/queries.js';
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,2 @@
1
+ ALTER TABLE "repos" ADD COLUMN "inbox_watch" boolean DEFAULT false NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "repos" ADD COLUMN "inbox_watch_started_at" timestamp with time zone;
@@ -0,0 +1 @@
1
+ ALTER TABLE "repos" ADD COLUMN "viewer_permission" text;