pierre-review 0.1.59 → 0.1.61

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.
@@ -11,17 +11,17 @@ function parseIntList(raw) {
11
11
  }
12
12
  export async function activityRoutes(app) {
13
13
  // The Activity aggregate: per repo, current-state stats + thread totals +
14
- // attention/unread flags + open PRs. Scoped to the account; `repoIds` + `userIds`
15
- // narrow to the active FilterBar repo + member selection (across ALL the account's
16
- // repos — watched-only was dropped). Pure DB read — no GitHub sync, no AI.
14
+ // attention/unread flags + open PRs. Scoped to the account's WATCHED repos (inboxWatch);
15
+ // `repoIds` + `userIds` narrow to the active FilterBar repo + member selection WITHIN
16
+ // watched. Pure DB read — no GitHub sync, no AI.
17
17
  app.get('/api/activity', async (req) => {
18
18
  const q = req.query;
19
19
  return getActivity(accountIdOf(req), parseIntList(q.repoIds), parseIntList(q.userIds));
20
20
  });
21
21
  // The consolidated Feed (the Activity "Feed" entry): one flat, chronological stream
22
- // merging My Turn actionables + the activity feed, deduped. Scoped by the FilterBar
23
- // repo + member selection across ALL the account's repos (watched-only dropped).
24
- // Pure DB read — no GitHub sync, no AI.
22
+ // merging My Turn actionables + the activity feed, deduped. Scoped to the account's
23
+ // WATCHED repos (inboxWatch); the FilterBar repo + member selection narrow WITHIN
24
+ // watched. Pure DB read — no GitHub sync, no AI.
25
25
  app.get('/api/activity/feed', async (req) => {
26
26
  const q = req.query;
27
27
  const limit = q.limit != null ? Number(q.limit) : null;
@@ -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
@@ -81,6 +81,7 @@ export async function timelineRoutes(app) {
81
81
  from: parseDate(q.from, new Date(now.getTime() - 14 * DAY_MS)),
82
82
  to: parseDate(q.to, now),
83
83
  repoIds: parseIntList(q.repoIds),
84
+ prIds: parseIntList(q.prIds),
84
85
  userIds: parseIntList(q.userIds),
85
86
  types: parseTypes(q.types),
86
87
  statuses: parseStatuses(q.statuses),
@@ -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);