pierre-review 0.1.58 → 0.1.60
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/prs.js +201 -2
- package/dist/coding/merge.js +60 -0
- package/dist/db/queries.js +96 -0
- package/dist/github/client.js +24 -0
- package/dist/github/mutations.js +50 -1
- package/package.json +1 -1
- package/public/assets/{index-ChVJlk1B.js → index-BfGXViGK.js} +100 -100
- package/public/assets/index-yvdhLCO_.css +10 -0
- package/public/index.html +2 -2
- package/public-landing/assets/index-B6mKVK3l.js +40 -0
- package/public-landing/assets/index-BVeVh56_.css +1 -0
- package/public-landing/index.html +2 -2
- package/public/assets/index-GFkviVe0.css +0 -10
- package/public-landing/assets/index-BPqLHHsO.css +0 -1
- package/public-landing/assets/index-tnZ3hmr4.js +0 -40
package/dist/api/routes/prs.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
|
+
import { config } from '../../config.js';
|
|
2
3
|
import { getAccessToken, getAccountUserId } from '../../auth/account.js';
|
|
3
4
|
import { fetchActionsJobLog } from '../../github/actions-logs.js';
|
|
4
|
-
import { getMentionCandidates, getPrDetail, getPrFilesContext, getPrWriteContext, getReviewerLogins, markAllViewed, markPrViewed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
|
|
5
|
+
import { getMentionCandidates, getPrDetail, getPrFilesContext, getPrWriteContext, getReviewerLogins, markAllViewed, markPrMergedLocally, markPrViewed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
|
|
5
6
|
import { buildFileAnchors, fallbackAnchor, isFindingAnchored, } from '../../github/diff-anchor.js';
|
|
6
|
-
import { addIssueComment, fetchHeadShaFor, fetchPrFilesWithPatch, postInlineComment, requestReviewers, rerunWorkflowRun, submitPrReview, } from '../../github/mutations.js';
|
|
7
|
+
import { addIssueComment, fetchHeadShaFor, fetchMergeability, fetchPrFilesWithPatch, fetchPrHeadInfo, fetchRepoMergeConfig, mergePullRequest, postInlineComment, requestReviewers, rerunWorkflowRun, submitPrReview, updatePullRequestBranch, } from '../../github/mutations.js';
|
|
7
8
|
import { hydratePrDetail } from '../../sync/hydrate-detail.js';
|
|
8
9
|
import { accountIdOf } from '../plugins/auth.js';
|
|
9
10
|
// GitHub anchors a file in the PR "Files changed" diff by the SHA-256 of its
|
|
@@ -92,6 +93,23 @@ const approveSchema = {
|
|
|
92
93
|
properties: { body: { type: 'string' } },
|
|
93
94
|
},
|
|
94
95
|
};
|
|
96
|
+
const mergeSchema = {
|
|
97
|
+
...idParamSchema,
|
|
98
|
+
body: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
required: ['method'],
|
|
101
|
+
additionalProperties: false,
|
|
102
|
+
properties: { method: { type: 'string', enum: ['merge', 'squash', 'rebase'] } },
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
const updateBranchSchema = {
|
|
106
|
+
...idParamSchema,
|
|
107
|
+
body: {
|
|
108
|
+
type: 'object',
|
|
109
|
+
additionalProperties: false,
|
|
110
|
+
properties: { strategy: { type: 'string', enum: ['rebase', 'merge'] } },
|
|
111
|
+
},
|
|
112
|
+
};
|
|
95
113
|
const reviewCommentSchema = {
|
|
96
114
|
...idParamSchema,
|
|
97
115
|
body: {
|
|
@@ -259,6 +277,187 @@ export async function prRoutes(app) {
|
|
|
259
277
|
};
|
|
260
278
|
}
|
|
261
279
|
});
|
|
280
|
+
// ---- Merge (CORE / free tier) ----
|
|
281
|
+
// The merge control's options — the repo's enabled merge methods + GitHub's live mergeability.
|
|
282
|
+
// Fetched lazily (only when the control opens) so the hot PR-detail path isn't slowed by a
|
|
283
|
+
// live GitHub call. Ownership-scoped via getPrWriteContext (→ 404).
|
|
284
|
+
app.get('/api/prs/:id/merge-options', async (req, reply) => {
|
|
285
|
+
const { id } = req.params;
|
|
286
|
+
const accountId = accountIdOf(req);
|
|
287
|
+
const ctx = await getPrWriteContext(id, accountId);
|
|
288
|
+
if (!ctx) {
|
|
289
|
+
reply.status(404);
|
|
290
|
+
return { error: 'NotFound', message: `PR ${id} not found` };
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
const token = await getAccessToken(accountId);
|
|
294
|
+
const [cfg, m] = await Promise.all([
|
|
295
|
+
fetchRepoMergeConfig(token, ctx.owner, ctx.name),
|
|
296
|
+
fetchMergeability(token, ctx.owner, ctx.name, ctx.number),
|
|
297
|
+
]);
|
|
298
|
+
const allowedMethods = ['merge', 'squash', 'rebase'].filter((meth) => meth === 'merge'
|
|
299
|
+
? cfg.allowMergeCommit
|
|
300
|
+
: meth === 'squash'
|
|
301
|
+
? cfg.allowSquashMerge
|
|
302
|
+
: cfg.allowRebaseMerge);
|
|
303
|
+
const conflicts = m.mergeable === false || m.mergeableState === 'dirty';
|
|
304
|
+
const behind = m.mergeableState === 'behind' || m.behindBy > 0;
|
|
305
|
+
const result = {
|
|
306
|
+
allowedMethods: [...allowedMethods],
|
|
307
|
+
defaultMethod: allowedMethods[0] ?? 'merge',
|
|
308
|
+
mergeable: m.mergeable,
|
|
309
|
+
mergeStateStatus: m.mergeableState,
|
|
310
|
+
conflicts,
|
|
311
|
+
behind,
|
|
312
|
+
blocked: m.mergeableState === 'blocked',
|
|
313
|
+
behindBy: m.behindBy,
|
|
314
|
+
baseRef: m.baseRef,
|
|
315
|
+
canUpdateBranch: behind && !conflicts,
|
|
316
|
+
canRebaseUpdate: !config.isCloud,
|
|
317
|
+
};
|
|
318
|
+
return result;
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
reply.status(502);
|
|
322
|
+
return { error: 'GitHubError', message: err instanceof Error ? err.message : String(err) };
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
// Merge the PR (native GitHub merge; merge/squash/rebase). Re-checks write+ permission
|
|
326
|
+
// (author allowed — GitHub lets an author merge their own PR) and pre-checks conflicts, then
|
|
327
|
+
// pins the merge to the current head SHA (409 if it moved). Optimistically stamps merged.
|
|
328
|
+
app.post('/api/prs/:id/merge', { schema: mergeSchema }, async (req, reply) => {
|
|
329
|
+
const { id } = req.params;
|
|
330
|
+
const { method } = req.body;
|
|
331
|
+
const accountId = accountIdOf(req);
|
|
332
|
+
const ctx = await getPrWriteContext(id, accountId);
|
|
333
|
+
if (!ctx) {
|
|
334
|
+
reply.status(404);
|
|
335
|
+
return { error: 'NotFound', message: `PR ${id} not found` };
|
|
336
|
+
}
|
|
337
|
+
if (!['WRITE', 'MAINTAIN', 'ADMIN'].includes(ctx.viewerPermission ?? '')) {
|
|
338
|
+
reply.status(403);
|
|
339
|
+
return { error: 'NotPermitted', message: 'You need write access to merge this PR.' };
|
|
340
|
+
}
|
|
341
|
+
try {
|
|
342
|
+
const token = await getAccessToken(accountId);
|
|
343
|
+
// Live conflict pre-check — never attempt a merge on a conflicting PR (no free-tier
|
|
344
|
+
// resolution). GitHub would 405 anyway; a 409 here is clearer.
|
|
345
|
+
const m = await fetchMergeability(token, ctx.owner, ctx.name, ctx.number);
|
|
346
|
+
if (m.mergeable === false || m.mergeableState === 'dirty') {
|
|
347
|
+
reply.status(409);
|
|
348
|
+
return {
|
|
349
|
+
error: 'Conflicts',
|
|
350
|
+
conflicts: true,
|
|
351
|
+
message: 'This PR conflicts with the base branch — resolve the conflicts on GitHub.',
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const info = await fetchPrHeadInfo(token, ctx.owner, ctx.name, ctx.number);
|
|
355
|
+
const out = await mergePullRequest(token, ctx.owner, ctx.name, ctx.number, {
|
|
356
|
+
method,
|
|
357
|
+
expectedHeadSha: info.headSha,
|
|
358
|
+
});
|
|
359
|
+
if (!out.ok) {
|
|
360
|
+
const status = out.reason === 'method_disallowed' ? 422 : 409;
|
|
361
|
+
reply.status(status);
|
|
362
|
+
return {
|
|
363
|
+
error: out.reason === 'head_moved'
|
|
364
|
+
? 'HeadMoved'
|
|
365
|
+
: out.reason === 'method_disallowed'
|
|
366
|
+
? 'MethodNotAllowed'
|
|
367
|
+
: 'NotMergeable',
|
|
368
|
+
message: out.message,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
const viewerUserId = await getAccountUserId(accountId);
|
|
372
|
+
await markPrMergedLocally(id, accountId, viewerUserId);
|
|
373
|
+
const result = { merged: true, sha: out.sha, state: 'merged' };
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
reply.status(502);
|
|
378
|
+
return { error: 'GitHubError', message: err instanceof Error ? err.message : String(err) };
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
// Update the PR's branch from the base/trunk before merging. Local: clone-based rebase
|
|
382
|
+
// (default) or merge, aborting on ANY conflict (no free-tier resolution → 409). Cloud: GitHub's
|
|
383
|
+
// native update-branch (merge-only, clone-free). Gated on write+ permission.
|
|
384
|
+
app.post('/api/prs/:id/update-branch', { schema: updateBranchSchema }, async (req, reply) => {
|
|
385
|
+
const { id } = req.params;
|
|
386
|
+
const { strategy } = (req.body ?? {});
|
|
387
|
+
const accountId = accountIdOf(req);
|
|
388
|
+
const ctx = await getPrWriteContext(id, accountId);
|
|
389
|
+
if (!ctx) {
|
|
390
|
+
reply.status(404);
|
|
391
|
+
return { error: 'NotFound', message: `PR ${id} not found` };
|
|
392
|
+
}
|
|
393
|
+
if (!['WRITE', 'MAINTAIN', 'ADMIN'].includes(ctx.viewerPermission ?? '')) {
|
|
394
|
+
reply.status(403);
|
|
395
|
+
return { error: 'NotPermitted', message: 'You need write access to update this branch.' };
|
|
396
|
+
}
|
|
397
|
+
try {
|
|
398
|
+
const token = await getAccessToken(accountId);
|
|
399
|
+
// Never attempt an update on a conflicting PR — conflict resolution is a Pro feature.
|
|
400
|
+
const m = await fetchMergeability(token, ctx.owner, ctx.name, ctx.number);
|
|
401
|
+
if (m.mergeable === false || m.mergeableState === 'dirty') {
|
|
402
|
+
reply.status(409);
|
|
403
|
+
return {
|
|
404
|
+
error: 'Conflicts',
|
|
405
|
+
conflicts: true,
|
|
406
|
+
message: 'This PR conflicts with the base branch. Resolving conflicts isn’t available on the free tier — resolve them on GitHub.',
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
const info = await fetchPrHeadInfo(token, ctx.owner, ctx.name, ctx.number);
|
|
410
|
+
if (config.isCloud) {
|
|
411
|
+
// Cloud: native update-branch (merge trunk in). No clone/git on the host.
|
|
412
|
+
const out = await updatePullRequestBranch(token, ctx.owner, ctx.name, ctx.number, info.headSha);
|
|
413
|
+
if (!out.ok) {
|
|
414
|
+
reply.status(409);
|
|
415
|
+
return out.reason === 'head_moved'
|
|
416
|
+
? { error: 'HeadMoved', headMoved: true, message: out.message }
|
|
417
|
+
: { error: 'Conflicts', conflicts: true, message: out.message };
|
|
418
|
+
}
|
|
419
|
+
const result = { ok: true, headSha: null, strategy: 'merge' };
|
|
420
|
+
return result;
|
|
421
|
+
}
|
|
422
|
+
// Local: clone-based rebase (default) or merge from trunk, autoResolve:false. Dynamic
|
|
423
|
+
// import so the clone/git machinery is only loaded on this path (never in cloud).
|
|
424
|
+
const strat = strategy === 'merge' ? 'merge' : 'rebase';
|
|
425
|
+
const { updatePrBranchFromTrunk } = await import('../../coding/merge.js');
|
|
426
|
+
const out = await updatePrBranchFromTrunk({
|
|
427
|
+
accountId,
|
|
428
|
+
owner: ctx.owner,
|
|
429
|
+
name: ctx.name,
|
|
430
|
+
prNumber: ctx.number,
|
|
431
|
+
headRef: info.headRef,
|
|
432
|
+
headSha: info.headSha,
|
|
433
|
+
trunk: info.baseRef,
|
|
434
|
+
strategy: strat,
|
|
435
|
+
});
|
|
436
|
+
const result = { ok: true, headSha: out.headSha, strategy: out.strategy };
|
|
437
|
+
return result;
|
|
438
|
+
}
|
|
439
|
+
catch (err) {
|
|
440
|
+
const code = err?.code;
|
|
441
|
+
if (code === 'CONFLICTS_UNRESOLVED') {
|
|
442
|
+
reply.status(409);
|
|
443
|
+
return {
|
|
444
|
+
error: 'Conflicts',
|
|
445
|
+
conflicts: true,
|
|
446
|
+
message: 'This PR conflicts with the base branch. Resolving conflicts isn’t available on the free tier — resolve them on GitHub.',
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
if (code === 'HEAD_MOVED') {
|
|
450
|
+
reply.status(409);
|
|
451
|
+
return { error: 'HeadMoved', headMoved: true, message: err.message };
|
|
452
|
+
}
|
|
453
|
+
if (code === 'PUSH_DENIED') {
|
|
454
|
+
reply.status(403);
|
|
455
|
+
return { error: 'NotPermitted', message: err.message };
|
|
456
|
+
}
|
|
457
|
+
reply.status(502);
|
|
458
|
+
return { error: 'GitHubError', message: err instanceof Error ? err.message : String(err) };
|
|
459
|
+
}
|
|
460
|
+
});
|
|
262
461
|
// Add ONE inline review comment, posted immediately. Validates the requested
|
|
263
462
|
// (path, line, side) lands on an addable diff line; if not, re-anchors to the
|
|
264
463
|
// file's first changed line; if the file has no changes at all, returns a
|
package/dist/coding/merge.js
CHANGED
|
@@ -480,6 +480,66 @@ export async function mergeResolveAndPush(args) {
|
|
|
480
480
|
await teardown(owner, name, repoCloneDir, worktreePath);
|
|
481
481
|
}
|
|
482
482
|
}
|
|
483
|
+
// ---- CORE (free tier): update a PR's OWN branch from its base/trunk ----
|
|
484
|
+
// Rebase (default) or merge the trunk into the PR's head branch and push. This is the local,
|
|
485
|
+
// clone-based path for the free-tier "Update branch from trunk" — reusing runRebase/runMerge
|
|
486
|
+
// with autoResolve:false, so on ANY conflict the op is aborted and CONFLICTS_UNRESOLVED is
|
|
487
|
+
// thrown (never the SDK resolver — conflict resolution is a Pro feature). No stored patch is
|
|
488
|
+
// involved (unlike the AI-Fix paths above): we check the PR head out as-is and reconcile it.
|
|
489
|
+
export async function updatePrBranchFromTrunk(args) {
|
|
490
|
+
const { accountId, owner, name, prNumber, headRef, headSha, trunk, strategy } = args;
|
|
491
|
+
const token = await getAccessToken(accountId);
|
|
492
|
+
const ident = await identFor(accountId);
|
|
493
|
+
// The head must not have moved out from under us, and a fork head must be pushable.
|
|
494
|
+
const info = await fetchPrHeadInfo(token, owner, name, prNumber);
|
|
495
|
+
if (info.headSha !== headSha) {
|
|
496
|
+
throw codedError('HEAD_MOVED', `the PR head advanced (now ${info.headSha.slice(0, 7)}, expected ${headSha.slice(0, 7)})`);
|
|
497
|
+
}
|
|
498
|
+
if (info.isFork && !info.maintainerCanModify) {
|
|
499
|
+
throw codedError('PUSH_DENIED', 'the PR head is a fork this account cannot push to — update it on GitHub instead');
|
|
500
|
+
}
|
|
501
|
+
// The PR head branch lives in the HEAD repo, which for a fork PR is NOT the base (watched)
|
|
502
|
+
// repo — push there, or a merge would create a stray branch on the base and a rebase's lease
|
|
503
|
+
// would target the wrong ref. The clone/fetch still uses the base repo below (pull/N/head
|
|
504
|
+
// resolves there); only the PUSH target is the head repo. full_name is always `owner/name`.
|
|
505
|
+
const slash = info.headRepoFullName.indexOf('/');
|
|
506
|
+
const headOwner = slash > 0 ? info.headRepoFullName.slice(0, slash) : owner;
|
|
507
|
+
const headName = slash > 0 ? info.headRepoFullName.slice(slash + 1) : name;
|
|
508
|
+
// autoResolve:false → runRebase/runMerge abort on the first conflict and throw
|
|
509
|
+
// CONFLICTS_UNRESOLVED; the model/resolver fields are never touched (no SDK import).
|
|
510
|
+
const opts = {
|
|
511
|
+
autoResolve: false,
|
|
512
|
+
model: '',
|
|
513
|
+
abortController: new AbortController(),
|
|
514
|
+
onProgress: () => { },
|
|
515
|
+
};
|
|
516
|
+
let repoCloneDir = null;
|
|
517
|
+
let worktreePath = null;
|
|
518
|
+
try {
|
|
519
|
+
// Check out the PR head branch (prepWorktree checks out at the given sha).
|
|
520
|
+
({ repoCloneDir, worktreePath } = await prepWorktree(owner, name, prNumber, headSha, token));
|
|
521
|
+
const trunkSha = await fetchTrunk(worktreePath, owner, name, token, trunk);
|
|
522
|
+
if (strategy === 'rebase') {
|
|
523
|
+
await runRebase(worktreePath, trunkSha, ident, opts); // throws CONFLICTS_UNRESOLVED on conflict
|
|
524
|
+
const newSha = (await git(['rev-parse', 'HEAD'], worktreePath)).stdout.trim();
|
|
525
|
+
// Rewriting history requires a force push; the lease pins to the sha we cloned so a
|
|
526
|
+
// concurrent push aborts it (never blows away someone else's work).
|
|
527
|
+
await pushForceWithLease(worktreePath, headOwner, headName, token, 'HEAD', headRef, headSha);
|
|
528
|
+
return { headSha: newSha, strategy };
|
|
529
|
+
}
|
|
530
|
+
await runMerge(worktreePath, trunkSha, trunk, headRef, ident, opts); // throws on conflict
|
|
531
|
+
const newSha = (await git(['rev-parse', 'HEAD'], worktreePath)).stdout.trim();
|
|
532
|
+
// A merge only adds a commit (the old head is its ancestor) → a plain push. Already
|
|
533
|
+
// up-to-date (runMerge no-op) leaves HEAD unchanged → nothing to push.
|
|
534
|
+
if (newSha !== headSha) {
|
|
535
|
+
await pushRef(worktreePath, headOwner, headName, token, 'HEAD', headRef);
|
|
536
|
+
}
|
|
537
|
+
return { headSha: newSha, strategy };
|
|
538
|
+
}
|
|
539
|
+
finally {
|
|
540
|
+
await teardown(owner, name, repoCloneDir, worktreePath);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
483
543
|
export async function pushResolved(args) {
|
|
484
544
|
const { accountId, owner, name, prNumber, trunk, mbox, target, onProgress } = args;
|
|
485
545
|
const token = await getAccessToken(accountId);
|
package/dist/db/queries.js
CHANGED
|
@@ -1134,6 +1134,76 @@ export async function getActivity(accountId, repoIds, userIds = null) {
|
|
|
1134
1134
|
// Every My Turn (participated) event is always kept; the plain activity rows are bounded to
|
|
1135
1135
|
// the most recent, so a busy multi-repo account doesn't render thousands of them.
|
|
1136
1136
|
const FEED_EVENT_CAP = 250;
|
|
1137
|
+
// A submitted review and the same actor's top-level PR comment(s) on the same PR are folded
|
|
1138
|
+
// into ONE card when they land within this window of each other (issue comments carry no head
|
|
1139
|
+
// SHA, so time is the only proxy for "posted together"). Symmetric around the review's time.
|
|
1140
|
+
const REVIEW_COMMENT_MERGE_WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
|
1141
|
+
// Fold each actor's near-in-time top-level PR comment(s) INTO their review on the same PR
|
|
1142
|
+
// (appending to the review's `mergedComments`) and remove those comment rows from `items` +
|
|
1143
|
+
// `byId`, IN PLACE. A comment with no review within the window keeps its own row; a comment
|
|
1144
|
+
// is claimed by its NEAREST review, so two reviews in one window don't both grab it. This is
|
|
1145
|
+
// what collapses "submit a review, then drop a summary comment" into a single feed card.
|
|
1146
|
+
// Exported for unit tests (pure over the item arrays).
|
|
1147
|
+
export function coalesceReviewComments(items, byId) {
|
|
1148
|
+
const reviewsByKey = new Map();
|
|
1149
|
+
const commentsByKey = new Map();
|
|
1150
|
+
const bucketPush = (m, key, it) => {
|
|
1151
|
+
const arr = m.get(key);
|
|
1152
|
+
if (arr)
|
|
1153
|
+
arr.push(it);
|
|
1154
|
+
else
|
|
1155
|
+
m.set(key, [it]);
|
|
1156
|
+
};
|
|
1157
|
+
for (const it of items) {
|
|
1158
|
+
if (it.actorId == null || it.prId == null)
|
|
1159
|
+
continue;
|
|
1160
|
+
const key = `${it.actorId}:${it.prId}`;
|
|
1161
|
+
if (it.kind === 'review_submitted')
|
|
1162
|
+
bucketPush(reviewsByKey, key, it);
|
|
1163
|
+
else if (it.kind === 'pr_comment' && it.commentId != null)
|
|
1164
|
+
bucketPush(commentsByKey, key, it);
|
|
1165
|
+
}
|
|
1166
|
+
if (reviewsByKey.size === 0 || commentsByKey.size === 0)
|
|
1167
|
+
return;
|
|
1168
|
+
const foldedIds = new Set();
|
|
1169
|
+
for (const [key, comments] of commentsByKey) {
|
|
1170
|
+
const reviews = reviewsByKey.get(key);
|
|
1171
|
+
if (reviews == null)
|
|
1172
|
+
continue;
|
|
1173
|
+
for (const comment of comments) {
|
|
1174
|
+
const ct = Date.parse(comment.occurredAt);
|
|
1175
|
+
let best = null;
|
|
1176
|
+
let bestDist = Infinity;
|
|
1177
|
+
for (const rev of reviews) {
|
|
1178
|
+
const dist = Math.abs(Date.parse(rev.occurredAt) - ct);
|
|
1179
|
+
if (dist <= REVIEW_COMMENT_MERGE_WINDOW_MS && dist < bestDist) {
|
|
1180
|
+
best = rev;
|
|
1181
|
+
bestDist = dist;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (best == null)
|
|
1185
|
+
continue;
|
|
1186
|
+
best.mergedComments.push({
|
|
1187
|
+
commentId: comment.commentId,
|
|
1188
|
+
content: comment.content ?? '',
|
|
1189
|
+
occurredAt: comment.occurredAt,
|
|
1190
|
+
});
|
|
1191
|
+
foldedIds.add(comment.id);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (foldedIds.size === 0)
|
|
1195
|
+
return;
|
|
1196
|
+
// Chronological within each review (a review can fold more than one comment).
|
|
1197
|
+
for (const reviews of reviewsByKey.values())
|
|
1198
|
+
for (const rev of reviews)
|
|
1199
|
+
if (rev.mergedComments.length > 1)
|
|
1200
|
+
rev.mergedComments.sort((a, b) => a.occurredAt.localeCompare(b.occurredAt));
|
|
1201
|
+
for (const id of foldedIds)
|
|
1202
|
+
byId.delete(id);
|
|
1203
|
+
const kept = items.filter((it) => !foldedIds.has(it.id));
|
|
1204
|
+
items.length = 0;
|
|
1205
|
+
items.push(...kept);
|
|
1206
|
+
}
|
|
1137
1207
|
// Commit runs that ADDRESSED a review thread — the only commit activity surfaced in the
|
|
1138
1208
|
// consolidated feed (plain pushes are noise, hidden on the timeline by default too). A
|
|
1139
1209
|
// "run" is consecutive commits by one author on one PR (a >6h gap splits runs). A run is
|
|
@@ -1359,6 +1429,7 @@ async function getCommitThreadItems(accountId, opts) {
|
|
|
1359
1429
|
changeSummary: `pushed ${run.commitCount} ${commitWord} · addressed ${affected.length} ${threadWord}`,
|
|
1360
1430
|
claudeReviewId: null,
|
|
1361
1431
|
claudeVerdict: null,
|
|
1432
|
+
mergedComments: [],
|
|
1362
1433
|
});
|
|
1363
1434
|
}
|
|
1364
1435
|
return out;
|
|
@@ -1437,6 +1508,7 @@ async function getClaudeReviewFeedItems(accountId, repoIds, since) {
|
|
|
1437
1508
|
claudeReviewId: r.reviewId,
|
|
1438
1509
|
// Prefer the user's decision when they've set one, else Claude's read-only verdict.
|
|
1439
1510
|
claudeVerdict: r.userVerdict ?? r.verdict,
|
|
1511
|
+
mergedComments: [],
|
|
1440
1512
|
});
|
|
1441
1513
|
}
|
|
1442
1514
|
return out;
|
|
@@ -2318,6 +2390,7 @@ export async function getConsolidatedFeed(accountId, opts = {}) {
|
|
|
2318
2390
|
changeSummary: null,
|
|
2319
2391
|
claudeReviewId: null,
|
|
2320
2392
|
claudeVerdict: null,
|
|
2393
|
+
mergedComments: [],
|
|
2321
2394
|
});
|
|
2322
2395
|
}
|
|
2323
2396
|
// Commit-push items (already repo/member/bot-scoped + thread-enriched in the SQL helper).
|
|
@@ -2328,6 +2401,13 @@ export async function getConsolidatedFeed(accountId, opts = {}) {
|
|
|
2328
2401
|
// flow but always retained (see the caps below) so the "Claude Reviews" pill finds them.
|
|
2329
2402
|
for (const it of claudeItems)
|
|
2330
2403
|
push(it);
|
|
2404
|
+
// Consolidate a submitted review + the SAME actor's top-level PR comment(s) on the SAME PR
|
|
2405
|
+
// posted within a short window (issue comments carry no head SHA, so time is the proxy):
|
|
2406
|
+
// fold the comment(s) into the review's `mergedComments` and drop their standalone rows, so
|
|
2407
|
+
// "review, then a summary comment" reads as one card everywhere (including FYI) instead of
|
|
2408
|
+
// two. Runs BEFORE the FYI enrich/cap/paginate so `total`, participation and page bounds
|
|
2409
|
+
// reflect the collapsed set (a comment folded here is never separately flagged or capped).
|
|
2410
|
+
coalesceReviewComments(items, byId);
|
|
2331
2411
|
// FYI / "My Turn" enrichment (Pro): flag each item `isMyTurn` by the viewer's participation
|
|
2332
2412
|
// in its PR. Runs BEFORE the cap so uncapped My-Turn rows survive. No-op (feed stays plain)
|
|
2333
2413
|
// when the plugin isn't loaded — the free tier.
|
|
@@ -3830,6 +3910,22 @@ export async function getPrWriteContext(prId, accountId) {
|
|
|
3830
3910
|
prUrl: `https://github.com/${row.owner}/${row.name}/pull/${row.number}`,
|
|
3831
3911
|
};
|
|
3832
3912
|
}
|
|
3913
|
+
// Optimistically stamp a PR as merged after a successful GitHub merge, so the UI reflects it
|
|
3914
|
+
// immediately (the ['pr',id] query is staleTime:Infinity + IndexedDB-persisted, so the mutation
|
|
3915
|
+
// MUST invalidate it — but stamping first avoids a flash of the stale 'open' state). Account-
|
|
3916
|
+
// scoped. The next sync reconciles the authoritative merge metadata.
|
|
3917
|
+
export async function markPrMergedLocally(prId, accountId, mergedById) {
|
|
3918
|
+
await db
|
|
3919
|
+
.update(pullRequests)
|
|
3920
|
+
.set({
|
|
3921
|
+
state: 'merged',
|
|
3922
|
+
mergedAt: new Date(),
|
|
3923
|
+
mergedById,
|
|
3924
|
+
mergeStateStatus: 'unknown',
|
|
3925
|
+
})
|
|
3926
|
+
.where(and(eq(pullRequests.id, prId), eq(pullRequests.accountId, accountId)))
|
|
3927
|
+
.execute();
|
|
3928
|
+
}
|
|
3833
3929
|
// Resolve a set of user ids to their GitHub logins for a reviewer request. Bots are
|
|
3834
3930
|
// dropped (GitHub 422s on bot reviewers); the `users` table is global so no account
|
|
3835
3931
|
// scope is needed (the caller already gated the PR by ownership + write access).
|
package/dist/github/client.js
CHANGED
|
@@ -64,6 +64,30 @@ export async function ghRestGetDiff(token, path) {
|
|
|
64
64
|
}
|
|
65
65
|
return res.text();
|
|
66
66
|
}
|
|
67
|
+
// REST PUT returning the parsed STATUS + body WITHOUT throwing on a non-2xx, so the caller can
|
|
68
|
+
// map GitHub's meaningful merge / update-branch statuses to structured results (405 not
|
|
69
|
+
// mergeable, 409 head-sha mismatch, 422 method disallowed / can't update). Per-account token.
|
|
70
|
+
export async function ghRestPutStatus(token, path, body) {
|
|
71
|
+
const res = await fetch(`https://api.github.com${path}`, {
|
|
72
|
+
method: 'PUT',
|
|
73
|
+
headers: {
|
|
74
|
+
authorization: `token ${token}`,
|
|
75
|
+
accept: 'application/vnd.github+json',
|
|
76
|
+
'x-github-api-version': '2022-11-28',
|
|
77
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
78
|
+
},
|
|
79
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
80
|
+
});
|
|
81
|
+
const text = await res.text().catch(() => '');
|
|
82
|
+
let json = null;
|
|
83
|
+
try {
|
|
84
|
+
json = text ? JSON.parse(text) : null;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
/* non-JSON body (rare on these endpoints) */
|
|
88
|
+
}
|
|
89
|
+
return { status: res.status, ok: res.ok, json, text };
|
|
90
|
+
}
|
|
67
91
|
// REST POST (submitting a PR review — inline line comments require the REST
|
|
68
92
|
// reviews endpoint) for a specific account's token.
|
|
69
93
|
export function ghRestPostFor(token, path, body) {
|
package/dist/github/mutations.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
//
|
|
7
7
|
// GraphQL note: @octokit/graphql RESERVES the variable name `query`. The
|
|
8
8
|
// operation-variable here is named `input` / etc., never `query`.
|
|
9
|
-
import { getGraphqlClientFor, ghRestGetDiff, ghRestGetFor, ghRestPostFor, ghRestPostNoContent, } from './client.js';
|
|
9
|
+
import { getGraphqlClientFor, ghRestGetDiff, ghRestGetFor, ghRestPostFor, ghRestPostNoContent, ghRestPutStatus, } from './client.js';
|
|
10
10
|
const ADD_REPLY_MUTATION = /* GraphQL */ `
|
|
11
11
|
mutation AddThreadReply($input: AddPullRequestReviewThreadReplyInput!) {
|
|
12
12
|
addPullRequestReviewThreadReply(input: $input) {
|
|
@@ -228,6 +228,55 @@ export async function fetchMergeability(token, owner, name, number) {
|
|
|
228
228
|
aheadBy,
|
|
229
229
|
};
|
|
230
230
|
}
|
|
231
|
+
// ---- PR merge + update-from-trunk (CORE / free tier) ----
|
|
232
|
+
// The repo's enabled merge methods + default branch (GET /repos/{o}/{n}). Not synced — a
|
|
233
|
+
// rarely-changing repo setting, fetched live only when the merge control opens. Missing flags
|
|
234
|
+
// default true (GitHub's own default for a new repo).
|
|
235
|
+
export async function fetchRepoMergeConfig(token, owner, name) {
|
|
236
|
+
const repo = await ghRestGetFor(token, `/repos/${owner}/${name}`);
|
|
237
|
+
return {
|
|
238
|
+
allowMergeCommit: repo.allow_merge_commit ?? true,
|
|
239
|
+
allowSquashMerge: repo.allow_squash_merge ?? true,
|
|
240
|
+
allowRebaseMerge: repo.allow_rebase_merge ?? true,
|
|
241
|
+
defaultBranch: repo.default_branch,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
// Merge the PR via GitHub's native endpoint (PUT .../merge). Pins `sha` to the expected head so
|
|
245
|
+
// GitHub 409s if the branch moved. Cloud-safe, clone-free. The caller pre-checks conflicts.
|
|
246
|
+
export async function mergePullRequest(token, owner, name, number, opts) {
|
|
247
|
+
const body = { merge_method: opts.method };
|
|
248
|
+
if (opts.expectedHeadSha)
|
|
249
|
+
body.sha = opts.expectedHeadSha;
|
|
250
|
+
const res = await ghRestPutStatus(token, `/repos/${owner}/${name}/pulls/${number}/merge`, body);
|
|
251
|
+
const j = (res.json ?? {});
|
|
252
|
+
if (res.ok && j.merged)
|
|
253
|
+
return { ok: true, sha: j.sha ?? '' };
|
|
254
|
+
const message = j.message ?? res.text.slice(0, 300);
|
|
255
|
+
const reason = res.status === 409
|
|
256
|
+
? 'head_moved'
|
|
257
|
+
: res.status === 422
|
|
258
|
+
? 'method_disallowed'
|
|
259
|
+
: res.status === 405
|
|
260
|
+
? 'not_mergeable'
|
|
261
|
+
: 'error';
|
|
262
|
+
return { ok: false, reason, message };
|
|
263
|
+
}
|
|
264
|
+
// GitHub's NATIVE "update branch from base" (PUT .../update-branch) — a merge commit of the base
|
|
265
|
+
// into the head. Cloud-safe, clone-free (the cloud path for update-from-trunk). Merge-only: there
|
|
266
|
+
// is no native rebase. 202 = accepted; 422 = can't update — which GitHub uses BOTH for a stale
|
|
267
|
+
// expected_head_sha (a head-moved race — its message mentions the expected head sha) AND for a
|
|
268
|
+
// genuine can't-merge, so we disambiguate on the message.
|
|
269
|
+
export async function updatePullRequestBranch(token, owner, name, number, expectedHeadSha) {
|
|
270
|
+
const body = expectedHeadSha ? { expected_head_sha: expectedHeadSha } : undefined;
|
|
271
|
+
const res = await ghRestPutStatus(token, `/repos/${owner}/${name}/pulls/${number}/update-branch`, body);
|
|
272
|
+
if (res.status === 202)
|
|
273
|
+
return { ok: true };
|
|
274
|
+
const j = (res.json ?? {});
|
|
275
|
+
const message = j.message ?? res.text.slice(0, 300);
|
|
276
|
+
const headMoved = res.status === 409 || /expected head sha/i.test(message);
|
|
277
|
+
const reason = headMoved ? 'head_moved' : res.status === 422 ? 'conflicts' : 'error';
|
|
278
|
+
return { ok: false, reason, message };
|
|
279
|
+
}
|
|
231
280
|
// The PR's unified diff via the REST API (Accept: application/vnd.github.diff) —
|
|
232
281
|
// cloud-ready, per-account. Bounded implicitly by GitHub's own diff-size limit.
|
|
233
282
|
export async function fetchPrUnifiedDiff(token, owner, name, number) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pierre-review",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.60",
|
|
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",
|