pierre-review 0.1.75 → 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.
- package/dist/api/routes/prs.js +46 -17
- package/dist/github/team-reviewers.js +96 -0
- package/package.json +1 -1
package/dist/api/routes/prs.js
CHANGED
|
@@ -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
|
|
158
|
-
// computed. Runs ONLY when the PR still warrants suggestions (same trigger getPrDetail
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
|
|
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
|
|
178
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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 [
|
|
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
|
|
256
|
-
// blocks the response). getPrDetail already gated the history part on
|
|
257
|
-
|
|
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.
|
|
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",
|