GeezerVison is showing github.com — chore(ci): Assign 3 PR reviewers via workflow (#26268) · getsentry/sentry-javascript@b31b20b
File treeExpand file treeCollapse file tree Open diff view settings Expand file treeCollapse file tree Open diff view settings Collapse file Copy file name to clipboard +47Lines changed: 47 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 Copy file name to clipboardExpand all lines: package.json +1-1Lines changed: 1 addition & 1 deletion | Original file line number | Diff line number | Diff line change |
|---|
| 36 | 36 | "dedupe-deps:fix": "pnpm dedupe", | 37 | 37 | "postpublish": "nx run-many -t postpublish --parallel=1", | 38 | 38 | "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", | 40 | 40 | "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", | 41 | 41 | "test:update-snapshots": "nx run-many -t test:update-snapshots", | 42 | 42 | "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 Copy file name to clipboard +149Lines changed: 149 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 Copy file name to clipboard +80Lines changed: 80 additions & 0 deletions | Original file line number | Diff line number | Diff line change |
|---|
| | 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 | +}); |
|