better-review 0.5.1 → 0.5.2

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/README.md CHANGED
@@ -60,7 +60,7 @@ seulement pour les PR qui me concernent. Recherche GitHub limitée à 1000 résu
60
60
  | `WEB_PORT` | `5173` | Port Vite en développement |
61
61
  | `GITHUB_TOKEN` | `gh auth token` | Token GitHub |
62
62
  | `BR_CONFIG_DIR` | `~/.config/better-review` | `config.json` (repos / orgs suivis, `staleDays`) — éditable à la main, rechargé à chaud |
63
- | `BR_DATA_DIR` | `~/.local/share/better-review` | `state.db` (SQLite : visites, hunks vus, threads lus, cache des diffs) |
63
+ | `BR_DATA_DIR` | `~/.local/share/better-review` | `state.db` (SQLite : visites, hunks vus, commentaires lus, cache des diffs) |
64
64
  | `BR_SESSION_TOKEN` | `BR_DATA_DIR/session-token` | Jeton de session imposé (scripts, tests) |
65
65
 
66
66
  ## Raccourcis (vue PR)
@@ -71,13 +71,26 @@ vu suivant), comme le bouton « Vu » d'un fichier · `h` hunk vu · `r` répond
71
71
  `?` aide.
72
72
 
73
73
  Commenter : clic sur un numéro de ligne (« + » au survol), côté ancien code pour une ligne supprimée ou côté
74
- nouveau code pour une ligne ajoutée, `Maj+clic` pour une plage ; bouton « Commenter » dans l'en-tête d'un fichier
75
- pour le fichier entier. Chaque commentaire part seul ou dans la review en attente.
74
+ nouveau code pour une ligne ajoutée, `Maj+clic` pour une plage (dans un même hunk) ; bouton « Commenter » dans
75
+ l'en-tête d'un fichier pour le fichier entier. Chaque commentaire part seul ou dans la review en attente. GitHub ne
76
+ place un commentaire que sur une ligne du diff de la PR : dans une plage « depuis ma dernière visite / review » ou un
77
+ commit, seules les lignes du nouveau code que ce diff montre aussi sont commentables.
76
78
 
77
79
  Réactions emoji (celles de GitHub) sous chaque commentaire, review et la description : clic sur une réaction pour
78
- ajouter / retirer la mienne, « ☺ » pour en choisir une autre. Les conversations résolues sont repliées (« Afficher N
79
- messages ») et s'ouvrent quand on y navigue. La CI de l'en-tête met les échecs en avant (détail au clic) et est sondée
80
- toutes les 15 s tant que des checks tournent.
80
+ ajouter / retirer la mienne, « ☺ » pour en choisir une autre ; au survol, qui a réagi (noms chargés à ce moment-là).
81
+ Les conversations résolues sont repliées (« Afficher N messages ») et s'ouvrent quand on y navigue. La CI de l'en-tête
82
+ met les échecs en avant (détail au clic) et est sondée toutes les 15 s tant que des checks tournent.
83
+
84
+ Commentaires non lus (liseré orange) : leur bouton « Vu » se remplit pendant le temps de lecture estimé, tant que le
85
+ commentaire est à l'écran ; plein, il passe en « ✓ Vu » et le commentaire est lu (clic : lu tout de suite). Un thread se
86
+ lit dans l'ordre et pas quand il est replié ; répondre le lit jusqu'à la réponse. Ce qui précède ma première visite de la
87
+ PR, ou mon dernier commentaire / ma dernière review, compte comme lu. À l'accueil, « non lue » = jamais visitée, nouveaux
88
+ commits, ou dernier mot d'un autre (thread ou conversation) pas encore lu.
89
+
90
+ Liens GitHub dans les commentaires, reviews et la description : ils s'ouvrent dans better-review quand c'est possible —
91
+ fichier ou lignes de la PR (`/files#diff-…`, `/blob/<head ou branche>/…#L10-L20`, surlignées en jaune), conversation
92
+ (`#discussion_r…`), commit de la PR, autre PR. Une plage qui ne montre pas la cible passe à « Tous les changements ». Le
93
+ reste (issues, fichiers hors PR, sites externes) s'ouvre dans un nouvel onglet ; Ctrl / Cmd+clic garde le lien GitHub.
81
94
 
82
95
  Plage : une PR s'ouvre toujours sur « Tous les changements ». « Depuis ma dernière visite / review » se choisit dans
83
96
  le sélecteur ; ces deltas sont désactivés quand la branche a été rebasée depuis (l'ancien head n'est plus dans la PR et
package/dist/cli.js CHANGED
@@ -242,8 +242,8 @@ var recordBodyBufferedBeforeDisconnect = (incoming) => {
242
242
  const incomingWithRecovery = incoming;
243
243
  incomingWithRecovery[bodyBufferedLengthBeforeDisconnectKey] ??= incoming.readableLength;
244
244
  };
245
- var readBodyBufferedBeforeDisconnect = (incoming, chunks) => {
246
- if (incoming.readableDidRead && !chunks || !isRecoverableDisconnectedIncoming(incoming)) return;
245
+ var readBodyBufferedBeforeDisconnect = (incoming, chunks2) => {
246
+ if (incoming.readableDidRead && !chunks2 || !isRecoverableDisconnectedIncoming(incoming)) return;
247
247
  const incomingWithRecovery = incoming;
248
248
  if (incomingWithRecovery[bodyBufferedBeforeDisconnectKey] !== void 0) return incomingWithRecovery[bodyBufferedBeforeDisconnectKey];
249
249
  let result;
@@ -251,7 +251,7 @@ var readBodyBufferedBeforeDisconnect = (incoming, chunks) => {
251
251
  if (errored && errored.code !== "ECONNRESET") result = errored;
252
252
  else if (incomingWithRecovery[bodyBufferedLengthBeforeDisconnectKey] !== void 0 && incoming.readableLength !== incomingWithRecovery[bodyBufferedLengthBeforeDisconnectKey]) result = newBodyUnusableError();
253
253
  else {
254
- const bodyChunks = chunks ?? [];
254
+ const bodyChunks = chunks2 ?? [];
255
255
  const chunk = incoming.read();
256
256
  if (chunk !== null) bodyChunks.push(toBufferChunk(chunk, incoming.readableEncoding));
257
257
  const buffer = bodyChunks.length === 1 ? bodyChunks[0] : Buffer.concat(bodyChunks);
@@ -427,7 +427,7 @@ var readBodyDirect = (request) => {
427
427
  return Promise.resolve(buffered);
428
428
  }
429
429
  const promise2 = new Promise((resolve, reject) => {
430
- const chunks = [];
430
+ const chunks2 = [];
431
431
  let settled = false;
432
432
  const finish = (callback) => {
433
433
  if (settled) return;
@@ -439,7 +439,7 @@ var readBodyDirect = (request) => {
439
439
  const streamError = incoming.errored ?? error62;
440
440
  if (!isRecoverableDisconnectedIncoming(incoming) || streamError && streamError.code !== "ECONNRESET") return false;
441
441
  finish(() => {
442
- const recovered = readBodyBufferedBeforeDisconnect(incoming, chunks);
442
+ const recovered = readBodyBufferedBeforeDisconnect(incoming, chunks2);
443
443
  if (recovered instanceof Error) reject(recovered);
444
444
  else if (recovered === void 0) reject(error62 ?? normalizeAbortError(request, incoming));
445
445
  else {
@@ -450,11 +450,11 @@ var readBodyDirect = (request) => {
450
450
  return true;
451
451
  };
452
452
  const onData = (chunk) => {
453
- chunks.push(toBufferChunk(chunk, incoming.readableEncoding));
453
+ chunks2.push(toBufferChunk(chunk, incoming.readableEncoding));
454
454
  };
455
455
  const onEnd = () => {
456
456
  finish(() => {
457
- const buffer = chunks.length === 1 ? chunks[0] : Buffer.concat(chunks);
457
+ const buffer = chunks2.length === 1 ? chunks2[0] : Buffer.concat(chunks2);
458
458
  request[bodyBufferKey] = buffer;
459
459
  resolve(buffer);
460
460
  });
@@ -25806,6 +25806,28 @@ var REACTION_FIELDS = (
25806
25806
  }
25807
25807
  `
25808
25808
  );
25809
+ var REACTORS_QUERY = (
25810
+ /* GraphQL */
25811
+ `
25812
+ query Reactors($id: ID!, $first: Int!) {
25813
+ node(id: $id) {
25814
+ ... on Reactable {
25815
+ reactionGroups {
25816
+ content
25817
+ reactors(first: $first) {
25818
+ totalCount
25819
+ nodes {
25820
+ ... on Actor {
25821
+ login
25822
+ }
25823
+ }
25824
+ }
25825
+ }
25826
+ }
25827
+ }
25828
+ }
25829
+ `
25830
+ );
25809
25831
  var REVIEW_COMMENT_FIELDS = (
25810
25832
  /* GraphQL */
25811
25833
  `
@@ -26214,6 +26236,7 @@ var CHECKS_QUERY = (
26214
26236
  ${CHECKS_FIELDS}
26215
26237
  reviewThreads(first: 100) {
26216
26238
  nodes {
26239
+ id
26217
26240
  isResolved
26218
26241
  }
26219
26242
  }
@@ -26234,12 +26257,14 @@ var DETAILS_QUERY = (
26234
26257
  ${CHECKS_FIELDS}
26235
26258
  viewerLatestReview {
26236
26259
  state
26260
+ submittedAt
26237
26261
  commit {
26238
26262
  oid
26239
26263
  }
26240
26264
  }
26241
26265
  comments(last: 1) {
26242
26266
  nodes {
26267
+ id
26243
26268
  author {
26244
26269
  login
26245
26270
  }
@@ -26248,9 +26273,11 @@ var DETAILS_QUERY = (
26248
26273
  }
26249
26274
  reviewThreads(first: 100) {
26250
26275
  nodes {
26276
+ id
26251
26277
  isResolved
26252
26278
  comments(last: 1) {
26253
26279
  nodes {
26280
+ id
26254
26281
  author {
26255
26282
  login
26256
26283
  }
@@ -26314,6 +26341,14 @@ var INBOX_FINGERPRINT_QUERY = (
26314
26341
  `
26315
26342
  );
26316
26343
 
26344
+ // ../server/src/readState.ts
26345
+ function readBaseline(firstVisitAt, myActivity) {
26346
+ if (firstVisitAt === null) return Number.POSITIVE_INFINITY;
26347
+ let at = firstVisitAt;
26348
+ for (const date5 of myActivity) if (date5) at = Math.max(at, Date.parse(date5));
26349
+ return at;
26350
+ }
26351
+
26317
26352
  // ../server/src/inbox.ts
26318
26353
  var MERGED_WINDOW_MS = 24 * 36e5;
26319
26354
  function scopeTerms(config2) {
@@ -26322,9 +26357,9 @@ function scopeTerms(config2) {
26322
26357
  var SCOPE_CHUNK = 20;
26323
26358
  function scopeChunks(config2) {
26324
26359
  const terms = scopeTerms(config2);
26325
- const chunks = [];
26326
- for (let i = 0; i < terms.length; i += SCOPE_CHUNK) chunks.push(terms.slice(i, i + SCOPE_CHUNK).join(" "));
26327
- return chunks;
26360
+ const chunks2 = [];
26361
+ for (let i = 0; i < terms.length; i += SCOPE_CHUNK) chunks2.push(terms.slice(i, i + SCOPE_CHUNK).join(" "));
26362
+ return chunks2;
26328
26363
  }
26329
26364
  function searchQueries(scope, mergedSince) {
26330
26365
  const open2 = `is:pr is:open archived:false ${scope}`;
@@ -26344,27 +26379,35 @@ function isApproved(decision, states) {
26344
26379
  if (decision !== null) return decision === "APPROVED";
26345
26380
  return states.includes("APPROVED") && !states.includes("CHANGES_REQUESTED");
26346
26381
  }
26347
- function toInboxPr(node2, me, visit2) {
26382
+ function toInboxPr(node2, me, reads) {
26348
26383
  const [owner = "", repo = ""] = node2.repository.nameWithOwner.split("/");
26384
+ const key = prKey(owner, repo, node2.number);
26385
+ const visit2 = reads.lastVisits.get(key);
26386
+ const myReview = node2.viewerLatestReview;
26387
+ const lastComment = node2.comments?.nodes[0];
26388
+ const lastInThreads = (node2.reviewThreads?.nodes ?? []).flatMap((t2) => {
26389
+ const last = t2.comments?.nodes[0];
26390
+ return last ? [{ threadId: t2.id, last }] : [];
26391
+ });
26392
+ const isMine = (c) => c.author?.login === me;
26393
+ const baseline = readBaseline(reads.firstVisits.get(key) ?? null, [
26394
+ myReview?.submittedAt ?? null,
26395
+ ...lastInThreads.filter(({ last }) => isMine(last)).map(({ last }) => last.createdAt),
26396
+ ...lastComment && isMine(lastComment) ? [lastComment.createdAt] : []
26397
+ ]);
26398
+ const unreadBy = (c, read) => !isMine(c) && Date.parse(c.createdAt) > baseline && !read;
26399
+ const unreadComment = lastInThreads.some(({ threadId, last }) => unreadBy(last, reads.threads.get(threadId) === last.id)) || lastComment !== void 0 && unreadBy(lastComment, reads.entries.has(lastComment.id));
26349
26400
  let threads;
26350
- let lastOtherActivity = 0;
26351
26401
  if (node2.reviewThreads) {
26352
26402
  threads = { total: node2.reviewThreads.nodes.length, open: 0, awaitingMyReply: 0 };
26353
26403
  for (const thread of node2.reviewThreads.nodes) {
26354
26404
  const last = thread.comments?.nodes[0];
26355
- const lastIsOther = last?.author?.login !== me;
26356
26405
  if (!thread.isResolved) {
26357
26406
  threads.open++;
26358
- if (last && lastIsOther) threads.awaitingMyReply++;
26407
+ if (last && !isMine(last)) threads.awaitingMyReply++;
26359
26408
  }
26360
- if (last && lastIsOther) lastOtherActivity = Math.max(lastOtherActivity, Date.parse(last.createdAt));
26361
26409
  }
26362
26410
  }
26363
- const lastComment = node2.comments?.nodes[0];
26364
- if (lastComment && lastComment.author?.login !== me) {
26365
- lastOtherActivity = Math.max(lastOtherActivity, Date.parse(lastComment.createdAt));
26366
- }
26367
- const myReview = node2.viewerLatestReview;
26368
26411
  return {
26369
26412
  id: node2.id,
26370
26413
  owner,
@@ -26400,7 +26443,7 @@ function toInboxPr(node2, me, visit2) {
26400
26443
  }
26401
26444
  } : {},
26402
26445
  lastVisitAt: visit2?.visitedAt ?? null,
26403
- unread: !visit2 || visit2.headOid !== node2.headRefOid || lastOtherActivity > visit2.visitedAt
26446
+ unread: !visit2 || visit2.headOid !== node2.headRefOid || unreadComment
26404
26447
  };
26405
26448
  }
26406
26449
  function stageOf(pr) {
@@ -26417,12 +26460,11 @@ function stageOf(pr) {
26417
26460
  const reviewed = pr.reviewDecision === "CHANGES_REQUESTED" || pr.reviewCount > 0 || pr.reviews.length > 0 || (pr.threads?.total ?? 0) > 0;
26418
26461
  return reviewed ? "review" : "waiting";
26419
26462
  }
26420
- function buildBoard(sources, me, visits, staleBefore) {
26463
+ function buildBoard(sources, me, reads, staleBefore) {
26421
26464
  const nodes = /* @__PURE__ */ new Map();
26422
26465
  for (const node2 of [...sources.open, ...sources.merged]) nodes.set(node2.id, node2);
26423
26466
  const prs = [...nodes.values()].map((node2) => {
26424
- const [owner = "", repo = ""] = node2.repository.nameWithOwner.split("/");
26425
- const pr = toInboxPr(node2, me, visits.get(prKey(owner, repo, node2.number)));
26467
+ const pr = toInboxPr(node2, me, reads);
26426
26468
  const stage = stageOf(pr);
26427
26469
  const mine = pr.author?.login === me;
26428
26470
  const tags = mine ? ["mine"] : [];
@@ -26535,18 +26577,29 @@ async function loadSources(gh, config2, now) {
26535
26577
  sources: { open: nodesOf(openIds), merged: nodesOf(mergedIds), requested, involved }
26536
26578
  };
26537
26579
  }
26538
- async function loadInbox(gh, config2, viewerFallback, lastVisits) {
26580
+ async function loadInbox(gh, config2, viewerFallback, store) {
26539
26581
  const now = Date.now();
26540
26582
  const { viewer: viewer2, truncated, sources } = await loadSources(gh, config2, now);
26541
26583
  const me = viewer2 ?? await viewerFallback();
26542
- const keys = [...sources.open, ...sources.merged].map((n) => {
26543
- const [owner = "", repo = ""] = n.repository.nameWithOwner.split("/");
26544
- return prKey(owner, repo, n.number);
26545
- });
26584
+ const all = [...sources.open, ...sources.merged];
26585
+ const keys = [
26586
+ ...new Set(
26587
+ all.map((n) => {
26588
+ const [owner = "", repo = ""] = n.repository.nameWithOwner.split("/");
26589
+ return prKey(owner, repo, n.number);
26590
+ })
26591
+ )
26592
+ ];
26593
+ const reads = {
26594
+ lastVisits: store.lastVisits(keys),
26595
+ firstVisits: store.firstVisits(keys),
26596
+ threads: store.threadReads(all.flatMap((n) => n.reviewThreads?.nodes.map((t2) => t2.id) ?? [])),
26597
+ entries: store.entryReads(all.flatMap((n) => n.comments?.nodes.map((c) => c.id) ?? []))
26598
+ };
26546
26599
  return {
26547
26600
  viewer: me,
26548
26601
  configured: config2.repos.length + config2.orgs.length > 0,
26549
- prs: buildBoard(sources, me.login, lastVisits([...new Set(keys)]), now - config2.staleDays * 864e5),
26602
+ prs: buildBoard(sources, me.login, reads, now - config2.staleDays * 864e5),
26550
26603
  truncated,
26551
26604
  fetchedAt: now
26552
26605
  };
@@ -26597,11 +26650,19 @@ async function loadPr(gh, store, ref) {
26597
26650
  )
26598
26651
  ]);
26599
26652
  const reads = store.threadReads(threadNodes.map((t2) => t2.id));
26653
+ const entryIds = [...pr.comments.nodes.map((c) => c.id), ...pr.reviews.nodes.map((r) => r.id)];
26654
+ const entriesRead = store.entryReads(entryIds);
26600
26655
  const visits = store.visits(key);
26601
- const lastVisitAt = visits[0]?.visitedAt ?? 0;
26602
26656
  const seenCommits = store.seenCommits(key);
26603
26657
  const pending = pr.reviews.nodes.find((r) => r.state === "PENDING" && r.author?.login === me) ?? null;
26604
- const threads = threadNodes.map((t2) => toThread(t2, me, reads.get(t2.id)));
26658
+ const mine = (author) => author?.login === me;
26659
+ const baseline = readBaseline(store.firstVisits([key]).get(key) ?? null, [
26660
+ ...threadNodes.flatMap((t2) => t2.comments.nodes.filter((c) => mine(c.author)).map((c) => c.createdAt)),
26661
+ ...pr.comments.nodes.filter((c) => mine(c.author)).map((c) => c.createdAt),
26662
+ ...pr.reviews.nodes.filter((r) => r.state !== "PENDING" && mine(r.author)).map((r) => r.submittedAt)
26663
+ ]);
26664
+ const entryUnread = (id, author, createdAt) => !mine(author) && Date.parse(createdAt) > baseline && !entriesRead.has(id);
26665
+ const threads = threadNodes.map((t2) => toThread(t2, me, { lastReadId: reads.get(t2.id), baseline }));
26605
26666
  const pendingComments = threads.reduce((n, t2) => n + t2.comments.filter((c) => c.pending).length, 0);
26606
26667
  const reviewers = /* @__PURE__ */ new Map();
26607
26668
  for (const r of pr.latestOpinionatedReviews.nodes) {
@@ -26624,7 +26685,7 @@ async function loadPr(gh, store, ref) {
26624
26685
  reactions: toReactions(c.reactionGroups),
26625
26686
  createdAt: c.createdAt,
26626
26687
  author: c.author,
26627
- unread: c.author?.login !== me && Date.parse(c.createdAt) > lastVisitAt
26688
+ unread: entryUnread(c.id, c.author, c.createdAt)
26628
26689
  })),
26629
26690
  ...pr.reviews.nodes.filter((r) => r.state !== "PENDING" && (r.body.trim() !== "" || r.state !== "COMMENTED")).map((r) => ({
26630
26691
  kind: "review",
@@ -26636,7 +26697,7 @@ async function loadPr(gh, store, ref) {
26636
26697
  reactions: toReactions(r.reactionGroups),
26637
26698
  createdAt: r.submittedAt ?? r.createdAt,
26638
26699
  author: r.author,
26639
- unread: r.author?.login !== me && Date.parse(r.submittedAt ?? r.createdAt) > lastVisitAt
26700
+ unread: entryUnread(r.id, r.author, r.submittedAt ?? r.createdAt)
26640
26701
  }))
26641
26702
  ].sort((a, b) => a.createdAt.localeCompare(b.createdAt));
26642
26703
  return {
@@ -26702,6 +26763,19 @@ async function loadPr(gh, store, ref) {
26702
26763
  function toReactions(groups) {
26703
26764
  return (groups ?? []).filter((g) => g.reactors.totalCount > 0).map((g) => ({ content: g.content, count: g.reactors.totalCount, mine: g.viewerHasReacted }));
26704
26765
  }
26766
+ var REACTORS_SHOWN = 20;
26767
+ async function loadReactors(gh, subjectId) {
26768
+ const data = await gh.graphql(REACTORS_QUERY, {
26769
+ id: subjectId,
26770
+ first: REACTORS_SHOWN
26771
+ });
26772
+ return (data.node?.reactionGroups ?? []).filter((g) => g.reactors.totalCount > 0).map((g) => ({
26773
+ content: g.content,
26774
+ // Deleted accounts come back null.
26775
+ logins: g.reactors.nodes.flatMap((n) => n?.login ? [n.login] : []),
26776
+ total: g.reactors.totalCount
26777
+ }));
26778
+ }
26705
26779
  function toComment(c) {
26706
26780
  return {
26707
26781
  id: c.id,
@@ -26712,16 +26786,19 @@ function toComment(c) {
26712
26786
  reactions: toReactions(c.reactionGroups),
26713
26787
  createdAt: c.createdAt,
26714
26788
  author: c.author,
26789
+ unread: false,
26715
26790
  pending: c.state === "PENDING",
26716
26791
  originalCommitOid: c.originalCommit?.oid ?? null,
26717
26792
  canUpdate: c.viewerCanUpdate,
26718
26793
  canDelete: c.viewerCanDelete
26719
26794
  };
26720
26795
  }
26721
- function toThread(t2, me, lastReadId) {
26722
- const comments = t2.comments.nodes.map(toComment);
26723
- const readIdx = lastReadId ? comments.findIndex((c) => c.id === lastReadId) : -1;
26724
- const unread = comments.slice(readIdx + 1).filter((c) => c.author?.login !== me).length;
26796
+ function toThread(t2, me, read) {
26797
+ const readIdx = read.lastReadId ? t2.comments.nodes.findIndex((c) => c.id === read.lastReadId) : -1;
26798
+ const comments = t2.comments.nodes.map((node2, i) => ({
26799
+ ...toComment(node2),
26800
+ unread: i > readIdx && node2.author?.login !== me && node2.state !== "PENDING" && Date.parse(node2.createdAt) > read.baseline
26801
+ }));
26725
26802
  return {
26726
26803
  id: t2.id,
26727
26804
  path: t2.path,
@@ -26739,8 +26816,7 @@ function toThread(t2, me, lastReadId) {
26739
26816
  canReply: t2.viewerCanReply,
26740
26817
  diffHunk: t2.comments.nodes[0]?.diffHunk ?? "",
26741
26818
  comments,
26742
- unread,
26743
- lastReadCommentId: readIdx === -1 ? null : lastReadId ?? null,
26819
+ unread: comments.filter((c) => c.unread).length,
26744
26820
  pending: comments.length > 0 && comments.every((c) => c.pending)
26745
26821
  };
26746
26822
  }
@@ -26901,12 +26977,21 @@ async function addThread(gh, prId, pendingReviewId, thread, mode) {
26901
26977
  ${THREAD_FIELDS}`,
26902
26978
  { input: { pullRequestReviewId: reviewId, ...threadInput(thread) } }
26903
26979
  );
26980
+ if (!data.addPullRequestReviewThread.thread) {
26981
+ throw new GitHubError(
26982
+ `GitHub n\u2019a pas pu placer le commentaire sur ${thread.path} : position hors du diff de la PR.`,
26983
+ 422
26984
+ );
26985
+ }
26904
26986
  node2 = data.addPullRequestReviewThread.thread;
26905
26987
  } catch (error62) {
26906
26988
  if (!pendingReviewId) await discardReview(gh, reviewId).catch(() => void 0);
26907
26989
  throw error62;
26908
26990
  }
26909
- const created = toThread(node2, "", node2.comments.nodes.at(-1)?.id);
26991
+ const created = toThread(node2, "", {
26992
+ lastReadId: node2.comments.nodes.at(-1)?.id,
26993
+ baseline: Number.POSITIVE_INFINITY
26994
+ });
26910
26995
  if (mode === "review") return { thread: created, pendingReviewId: reviewId };
26911
26996
  await submitReview(gh, prId, reviewId, "COMMENT", "");
26912
26997
  return {
@@ -28492,14 +28577,7 @@ var configRouter = router({
28492
28577
  remove: procedure.input(trackedEntrySchema).mutation(({ ctx, input: input2 }) => ctx.config.remove(input2))
28493
28578
  });
28494
28579
  var inboxRouter = router({
28495
- get: procedure.query(
28496
- ({ ctx }) => loadInbox(
28497
- ctx.gh,
28498
- ctx.config.get(),
28499
- () => viewer(ctx),
28500
- (keys) => ctx.store.lastVisits(keys)
28501
- )
28502
- ),
28580
+ get: procedure.query(({ ctx }) => loadInbox(ctx.gh, ctx.config.get(), () => viewer(ctx), ctx.store)),
28503
28581
  onChange: procedure.subscription(async function* ({ ctx, signal }) {
28504
28582
  yield* pollChanges(
28505
28583
  INBOX_POLL_MS,
@@ -28543,6 +28621,9 @@ var prRouter = router({
28543
28621
  markThreadsRead: procedure.input(external_exports.array(external_exports.object({ threadId: external_exports.string(), lastCommentId: external_exports.string() }))).mutation(({ ctx, input: input2 }) => {
28544
28622
  ctx.store.markThreadsRead(input2);
28545
28623
  }),
28624
+ markEntriesRead: procedure.input(external_exports.array(external_exports.string()).min(1)).mutation(({ ctx, input: input2 }) => {
28625
+ ctx.store.markEntriesRead(input2);
28626
+ }),
28546
28627
  reply: procedure.input(
28547
28628
  external_exports.object({
28548
28629
  threadId: external_exports.string(),
@@ -28554,6 +28635,7 @@ var prRouter = router({
28554
28635
  ),
28555
28636
  setResolved: procedure.input(external_exports.object({ threadId: external_exports.string(), resolved: external_exports.boolean() })).mutation(({ ctx, input: input2 }) => setThreadResolved(ctx.gh, input2.threadId, input2.resolved)),
28556
28637
  react: procedure.input(external_exports.object({ subjectId: external_exports.string(), content: reactionContentSchema, on: external_exports.boolean() })).mutation(({ ctx, input: input2 }) => setReaction(ctx.gh, input2.subjectId, input2.content, input2.on)),
28638
+ reactors: procedure.input(external_exports.object({ subjectId: external_exports.string() })).query(({ ctx, input: input2 }) => loadReactors(ctx.gh, input2.subjectId)),
28557
28639
  addThread: procedure.input(
28558
28640
  external_exports.object({
28559
28641
  prId: external_exports.string(),
@@ -28763,8 +28845,24 @@ var MIGRATIONS = [
28763
28845
  thread_id TEXT PRIMARY KEY,
28764
28846
  last_comment_id TEXT NOT NULL,
28765
28847
  read_at INTEGER NOT NULL
28766
- );`
28848
+ );`,
28849
+ // Read state of conversation entries (PR comments, reviews); first visit as the « already read » line.
28850
+ `CREATE TABLE entry_read (
28851
+ subject_id TEXT PRIMARY KEY,
28852
+ read_at INTEGER NOT NULL
28853
+ );
28854
+ CREATE TABLE pr_first_visit (
28855
+ pr_key TEXT PRIMARY KEY,
28856
+ visited_at INTEGER NOT NULL
28857
+ );
28858
+ INSERT INTO pr_first_visit (pr_key, visited_at) SELECT pr_key, MIN(visited_at) FROM pr_visit GROUP BY pr_key;`
28767
28859
  ];
28860
+ var IN_CHUNK = 500;
28861
+ function chunks(items) {
28862
+ const out = [];
28863
+ for (let i = 0; i < items.length; i += IN_CHUNK) out.push(items.slice(i, i + IN_CHUNK));
28864
+ return out;
28865
+ }
28768
28866
  var Store = class {
28769
28867
  db;
28770
28868
  constructor(dir) {
@@ -28803,8 +28901,10 @@ var Store = class {
28803
28901
  }
28804
28902
  recordVisit(prKey2, headOid, commitOids) {
28805
28903
  this.db.exec("BEGIN");
28904
+ const now = Date.now();
28806
28905
  try {
28807
- this.db.prepare("INSERT OR REPLACE INTO pr_visit (pr_key, head_oid, visited_at) VALUES (?, ?, ?)").run(prKey2, headOid, Date.now());
28906
+ this.db.prepare("INSERT OR REPLACE INTO pr_visit (pr_key, head_oid, visited_at) VALUES (?, ?, ?)").run(prKey2, headOid, now);
28907
+ this.db.prepare("INSERT OR IGNORE INTO pr_first_visit (pr_key, visited_at) VALUES (?, ?)").run(prKey2, now);
28808
28908
  const insert = this.db.prepare("INSERT OR IGNORE INTO commit_seen (pr_key, oid) VALUES (?, ?)");
28809
28909
  for (const oid of commitOids) insert.run(prKey2, oid);
28810
28910
  this.db.exec("COMMIT");
@@ -28837,13 +28937,26 @@ ${r.hash}`);
28837
28937
  throw error62;
28838
28938
  }
28839
28939
  }
28940
+ /** First visit per PR in better-review: what was there before counts as read. */
28941
+ firstVisits(prKeys) {
28942
+ const result = /* @__PURE__ */ new Map();
28943
+ for (const keys of chunks(prKeys)) {
28944
+ const rows = this.db.prepare(
28945
+ `SELECT pr_key, visited_at FROM pr_first_visit WHERE pr_key IN (${keys.map(() => "?").join(",")})`
28946
+ ).all(...keys);
28947
+ for (const row of rows) result.set(row.pr_key, row.visited_at);
28948
+ }
28949
+ return result;
28950
+ }
28951
+ /** Last comment read per thread. */
28840
28952
  threadReads(threadIds) {
28841
28953
  const result = /* @__PURE__ */ new Map();
28842
- if (threadIds.length === 0) return result;
28843
- const rows = this.db.prepare(
28844
- `SELECT thread_id, last_comment_id FROM thread_read WHERE thread_id IN (${threadIds.map(() => "?").join(",")})`
28845
- ).all(...threadIds);
28846
- for (const row of rows) result.set(row.thread_id, row.last_comment_id);
28954
+ for (const ids of chunks(threadIds)) {
28955
+ const rows = this.db.prepare(
28956
+ `SELECT thread_id, last_comment_id FROM thread_read WHERE thread_id IN (${ids.map(() => "?").join(",")})`
28957
+ ).all(...ids);
28958
+ for (const row of rows) result.set(row.thread_id, row.last_comment_id);
28959
+ }
28847
28960
  return result;
28848
28961
  }
28849
28962
  markThreadsRead(items) {
@@ -28853,6 +28966,20 @@ ${r.hash}`);
28853
28966
  const now = Date.now();
28854
28967
  for (const item of items) stmt.run(item.threadId, item.lastCommentId, now);
28855
28968
  }
28969
+ /** Conversation entries (PR comments, reviews) read among `ids`. */
28970
+ entryReads(ids) {
28971
+ const result = /* @__PURE__ */ new Set();
28972
+ for (const chunk of chunks(ids)) {
28973
+ const rows = this.db.prepare(`SELECT subject_id FROM entry_read WHERE subject_id IN (${chunk.map(() => "?").join(",")})`).all(...chunk);
28974
+ for (const row of rows) result.add(row.subject_id);
28975
+ }
28976
+ return result;
28977
+ }
28978
+ markEntriesRead(ids) {
28979
+ const stmt = this.db.prepare("INSERT OR IGNORE INTO entry_read (subject_id, read_at) VALUES (?, ?)");
28980
+ const now = Date.now();
28981
+ for (const id of ids) stmt.run(id, now);
28982
+ }
28856
28983
  };
28857
28984
 
28858
28985
  // ../server/src/env.ts