pierre-review 0.1.74 → 0.1.76

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.
@@ -4,6 +4,7 @@ import { getAccessToken, getAccountUserId } from '../../auth/account.js';
4
4
  import { fetchActionsJobLog } from '../../github/actions-logs.js';
5
5
  import { getMentionCandidates, getPrDetail, getPrFilesContext, getPrWriteContext, getReviewerLogins, getResolvableBotThreads, getUsersByLogins, markAllViewed, markPrMergedLocally, markPrViewed, upsertLocalPrComment, upsertLocalReview, } from '../../db/queries.js';
6
6
  import { getCodeownersMatch } from '../../github/codeowners.js';
7
+ import { suggestTeamsFromHistory } from '../../github/team-reviewers.js';
7
8
  import { buildFileAnchors, fallbackAnchor, isFindingAnchored, } from '../../github/diff-anchor.js';
8
9
  import { addIssueComment, fetchHeadShaFor, fetchMergeability, fetchPrFilesWithPatch, fetchPrHeadInfo, fetchRepoMergeConfig, mergePullRequest, postInlineComment, requestReviewers, rerunWorkflowRun, submitPrReview, updatePullRequestBranch, } from '../../github/mutations.js';
9
10
  import { hydratePrDetail } from '../../sync/hydrate-detail.js';
@@ -154,12 +155,17 @@ const requestReviewersSchema = {
154
155
  },
155
156
  },
156
157
  };
157
- // Layer CODEOWNERS-derived suggestions on top of the history-based ones getPrDetail already
158
- // computed. Runs ONLY when the PR still warrants suggestions (same trigger getPrDetail used).
159
- // Best-effort: any failure (no CODEOWNERS, org wall, parse error) leaves the history
160
- // suggestions untouched. CODEOWNERS owners (declared ownership) take precedence, then history
161
- // fills up to a small cap. Mutates + returns the detail.
162
- async function enrichSuggestionsWithCodeowners(detail, accountId) {
158
+ // Layer network-derived reviewer suggestions on top of the history-USER ones getPrDetail
159
+ // already computed. Runs ONLY when the PR still warrants suggestions (same trigger getPrDetail
160
+ // used). Two best-effort sources, fetched in parallel and both cached per-repo:
161
+ // CODEOWNERS declared ownership for the touched paths (users + teams).
162
+ // Team history which team(s) are usually REQUESTED to review this repo (the behavioural
163
+ // fallback when CODEOWNERS declares no team; repo-level, so it runs even when the PR
164
+ // touches no owned path). See github/team-reviewers.ts.
165
+ // Any failure (no CODEOWNERS, org wall, a repo that doesn't use team requests) simply leaves
166
+ // the history-user suggestions untouched. Precedence: declared CODEOWNERS owners, then the
167
+ // inferred team(s), then history users, up to a small cap. Mutates + returns the detail.
168
+ async function enrichSuggestions(detail, accountId) {
163
169
  const wants = detail.state === 'open' &&
164
170
  !detail.isDraft &&
165
171
  detail.requestedReviewers.length === 0 &&
@@ -169,13 +175,19 @@ async function enrichSuggestionsWithCodeowners(detail, accountId) {
169
175
  const [owner, name] = detail.repoFullName.split('/');
170
176
  if (!owner || !name)
171
177
  return detail;
172
- const paths = detail.files.map((f) => f.path);
173
- if (paths.length === 0)
174
- return detail;
175
178
  try {
176
179
  const token = await getAccessToken(accountId);
177
- const match = await getCodeownersMatch(token, owner, name, accountId, paths);
178
- if (match.logins.length === 0 && match.teams.length === 0)
180
+ const paths = detail.files.map((f) => f.path);
181
+ const emptyMatch = { logins: [], teams: [] };
182
+ const [match, historyTeams] = await Promise.all([
183
+ paths.length > 0
184
+ ? getCodeownersMatch(token, owner, name, accountId, paths)
185
+ : Promise.resolve(emptyMatch),
186
+ suggestTeamsFromHistory(token, owner, name, accountId),
187
+ ]);
188
+ if (match.logins.length === 0 &&
189
+ match.teams.length === 0 &&
190
+ historyTeams.length === 0)
179
191
  return detail;
180
192
  const authorLogin = detail.authorId != null
181
193
  ? detail.users.find((u) => u.id === detail.authorId)?.githubLogin ?? null
@@ -203,12 +215,28 @@ async function enrichSuggestionsWithCodeowners(detail, accountId) {
203
215
  reason: 'owns this path (CODEOWNERS)',
204
216
  source: 'codeowners',
205
217
  }));
206
- // Merge: CODEOWNERS users, then teams, then the history suggestions — dedup users by
207
- // login and teams by slug, capped so the row stays digestible.
218
+ const historyTeamSuggestions = historyTeams.map((t) => ({
219
+ kind: 'team',
220
+ login: null,
221
+ userId: null,
222
+ teamSlug: t.slug,
223
+ teamName: t.name,
224
+ reason: `usually requested here (${t.count} recent PR${t.count === 1 ? '' : 's'})`,
225
+ source: 'history',
226
+ }));
227
+ // Merge = precedence: CODEOWNERS users, CODEOWNERS teams (declared), inferred teams, then
228
+ // the history-user suggestions. Dedup users by login and teams by slug (so a team that's
229
+ // both a CODEOWNER and historically requested shows once, as the CODEOWNER), capped so the
230
+ // row stays digestible.
208
231
  const seenLogins = new Set();
209
232
  const seenTeams = new Set();
210
233
  const merged = [];
211
- for (const s of [...codeownerUsers, ...codeownerTeams, ...detail.suggestedReviewers]) {
234
+ for (const s of [
235
+ ...codeownerUsers,
236
+ ...codeownerTeams,
237
+ ...historyTeamSuggestions,
238
+ ...detail.suggestedReviewers,
239
+ ]) {
212
240
  if (s.kind === 'team') {
213
241
  if (s.teamSlug && !seenTeams.has(s.teamSlug)) {
214
242
  seenTeams.add(s.teamSlug);
@@ -252,9 +280,10 @@ export async function prRoutes(app) {
252
280
  // Cloud lean mode: fill in bulky text from GitHub (no-op in local). The client
253
281
  // caches the result in IndexedDB keyed by updatedAt so unchanged PRs don't refetch.
254
282
  const detail = await hydratePrDetail(pr, accountId);
255
- // Layer CODEOWNERS ownership onto the history-based suggestions (best-effort; never
256
- // blocks the response). getPrDetail already gated the history part on the same trigger.
257
- return enrichSuggestionsWithCodeowners(detail, accountId);
283
+ // Layer CODEOWNERS ownership + inferred review team(s) onto the history-based suggestions
284
+ // (best-effort; never blocks the response). getPrDetail already gated the history part on
285
+ // the same trigger.
286
+ return enrichSuggestions(detail, accountId);
258
287
  });
259
288
  // Candidates for an @mention autocomplete, ranked by proximity to this PR
260
289
  // (participants first, then repo people), self + bots excluded. Account-scoped:
@@ -0,0 +1,96 @@
1
+ // History-based TEAM reviewer suggestions (CORE). GitHub lets a PR request an `@org/team` as
2
+ // a reviewer; that request is EPHEMERAL (GitHub drops it from `reviewRequests` once a member
3
+ // reviews), but the PR timeline keeps a permanent `ReviewRequestedEvent` for it. This mines a
4
+ // repo's recent PRs for those team requests, ranks the teams by how often they're asked to
5
+ // review here, and returns the top few — the "which team usually reviews this repo" signal
6
+ // that CODEOWNERS provides statically but many repos never declare.
7
+ //
8
+ // Best-effort throughout: any failure (network, org wall, a repo that simply doesn't use team
9
+ // review-requests) degrades to "no team suggestion", NEVER an error on the PR-detail path.
10
+ //
11
+ // Auth caveat (same reality codeowners.ts notes for the org/teams API): a Team
12
+ // `requestedReviewer` is only visible to a token with org/team visibility — org membership,
13
+ // or a GitHub App installed with org read. For an outside token GitHub returns no team on the
14
+ // event, so this yields nothing. That's fine: it lights up for your own org's repos (the
15
+ // common case — you watch your org's repos with your own token) and stays silent elsewhere.
16
+ import { getGraphqlClientFor } from './client.js';
17
+ // Per-(account, repo) cache. Which team reviews a repo is very stable, so a longish TTL keeps
18
+ // the extra GraphQL call off the hot path. Cached even when EMPTY (the "repo doesn't use team
19
+ // requests" / "token can't see teams" case) so we don't re-query on every PR open. Keyed by
20
+ // account so one tenant's token result never leaks to another.
21
+ const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
22
+ const cache = new Map();
23
+ const PRS_SCANNED = 50; // recent PRs to mine — bounded for GraphQL node cost
24
+ const DEFAULT_MIN_PRS = 2; // ignore a one-off request; require a repeated pattern
25
+ const DEFAULT_TOP_N = 2; // at most this many team suggestions
26
+ const TEAM_HISTORY_QUERY = `
27
+ query TeamReviewHistory($owner: String!, $name: String!, $prs: Int!) {
28
+ repository(owner: $owner, name: $name) {
29
+ pullRequests(first: $prs, orderBy: { field: UPDATED_AT, direction: DESC }) {
30
+ nodes {
31
+ number
32
+ timelineItems(itemTypes: [REVIEW_REQUESTED_EVENT], first: 20) {
33
+ nodes {
34
+ ... on ReviewRequestedEvent {
35
+ createdAt
36
+ requestedReviewer {
37
+ __typename
38
+ ... on Team { slug }
39
+ }
40
+ }
41
+ }
42
+ }
43
+ }
44
+ }
45
+ }
46
+ }`;
47
+ // Aggregate team review-requests from PR timeline nodes → teams ranked by DISTINCT-PR count
48
+ // then recency, dropping any under `minPrs`. Pure (no network) so it's unit-testable. Counting
49
+ // distinct PRs (not raw events) means a re-request on a single PR isn't double-weighted.
50
+ export function rankTeamRequests(prNodes, owner, minPrs = DEFAULT_MIN_PRS) {
51
+ const agg = new Map();
52
+ for (const pr of prNodes) {
53
+ for (const ev of pr.timelineItems?.nodes ?? []) {
54
+ const rr = ev.requestedReviewer;
55
+ if (!rr || rr.__typename !== 'Team' || !rr.slug)
56
+ continue;
57
+ const cur = agg.get(rr.slug) ?? { prs: new Set(), lastAt: '' };
58
+ cur.prs.add(pr.number);
59
+ const when = ev.createdAt ?? '';
60
+ if (when > cur.lastAt)
61
+ cur.lastAt = when;
62
+ agg.set(rr.slug, cur);
63
+ }
64
+ }
65
+ return [...agg.entries()]
66
+ .map(([slug, v]) => ({
67
+ slug,
68
+ name: `${owner}/${slug}`,
69
+ count: v.prs.size,
70
+ lastAt: v.lastAt,
71
+ }))
72
+ .filter((t) => t.count >= minPrs)
73
+ .sort((a, b) => b.count - a.count ||
74
+ (a.lastAt < b.lastAt ? 1 : a.lastAt > b.lastAt ? -1 : 0));
75
+ }
76
+ // Mine a repo's recent PRs for the team(s) most often requested to review here (cached per
77
+ // repo). Returns the top `topN`. Never throws — a failure/permission-wall yields [].
78
+ export async function suggestTeamsFromHistory(token, owner, name, accountId, opts = {}) {
79
+ const topN = opts.topN ?? DEFAULT_TOP_N;
80
+ const key = `${accountId}:${owner}/${name}`;
81
+ const hit = cache.get(key);
82
+ if (hit && Date.now() - hit.at < CACHE_TTL_MS)
83
+ return hit.ranked.slice(0, topN);
84
+ let ranked = [];
85
+ try {
86
+ const client = getGraphqlClientFor(token);
87
+ const data = await client(TEAM_HISTORY_QUERY, { owner, name, prs: PRS_SCANNED });
88
+ ranked = rankTeamRequests(data.repository?.pullRequests?.nodes ?? [], owner, opts.minPrs ?? DEFAULT_MIN_PRS);
89
+ }
90
+ catch {
91
+ ranked = [];
92
+ }
93
+ cache.set(key, { ranked, at: Date.now() });
94
+ return ranked.slice(0, topN);
95
+ }
96
+ //# sourceMappingURL=team-reviewers.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pierre-review",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
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",