pierre-review 0.1.59 → 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.
@@ -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
@@ -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);
@@ -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).
@@ -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) {
@@ -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.59",
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",