pierre-review 0.1.67 → 0.1.68

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.
@@ -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
- type: 'array',
136
- minItems: 1,
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
- return hydratePrDetail(pr, accountId);
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 resolve to
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 logins = (await getReviewerLogins(wanted)).map((r) => r.login);
650
- if (logins.length === 0) {
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,
@@ -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,
@@ -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
@@ -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 access; GitHub 422s if a
340
- // login isn't a collaborator or is the PR author (the caller filters the author out).
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
- await ghRestPostFor(token, `/repos/${owner}/${name}/pulls/${number}/requested_reviewers`, { reviewers: logins });
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.67",
3
+ "version": "0.1.68",
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",