pierre-review 0.1.67 → 0.1.69
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 +100 -16
- package/dist/db/queries.js +117 -0
- package/dist/github/client.js +16 -0
- package/dist/github/codeowners.js +163 -0
- package/dist/github/mutations.js +9 -4
- package/package.json +1 -1
- package/public/assets/AiFixTab-B1BGyJ-q.js +3 -0
- package/public/assets/ClaudeReviewTab-WT_d-FZp.js +11 -0
- package/public/assets/highlight-BEHUn5zE.css +10 -0
- package/public/assets/highlight-C8vPehS1.js +5 -0
- package/public/assets/index-DNiwLu1C.js +49 -0
- package/public/assets/index-DlGtrzyK.css +1 -0
- package/public/assets/markdown-BUNN1sk6.js +32 -0
- package/public/assets/tanstack-R-s9abdD.js +17 -0
- package/public/assets/vis-C3BCkP5U.js +1280 -0
- package/public/assets/vis-ziCVehMy.css +1 -0
- package/public/index.html +8 -2
- package/public/assets/index-BF0g4p9J.js +0 -1390
- package/public/assets/index-FnMZwCYx.css +0 -10
package/dist/api/routes/prs.js
CHANGED
|
@@ -2,7 +2,8 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
import { config } from '../../config.js';
|
|
3
3
|
import { getAccessToken, getAccountUserId } from '../../auth/account.js';
|
|
4
4
|
import { fetchActionsJobLog } from '../../github/actions-logs.js';
|
|
5
|
-
import { getMentionCandidates, getPrDetail, getPrFilesContext, getPrWriteContext, getReviewerLogins, markAllViewed, markPrMergedLocally, markPrViewed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
|
|
5
|
+
import { getMentionCandidates, getPrDetail, getPrFilesContext, getPrWriteContext, getReviewerLogins, getUsersByLogins, markAllViewed, markPrMergedLocally, markPrViewed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
|
|
6
|
+
import { getCodeownersMatch } from '../../github/codeowners.js';
|
|
6
7
|
import { buildFileAnchors, fallbackAnchor, isFindingAnchored, } from '../../github/diff-anchor.js';
|
|
7
8
|
import { addIssueComment, fetchHeadShaFor, fetchMergeability, fetchPrFilesWithPatch, fetchPrHeadInfo, fetchRepoMergeConfig, mergePullRequest, postInlineComment, requestReviewers, rerunWorkflowRun, submitPrReview, updatePullRequestBranch, } from '../../github/mutations.js';
|
|
8
9
|
import { hydratePrDetail } from '../../sync/hydrate-detail.js';
|
|
@@ -128,18 +129,95 @@ const requestReviewersSchema = {
|
|
|
128
129
|
...idParamSchema,
|
|
129
130
|
body: {
|
|
130
131
|
type: 'object',
|
|
131
|
-
required: ['userIds'],
|
|
132
132
|
additionalProperties: false,
|
|
133
|
+
// All three are optional; the handler 400s if the combined set is empty. `userIds`
|
|
134
|
+
// are resolved to logins; `logins` pass through (suggested reviewers we haven't synced);
|
|
135
|
+
// `teamSlugs` become team review requests (CODEOWNERS @org/team).
|
|
133
136
|
properties: {
|
|
134
|
-
userIds: {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
maxItems: 15,
|
|
138
|
-
items: { type: 'integer' },
|
|
139
|
-
},
|
|
137
|
+
userIds: { type: 'array', maxItems: 15, items: { type: 'integer' } },
|
|
138
|
+
logins: { type: 'array', maxItems: 15, items: { type: 'string' } },
|
|
139
|
+
teamSlugs: { type: 'array', maxItems: 15, items: { type: 'string' } },
|
|
140
140
|
},
|
|
141
141
|
},
|
|
142
142
|
};
|
|
143
|
+
// Layer CODEOWNERS-derived suggestions on top of the history-based ones getPrDetail already
|
|
144
|
+
// computed. Runs ONLY when the PR still warrants suggestions (same trigger getPrDetail used).
|
|
145
|
+
// Best-effort: any failure (no CODEOWNERS, org wall, parse error) leaves the history
|
|
146
|
+
// suggestions untouched. CODEOWNERS owners (declared ownership) take precedence, then history
|
|
147
|
+
// fills up to a small cap. Mutates + returns the detail.
|
|
148
|
+
async function enrichSuggestionsWithCodeowners(detail, accountId) {
|
|
149
|
+
const wants = detail.state === 'open' &&
|
|
150
|
+
!detail.isDraft &&
|
|
151
|
+
detail.requestedReviewers.length === 0 &&
|
|
152
|
+
detail.reviews.length === 0;
|
|
153
|
+
if (!wants)
|
|
154
|
+
return detail;
|
|
155
|
+
const [owner, name] = detail.repoFullName.split('/');
|
|
156
|
+
if (!owner || !name)
|
|
157
|
+
return detail;
|
|
158
|
+
const paths = detail.files.map((f) => f.path);
|
|
159
|
+
if (paths.length === 0)
|
|
160
|
+
return detail;
|
|
161
|
+
try {
|
|
162
|
+
const token = await getAccessToken(accountId);
|
|
163
|
+
const match = await getCodeownersMatch(token, owner, name, accountId, paths);
|
|
164
|
+
if (match.logins.length === 0 && match.teams.length === 0)
|
|
165
|
+
return detail;
|
|
166
|
+
const authorLogin = detail.authorId != null
|
|
167
|
+
? detail.users.find((u) => u.id === detail.authorId)?.githubLogin ?? null
|
|
168
|
+
: null;
|
|
169
|
+
// Resolve @user owners to synced users (avatar/link); unsynced owners show login-only.
|
|
170
|
+
// Exclude the PR author (GitHub rejects self-review requests).
|
|
171
|
+
const uniqueLogins = [...new Set(match.logins)].filter((l) => l !== authorLogin);
|
|
172
|
+
const resolved = await getUsersByLogins(uniqueLogins);
|
|
173
|
+
const byLogin = new Map(resolved.map((u) => [u.githubLogin, u]));
|
|
174
|
+
const codeownerUsers = uniqueLogins.map((login) => ({
|
|
175
|
+
kind: 'user',
|
|
176
|
+
login,
|
|
177
|
+
userId: byLogin.get(login)?.id ?? null,
|
|
178
|
+
teamSlug: null,
|
|
179
|
+
teamName: null,
|
|
180
|
+
reason: 'owns this path (CODEOWNERS)',
|
|
181
|
+
source: 'codeowners',
|
|
182
|
+
}));
|
|
183
|
+
const codeownerTeams = match.teams.map((t) => ({
|
|
184
|
+
kind: 'team',
|
|
185
|
+
login: null,
|
|
186
|
+
userId: null,
|
|
187
|
+
teamSlug: t.slug,
|
|
188
|
+
teamName: t.name,
|
|
189
|
+
reason: 'owns this path (CODEOWNERS)',
|
|
190
|
+
source: 'codeowners',
|
|
191
|
+
}));
|
|
192
|
+
// Merge: CODEOWNERS users, then teams, then the history suggestions — dedup users by
|
|
193
|
+
// login and teams by slug, capped so the row stays digestible.
|
|
194
|
+
const seenLogins = new Set();
|
|
195
|
+
const seenTeams = new Set();
|
|
196
|
+
const merged = [];
|
|
197
|
+
for (const s of [...codeownerUsers, ...codeownerTeams, ...detail.suggestedReviewers]) {
|
|
198
|
+
if (s.kind === 'team') {
|
|
199
|
+
if (s.teamSlug && !seenTeams.has(s.teamSlug)) {
|
|
200
|
+
seenTeams.add(s.teamSlug);
|
|
201
|
+
merged.push(s);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else if (s.login && !seenLogins.has(s.login)) {
|
|
205
|
+
seenLogins.add(s.login);
|
|
206
|
+
merged.push(s);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
detail.suggestedReviewers = merged.slice(0, 5);
|
|
210
|
+
// Surface any newly-resolved codeowner users so the client can render avatars/links.
|
|
211
|
+
const known = new Set(detail.users.map((u) => u.id));
|
|
212
|
+
for (const u of resolved)
|
|
213
|
+
if (!known.has(u.id))
|
|
214
|
+
detail.users.push(u);
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
/* best-effort: keep the history suggestions */
|
|
218
|
+
}
|
|
219
|
+
return detail;
|
|
220
|
+
}
|
|
143
221
|
export async function prRoutes(app) {
|
|
144
222
|
// Bulk "mark all seen": stamp every open PR (optionally scoped to repoIds) viewed
|
|
145
223
|
// at its head, clearing all new-since badges at once. Static path — no :id — so it
|
|
@@ -159,7 +237,10 @@ export async function prRoutes(app) {
|
|
|
159
237
|
}
|
|
160
238
|
// Cloud lean mode: fill in bulky text from GitHub (no-op in local). The client
|
|
161
239
|
// caches the result in IndexedDB keyed by updatedAt so unchanged PRs don't refetch.
|
|
162
|
-
|
|
240
|
+
const detail = await hydratePrDetail(pr, accountId);
|
|
241
|
+
// Layer CODEOWNERS ownership onto the history-based suggestions (best-effort; never
|
|
242
|
+
// blocks the response). getPrDetail already gated the history part on the same trigger.
|
|
243
|
+
return enrichSuggestionsWithCodeowners(detail, accountId);
|
|
163
244
|
});
|
|
164
245
|
// Candidates for an @mention autocomplete, ranked by proximity to this PR
|
|
165
246
|
// (participants first, then repo people), self + bots excluded. Account-scoped:
|
|
@@ -628,7 +709,7 @@ export async function prRoutes(app) {
|
|
|
628
709
|
// next sync (reviewRequests are re-derived each pass).
|
|
629
710
|
app.post('/api/prs/:id/request-reviewers', { schema: requestReviewersSchema }, async (req, reply) => {
|
|
630
711
|
const { id } = req.params;
|
|
631
|
-
const { userIds } = req.body;
|
|
712
|
+
const { userIds = [], logins: directLogins = [], teamSlugs = [], } = req.body;
|
|
632
713
|
const accountId = accountIdOf(req);
|
|
633
714
|
const ctx = await getPrWriteContext(id, accountId);
|
|
634
715
|
if (!ctx) {
|
|
@@ -643,20 +724,23 @@ export async function prRoutes(app) {
|
|
|
643
724
|
message: 'You need write access to this repo to request reviewers.',
|
|
644
725
|
};
|
|
645
726
|
}
|
|
646
|
-
// Drop the PR author (GitHub rejects self-review requests), then
|
|
647
|
-
// logins (also drops bots + unknown ids).
|
|
727
|
+
// Drop the PR author from the id set (GitHub rejects self-review requests), then
|
|
728
|
+
// resolve to logins (also drops bots + unknown ids). Union with any direct logins
|
|
729
|
+
// (suggested reviewers we haven't synced as users), deduped.
|
|
648
730
|
const wanted = userIds.filter((uid) => uid !== ctx.authorId);
|
|
649
|
-
const
|
|
650
|
-
|
|
731
|
+
const resolvedLogins = (await getReviewerLogins(wanted)).map((r) => r.login);
|
|
732
|
+
const logins = [...new Set([...resolvedLogins, ...directLogins])];
|
|
733
|
+
const teams = [...new Set(teamSlugs)];
|
|
734
|
+
if (logins.length === 0 && teams.length === 0) {
|
|
651
735
|
reply.status(400);
|
|
652
736
|
return {
|
|
653
737
|
error: 'NoReviewers',
|
|
654
|
-
message: 'None of the selected users can be requested as reviewers.',
|
|
738
|
+
message: 'None of the selected users or teams can be requested as reviewers.',
|
|
655
739
|
};
|
|
656
740
|
}
|
|
657
741
|
try {
|
|
658
742
|
const token = await getAccessToken(accountId);
|
|
659
|
-
await requestReviewers(token, ctx.owner, ctx.name, ctx.number, logins);
|
|
743
|
+
await requestReviewers(token, ctx.owner, ctx.name, ctx.number, logins, teams);
|
|
660
744
|
const result = {
|
|
661
745
|
status: 'ok',
|
|
662
746
|
requestedLogins: logins,
|
package/dist/db/queries.js
CHANGED
|
@@ -3329,6 +3329,108 @@ async function getThreadsAwaiting(localUserId, accountId, repoNameById) {
|
|
|
3329
3329
|
out.sort((a, b) => b.lastReplyAt.localeCompare(a.lastReplyAt));
|
|
3330
3330
|
return out;
|
|
3331
3331
|
}
|
|
3332
|
+
// Recent-activity window for the CORE reviewer suggester (both the dir-overlap authorship
|
|
3333
|
+
// signal and the "reviews here often" pool). 90d = the board's max range; beyond it a
|
|
3334
|
+
// contributor is off the radar.
|
|
3335
|
+
const REVIEWER_SUGGEST_WINDOW_MS = 90 * 86_400_000;
|
|
3336
|
+
// Suggest reviewers for a PR that has NONE assigned, from ALREADY-SYNCED data only (no
|
|
3337
|
+
// GitHub calls). Candidate pool = people with merge rights in the repo ∪ people who review
|
|
3338
|
+
// it often; ranked by how much they've recently worked in the top-level dirs this PR
|
|
3339
|
+
// touches. The PR author + bots are excluded. Returns up to 3 user suggestions
|
|
3340
|
+
// (source 'history'). CODEOWNERS suggestions are layered on top in the route (they need a
|
|
3341
|
+
// token + network); this is the always-available core signal.
|
|
3342
|
+
async function suggestReviewersFromHistory(accountId, repoId, authorId, changedPaths) {
|
|
3343
|
+
const since = new Date(Date.now() - REVIEWER_SUGGEST_WINDOW_MS);
|
|
3344
|
+
const dirs = [...new Set(changedPaths.map(topLevelDir))];
|
|
3345
|
+
// Repo-wide "who recently worked where" (author × top-level dir), from the always-stored
|
|
3346
|
+
// pull_requests.files — real recent activity per area, no commit-file dependency.
|
|
3347
|
+
const dirCount = new Map(); // dir -> uid -> #PRs
|
|
3348
|
+
for (const pr of await db
|
|
3349
|
+
.select({ authorId: pullRequests.authorId, files: pullRequests.files })
|
|
3350
|
+
.from(pullRequests)
|
|
3351
|
+
.where(and(eq(pullRequests.accountId, accountId), eq(pullRequests.repoId, repoId), isNotNull(pullRequests.authorId), isNotNull(pullRequests.files), gte(pullRequests.updatedAt, since)))
|
|
3352
|
+
.execute()) {
|
|
3353
|
+
if (pr.authorId == null || pr.files == null)
|
|
3354
|
+
continue;
|
|
3355
|
+
for (const d of new Set(pr.files.map((f) => topLevelDir(f.path)))) {
|
|
3356
|
+
const m = dirCount.get(d) ?? new Map();
|
|
3357
|
+
m.set(pr.authorId, (m.get(pr.authorId) ?? 0) + 1);
|
|
3358
|
+
dirCount.set(d, m);
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
// Merge-rights set for this repo.
|
|
3362
|
+
const repoMergers = new Set((await getMergers(accountId)).find((m) => m.repoId === repoId)?.userIds ?? []);
|
|
3363
|
+
// Frequent reviewers of this repo (review count per author, recent window).
|
|
3364
|
+
const reviewCount = new Map();
|
|
3365
|
+
for (const r of await db
|
|
3366
|
+
.select({ authorId: reviews.authorId })
|
|
3367
|
+
.from(reviews)
|
|
3368
|
+
.innerJoin(pullRequests, eq(pullRequests.id, reviews.prId))
|
|
3369
|
+
.where(and(eq(pullRequests.accountId, accountId), eq(pullRequests.repoId, repoId), isNotNull(reviews.authorId), gte(reviews.submittedAt, since)))
|
|
3370
|
+
.execute()) {
|
|
3371
|
+
if (r.authorId == null)
|
|
3372
|
+
continue;
|
|
3373
|
+
reviewCount.set(r.authorId, (reviewCount.get(r.authorId) ?? 0) + 1);
|
|
3374
|
+
}
|
|
3375
|
+
// Candidate pool = mergers ∪ frequent reviewers, minus the author.
|
|
3376
|
+
const pool = new Set([...repoMergers, ...reviewCount.keys()]);
|
|
3377
|
+
if (authorId != null)
|
|
3378
|
+
pool.delete(authorId);
|
|
3379
|
+
if (pool.size === 0)
|
|
3380
|
+
return [];
|
|
3381
|
+
// Score by dir-overlap; track each candidate's most-touched matching dir for the reason.
|
|
3382
|
+
const overlap = new Map();
|
|
3383
|
+
const topDir = new Map();
|
|
3384
|
+
for (const d of dirs) {
|
|
3385
|
+
const m = dirCount.get(d);
|
|
3386
|
+
if (!m)
|
|
3387
|
+
continue;
|
|
3388
|
+
for (const [uid, cnt] of m) {
|
|
3389
|
+
if (!pool.has(uid))
|
|
3390
|
+
continue;
|
|
3391
|
+
overlap.set(uid, (overlap.get(uid) ?? 0) + cnt);
|
|
3392
|
+
const cur = topDir.get(uid);
|
|
3393
|
+
if (!cur || cnt > cur.cnt)
|
|
3394
|
+
topDir.set(uid, { dir: d, cnt });
|
|
3395
|
+
}
|
|
3396
|
+
}
|
|
3397
|
+
// Resolve to logins (drops bots + null logins), then rank.
|
|
3398
|
+
const resolved = await getReviewerLogins([...pool]);
|
|
3399
|
+
const dirLabel = (d) => (d === '.' ? 'the repo root' : `${d}/`);
|
|
3400
|
+
const ranked = resolved
|
|
3401
|
+
.sort((a, b) => {
|
|
3402
|
+
const ov = (overlap.get(b.userId) ?? 0) - (overlap.get(a.userId) ?? 0);
|
|
3403
|
+
if (ov !== 0)
|
|
3404
|
+
return ov;
|
|
3405
|
+
const rv = (reviewCount.get(b.userId) ?? 0) - (reviewCount.get(a.userId) ?? 0);
|
|
3406
|
+
if (rv !== 0)
|
|
3407
|
+
return rv;
|
|
3408
|
+
return (repoMergers.has(b.userId) ? 1 : 0) - (repoMergers.has(a.userId) ? 1 : 0);
|
|
3409
|
+
})
|
|
3410
|
+
.slice(0, 3);
|
|
3411
|
+
return ranked.map(({ userId, login }) => {
|
|
3412
|
+
const top = topDir.get(userId);
|
|
3413
|
+
const reason = top && (overlap.get(userId) ?? 0) > 0
|
|
3414
|
+
? `recently changed ${dirLabel(top.dir)}`
|
|
3415
|
+
: (reviewCount.get(userId) ?? 0) > 0
|
|
3416
|
+
? 'reviews here often'
|
|
3417
|
+
: 'has merge rights here';
|
|
3418
|
+
return { kind: 'user', login, userId, teamSlug: null, teamName: null, reason, source: 'history' };
|
|
3419
|
+
});
|
|
3420
|
+
}
|
|
3421
|
+
// Resolve GitHub logins to synced User rows (for CODEOWNERS suggestion enrichment in the
|
|
3422
|
+
// route — an @user owner may or may not be someone we've synced). Case-insensitive on
|
|
3423
|
+
// login is unnecessary: GitHub logins in CODEOWNERS match the stored githubLogin exactly.
|
|
3424
|
+
export async function getUsersByLogins(logins) {
|
|
3425
|
+
if (logins.length === 0)
|
|
3426
|
+
return [];
|
|
3427
|
+
const rows = await db
|
|
3428
|
+
.select()
|
|
3429
|
+
.from(users)
|
|
3430
|
+
.where(inArray(users.githubLogin, logins))
|
|
3431
|
+
.execute();
|
|
3432
|
+
return rows.map(mapUser);
|
|
3433
|
+
}
|
|
3332
3434
|
export async function getPrDetail(id, accountId) {
|
|
3333
3435
|
const rows = await db
|
|
3334
3436
|
.select()
|
|
@@ -3449,6 +3551,17 @@ export async function getPrDetail(id, accountId) {
|
|
|
3449
3551
|
userId: r.userId,
|
|
3450
3552
|
teamName: r.teamName,
|
|
3451
3553
|
}));
|
|
3554
|
+
// Suggested reviewers (CORE) — only when the PR warrants them: open, non-draft, and
|
|
3555
|
+
// nobody's been requested OR has reviewed yet. The route layers CODEOWNERS suggestions
|
|
3556
|
+
// on top (they need a token + network); this is the history-based signal from synced
|
|
3557
|
+
// data alone. Keep this trigger in lockstep with the route's CODEOWNERS gate.
|
|
3558
|
+
const wantsSuggestions = pr.state === 'open' &&
|
|
3559
|
+
!pr.isDraft &&
|
|
3560
|
+
requestedReviewers.length === 0 &&
|
|
3561
|
+
reviewsOut.length === 0;
|
|
3562
|
+
const suggestedReviewers = wantsSuggestions
|
|
3563
|
+
? await suggestReviewersFromHistory(accountId, pr.repoId, pr.authorId, (pr.files ?? []).map((f) => f.path))
|
|
3564
|
+
: [];
|
|
3452
3565
|
// Gather referenced users for client-side lookup.
|
|
3453
3566
|
const userIds = new Set();
|
|
3454
3567
|
if (pr.authorId)
|
|
@@ -3474,6 +3587,9 @@ export async function getPrDetail(id, accountId) {
|
|
|
3474
3587
|
for (const r of reviewerRows)
|
|
3475
3588
|
if (r.userId)
|
|
3476
3589
|
userIds.add(r.userId);
|
|
3590
|
+
for (const s of suggestedReviewers)
|
|
3591
|
+
if (s.userId != null)
|
|
3592
|
+
userIds.add(s.userId);
|
|
3477
3593
|
// A maintainer who only merged the PR (never authored/reviewed/commented) is
|
|
3478
3594
|
// otherwise absent from userList, leaving "Merged by" unresolved.
|
|
3479
3595
|
if (pr.mergedById)
|
|
@@ -3581,6 +3697,7 @@ export async function getPrDetail(id, accountId) {
|
|
|
3581
3697
|
changedFilesCount: pr.changedFiles,
|
|
3582
3698
|
files: filesOut,
|
|
3583
3699
|
requestedReviewers,
|
|
3700
|
+
suggestedReviewers,
|
|
3584
3701
|
viewerCanApprove,
|
|
3585
3702
|
viewerCanPush,
|
|
3586
3703
|
viewerHasApprovedStanding,
|
package/dist/github/client.js
CHANGED
|
@@ -112,6 +112,22 @@ export async function ghRestGetText(token, path) {
|
|
|
112
112
|
const text = await res.text().catch(() => '');
|
|
113
113
|
return { status: res.status, ok: res.ok, text };
|
|
114
114
|
}
|
|
115
|
+
// REST GET a repo file's RAW contents (Accept: application/vnd.github.raw), NOT throwing
|
|
116
|
+
// on a non-2xx — returns the status + body so the caller can branch. Used to fetch a
|
|
117
|
+
// repo's CODEOWNERS file (404 when absent → degrade to no CODEOWNERS suggestions). The
|
|
118
|
+
// raw media type returns the file bytes directly (no base64/JSON envelope to decode).
|
|
119
|
+
export async function ghRestGetContentRaw(token, path) {
|
|
120
|
+
const res = await fetch(`https://api.github.com${path}`, {
|
|
121
|
+
method: 'GET',
|
|
122
|
+
headers: {
|
|
123
|
+
authorization: `token ${token}`,
|
|
124
|
+
accept: 'application/vnd.github.raw',
|
|
125
|
+
'x-github-api-version': '2022-11-28',
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
const text = await res.text().catch(() => '');
|
|
129
|
+
return { status: res.status, ok: res.ok, text };
|
|
130
|
+
}
|
|
115
131
|
// REST GET in GitHub's raw `diff` media type (Accept: application/vnd.github.diff),
|
|
116
132
|
// NOT throwing on a non-2xx — returns the status + body so the caller can branch. GitHub
|
|
117
133
|
// caps this media type at 20,000 lines and 406s past it (a huge PR), which the caller
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// CODEOWNERS-based reviewer suggestions (CORE). Fetches a repo's CODEOWNERS file via the
|
|
2
|
+
// contents API, parses it, and — given a PR's changed paths — returns the owning users +
|
|
3
|
+
// teams. Best-effort throughout: any failure (no file, token can't read the repo, a parse
|
|
4
|
+
// hiccup) degrades to "no CODEOWNERS suggestions", NEVER an error on the PR-detail path.
|
|
5
|
+
//
|
|
6
|
+
// Why CODEOWNERS and not the org/teams API: the contents API is reachable in every auth
|
|
7
|
+
// mode (local gh token, cloud OAuth `public_repo`, GitHub App with contents:read), and a
|
|
8
|
+
// `@org/team` owner is directly requestable as a review via `team_reviewers` — no need to
|
|
9
|
+
// expand team membership (which needs `read:org` + org membership and is unreliable under
|
|
10
|
+
// the cloud GitHub App). Individual `@user` owners become user suggestions directly.
|
|
11
|
+
import { ghRestGetContentRaw } from './client.js';
|
|
12
|
+
// The candidate file locations GitHub honours, in the order it resolves them.
|
|
13
|
+
const CODEOWNERS_PATHS = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS'];
|
|
14
|
+
// Per-(account, repo) cache. CODEOWNERS changes rarely, so a short TTL keeps the extra
|
|
15
|
+
// contents-API call off the hot path while staying reasonably fresh. A `null` rules value
|
|
16
|
+
// is cached too (the common "repo has no CODEOWNERS" case) so we don't refetch a 404 on
|
|
17
|
+
// every PR open. Keyed by account so one tenant's token result never leaks to another.
|
|
18
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
19
|
+
const cache = new Map();
|
|
20
|
+
// Convert a CODEOWNERS (gitignore-style) path pattern into a regex over POSIX file paths.
|
|
21
|
+
// A best-effort subset matching GitHub's documented CODEOWNERS semantics: `*` matches
|
|
22
|
+
// within a single segment (NOT across `/`), `**` across segments, `?` one non-slash char;
|
|
23
|
+
// a leading `/` or any internal `/` anchors to the repo root, otherwise the pattern may
|
|
24
|
+
// match at any directory depth; a trailing `/` (or a bare directory name) owns everything
|
|
25
|
+
// under that directory. Exported for the unit test that locks these semantics.
|
|
26
|
+
export function globToRegExp(raw) {
|
|
27
|
+
let p = raw.trim();
|
|
28
|
+
const dirOnly = p.endsWith('/');
|
|
29
|
+
if (dirOnly)
|
|
30
|
+
p = p.slice(0, -1);
|
|
31
|
+
const startSlash = p.startsWith('/');
|
|
32
|
+
if (startSlash)
|
|
33
|
+
p = p.slice(1);
|
|
34
|
+
// Git: a pattern with a slash anywhere but the end is relative to the root (anchored);
|
|
35
|
+
// a pattern with no internal slash matches by name at any level.
|
|
36
|
+
const anchored = startSlash || p.includes('/');
|
|
37
|
+
let re = '';
|
|
38
|
+
for (let i = 0; i < p.length; i++) {
|
|
39
|
+
const c = p[i];
|
|
40
|
+
if (c === '*') {
|
|
41
|
+
if (p[i + 1] === '*') {
|
|
42
|
+
// `**` spans path segments. A segment-aligned `**/` becomes OPTIONAL leading
|
|
43
|
+
// directories that preserve the `/` boundary (so `**/logs` matches `a/logs` but
|
|
44
|
+
// NOT `mylogs`); a trailing/standalone `**` matches anything.
|
|
45
|
+
if (p[i + 2] === '/') {
|
|
46
|
+
re += '(?:.*/)?';
|
|
47
|
+
i += 2; // consume the second '*' and the '/'
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
re += '.*';
|
|
51
|
+
i += 1; // consume the second '*'
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
re += '[^/]*';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (c === '?') {
|
|
59
|
+
re += '[^/]';
|
|
60
|
+
}
|
|
61
|
+
else if ('.+^${}()|[]\\'.includes(c)) {
|
|
62
|
+
re += `\\${c}`;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
re += c;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
// A trailing-slash directory (or a bare literal path) OWNS everything under it, so it
|
|
69
|
+
// gets a "…/.*" / "(?:/.*)?" tail. A wildcard-TERMINATED pattern (e.g. `docs/*`) is
|
|
70
|
+
// single-level per GitHub — it must NOT gain that tail, or it would over-match nested
|
|
71
|
+
// files (`docs/*` matches `docs/a.md` but not `docs/sub/a.md`).
|
|
72
|
+
const endsWithWildcard = /[*?]$/.test(p);
|
|
73
|
+
const body = dirOnly ? `${re}/.*` : endsWithWildcard ? re : `${re}(?:/.*)?`;
|
|
74
|
+
const full = anchored ? `^${body}$` : `^(?:.*/)?${body}$`;
|
|
75
|
+
return new RegExp(full);
|
|
76
|
+
}
|
|
77
|
+
// Parse CODEOWNERS text into ordered rules (order matters: the LAST matching rule wins).
|
|
78
|
+
function parseCodeowners(text) {
|
|
79
|
+
const rules = [];
|
|
80
|
+
for (const line of text.split('\n')) {
|
|
81
|
+
const trimmed = line.trim();
|
|
82
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
83
|
+
continue;
|
|
84
|
+
// Strip an inline comment, then split on whitespace: first token = pattern, rest = owners.
|
|
85
|
+
const noComment = trimmed.split('#')[0].trim();
|
|
86
|
+
if (!noComment)
|
|
87
|
+
continue;
|
|
88
|
+
const parts = noComment.split(/\s+/);
|
|
89
|
+
const pattern = parts[0];
|
|
90
|
+
// Owners are the @-prefixed tokens (drop bare emails — not requestable as reviewers).
|
|
91
|
+
const owners = parts.slice(1).filter((o) => o.startsWith('@'));
|
|
92
|
+
if (owners.length === 0)
|
|
93
|
+
continue;
|
|
94
|
+
try {
|
|
95
|
+
rules.push({ re: globToRegExp(pattern), owners });
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* skip an un-compilable pattern rather than fail the whole file */
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return rules;
|
|
102
|
+
}
|
|
103
|
+
// Fetch + parse a repo's CODEOWNERS (cached). Returns null when there's no file / it can't
|
|
104
|
+
// be read. Never throws.
|
|
105
|
+
async function loadRules(token, owner, name, accountId) {
|
|
106
|
+
const key = `${accountId}:${owner}/${name}`;
|
|
107
|
+
const hit = cache.get(key);
|
|
108
|
+
if (hit && Date.now() - hit.at < CACHE_TTL_MS)
|
|
109
|
+
return hit.rules;
|
|
110
|
+
let rules = null;
|
|
111
|
+
try {
|
|
112
|
+
for (const path of CODEOWNERS_PATHS) {
|
|
113
|
+
const res = await ghRestGetContentRaw(token, `/repos/${owner}/${name}/contents/${path}`);
|
|
114
|
+
if (res.ok && res.text) {
|
|
115
|
+
rules = parseCodeowners(res.text);
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
// Any non-404 (403 org wall / 401) also means "give up on CODEOWNERS" — stop trying.
|
|
119
|
+
if (res.status !== 404)
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
rules = null;
|
|
125
|
+
}
|
|
126
|
+
cache.set(key, { rules, at: Date.now() });
|
|
127
|
+
return rules;
|
|
128
|
+
}
|
|
129
|
+
// Given a PR's changed paths, return the CODEOWNERS-derived owners (users + teams). The
|
|
130
|
+
// LAST matching rule for each file wins (git semantics); owners are unioned across files.
|
|
131
|
+
export async function getCodeownersMatch(token, owner, name, accountId, changedPaths) {
|
|
132
|
+
const empty = { logins: [], teams: [] };
|
|
133
|
+
if (changedPaths.length === 0)
|
|
134
|
+
return empty;
|
|
135
|
+
const rules = await loadRules(token, owner, name, accountId);
|
|
136
|
+
if (!rules || rules.length === 0)
|
|
137
|
+
return empty;
|
|
138
|
+
const ownerTokens = new Set();
|
|
139
|
+
for (const path of changedPaths) {
|
|
140
|
+
let last = null;
|
|
141
|
+
for (const r of rules)
|
|
142
|
+
if (r.re.test(path))
|
|
143
|
+
last = r; // last match wins
|
|
144
|
+
if (last)
|
|
145
|
+
for (const o of last.owners)
|
|
146
|
+
ownerTokens.add(o);
|
|
147
|
+
}
|
|
148
|
+
const logins = [];
|
|
149
|
+
const teams = [];
|
|
150
|
+
for (const tok of ownerTokens) {
|
|
151
|
+
const handle = tok.slice(1); // drop '@'
|
|
152
|
+
if (handle.includes('/')) {
|
|
153
|
+
const slug = handle.slice(handle.indexOf('/') + 1);
|
|
154
|
+
if (slug)
|
|
155
|
+
teams.push({ slug, name: handle });
|
|
156
|
+
}
|
|
157
|
+
else if (handle) {
|
|
158
|
+
logins.push(handle);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { logins, teams };
|
|
162
|
+
}
|
|
163
|
+
//# sourceMappingURL=codeowners.js.map
|
package/dist/github/mutations.js
CHANGED
|
@@ -336,12 +336,17 @@ export async function createPullRequest(token, args) {
|
|
|
336
336
|
}
|
|
337
337
|
// ---- Request reviewers on a PR (REST) ----
|
|
338
338
|
// Request one or more reviewers on a PR (POST .../pulls/:n/requested_reviewers with
|
|
339
|
-
// { reviewers: [login…] }). Needs a token with write/triage
|
|
340
|
-
// login isn't a collaborator or is the PR author (the caller
|
|
339
|
+
// { reviewers: [login…], team_reviewers: [slug…] }). Needs a token with write/triage
|
|
340
|
+
// access; GitHub 422s if a login isn't a collaborator or is the PR author (the caller
|
|
341
|
+
// filters the author out). `teamSlugs` requests a whole team (a CODEOWNERS `@org/team`)
|
|
342
|
+
// WITHOUT expanding its membership — GitHub resolves the slug against the repo's org.
|
|
341
343
|
// Returns 201 with the updated PR body, which we don't need — the refreshed request
|
|
342
344
|
// state arrives on the next sync (reviewRequests are re-derived each sync).
|
|
343
|
-
export async function requestReviewers(token, owner, name, number, logins) {
|
|
344
|
-
|
|
345
|
+
export async function requestReviewers(token, owner, name, number, logins, teamSlugs = []) {
|
|
346
|
+
const body = { reviewers: logins };
|
|
347
|
+
if (teamSlugs.length > 0)
|
|
348
|
+
body.team_reviewers = teamSlugs;
|
|
349
|
+
await ghRestPostFor(token, `/repos/${owner}/${name}/pulls/${number}/requested_reviewers`, body);
|
|
345
350
|
}
|
|
346
351
|
// ---- Re-trigger a GitHub Actions workflow run ----
|
|
347
352
|
// Re-run a workflow run (per-account). `mode: 'failed'` reruns only the failed jobs
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pierre-review",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.69",
|
|
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",
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import{r as o,j as e}from"./tanstack-R-s9abdD.js";import{D as te,w as W,A as re,c as ne,d as ae,q as ie,G as ce,I as J,M as K,i as le,l as oe,k as de,C as ue,b as xe,R as Q,a as he,p as X,F as Z,r as I,E as ge,J as me,n as be,m as pe,y as fe,j as Y}from"./index-DNiwLu1C.js";import"./markdown-BUNN1sk6.js";import"./highlight-C8vPehS1.js";import"./vis-C3BCkP5U.js";const S="whitespace-nowrap rounded border border-blue-400 px-2.5 py-1 text-xs text-blue-600 hover:bg-blue-50 disabled:opacity-50 dark:border-blue-600 dark:text-blue-400 dark:hover:bg-blue-900/30",j="whitespace-nowrap rounded border border-gray-300 px-2.5 py-1 text-xs hover:border-gray-400 disabled:opacity-50 dark:border-gray-700 dark:hover:border-gray-500",ye={fetching_diff:"Reading the PR",cloning:"Checking out the code",fixing:"Applying the fix",capturing:"Capturing changes",persisting:"Saving"};function je(s){var t;if(!s||s.status==="idle")return null;if(s.status==="queued")return 6;const r=s.progress;if(!r)return 10;switch(r.phase){case"fetching_diff":return 12;case"cloning":return 28;case"fixing":return Math.min(90,45+(((t=r.recentActivity)==null?void 0:t.length)??0)*3);case"capturing":return 92;case"persisting":return 96;default:return 20}}const ke={cloning:"Checking out the code",applying_fix:"Applying the fix",fetching_trunk:"Fetching the trunk",rebasing:"Rebasing onto the trunk",merging:"Merging the trunk in",resolving_conflicts:"Resolving conflicts with Claude",verifying:"Verifying the result",pushing:"Pushing"};function Ne(s){var t;if(!s||s.status==="idle")return null;if(s.status==="queued")return 6;const r=s.progress;if(!r)return 10;switch(r.phase){case"cloning":return 15;case"applying_fix":return 25;case"fetching_trunk":return 35;case"rebasing":case"merging":return 45;case"resolving_conflicts":return Math.min(90,55+(((t=r.recentActivity)==null?void 0:t.length)??0)*3);case"verifying":return 92;case"pushing":return 96;default:return 20}}function L({children:s}){return e.jsx("div",{className:"px-4 pb-1 pt-3 text-xs font-semibold uppercase tracking-wide text-gray-400",children:s})}const ve={high:"bg-green-100 text-green-700 dark:bg-green-950/40 dark:text-green-300",medium:"bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300",low:"bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-300"};function z({label:s,value:r}){return r?e.jsxs("span",{className:`rounded px-1.5 py-0.5 text-[10px] font-medium uppercase ${ve[r]}`,title:s==="Fixability"?"How confident the analysis is that Pierre's agent could fix this":"How confident the analysis is about the root cause",children:[s,": ",r]}):null}function De({pr:s}){const{aiAnalysis:r,aiFix:t}=te(),n=W(x=>x.aiFixTabFocus),i=W(x=>x.consumeAiFixTabFocus),[c,m]=o.useState(null);return o.useEffect(()=>{n&&n.prId===s.id&&(n.reviewText&&m(n.reviewText),i())},[n,s.id,i]),!r&&!t?e.jsx("div",{className:"p-4 text-sm text-gray-500",children:"AI Analysis and Fix is not enabled."}):e.jsxs("div",{className:"pb-6",children:[r&&e.jsx("div",{className:"border-b border-gray-200 dark:border-gray-800",children:e.jsx(re,{pr:s})}),e.jsx(Ce,{pr:s}),r&&e.jsx(Pe,{pr:s,canFix:t}),t&&e.jsx(Re,{pr:s,seedReviewText:c,onSeedConsumed:()=>m(null)})]})}function Ce({pr:s}){const r=s.checkRuns;return r.length===0?null:e.jsxs("div",{className:"border-b border-gray-200 dark:border-gray-800",children:[e.jsx(L,{children:"CI status"}),e.jsxs("div",{className:"px-4 pb-3",children:[e.jsx(ne,{prId:s.id,checks:r}),e.jsx(ae,{prId:s.id,checks:r,viewerCanPush:s.viewerCanPush})]})]})}function Pe({pr:s,canFix:r}){const{data:t}=ie(s.id,!0),n=ce(s.id),i=J(s.id),c=o.useMemo(()=>s.checkRuns.filter(l=>l.state==="failure"||l.state==="error").map(l=>({name:l.name,jobId:l.jobId,state:l.state})),[s.checkRuns]);if(!(c.length>0||((t==null?void 0:t.hasFailures)??!1))&&!(t!=null&&t.analysis))return null;const x=(t==null?void 0:t.fixability)==="low";return e.jsxs("div",{className:"border-b border-gray-200 dark:border-gray-800",children:[e.jsx(L,{children:"CI failure analysis"}),e.jsxs("div",{className:"px-4 pb-3",children:[(t==null?void 0:t.analysis)&&(t.rootCauseConfidence||t.fixability)&&e.jsxs("div",{className:"mb-1.5 flex flex-wrap items-center gap-1.5",children:[e.jsx("span",{className:"text-[10px] font-semibold uppercase tracking-wide text-gray-400",title:"How confident the analysis is — not a severity rating",children:"Confidence"}),e.jsx(z,{label:"Root cause",value:t.rootCauseConfidence}),e.jsx(z,{label:"Fixability",value:t.fixability})]}),t!=null&&t.analysis?e.jsx("div",{className:"prose prose-sm max-w-none dark:prose-invert",children:e.jsx(K,{children:t.analysis})}):e.jsx("p",{className:"text-sm text-gray-500",children:"Diagnose the failing CI: likely root cause + a suggested fix, from the full logs of every failing check."}),e.jsxs("div",{className:"mt-2 flex flex-wrap items-center gap-2",children:[e.jsx("button",{type:"button",className:j,disabled:n.isPending||c.length===0,onClick:()=>n.mutate(c),children:n.isPending?"Analyzing…":t!=null&&t.analysis?"Re-analyze":"Analyze CI failure"}),r&&(t==null?void 0:t.analysis)&&e.jsx("button",{type:"button",className:S,disabled:i.isPending,onClick:()=>i.mutate({model:"claude-sonnet-5",seed:"ci_analysis"}),title:"Launch an agent to fix the CI failure",children:"Fix it →"}),r&&x&&(t==null?void 0:t.analysis)&&e.jsx("span",{className:"text-[11px] text-gray-500",children:"low confidence this is auto-fixable"}),n.isError&&e.jsx("span",{className:"text-[11px] text-red-500",children:D(n.error)})]})]})]})}function Re({pr:s,seedReviewText:r,onSeedConsumed:t}){var k,F,N,A;const{data:n,isLoading:i}=le(s.id,!0),[c,m]=o.useState("claude-sonnet-5"),x=J(s.id),l=oe(s.id),p=((k=n==null?void 0:n.fix)==null?void 0:k.status)??null,h=p==="running"||p==="queued"||x.isPending,{status:d}=de(s.id,h),R=(d==null?void 0:d.status)??p??"idle",f=R==="running"||R==="queued",w=r?"review":"plain",M=()=>{x.mutate({model:c,seed:w,reviewText:r??void 0},{onSuccess:()=>t()})},u=(n==null?void 0:n.fix)??null;return e.jsxs("div",{children:[e.jsx(L,{children:"AI Fix"}),e.jsxs("div",{className:"px-4",children:[(n==null?void 0:n.enabled)===!1?e.jsx("p",{className:"text-sm text-gray-500",children:"The agentic fixer is disabled."}):(n==null?void 0:n.auth)==="none"?e.jsx("p",{className:"rounded border border-amber-300 bg-amber-50 p-2 text-xs text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300",children:n.authMessage??"No Claude authentication found. Sign in to Claude or set an API key, then restart."}):e.jsxs(e.Fragment,{children:[r&&!f&&e.jsx("div",{className:"mb-2 rounded border border-blue-200 bg-blue-50 p-2 text-xs text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-300",children:"Ready to generate a fix from the selected review."}),e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsx("select",{className:"rounded border border-gray-300 bg-transparent px-2 py-1 text-xs dark:border-gray-700",value:c,onChange:y=>m(y.target.value),disabled:f,children:ue.map(y=>e.jsx("option",{value:y,children:xe[y]},y))}),f?e.jsx("button",{type:"button",className:j,onClick:()=>l.mutate(),children:"Cancel"}):e.jsx("button",{type:"button",className:S,disabled:x.isPending,onClick:M,children:u?"Generate new fix":"Generate fix"}),x.isError&&e.jsx("span",{className:"text-[11px] text-red-500",children:D(x.error)})]}),f&&e.jsxs("div",{className:"mt-3",children:[e.jsx(Q,{active:!0,label:"Running AI fix",value:je(d),timeConstantSec:40}),e.jsx("div",{className:"mt-1 text-[11px] text-gray-500",children:ye[((F=d==null?void 0:d.progress)==null?void 0:F.phase)??""]??"Working…"}),((N=d==null?void 0:d.progress)==null?void 0:N.recentActivity)&&d.progress.recentActivity.length>0&&e.jsx("pre",{className:"mt-1 max-h-32 overflow-auto rounded bg-gray-50 p-2 text-[11px] leading-relaxed text-gray-500 dark:bg-gray-900",children:d.progress.recentActivity.slice(-8).join(`
|
|
2
|
+
`)})]}),!f&&u&&e.jsx(we,{pr:s,fix:u,headInfo:(n==null?void 0:n.headInfo)??null,viewerCanPush:(n==null?void 0:n.viewerCanPush)??!1}),i&&!u&&e.jsx("p",{className:"mt-3 text-xs text-gray-400",children:"Loading…"})]}),e.jsx(Fe,{history:(n==null?void 0:n.history)??[],currentFixId:((A=n==null?void 0:n.fix)==null?void 0:A.id)??null})]})]})}function we({pr:s,fix:r,headInfo:t,viewerCanPush:n}){const i=o.useMemo(()=>X(r.patch),[r.patch]);if(r.status==="failed")return e.jsxs("div",{className:"mt-3 rounded border border-red-200 bg-red-50 p-2 text-xs text-red-700 dark:border-red-800 dark:bg-red-950/30 dark:text-red-300",children:["The fix run failed: ",r.error??"unknown error"]});if(r.status==="cancelled")return e.jsx("p",{className:"mt-3 text-xs text-gray-500",children:"The fix run was cancelled."});if(r.status!=="succeeded")return e.jsx(e.Fragment,{});const c=!r.patch||r.filesChanged.length===0;return e.jsxs("div",{className:"mt-3",children:[r.summary&&e.jsx("div",{className:"prose prose-sm max-w-none dark:prose-invert",children:e.jsx(K,{children:r.summary})}),c?e.jsx("p",{className:"mt-2 text-xs text-gray-500",children:"The agent made no changes."}):e.jsxs(e.Fragment,{children:[e.jsxs("div",{className:"mb-1 mt-2 text-[11px] text-gray-500",children:[r.filesChanged.length," file",r.filesChanged.length===1?"":"s"," changed"]}),e.jsx("div",{className:"overflow-hidden rounded border border-gray-200 text-gray-800 dark:border-gray-800 dark:text-gray-200",children:e.jsx(Z,{files:i})}),e.jsx(Se,{pr:s,fix:r,headInfo:t,viewerCanPush:n})]})]})}function _({fix:s}){return e.jsxs("div",{className:"mb-2 rounded border border-green-200 bg-green-50 p-2 text-xs text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300",children:[e.jsxs("div",{children:["Pushed to ",e.jsx("span",{className:"font-mono",children:s.pushedBranch}),s.pushedPrUrl&&e.jsxs(e.Fragment,{children:[" · ",e.jsxs("a",{href:s.pushedPrUrl,target:"_blank",rel:"noreferrer",className:"underline",children:["PR #",s.pushedPrNumber]})]}),s.pushedAt&&e.jsxs(e.Fragment,{children:[" · ",I(s.pushedAt)]})]}),s.commitMessage&&e.jsx("div",{className:"mt-1 font-mono text-[11px] text-green-800 dark:text-green-300",children:s.commitMessage})]})}function Fe({history:s,currentFixId:r}){const t=s.filter(n=>n.pushedAt!=null&&n.id!==r);return t.length===0?null:e.jsxs("div",{className:"mt-4 border-t border-gray-200 pt-3 dark:border-gray-800",children:[e.jsx("div",{className:"mb-1.5 text-xs font-semibold uppercase tracking-wide text-gray-400",children:"Fixes pushed via Pierre"}),e.jsx("ul",{className:"space-y-2",children:t.map(n=>e.jsxs("li",{className:"text-xs",children:[e.jsx("div",{className:"font-mono text-gray-700 dark:text-gray-200",children:n.commitMessage??"(no commit message)"}),e.jsxs("div",{className:"mt-0.5 text-[11px] text-gray-400",children:[n.pushedBranch&&e.jsx("span",{className:"font-mono",children:n.pushedBranch}),n.pushedPrUrl&&e.jsxs(e.Fragment,{children:[" · ",e.jsxs("a",{href:n.pushedPrUrl,target:"_blank",rel:"noreferrer",className:"underline",children:["PR #",n.pushedPrNumber]})]}),n.pushedAt&&e.jsxs(e.Fragment,{children:[" · ",I(n.pushedAt)]})]})]},n.id))})]})}function Ae({preview:s,loading:r}){if(r)return e.jsx("p",{className:"text-[11px] text-gray-400",children:"Checking against the trunk…"});if(!s||!s.available||!s.trunkSha)return null;if(!s.clean){const t=s.conflictFiles;return e.jsxs("div",{className:"rounded border border-amber-300 bg-amber-50 p-2 text-[11px] text-amber-700 dark:border-amber-700 dark:bg-amber-950/30 dark:text-amber-300",children:["⚠ Conflicts with ",e.jsx("span",{className:"font-mono",children:s.trunk}),t.length>0?e.jsxs(e.Fragment,{children:[" in ",t.length," file",t.length===1?"":"s",e.jsxs("span",{className:"text-amber-600/90 dark:text-amber-400/90",children:[": ",t.slice(0,6).join(", "),t.length>6?"…":""]})]}):null,". Resolving before you push avoids a conflicted PR."]})}return s.behindBy>0?e.jsxs("p",{className:"text-[11px] text-gray-500",children:[e.jsx("span",{className:"font-mono",children:s.trunk})," is ",s.behindBy," ","commit",s.behindBy===1?"":"s"," ahead — the fix merges cleanly."]}):e.jsxs("p",{className:"text-[11px] text-green-600 dark:text-green-400",children:["Up to date with ",e.jsx("span",{className:"font-mono",children:s.trunk})," — no conflicts."]})}function Ee({resolved:s,target:r,pushing:t,onPush:n,onRedo:i,disabled:c}){const m=o.useMemo(()=>X(s.diff),[s.diff]);return e.jsxs("div",{className:"mt-2 space-y-2",children:[e.jsxs("div",{className:"rounded border border-green-200 bg-green-50 p-2 text-[11px] text-green-700 dark:border-green-800 dark:bg-green-950/30 dark:text-green-300",children:["Rebased onto ",e.jsx("span",{className:"font-mono",children:s.trunk}),".",s.resolvedConflicts?` Claude resolved conflicts in ${s.conflictFiles.length} file${s.conflictFiles.length===1?"":"s"}${s.conflictFiles.length?`: ${s.conflictFiles.join(", ")}`:""}.`:" No conflicts."," ","Review the result below, then push."]}),e.jsxs("div",{className:"text-[11px] text-gray-500",children:[s.filesChanged.length," file",s.filesChanged.length===1?"":"s"," in the rebased result"]}),e.jsx("div",{className:"overflow-hidden rounded border border-gray-200 text-gray-800 dark:border-gray-800 dark:text-gray-200",children:e.jsx(Z,{files:m})}),e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsx("button",{type:"button",className:S,disabled:t||c,onClick:n,children:t?"Pushing…":r==="new"?"Push rebased + open PR":"Push rebased (force-with-lease)"}),e.jsx("button",{type:"button",className:j,disabled:t,onClick:i,children:"Re-rebase"})]})]})}function Se({pr:s,fix:r,headInfo:t,viewerCanPush:n}){var V,G;const i=ge(s.id),c=me(),m=be(),x=pe(),l=fe(),p=(t==null?void 0:t.canPushSameBranch)??!1,[h,d]=o.useState(p?"existing":"new"),[R,f]=o.useState((t==null?void 0:t.suggestedBranch)??""),[w,M]=o.useState(!0),[u,k]=o.useState("idle"),[F,N]=o.useState(null),A=o.useRef(!1),y=o.useRef(!1);o.useEffect(()=>{!A.current&&(t!=null&&t.suggestedBranch)&&(f(t.suggestedBranch),A.current=!0,t.canPushSameBranch||d("new"))},[t]);const g=r.pushedAt!=null,q=g&&r.pushedPrNumber==null,U=!g||q;o.useEffect(()=>{!y.current&&n&&U&&(y.current=!0,l.mutate(r.id))},[n,U,r.id]);const a=l.data??null,{status:v}=Y(s.id,r.id,"rebase",u==="rebasing"),{status:C}=Y(s.id,r.id,"push",u==="pushing");if(o.useEffect(()=>{u==="rebasing"&&v&&v.status!=="running"&&v.status!=="queued"&&(k("idle"),v.error&&N(v.error))},[u,v]),o.useEffect(()=>{u==="pushing"&&C&&C.status!=="running"&&C.status!=="queued"&&(k("idle"),C.error&&N(C.error))},[u,C]),!n)return g?e.jsx(_,{fix:r}):e.jsx("p",{className:"mt-2 text-xs text-gray-500",children:"You need write access to this repository to push this fix."});if(g&&!q)return e.jsx(_,{fix:r});const B=h==="new"&&R.trim().length===0,P=u!=="idle"||i.isPending||c.isPending,H=r.model,ee=g&&!!a&&a.available&&a.clean&&a.behindBy===0,T=E=>{N(null),i.mutate({fixId:r.id,body:{target:h,branch:h==="new"?R.trim():void 0,strategy:E,autoResolve:w,model:H}},{onSuccess:se=>{"pushedBranch"in se||k("pushing")}})},O=()=>{N(null),c.mutate({fixId:r.id,body:{autoResolve:w,model:H}},{onSuccess:()=>k("rebasing")})},b=u==="rebasing"?v:C,$=r.resolved;return e.jsxs("div",{className:"mt-3 rounded border border-gray-200 p-2 dark:border-gray-800",children:[g&&e.jsx(_,{fix:r}),e.jsx("div",{className:"mb-2 text-xs font-semibold text-gray-600 dark:text-gray-300",children:g?"Reconcile with the trunk":"Push this fix"}),!g&&r.commitMessage&&e.jsxs("div",{className:"mb-2 rounded bg-gray-50 p-2 text-[11px] dark:bg-gray-900",children:[e.jsx("span",{className:"uppercase tracking-wide text-gray-400",children:"Commit message"}),e.jsx("div",{className:"mt-0.5 font-mono text-gray-700 dark:text-gray-200",children:r.commitMessage})]}),e.jsxs("label",{className:`flex items-center gap-2 text-xs ${p?"":"opacity-40"}`,title:p?void 0:"The PR head is a fork you cannot push to — use a new branch",children:[e.jsx("input",{type:"radio",checked:h==="existing",disabled:!p||P,onChange:()=>d("existing")}),"Push to the PR branch",(t==null?void 0:t.headRef)&&e.jsxs("span",{className:"font-mono text-gray-400",children:["(",t.headRef,")"]})]}),e.jsxs("label",{className:"mt-1 flex items-center gap-2 text-xs",children:[e.jsx("input",{type:"radio",checked:h==="new",disabled:P,onChange:()=>d("new")}),"New branch + open a PR"]}),h==="new"&&e.jsx("input",{type:"text",className:"mt-1 w-full rounded border border-gray-300 bg-transparent px-2 py-1 font-mono text-xs dark:border-gray-700",value:R,disabled:P,onChange:E=>f(E.target.value),placeholder:"branch-name"}),e.jsxs("div",{className:"mt-2 flex flex-wrap items-center gap-2",children:[e.jsx("div",{className:"min-w-0 flex-1",children:e.jsx(Ae,{preview:a,loading:l.isPending})}),e.jsx("button",{type:"button",className:j,disabled:l.isPending||P,onClick:()=>l.mutate(r.id),title:"Re-check this fix against the current trunk",children:l.isPending?"Checking…":"Re-check trunk"}),l.isError&&!l.isPending&&e.jsx("span",{className:"text-[11px] text-red-500",children:"Couldn't check the trunk — try again."})]}),u!=="idle"?e.jsxs("div",{className:"mt-2",children:[e.jsx(Q,{active:!0,label:u==="rebasing"?"Rebasing & resolving":"Pushing",value:Ne(b),timeConstantSec:40}),e.jsxs("div",{className:"mt-1 flex items-center gap-2 text-[11px] text-gray-500",children:[e.jsx("span",{children:ke[((V=b==null?void 0:b.progress)==null?void 0:V.phase)??""]??"Working…"}),e.jsx("button",{type:"button",className:j,onClick:()=>u==="rebasing"?m.mutate(r.id):x.mutate(r.id),children:"Cancel"})]}),((G=b==null?void 0:b.progress)==null?void 0:G.recentActivity)&&b.progress.recentActivity.length>0&&e.jsx("pre",{className:"mt-1 max-h-32 overflow-auto rounded bg-gray-50 p-2 text-[11px] leading-relaxed text-gray-500 dark:bg-gray-900",children:b.progress.recentActivity.slice(-8).join(`
|
|
3
|
+
`)})]}):$?e.jsx(Ee,{resolved:$,target:h,pushing:i.isPending,disabled:B,onPush:()=>T("rebase"),onRedo:O}):ee?e.jsxs("p",{className:"mt-2 text-[11px] text-gray-500",children:["No trunk changes to reconcile — the pushed fix is up to date with"," ",e.jsx("span",{className:"font-mono",children:a==null?void 0:a.trunk}),"."]}):e.jsxs("div",{className:"mt-2 space-y-2",children:[a&&a.available&&!a.clean&&e.jsxs("div",{className:"text-[11px] text-gray-500",children:["Recommended: rebase onto"," ",e.jsx("span",{className:"font-mono",children:a.trunk})," — Claude resolves the conflicts and you review the result before it pushes."]}),e.jsxs("label",{className:"flex items-center gap-2 text-[11px] text-gray-600 dark:text-gray-300",children:[e.jsx("input",{type:"checkbox",checked:w,onChange:E=>M(E.target.checked)}),"Let Claude resolve conflicts"]}),e.jsxs("div",{className:"flex flex-wrap items-center gap-2",children:[e.jsxs("button",{type:"button",className:a&&!a.clean?S:j,disabled:P||B,onClick:O,title:"Rebase the fix onto the trunk; Claude resolves conflicts and you review before a force-with-lease push",children:["Rebase onto ",(a==null?void 0:a.trunk)??"trunk"]}),!g&&e.jsxs("button",{type:"button",className:j,disabled:P||B,onClick:()=>T("merge"),title:"Merge the trunk into the fix branch (a merge commit; never force-pushes)",children:["Merge ",(a==null?void 0:a.trunk)??"trunk"," in"]}),!g&&e.jsxs("button",{type:"button",className:!a||a.clean?S:j,disabled:P||B,onClick:()=>T("plain"),title:"Push the fix as-is (never force-pushes). If it conflicts with the trunk, the PR will show as conflicted.",children:[h==="new"?"Push + open PR":"Push",a&&!a.clean?" anyway":""]})]}),g&&e.jsxs("p",{className:"text-[11px] text-gray-400",children:["Rebasing replays the fix onto"," ",e.jsx("span",{className:"font-mono",children:(a==null?void 0:a.trunk)??"the trunk"})," ","and force-pushes (with lease) onto the PR branch — the safe way to reconcile an already-pushed fix."]}),h==="existing"&&e.jsxs("p",{className:"text-[11px] text-gray-400",children:["Rebase force-pushes (with lease) onto"," ",e.jsx("span",{className:"font-mono",children:t==null?void 0:t.headRef}),"; merge and push do not."]})]}),(F||i.isError||c.isError)&&e.jsx("div",{className:"mt-2 text-[11px] text-red-500",children:F??D(i.error??c.error)})]})}function D(s){return s instanceof he||s instanceof Error?s.message:"Something went wrong"}export{De as AiFixTab};
|