pierre-review 0.1.33 → 0.1.35

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.
@@ -10,7 +10,7 @@ const dismissSchema = {
10
10
  properties: {
11
11
  kind: {
12
12
  type: 'string',
13
- enum: ['review_request', 'thread', 'watched_repo_pr', 'claude_review'],
13
+ enum: ['review_request', 'thread', 'watched_repo_pr', 'pr_approved', 'claude_review'],
14
14
  },
15
15
  refId: { type: 'integer' },
16
16
  },
@@ -25,6 +25,7 @@ export async function meRoutes(app) {
25
25
  counts: {
26
26
  awaitingReview: myTurn.awaitingReview.length,
27
27
  yourPrsActivity: myTurn.yourPrs.length,
28
+ approvedPrs: myTurn.approvedPrs.length,
28
29
  threadsAwaiting: myTurn.threadsAwaiting.length,
29
30
  watchedRepoPrs: myTurn.watchedRepoPrs.length,
30
31
  claudeReviewsToAction: myTurn.claudeReviewsToAction.length,
@@ -1,5 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { getAccessToken, getAccountUserId } from '../../auth/account.js';
3
+ import { ghRestGetText } from '../../github/client.js';
3
4
  import { getPrDetail, getPrFilesContext, getPrWriteContext, markAllViewed, markPrViewed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
4
5
  import { buildFileAnchors, fallbackAnchor, isFindingAnchored, } from '../../github/diff-anchor.js';
5
6
  import { addIssueComment, fetchHeadShaFor, fetchPrFilesWithPatch, postInlineComment, submitPrReview, } from '../../github/mutations.js';
@@ -33,6 +34,20 @@ const idParamSchema = {
33
34
  properties: { id: { type: 'integer' } },
34
35
  },
35
36
  };
37
+ const checkLogsSchema = {
38
+ params: {
39
+ type: 'object',
40
+ required: ['id', 'jobId'],
41
+ properties: {
42
+ id: { type: 'integer' },
43
+ jobId: { type: 'integer', minimum: 1 },
44
+ },
45
+ },
46
+ querystring: {
47
+ type: 'object',
48
+ properties: { tail: { type: 'integer', minimum: 1, maximum: 1000 } },
49
+ },
50
+ };
36
51
  const markViewedSchema = {
37
52
  ...idParamSchema,
38
53
  body: {
@@ -312,5 +327,56 @@ export async function prRoutes(app) {
312
327
  return result;
313
328
  }
314
329
  });
330
+ // Failed-check logs: the tail of a GitHub Actions job's log, fetched live (never
331
+ // stored). The jobId comes from CheckRun.jobId (parsed from the Actions detailsUrl);
332
+ // only Actions checks have one, so the frontend offers this on failed Actions rows.
333
+ // Degrades to {available:false, reason} on any GitHub error (expired logs, no
334
+ // actions:read, network) instead of 500ing.
335
+ app.get('/api/prs/:id/checks/:jobId/logs', { schema: checkLogsSchema }, async (req, reply) => {
336
+ const { id, jobId } = req.params;
337
+ const { tail } = req.query;
338
+ const accountId = accountIdOf(req);
339
+ const ctx = await getPrWriteContext(id, accountId);
340
+ if (!ctx) {
341
+ reply.status(404);
342
+ return { error: 'NotFound', message: `PR ${id} not found` };
343
+ }
344
+ const tailLines = Math.min(Math.max(tail ?? 200, 1), 1000);
345
+ const unavailable = (reason) => ({
346
+ available: false,
347
+ reason,
348
+ text: '',
349
+ totalLines: 0,
350
+ returnedLines: 0,
351
+ });
352
+ try {
353
+ const token = await getAccessToken(accountId);
354
+ const res = await ghRestGetText(token, `/repos/${ctx.owner}/${ctx.name}/actions/jobs/${jobId}/logs`);
355
+ if (!res.ok) {
356
+ const reason = res.status === 404 || res.status === 410
357
+ ? 'Logs are no longer available (expired, or the job was re-run).'
358
+ : res.status === 403
359
+ ? 'No permission to read GitHub Actions logs for this repo.'
360
+ : `Couldn't fetch logs (GitHub returned ${res.status}).`;
361
+ return unavailable(reason);
362
+ }
363
+ // A job log can be many MB — normalise line endings, drop a trailing blank,
364
+ // and tail server-side so only the last N lines reach the browser. Guard the
365
+ // empty/blank body so it reports 0 lines (not [''] → a misleading "1 of 1").
366
+ const trimmed = res.text.replace(/\r\n/g, '\n').replace(/\n+$/, '');
367
+ const lines = trimmed === '' ? [] : trimmed.split('\n');
368
+ const tailed = lines.slice(-tailLines);
369
+ const result = {
370
+ available: true,
371
+ text: tailed.join('\n'),
372
+ totalLines: lines.length,
373
+ returnedLines: tailed.length,
374
+ };
375
+ return result;
376
+ }
377
+ catch {
378
+ return unavailable("Couldn't reach GitHub to fetch the logs.");
379
+ }
380
+ });
315
381
  }
316
382
  //# sourceMappingURL=prs.js.map
@@ -16,7 +16,7 @@ const REASON_PRIORITY = [
16
16
  import { db, schema, isPg } from './client.js';
17
17
  import { runTransaction } from './client.js';
18
18
  import { config } from '../config.js';
19
- import { computeTriage } from './triage.js';
19
+ import { computeApprovalInfoByPr, computeTriage, } from './triage.js';
20
20
  import { getAccountUserId } from '../auth/account.js';
21
21
  // Bind a JS Date into a raw-`sql` epoch comparison portably: Postgres columns are
22
22
  // timestamptz (drizzle binds the Date through the codec), whereas SQLite columns
@@ -1073,8 +1073,10 @@ export async function markAllViewed(accountId, repoIds) {
1073
1073
  // doesn't own (refId is a local PR/thread id). Defense-in-depth; the read path is
1074
1074
  // already account-scoped.
1075
1075
  async function ownsDismissRef(accountId, kind, refId) {
1076
- if (kind === 'review_request' || kind === 'watched_repo_pr') {
1077
- // Both reference a PR id directly.
1076
+ if (kind === 'review_request' ||
1077
+ kind === 'watched_repo_pr' ||
1078
+ kind === 'pr_approved') {
1079
+ // All three reference a PR id directly.
1078
1080
  const rows = await db
1079
1081
  .select({ id: pullRequests.id })
1080
1082
  .from(pullRequests)
@@ -1146,6 +1148,7 @@ export async function getCompletedDismissals(accountId, daysBefore = 90) {
1146
1148
  const reviewDismissals = dismissals.filter((d) => d.kind === 'review_request');
1147
1149
  const threadDismissals = dismissals.filter((d) => d.kind === 'thread');
1148
1150
  const watchedDismissals = dismissals.filter((d) => d.kind === 'watched_repo_pr');
1151
+ const approvedDismissals = dismissals.filter((d) => d.kind === 'pr_approved');
1149
1152
  const claudeDismissals = dismissals.filter((d) => d.kind === 'claude_review');
1150
1153
  const items = [];
1151
1154
  const referencedUsers = new Set();
@@ -1223,6 +1226,42 @@ export async function getCompletedDismissals(accountId, daysBefore = 90) {
1223
1226
  });
1224
1227
  }
1225
1228
  }
1229
+ // pr_approved dismissals → their PRs (account-scoped). Same shape as a
1230
+ // review_request dismissal, a different kind tag.
1231
+ if (approvedDismissals.length > 0) {
1232
+ const prRows = await db
1233
+ .select()
1234
+ .from(pullRequests)
1235
+ .innerJoin(repos, eq(repos.id, pullRequests.repoId))
1236
+ .where(and(eq(pullRequests.accountId, accountId), inArray(pullRequests.id, approvedDismissals.map((d) => d.refId))))
1237
+ .execute();
1238
+ const byId = new Map(prRows.map((r) => [r.pull_requests.id, r]));
1239
+ for (const d of approvedDismissals) {
1240
+ const row = byId.get(d.refId);
1241
+ if (!row)
1242
+ continue;
1243
+ const { pull_requests: pr, repos: repo } = row;
1244
+ if (pr.authorId != null)
1245
+ referencedUsers.add(pr.authorId);
1246
+ const restorable = actionable.approvedPrIds.has(pr.id);
1247
+ items.push({
1248
+ kind: 'pr_approved',
1249
+ prId: pr.id,
1250
+ repoFullName: `${repo.owner}/${repo.name}`,
1251
+ number: pr.number,
1252
+ title: pr.title,
1253
+ authorId: pr.authorId,
1254
+ state: pr.state,
1255
+ openedAt: pr.openedAt.toISOString(),
1256
+ githubUrl: `https://github.com/${repo.owner}/${repo.name}/pull/${pr.number}`,
1257
+ dismissedAt: d.dismissedAt.toISOString(),
1258
+ restorable,
1259
+ ...(restorable
1260
+ ? {}
1261
+ : { reason: prClosedReason(pr.state) ?? 'No longer approved' }),
1262
+ });
1263
+ }
1264
+ }
1226
1265
  // thread dismissals → their review threads + parent PR + last reply.
1227
1266
  if (threadDismissals.length > 0) {
1228
1267
  const threadIds = threadDismissals.map((d) => d.refId);
@@ -1396,6 +1435,7 @@ async function getActionableInboxIds(accountId) {
1396
1435
  const empty = {
1397
1436
  reviewRequestPrIds: new Set(),
1398
1437
  watchedPrIds: new Set(),
1438
+ approvedPrIds: new Set(),
1399
1439
  threadIds: new Set(),
1400
1440
  claudeReviewIds: new Set(),
1401
1441
  };
@@ -1409,6 +1449,12 @@ async function getActionableInboxIds(accountId) {
1409
1449
  const open = await buildTimelinePrs(openRows, accountId);
1410
1450
  const reviewRequestPrIds = new Set(open.filter((t) => t.reviewRequestedFromMe).map((t) => t.id));
1411
1451
  const watchedPrIds = await getWatchedActionablePrIds(accountId, localUserId, open, openRows);
1452
+ // Your authored, open PRs with a standing approval (restorability source for the
1453
+ // pr_approved Done entries).
1454
+ const approvalInfo = await computeApprovalInfoByPr(open.map((t) => t.id));
1455
+ const approvedPrIds = new Set(open
1456
+ .filter((t) => t.authorId === localUserId && !t.isDraft && approvalInfo.get(t.id)?.approved)
1457
+ .map((t) => t.id));
1412
1458
  const repoNameById = new Map();
1413
1459
  for (const r of await listRepos(accountId))
1414
1460
  repoNameById.set(r.id, r.fullName);
@@ -1416,13 +1462,14 @@ async function getActionableInboxIds(accountId) {
1416
1462
  const claudeReviewIds = config.claudeReviewEnabled
1417
1463
  ? new Set((await getUnactionedClaudeReviews(accountId)).map((c) => c.reviewId))
1418
1464
  : new Set();
1419
- return { reviewRequestPrIds, watchedPrIds, threadIds, claudeReviewIds };
1465
+ return { reviewRequestPrIds, watchedPrIds, approvedPrIds, threadIds, claudeReviewIds };
1420
1466
  }
1421
1467
  export async function getMyTurn(accountId) {
1422
1468
  const localUserId = await getAccountUserId(accountId);
1423
1469
  const empty = {
1424
1470
  awaitingReview: [],
1425
1471
  yourPrs: [],
1472
+ approvedPrs: [],
1426
1473
  threadsAwaiting: [],
1427
1474
  watchedRepoPrs: [],
1428
1475
  claudeReviewsToAction: [],
@@ -1449,6 +1496,9 @@ export async function getMyTurn(accountId) {
1449
1496
  .execute();
1450
1497
  const reviewDismissedAt = new Map();
1451
1498
  const threadDismissedAt = new Map();
1499
+ // Dismissed "your PR was approved" entries. Keyed by PR id; honoured until a NEWER
1500
+ // approval lands (compared against the latest approving review's timestamp below).
1501
+ const approvedDismissedAt = new Map();
1452
1502
  // Dismissed Claude-review run ids. Keyed by run id (not PR id): a fresh run gets
1453
1503
  // a new id, so it naturally re-appears without a timestamp comparison.
1454
1504
  const claudeDismissedIds = new Set();
@@ -1460,6 +1510,8 @@ export async function getMyTurn(accountId) {
1460
1510
  reviewDismissedAt.set(d.refId, d.dismissedAt);
1461
1511
  else if (d.kind === 'thread')
1462
1512
  threadDismissedAt.set(d.refId, d.dismissedAt);
1513
+ else if (d.kind === 'pr_approved')
1514
+ approvedDismissedAt.set(d.refId, d.dismissedAt);
1463
1515
  else if (d.kind === 'claude_review')
1464
1516
  claudeDismissedIds.add(d.refId);
1465
1517
  else if (d.kind === 'watched_repo_pr')
@@ -1496,9 +1548,38 @@ export async function getMyTurn(accountId) {
1496
1548
  const others = await countOtherReviewers(t.id, localUserId);
1497
1549
  return { ...toMyTurnPr(t), alsoRequested: others };
1498
1550
  }));
1499
- // 2. Your PRs with new activity since you last looked.
1551
+ // 2. Your authored, open PRs that have a standing approval (likely ready to merge).
1552
+ // An approving review lands them here; a "Done" dismissal hides them until a
1553
+ // NEWER approval arrives (compared against the latest approving review's
1554
+ // timestamp — not the PR's updatedAt, which any commit would bump and re-nag).
1555
+ // They leave automatically once the PR is merged/closed (drops out of `open`).
1556
+ const approvalInfo = await computeApprovalInfoByPr(open.map((t) => t.id));
1557
+ const approvedPrs = open
1558
+ .filter((t) => {
1559
+ // Drafts can't merge even when approved — don't claim "ready to merge".
1560
+ if (t.authorId !== localUserId || t.isDraft)
1561
+ return false;
1562
+ const info = approvalInfo.get(t.id);
1563
+ if (!info?.approved)
1564
+ return false;
1565
+ const dismissedAt = approvedDismissedAt.get(t.id);
1566
+ if (!dismissedAt)
1567
+ return true;
1568
+ const latest = info.latestApprovalAt?.getTime() ?? 0;
1569
+ return latest > dismissedAt.getTime();
1570
+ })
1571
+ .map((t) => ({
1572
+ ...toMyTurnPr(t),
1573
+ approvals: approvalInfo.get(t.id)?.approvals ?? 0,
1574
+ mergeable: t.mergeable,
1575
+ mergeStateStatus: t.mergeStateStatus,
1576
+ }));
1577
+ const approvedShownIds = new Set(approvedPrs.map((i) => i.prId));
1578
+ // 3. Your PRs with new activity since you last looked — excluding ones already shown
1579
+ // under "approved" (the stronger, more actionable signal wins).
1500
1580
  const yourPrs = open
1501
1581
  .filter((t) => t.authorId === localUserId &&
1582
+ !approvedShownIds.has(t.id) &&
1502
1583
  t.newSinceLastViewed != null &&
1503
1584
  (t.newSinceLastViewed.comments > 0 ||
1504
1585
  t.newSinceLastViewed.reviews > 0 ||
@@ -1517,6 +1598,7 @@ export async function getMyTurn(accountId) {
1517
1598
  const inOtherSections = new Set([
1518
1599
  ...awaitingReview.map((i) => i.prId),
1519
1600
  ...yourPrs.map((i) => i.prId),
1601
+ ...approvedPrs.map((i) => i.prId),
1520
1602
  ]);
1521
1603
  const watchedRepoPrs = open
1522
1604
  .filter((t) => watchedEligible.has(t.id) &&
@@ -1549,6 +1631,7 @@ export async function getMyTurn(accountId) {
1549
1631
  return {
1550
1632
  awaitingReview,
1551
1633
  yourPrs,
1634
+ approvedPrs,
1552
1635
  threadsAwaiting,
1553
1636
  watchedRepoPrs,
1554
1637
  claudeReviewsToAction,
@@ -167,7 +167,7 @@ export const myTurnDismissals = pgTable('my_turn_dismissals', {
167
167
  .notNull()
168
168
  .references(() => accounts.id),
169
169
  kind: text('kind', {
170
- enum: ['review_request', 'thread', 'watched_repo_pr', 'claude_review'],
170
+ enum: ['review_request', 'thread', 'watched_repo_pr', 'pr_approved', 'claude_review'],
171
171
  }).notNull(),
172
172
  refId: integer('ref_id').notNull(),
173
173
  dismissedAt: timestamp('dismissed_at', {
@@ -193,7 +193,7 @@ export const myTurnDismissals = sqliteTable('my_turn_dismissals', {
193
193
  .notNull()
194
194
  .references(() => accounts.id),
195
195
  kind: text('kind', {
196
- enum: ['review_request', 'thread', 'watched_repo_pr', 'claude_review'],
196
+ enum: ['review_request', 'thread', 'watched_repo_pr', 'pr_approved', 'claude_review'],
197
197
  }).notNull(),
198
198
  refId: integer('ref_id').notNull(),
199
199
  dismissedAt: integer('dismissed_at', { mode: 'timestamp' }).notNull(),
package/dist/db/triage.js CHANGED
@@ -5,11 +5,17 @@ const { reviewRequests, prViews, events, reviews } = schema;
5
5
  function emptyNew() {
6
6
  return { commits: 0, comments: 0, reviews: 0 };
7
7
  }
8
- /** Per-author latest review state → is the PR approved with no blocking review? */
9
- async function computeApprovedByPr(prIds) {
10
- const approved = new Set();
8
+ /**
9
+ * Per-author latest review state → per-PR approval standing. A reviewer's standing
10
+ * decision is their latest non-"commented" review (approved / changes_requested);
11
+ * a PR is "approved" when at least one reviewer's standing decision is approved and
12
+ * none is changes_requested. Used both for the `approved_ready` reason tag and the
13
+ * "your PR was approved" My Turn section.
14
+ */
15
+ export async function computeApprovalInfoByPr(prIds) {
16
+ const out = new Map();
11
17
  if (prIds.length === 0)
12
- return approved;
18
+ return out;
13
19
  const rows = await db
14
20
  .select({
15
21
  prId: reviews.prId,
@@ -26,25 +32,41 @@ async function computeApprovedByPr(prIds) {
26
32
  if (r.state !== 'approved' && r.state !== 'changes_requested')
27
33
  continue;
28
34
  const key = `${r.prId}:${r.authorId}`;
29
- const at = r.submittedAt.getTime();
30
35
  const prev = latest.get(key);
31
- if (!prev || at > prev.at)
32
- latest.set(key, { state: r.state, at });
36
+ if (!prev || r.submittedAt.getTime() > prev.at.getTime()) {
37
+ latest.set(key, { prId: r.prId, state: r.state, at: r.submittedAt });
38
+ }
33
39
  }
34
40
  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')
41
+ for (const v of latest.values()) {
42
+ const entry = byPr.get(v.prId) ?? { approvals: 0, blocks: 0, latestApprovalAt: null };
43
+ if (v.state === 'approved') {
39
44
  entry.approvals += 1;
40
- else
45
+ if (!entry.latestApprovalAt || v.at.getTime() > entry.latestApprovalAt.getTime()) {
46
+ entry.latestApprovalAt = v.at;
47
+ }
48
+ }
49
+ else {
41
50
  entry.blocks += 1;
42
- byPr.set(prId, entry);
51
+ }
52
+ byPr.set(v.prId, entry);
43
53
  }
44
54
  for (const [prId, e] of byPr) {
45
- if (e.approvals > 0 && e.blocks === 0)
46
- approved.add(prId);
55
+ out.set(prId, {
56
+ approved: e.approvals > 0 && e.blocks === 0,
57
+ approvals: e.approvals,
58
+ latestApprovalAt: e.latestApprovalAt,
59
+ });
47
60
  }
61
+ return out;
62
+ }
63
+ /** Per-author latest review state → is the PR approved with no blocking review? */
64
+ async function computeApprovedByPr(prIds) {
65
+ const info = await computeApprovalInfoByPr(prIds);
66
+ const approved = new Set();
67
+ for (const [prId, e] of info)
68
+ if (e.approved)
69
+ approved.add(prId);
48
70
  return approved;
49
71
  }
50
72
  /**
@@ -29,6 +29,23 @@ async function ghRest(token, method, path, body) {
29
29
  export function ghRestGetFor(token, path) {
30
30
  return ghRest(token, 'GET', path);
31
31
  }
32
+ // REST GET returning the raw response TEXT (not JSON), for endpoints with a plain-text
33
+ // body — notably the Actions job-logs endpoint, which 302-redirects to a signed
34
+ // download URL. fetch follows the redirect automatically AND strips the Authorization
35
+ // header on the cross-origin hop, so our token is never sent to the signed URL. Does
36
+ // NOT throw on a non-2xx status — returns it so the caller can degrade gracefully
37
+ // (logs expire after ~90 days / on a re-run → 404/410; missing actions:read → 403).
38
+ export async function ghRestGetText(token, path) {
39
+ const res = await fetch(`https://api.github.com${path}`, {
40
+ method: 'GET',
41
+ headers: {
42
+ authorization: `token ${token}`,
43
+ 'x-github-api-version': '2022-11-28',
44
+ },
45
+ });
46
+ const text = await res.text().catch(() => '');
47
+ return { status: res.status, ok: res.ok, text };
48
+ }
32
49
  // REST POST (submitting a PR review — inline line comments require the REST
33
50
  // reviews endpoint) for a specific account's token.
34
51
  export function ghRestPostFor(token, path, body) {
@@ -185,13 +185,30 @@ function checkContextState(c) {
185
185
  return 'unknown';
186
186
  }
187
187
  }
188
+ // A GitHub Actions check's detailsUrl is .../actions/runs/<runId>/job/<jobId> — parse
189
+ // the two ids so the frontend can fetch that job's logs on demand. Third-party CI
190
+ // (StatusContext / external CheckRuns) has a detailsUrl pointing elsewhere; no match →
191
+ // null, and the UI keeps it as a plain external link (logs aren't retrievable).
192
+ const ACTIONS_JOB_RE = /\/actions\/runs\/(\d+)\/job\/(\d+)/;
193
+ function parseActionsIds(url) {
194
+ const m = url ? ACTIONS_JOB_RE.exec(url) : null;
195
+ if (!m)
196
+ return { runId: null, jobId: null };
197
+ return { runId: Number(m[1]), jobId: Number(m[2]) };
198
+ }
188
199
  export function checkRunsFrom(head) {
189
200
  const nodes = head?.statusCheckRollup?.contexts?.nodes ?? [];
190
- return nodes.map((c) => ({
191
- name: c.__typename === 'CheckRun' ? c.name : c.context,
192
- state: checkContextState(c),
193
- url: c.__typename === 'CheckRun' ? c.detailsUrl : c.targetUrl,
194
- }));
201
+ return nodes.map((c) => {
202
+ const url = c.__typename === 'CheckRun' ? c.detailsUrl : c.targetUrl;
203
+ const { runId, jobId } = c.__typename === 'CheckRun' ? parseActionsIds(url) : { runId: null, jobId: null };
204
+ return {
205
+ name: c.__typename === 'CheckRun' ? c.name : c.context,
206
+ state: checkContextState(c),
207
+ url,
208
+ runId,
209
+ jobId,
210
+ };
211
+ });
195
212
  }
196
213
  const REVIEW_STATES = new Set([
197
214
  'approved',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pierre-review",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "description": "Dashboard for tracking your team's GitHub PR activity across repos — local (SQLite + gh) or self-hosted multi-tenant cloud (Postgres + GitHub App).",
5
5
  "type": "module",
6
6
  "author": "Alex Wakeman",