|
1 | 1 | const { Octokit } = require("@octokit/rest"); |
2 | 2 | const fs = require("fs"); |
3 | 3 |
|
4 | | -const token = process.env.APP_TOKEN; // GitHub App installation token |
5 | | -const org = process.env.ORG; |
6 | | -const sf = process.env.SF_TEAM_SLUG; |
7 | | -const prg = process.env.PRG_TEAM_SLUG; |
8 | | -const n = parseInt(process.env.REVIEWERS_TO_REQUEST || "1", 10); |
9 | | -const TEAM_MODE = (process.env.TEAM_MODE || "false").toLowerCase() === "true"; |
| 4 | +const token = process.env.APP_TOKEN; |
| 5 | +const org = process.env.ORG; |
| 6 | +const sf = process.env.SF_TEAM_SLUG; |
| 7 | +const prg = process.env.PRG_TEAM_SLUG; |
| 8 | +const n = Math.max(1, parseInt(process.env.REVIEWERS_TO_REQUEST || "1", 10)); |
10 | 9 |
|
11 | 10 | const gh = new Octokit({ auth: token }); |
12 | 11 | const [owner, repo] = process.env.GITHUB_REPOSITORY.split("/"); |
13 | 12 |
|
14 | | -// ---- helpers ---- |
15 | 13 | function getEvent() { |
16 | 14 | return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); |
17 | 15 | } |
| 16 | + |
18 | 17 | function prNumber(ev) { |
19 | | - return ev.pull_request?.number || null; |
| 18 | + return ev.pull_request?.number ?? null; |
20 | 19 | } |
| 20 | + |
21 | 21 | async function getPR(num) { |
22 | 22 | const { data } = await gh.pulls.get({ owner, repo, pull_number: num }); |
23 | 23 | return data; |
24 | 24 | } |
| 25 | + |
25 | 26 | async function getUserTeams(login) { |
26 | 27 | const data = await gh.graphql( |
27 | 28 | `query($org:String!, $login:String!){ |
28 | 29 | organization(login:$org){ |
29 | 30 | teams(first:100, userLogins: [$login]){ nodes { slug } } |
30 | 31 | } |
31 | 32 | }`, |
32 | | - { org, login } |
| 33 | + { org, login }, |
33 | 34 | ); |
34 | | - return (data.organization?.teams?.nodes || []).map(t => t.slug); |
| 35 | + return (data.organization?.teams?.nodes || []).map((t) => t.slug); |
35 | 36 | } |
| 37 | + |
36 | 38 | async function listTeamMembers(teamSlug) { |
37 | 39 | const res = await gh.teams.listMembersInOrg({ org, team_slug: teamSlug, per_page: 100 }); |
38 | | - return res.data.map(u => u.login); |
| 40 | + return res.data.map((u) => u.login); |
39 | 41 | } |
| 42 | + |
40 | 43 | function pickRandom(arr, k) { |
41 | | - const a = [...arr]; |
42 | | - for (let i = a.length - 1; i > 0; i--) { |
| 44 | + const list = [...arr]; |
| 45 | + for (let i = list.length - 1; i > 0; i--) { |
43 | 46 | const j = Math.floor(Math.random() * (i + 1)); |
44 | | - [a[i], a[j]] = [a[j], a[i]]; |
45 | | - } |
46 | | - return a.slice(0, Math.max(0, Math.min(k, a.length))); |
47 | | -} |
48 | | - |
49 | | -// Read CODEOWNERS (from usual locations) and parse the global "*" owners. |
50 | | -// Returns { users: [logins], teams: [teamSlugs] } |
51 | | -async function readDefaultCodeownersOwners(baseRef) { |
52 | | - const paths = [".github/CODEOWNERS", "CODEOWNERS"]; |
53 | | - let text = ""; |
54 | | - for (const path of paths) { |
55 | | - try { |
56 | | - const { data } = await gh.repos.getContent({ owner, repo, path, ref: baseRef }); |
57 | | - if (Array.isArray(data)) continue; // directory |
58 | | - text = Buffer.from(data.content, "base64").toString("utf8"); |
59 | | - break; |
60 | | - } catch { /* try next */ } |
61 | | - } |
62 | | - if (!text) return { users: [], teams: [] }; |
63 | | - |
64 | | - // find last matching "*" rule (later rules take precedence) |
65 | | - const lines = text.split(/\r?\n/); |
66 | | - let starOwners = null; |
67 | | - for (const raw of lines) { |
68 | | - const line = raw.trim(); |
69 | | - if (!line || line.startsWith("#")) continue; |
70 | | - const parts = line.split(/\s+/); |
71 | | - if (parts[0] === "*") starOwners = parts.slice(1); |
72 | | - } |
73 | | - if (!starOwners) return { users: [], teams: [] }; |
74 | | - |
75 | | - const users = []; |
76 | | - const teams = []; |
77 | | - for (const ownerRef of starOwners) { |
78 | | - if (!ownerRef.startsWith("@")) continue; |
79 | | - const clean = ownerRef.slice(1); // remove leading '@' |
80 | | - const slash = clean.indexOf("/"); |
81 | | - if (slash > -1) { |
82 | | - // looks like org/team |
83 | | - const maybeOrg = clean.slice(0, slash); |
84 | | - const teamSlug = clean.slice(slash + 1); |
85 | | - if (maybeOrg.toLowerCase() === org.toLowerCase()) teams.push(teamSlug); |
86 | | - // if CODEOWNERS references another org's team, we ignore |
87 | | - } else { |
88 | | - users.push(clean); |
89 | | - } |
| 47 | + [list[i], list[j]] = [list[j], list[i]]; |
90 | 48 | } |
91 | | - return { users, teams }; |
| 49 | + return list.slice(0, Math.max(0, Math.min(k, list.length))); |
92 | 50 | } |
93 | 51 |
|
94 | 52 | (async () => { |
95 | 53 | const ev = getEvent(); |
96 | 54 | const num = prNumber(ev); |
97 | | - if (!num) { console.log("No PR number; exiting."); return; } |
| 55 | + if (!num) { |
| 56 | + console.log("No PR number in event; exiting."); |
| 57 | + return; |
| 58 | + } |
98 | 59 |
|
99 | | - const baseRef = ev.pull_request?.base?.ref || "main"; |
100 | 60 | const pr = await getPR(num); |
101 | 61 | const author = pr.user.login; |
| 62 | + const alreadyAssigned = new Set((pr.assignees || []).map((a) => a.login)); |
102 | 63 |
|
103 | | - // Determine author site |
104 | 64 | const teams = await getUserTeams(author); |
105 | 65 | const site = teams.includes(sf) ? sf : teams.includes(prg) ? prg : null; |
106 | 66 |
|
107 | | - // If author is not in eng-sf or eng-prg => do nothing (keep CODEOWNERS defaults) |
108 | 67 | if (!site) { |
109 | | - console.log("Author not in eng-sf or eng-prg; keeping CODEOWNERS reviewers."); |
| 68 | + console.log("Author not in configured teams; skipping assignee update."); |
| 69 | + return; |
| 70 | + } |
| 71 | + |
| 72 | + const siteMembers = (await listTeamMembers(site)).filter((user) => user !== author); |
| 73 | + if (!siteMembers.length) { |
| 74 | + console.log(`No teammates found in ${site}; skipping assignee update.`); |
110 | 75 | return; |
111 | 76 | } |
112 | 77 |
|
113 | | - // Author IS internal: remove global CODEOWNERS defaults, then assign same-site reviewers |
114 | | - const defaults = await readDefaultCodeownersOwners(baseRef); |
115 | | - console.log("CODEOWNERS * defaults:", defaults); |
116 | | - |
117 | | - // Find currently requested users & teams |
118 | | - const { data: req } = await gh.pulls.listRequestedReviewers({ owner, repo, pull_number: num }); |
119 | | - const currentUsers = new Set(req.users.map(u => u.login)); |
120 | | - const currentTeams = new Set(req.teams.map(t => t.slug)); |
121 | | - |
122 | | - // Compute removal sets based on CODEOWNERS defaults |
123 | | - const toRemoveUsers = defaults.users.filter(u => currentUsers.has(u)); |
124 | | - const toRemoveTeams = defaults.teams.filter(t => currentTeams.has(t)); |
125 | | - |
126 | | - if (toRemoveUsers.length || toRemoveTeams.length) { |
127 | | - await gh.pulls.removeRequestedReviewers({ |
128 | | - owner, repo, pull_number: num, |
129 | | - reviewers: toRemoveUsers, |
130 | | - team_reviewers: toRemoveTeams |
131 | | - }); |
132 | | - console.log( |
133 | | - "Removed CODEOWNERS defaults:", |
134 | | - toRemoveUsers.length ? `users=[${toRemoveUsers.join(", ")}]` : "users=[]", |
135 | | - toRemoveTeams.length ? `teams=[${toRemoveTeams.join(", ")}]` : "teams=[]" |
136 | | - ); |
137 | | - } else { |
138 | | - console.log("No CODEOWNERS defaults currently requested (nothing to remove)."); |
| 78 | + const siteMemberSet = new Set(siteMembers); |
| 79 | + const assignedFromSite = [...alreadyAssigned].filter((login) => siteMemberSet.has(login)); |
| 80 | + |
| 81 | + if (assignedFromSite.length >= n) { |
| 82 | + console.log(`PR #${num} already has ${assignedFromSite.length} teammate assignee(s); nothing to do.`); |
| 83 | + return; |
139 | 84 | } |
140 | 85 |
|
141 | | - // Now request same-site reviewers |
142 | | - if (TEAM_MODE) { |
143 | | - await gh.pulls.requestReviewers({ owner, repo, pull_number: num, team_reviewers: [site] }); |
144 | | - console.log(`Requested team ${site}`); |
| 86 | + const needed = n - assignedFromSite.length; |
| 87 | + |
| 88 | + const candidates = siteMembers.filter((member) => !alreadyAssigned.has(member)); |
| 89 | + if (!candidates.length) { |
| 90 | + console.log(`All teammates from ${site} are already assigned; nothing to add.`); |
145 | 91 | return; |
146 | 92 | } |
147 | 93 |
|
148 | | - const siteMembers = (await listTeamMembers(site)).filter(u => u !== author); |
149 | | - // refresh requested reviewers after potential removals |
150 | | - const { data: req2 } = await gh.pulls.listRequestedReviewers({ owner, repo, pull_number: num }); |
151 | | - const already = new Set(req2.users.map(u => u.login)); |
152 | | - const candidates = siteMembers.filter(m => !already.has(m)); |
153 | | - const reviewers = pickRandom(candidates, n); |
154 | | - |
155 | | - if (reviewers.length) { |
156 | | - await gh.pulls.requestReviewers({ owner, repo, pull_number: num, reviewers }); |
157 | | - console.log(`Requested ${reviewers.join(", ")} from ${site}`); |
158 | | - } else { |
159 | | - console.log(`No candidates to request from ${site}.`); |
| 94 | + const assignees = pickRandom(candidates, needed); |
| 95 | + if (!assignees.length) { |
| 96 | + console.log(`Unable to select additional assignees from ${site}; skipping.`); |
| 97 | + return; |
160 | 98 | } |
161 | | -})().catch(e => { console.error(e); process.exit(1); }); |
| 99 | + |
| 100 | + await gh.issues.addAssignees({ |
| 101 | + owner, |
| 102 | + repo, |
| 103 | + issue_number: num, |
| 104 | + assignees, |
| 105 | + }); |
| 106 | + |
| 107 | + console.log(`Assigned ${assignees.join(", ")} to PR #${num}.`); |
| 108 | +})().catch((error) => { |
| 109 | + console.error(error); |
| 110 | + process.exit(1); |
| 111 | +}); |
0 commit comments