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.
- package/dist/api/routes/me.js +2 -1
- package/dist/api/routes/prs.js +66 -0
- package/dist/db/queries.js +88 -5
- package/dist/db/schema.pg.js +1 -1
- package/dist/db/schema.sqlite.js +1 -1
- package/dist/db/triage.js +37 -15
- package/dist/github/client.js +17 -0
- package/dist/sync/upsert.js +22 -5
- package/package.json +1 -1
- package/public/assets/index-DAMfX915.js +1373 -0
- package/public/assets/index-Dszr6Qrp.css +10 -0
- package/public/index.html +2 -2
- package/public/assets/index-Cpyxyw6b.js +0 -1373
- package/public/assets/index-D0MK9jNL.css +0 -10
package/dist/api/routes/me.js
CHANGED
|
@@ -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,
|
package/dist/api/routes/prs.js
CHANGED
|
@@ -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
|
package/dist/db/queries.js
CHANGED
|
@@ -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' ||
|
|
1077
|
-
|
|
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
|
|
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,
|
package/dist/db/schema.pg.js
CHANGED
|
@@ -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', {
|
package/dist/db/schema.sqlite.js
CHANGED
|
@@ -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
|
-
/**
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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 ||
|
|
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
|
|
36
|
-
const
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
}
|
|
52
|
+
byPr.set(v.prId, entry);
|
|
43
53
|
}
|
|
44
54
|
for (const [prId, e] of byPr) {
|
|
45
|
-
|
|
46
|
-
approved.
|
|
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
|
/**
|
package/dist/github/client.js
CHANGED
|
@@ -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) {
|
package/dist/sync/upsert.js
CHANGED
|
@@ -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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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.
|
|
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",
|