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.
- package/dist/api/routes/activity.js +6 -6
- package/dist/api/routes/prs.js +201 -2
- package/dist/api/routes/timeline.js +1 -0
- package/dist/coding/merge.js +60 -0
- package/dist/db/queries.js +286 -103
- package/dist/github/client.js +24 -0
- package/dist/github/mutations.js +50 -1
- package/package.json +1 -1
- package/public/assets/index-D3B3ocm3.js +1390 -0
- package/public/assets/index-WLWeK-9-.css +10 -0
- package/public/index.html +2 -2
- package/public/assets/index-ChVJlk1B.js +0 -1390
- package/public/assets/index-GFkviVe0.css +0 -10
|
@@ -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
|
|
15
|
-
// narrow to the active FilterBar repo + member selection
|
|
16
|
-
//
|
|
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
|
|
23
|
-
//
|
|
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;
|
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
|
|
@@ -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),
|
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);
|