GV

GeezerVison

The World Wide Web, restored to 800 pixels of glory.

Home|About|Gaming cabinet|Archives|How it works|Starter sites

On this site

  • Sign in
  • ActionsCI/CD and automation
  • CodespacesCloud development environments
  • All Features
  • Documentation
  • Training
  • Certifications
  • Build with Copilot
  • Customize Copilot
  • Integrations
  • Trending repos
  • GitHub Sponsors
  • Developer articles
  • Enterprises

This view

  • Original page
  • New address

GeezerVison is showing github.com — chore(ci): Assign 3 PR reviewers via workflow (#26268) · getsentry/sentry-javascript@b31b20b

File tree

Expand file treeCollapse file tree
Open diff view settings
Filter options
  • .github/workflows
    • assign-reviewers.yml
  • package.json
  • scripts
    • assign-reviewers.mjs
    • assign-reviewers.test.mjs
Expand file treeCollapse file tree
Open diff view settings
Collapse file

‎.github/workflows/assign-reviewers.yml‎

Copy file name to clipboard
+47Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: 'PR: Assign reviewers'
2+
3+
on:
4+
# pull_request_target so this also runs for fork PRs. It only checks out the base branch,
5+
# never the PR's code.
6+
pull_request_target:
7+
types:
8+
- opened
9+
- reopened
10+
- ready_for_review
11+
12+
concurrency:
13+
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
14+
cancel-in-progress: false
15+
16+
permissions:
17+
contents: read
18+
19+
jobs:
20+
assign-reviewers:
21+
name: Assign reviewers
22+
runs-on: ubuntu-24.04
23+
if: github.event.pull_request.draft == false
24+
steps:
25+
# Listing team members needs org `members: read`, which GITHUB_TOKEN can't have.
26+
- name: Generate GitHub App token
27+
id: app-token
28+
uses: actions/create-github-app-token@v3
29+
with:
30+
client-id: ${{ vars.GITFLOW_APP_ID }}
31+
private-key: ${{ secrets.GITFLOW_APP_PRIVATE_KEY }}
32+
33+
- name: Checkout base branch
34+
uses: actions/checkout@v7
35+
with:
36+
sparse-checkout: |
37+
.github/CODEOWNERS
38+
scripts/assign-reviewers.mjs
39+
sparse-checkout-cone-mode: false
40+
41+
- name: Assign reviewers
42+
uses: actions/github-script@v9
43+
with:
44+
github-token: ${{ steps.app-token.outputs.token }}
45+
script: |
46+
const { assignReviewers } = await import('${{ github.workspace }}/scripts/assign-reviewers.mjs');
47+
await assignReviewers({ github, context, core });
Collapse file

‎package.json‎

Copy file name to clipboardExpand all lines: package.json
+1-1Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
"dedupe-deps:fix": "pnpm dedupe",
3737
"postpublish": "nx run-many -t postpublish --parallel=1",
3838
"test": "nx run-many -t test --exclude=@sentry-internal/browser-integration-tests --exclude=@sentry-internal/bun-integration-tests --exclude=@sentry-internal/deno-integration-tests --exclude=@sentry-internal/e2e-tests --exclude=@sentry-internal/integration-shims --exclude=@sentry-internal/node-integration-tests --exclude=@sentry-internal/cloudflare-integration-tests --exclude=@sentry-internal/bundler-plugin-integration-tests",
39-
"test:scripts": "vitest run scripts/*.test.ts",
39+
"test:scripts": "vitest run scripts/*.test.ts scripts/*.test.mjs",
4040
"test:unit": "nx run-many -t test:unit --exclude=@sentry-internal/browser-integration-tests --exclude=@sentry-internal/bun-integration-tests --exclude=@sentry-internal/deno-integration-tests --exclude=@sentry-internal/e2e-tests --exclude=@sentry-internal/integration-shims --exclude=@sentry-internal/node-integration-tests --exclude=@sentry-internal/cloudflare-integration-tests --exclude=@sentry-internal/bundler-plugin-integration-tests",
4141
"test:update-snapshots": "nx run-many -t test:update-snapshots",
4242
"test:pr": "nx affected -t test --exclude=@sentry-internal/browser-integration-tests --exclude=@sentry-internal/bun-integration-tests --exclude=@sentry-internal/deno-integration-tests --exclude=@sentry-internal/e2e-tests --exclude=@sentry-internal/integration-shims --exclude=@sentry-internal/node-integration-tests --exclude=@sentry-internal/cloudflare-integration-tests --exclude=@sentry-internal/bundler-plugin-integration-tests",
Collapse file

‎scripts/assign-reviewers.mjs‎

Copy file name to clipboard
+149Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import * as fs from 'fs';
2+
3+
const REQUIRED_REVIEWERS = 3;
4+
const TEAM_SLUG = 'team-javascript-sdks';
5+
// Managers who are members (and code owners) of the SDK teams, but should never be auto-requested for review.
6+
const NEVER_REQUEST = ['mydea', 'stephanie-anderson'];
7+
const CODEOWNERS_PATH = '.github/CODEOWNERS';
8+
9+
function patternToRegExp(pattern) {
10+
let p = pattern;
11+
const anchored = p.startsWith('/') || p.slice(0, -1).includes('/');
12+
p = p.replace(/^\//, '');
13+
const directoryOnly = p.endsWith('/');
14+
p = p.replace(/\/$/, '');
15+
16+
const body = p
17+
.split('/')
18+
.map(segment => {
19+
if (segment === '**') {
20+
return '(?:.*)';
21+
}
22+
return segment
23+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
24+
.replace(/\*/g, '[^/]*')
25+
.replace(/\?/g, '[^/]');
26+
})
27+
.join('/')
28+
.replace(/\(\?:\.\*\)\//g, '(?:.*/)?');
29+
30+
const prefix = anchored ? '^' : '^(?:.*/)?';
31+
const suffix = directoryOnly ? '/.*$' : '(?:/.*)?$';
32+
return new RegExp(prefix + body + suffix);
33+
}
34+
35+
export function parseCodeowners(content) {
36+
return content
37+
.split('\n')
38+
.map(line => line.trim())
39+
.filter(line => line && !line.startsWith('#'))
40+
.map(line => {
41+
const [pattern, ...owners] = line.split(/\s+/);
42+
return { regexp: patternToRegExp(pattern), owners };
43+
});
44+
}
45+
46+
/** CODEOWNERS semantics: the last matching rule wins for each file. */
47+
export function getOwnersForFiles(rules, files) {
48+
const owners = new Set();
49+
for (const file of files) {
50+
const rule = rules.findLast(r => r.regexp.test(file));
51+
rule?.owners.forEach(owner => owners.add(owner));
52+
}
53+
return [...owners];
54+
}
55+
56+
function shuffle(items) {
57+
const result = [...items];
58+
for (let i = result.length - 1; i > 0; i--) {
59+
const j = Math.floor(Math.random() * (i + 1));
60+
[result[i], result[j]] = [result[j], result[i]];
61+
}
62+
return result;
63+
}
64+
65+
/**
66+
* Picks reviewers to top the PR up to `REQUIRED_REVIEWERS`, preferring the codeowner pool and falling back
67+
* to the whole team. Logins are compared case-insensitively, as GitHub does.
68+
*/
69+
export function pickReviewers({ existing, codeownerPool, teamPool, exclude }) {
70+
const taken = new Set([...existing, ...exclude].map(login => login.toLowerCase()));
71+
const picked = [];
72+
73+
for (const pool of [codeownerPool, teamPool]) {
74+
for (const login of shuffle(pool)) {
75+
if (existing.length + picked.length >= REQUIRED_REVIEWERS) {
76+
return picked;
77+
}
78+
if (!taken.has(login.toLowerCase())) {
79+
taken.add(login.toLowerCase());
80+
picked.push(login);
81+
}
82+
}
83+
}
84+
85+
return picked;
86+
}
87+
88+
export async function assignReviewers({ github, context, core }) {
89+
const { owner, repo } = context.repo;
90+
const pull_number = context.payload.pull_request.number;
91+
92+
const { data: currentPr } = await github.rest.pulls.get({ owner, repo, pull_number });
93+
if (currentPr.draft || currentPr.state !== 'open') {
94+
core.info('PR is a draft or not open, skipping.');
95+
return;
96+
}
97+
98+
const author = currentPr.user.login;
99+
const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number });
100+
// Requested reviewers drop off `requested_reviewers` once they submit a review, so count both.
101+
// Bots (AI reviewers) don't count towards the required reviewers.
102+
const existing = [
103+
...new Set(
104+
[...currentPr.requested_reviewers, ...reviews.map(r => r.user)]
105+
.filter(user => user && user.type !== 'Bot' && user.login.toLowerCase() !== author.toLowerCase())
106+
.map(user => user.login),
107+
),
108+
];
109+
110+
if (existing.length >= REQUIRED_REVIEWERS) {
111+
core.info(`PR already has ${existing.length} reviewers (${existing.join(', ')}), not changing anything.`);
112+
return;
113+
}
114+
115+
const listTeamMembers = async team_slug =>
116+
(await github.paginate(github.rest.teams.listMembersInOrg, { org: owner, team_slug })).map(m => m.login);
117+
118+
const files = (await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number })).map(f => f.filename);
119+
const codeowners = getOwnersForFiles(parseCodeowners(fs.readFileSync(CODEOWNERS_PATH, 'utf8')), files);
120+
121+
const codeownerPool = [];
122+
for (const codeowner of codeowners) {
123+
// `@org/team` or `@user`; email owners can't be requested as reviewers, so they are skipped.
124+
const [, name, teamSlug] = codeowner.match(/^@([^/]+)(?:\/(.+))?$/) ?? [];
125+
if (teamSlug && name.toLowerCase() === owner.toLowerCase()) {
126+
codeownerPool.push(...(await listTeamMembers(teamSlug)));
127+
} else if (name && !teamSlug) {
128+
codeownerPool.push(name);
129+
}
130+
}
131+
132+
const reviewers = pickReviewers({
133+
existing,
134+
codeownerPool,
135+
teamPool: await listTeamMembers(TEAM_SLUG),
136+
exclude: [author, ...NEVER_REQUEST],
137+
});
138+
139+
core.info(`Codeowners: ${codeowners.join(', ') || '(none)'}`);
140+
core.info(`Existing reviewers: ${existing.join(', ') || '(none)'}`);
141+
142+
if (!reviewers.length) {
143+
core.info('No eligible reviewers left to request.');
144+
return;
145+
}
146+
147+
core.info(`Requesting reviews from: ${reviewers.join(', ')}`);
148+
await github.rest.pulls.requestReviewers({ owner, repo, pull_number, reviewers });
149+
}
Collapse file

‎scripts/assign-reviewers.test.mjs‎

Copy file name to clipboard
+80Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { getOwnersForFiles, parseCodeowners, pickReviewers } from './assign-reviewers.mjs';
4+
5+
const CODEOWNERS = `
6+
# comment
7+
/packages/browser/ @getsentry/browser
8+
/packages/node/ @getsentry/server
9+
/packages/node/src/x.ts @someone
10+
*.md @getsentry/docs
11+
`;
12+
13+
describe('getOwnersForFiles', () => {
14+
const rules = parseCodeowners(CODEOWNERS);
15+
16+
it('collects owners across all touched files', () => {
17+
expect(getOwnersForFiles(rules, ['packages/browser/src/index.ts', 'packages/node/src/sdk.ts'])).toEqual([
18+
'@getsentry/browser',
19+
'@getsentry/server',
20+
]);
21+
});
22+
23+
it('uses the last matching rule for a file', () => {
24+
expect(getOwnersForFiles(rules, ['packages/node/src/x.ts'])).toEqual(['@someone']);
25+
});
26+
27+
it('matches unanchored patterns at any depth', () => {
28+
expect(getOwnersForFiles(rules, ['packages/node/README.md'])).toEqual(['@getsentry/docs']);
29+
});
30+
31+
it('does not match sibling directories that share a prefix', () => {
32+
expect(getOwnersForFiles(rules, ['packages/browser-utils/src/index.ts', 'packages/nodejs/a.ts'])).toEqual([]);
33+
});
34+
});
35+
36+
describe('pickReviewers', () => {
37+
const teamPool = ['a', 'b', 'c', 'd', 'e', 'mydea'];
38+
39+
it('picks only from the codeowner pool when it is large enough', () => {
40+
const picked = pickReviewers({
41+
existing: [],
42+
codeownerPool: ['x1', 'x2', 'x3', 'y1', 'y2', 'y3'],
43+
teamPool,
44+
exclude: [],
45+
});
46+
expect(picked).toHaveLength(3);
47+
picked.forEach(login => expect(['x1', 'x2', 'x3', 'y1', 'y2', 'y3']).toContain(login));
48+
});
49+
50+
it('fills up from the team when the codeowner pool is too small', () => {
51+
const picked = pickReviewers({ existing: [], codeownerPool: ['x1'], teamPool, exclude: ['mydea'] });
52+
expect(picked).toHaveLength(3);
53+
expect(picked[0]).toBe('x1');
54+
expect(picked).not.toContain('mydea');
55+
});
56+
57+
it('picks from the whole team when no codeowners match', () => {
58+
const picked = pickReviewers({ existing: [], codeownerPool: [], teamPool: ['a', 'b', 'c'], exclude: [] });
59+
expect(picked.sort()).toEqual(['a', 'b', 'c']);
60+
});
61+
62+
it('never picks the author, excluded people, or existing reviewers, case-insensitively', () => {
63+
const picked = pickReviewers({
64+
existing: ['B'],
65+
codeownerPool: ['A', 'b', 'Mydea'],
66+
teamPool: ['a', 'c', 'd'],
67+
exclude: ['a', 'mydea'],
68+
});
69+
expect(picked.sort()).toEqual(['c', 'd']);
70+
});
71+
72+
it('only tops up to the required number of reviewers', () => {
73+
expect(pickReviewers({ existing: ['x', 'y'], codeownerPool: [], teamPool, exclude: [] })).toHaveLength(1);
74+
expect(pickReviewers({ existing: ['x', 'y', 'z'], codeownerPool: [], teamPool, exclude: [] })).toEqual([]);
75+
});
76+
77+
it('returns fewer reviewers when not enough people are eligible', () => {
78+
expect(pickReviewers({ existing: [], codeownerPool: [], teamPool: ['a'], exclude: [] })).toEqual(['a']);
79+
});
80+
});

0 commit comments

Comments
0 (0)
You are visitor 01402520 — GeezerVison does not store the pages you look up.
Best viewed with Netscape Navigator 4.7 or Internet Explorer 6 at 800×600. No cookies required.