Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/protected-paths
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# A change to these paths needs an approval from someone with maintain or admin access, unless one of them wrote it.
# A change to these paths needs an approval from a Team Mobile member (maintain or admin access) other than the author.
# The `Code owner approval` check enforces this. Patterns follow CODEOWNERS syntax, without owners.

/.github/protected-paths
Expand Down
37 changes: 22 additions & 15 deletions .github/scripts/code-owner-approval.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { client, paged } from './github.mjs';

const TRUSTED_ROLES = new Set(['admin', 'maintain']);
const PROTECTED_PATHS = '.github/protected-paths';
const CODERABBIT = 'coderabbitai[bot]';

const GLOB = { '**/': '(?:.*/)?', '/**': '(?:/.*)?', '**': '.*', '*': '[^/]*', '?': '[^/]' };

Expand Down Expand Up @@ -50,32 +51,38 @@ async function trusted(get, login) {
return TRUSTED_ROLES.has(role);
}

// reason says why the check failed: 'author', 'files', 'protected' or 'coderabbit'.
async function codeOwnerApproval({ fetch, api, token, repo, number, author, baseRef }) {
const get = client({ fetch, api, token, repo });
const approved = approvers(await paged(get, `/pulls/${number}/reviews`), author);
for (const login of approved) {
if (await trusted(get, login)) {
return { pass: true, message: `${login} approved, and has maintain or admin access.` };
}
}
if (!(await trusted(get, author))) {
return { pass: false, reason: 'author', message: `${author} doesn't have maintain or admin access, so this needs an approval from Team Mobile.` };
}
const list = await get(`/contents/${PROTECTED_PATHS}?ref=${encodeURIComponent(baseRef)}`, { missing: null });
const patterns = list === null ? [] : parseProtectedPaths(Buffer.from(list.content, 'base64').toString('utf8'));
const files = await paged(get, `/pulls/${number}/files`);
const { changed_files: changed } = await get(`/pulls/${number}`);
if (files.length < changed) {
return { pass: false, message: `GitHub listed ${files.length} of the ${changed} changed files, so this check can't tell which are protected. Split the pull request.` };
return { pass: false, reason: 'files', message: `GitHub listed ${files.length} of the ${changed} changed files, so this check can't tell which are protected. Split the pull request.` };
}
const owned = protectedFiles(patterns, [...new Set(files.flatMap(file => [file.filename, file.previous_filename].filter(Boolean)))]);
if (owned.length === 0) {
return { pass: true, message: `No file listed in ${PROTECTED_PATHS} changed.` };
if (owned.length > 0) {
const shown = owned.slice(0, 10).join(', ');
return {
pass: false,
reason: 'protected',
message: `This changes ${owned.length} protected file(s) (${shown}${owned.length > 10 ? ', ...' : ''}), so it needs an approval from another Team Mobile member.`,
};
}
if (await trusted(get, author)) {
return { pass: true, message: `${author} has maintain or admin access, so their change to protected files needs no other approval.` };
}
for (const login of approvers(await paged(get, `/pulls/${number}/reviews`), author)) {
if (await trusted(get, login)) {
return { pass: true, message: `${login} approved, and has maintain or admin access.` };
}
if (!approved.includes(CODERABBIT)) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
return { pass: false, reason: 'coderabbit', message: "CodeRabbit hasn't approved this commit. If it rates the merge risk high or critical, it won't, and this needs an approval from another Team Mobile member." };
}
const shown = owned.slice(0, 10).join(', ');
return {
pass: false,
message: `This changes ${owned.length} protected file(s) (${shown}${owned.length > 10 ? ', ...' : ''}). It needs an approval from someone with maintain or admin access.`,
};
return { pass: true, message: `${author} has maintain or admin access, and CodeRabbit approved.` };
}

async function main(env, { fetch = globalThis.fetch, out = process.stdout } = {}) {
Expand Down
63 changes: 40 additions & 23 deletions .github/scripts/code-owner-approval.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -70,40 +70,57 @@ describe('protectedFiles', () => {
});

describe('code owner approval', () => {
it('passes when no protected file changed', async () => {
const { pass, output } = await run({ files: [{ filename: 'Sources/ClerkKit/Clerk.swift' }] });
const coderabbit = review('coderabbitai[bot]', 'APPROVED');
const ordinary = [{ filename: 'Sources/ClerkKit/Clerk.swift' }];
const workflow = [{ filename: '.github/workflows/pr-ci.yml' }];

it('passes a Team Mobile author on ordinary files once CodeRabbit approves', async () => {
const { pass, output } = await run({ files: ordinary, reviews: [coderabbit], roles: { mikepitre: 'admin' } }, 'mikepitre');
assert.equal(pass, true);
assert.match(output, /No file listed in \.github\/protected-paths changed/);
assert.match(output, /mikepitre has maintain or admin access, and CodeRabbit approved/);
});

it('fails a Team Mobile author until CodeRabbit approves, as when it rates the risk high', async () => {
const { pass, output } = await run({ files: ordinary, reviews: [review('coderabbitai[bot]', 'COMMENTED')], roles: { mikepitre: 'admin' } }, 'mikepitre');
assert.equal(pass, false);
assert.match(output, /CodeRabbit hasn't approved this commit/);
});

it('fails a Team Mobile author on protected files even after CodeRabbit approves', async () => {
const { pass, output } = await run({ files: workflow, reviews: [coderabbit], roles: { swolfand: 'maintain' } }, 'swolfand');
assert.equal(pass, false);
assert.match(output, /This changes 1 protected file\(s\) \(\.github\/workflows\/pr-ci\.yml\), so it needs an approval from another Team Mobile member/);
});

it('passes when the author has maintain or admin access', async () => {
const { pass } = await run({ files: [{ filename: '.github/workflows/pr-ci.yml' }], roles: { mikepitre: 'admin' } }, 'mikepitre');
it('passes a Team Mobile author on protected files once another member approves', async () => {
const { pass, output } = await run({ files: workflow, reviews: [review('seanperez29', 'APPROVED')], roles: { swolfand: 'maintain', seanperez29: 'admin' } }, 'swolfand');
assert.equal(pass, true);
assert.match(output, /seanperez29 approved/);
});

it('fails for an author with write access and no trusted approval', async () => {
it('fails an author with write access on ordinary files even after CodeRabbit and a writer approve', async () => {
const { pass, output } = await run({
files: [{ filename: '.github/workflows/pr-ci.yml' }],
reviews: [review('coderabbitai[bot]', 'APPROVED'), review('writer', 'APPROVED')],
files: ordinary,
reviews: [coderabbit, review('writer', 'APPROVED')],
roles: { 'outside-dev': 'write', writer: 'write' },
});
assert.equal(pass, false);
assert.match(output, /^::error title=Code owner approval::This changes 1 protected file\(s\) \(\.github\/workflows\/pr-ci\.yml\)/);
assert.match(output, /^::error title=Code owner approval::outside-dev doesn't have maintain or admin access/);
});

it('passes on an approval from someone with maintain access', async () => {
const { pass, output } = await run({
files: [{ filename: 'Package.swift' }],
reviews: [review('swolfand', 'APPROVED')],
roles: { 'outside-dev': 'write', swolfand: 'maintain' },
});
it('fails a contributor with no access to the repository', async () => {
const { pass } = await run({ files: ordinary, reviews: [coderabbit] }, 'fork-contributor');
assert.equal(pass, false);
});

it('passes an author with write access once Team Mobile approves', async () => {
const { pass } = await run({ files: workflow, reviews: [review('wobsoriano', 'APPROVED')], roles: { 'outside-dev': 'write', wobsoriano: 'maintain' } });
assert.equal(pass, true);
assert.match(output, /swolfand approved/);
});

it('ignores an approval that was later dismissed or followed by a change request', async () => {
const { pass } = await run({
files: [{ filename: 'Package.swift' }],
files: ordinary,
reviews: [review('swolfand', 'APPROVED'), review('swolfand', 'DISMISSED'), review('seanperez29', 'APPROVED'), review('seanperez29', 'COMMENTED'), review('seanperez29', 'CHANGES_REQUESTED')],
roles: { swolfand: 'maintain', seanperez29: 'admin' },
});
Expand All @@ -112,33 +129,33 @@ describe('code owner approval', () => {

it('keeps an approval when a later review only comments', async () => {
const { pass } = await run({
files: [{ filename: 'Package.swift' }],
files: ordinary,
reviews: [review('seanperez29', 'APPROVED'), review('seanperez29', 'COMMENTED')],
roles: { seanperez29: 'admin' },
});
assert.equal(pass, true);
});

it('counts the old path of a renamed file', async () => {
const { pass } = await run({ files: [{ filename: 'Old/Identity.swift', previous_filename: 'Sources/ClerkKit/Identity/Store.swift' }] });
const { pass } = await run({ files: [{ filename: 'Old/Identity.swift', previous_filename: 'Sources/ClerkKit/Identity/Store.swift' }], reviews: [coderabbit], roles: { mikepitre: 'admin' } }, 'mikepitre');
assert.equal(pass, false);
});

it('reads every page of changed files', async () => {
const files = Array.from({ length: 150 }, (_, i) => ({ filename: `Sources/File${i}.swift` }));
files.push({ filename: '.github/workflows/late.yml' });
const { pass } = await run({ files });
const { pass } = await run({ files, reviews: [coderabbit], roles: { mikepitre: 'admin' } }, 'mikepitre');
assert.equal(pass, false);
});

it('fails when GitHub lists fewer files than the pull request changed', async () => {
const { pass, output } = await run({ files: [{ filename: 'Sources/ClerkKit/Clerk.swift' }], changed: 3001 });
const { pass, output } = await run({ files: ordinary, changed: 3001, reviews: [coderabbit], roles: { mikepitre: 'admin' } }, 'mikepitre');
assert.equal(pass, false);
assert.match(output, /listed 1 of the 3001 changed files/);
});

it('passes when the base branch has no protected-paths list', async () => {
const { pass } = await run({ list: null, files: [{ filename: '.github/workflows/pr-ci.yml' }] });
it('treats every file as unprotected when the base branch has no protected-paths list', async () => {
const { pass } = await run({ list: null, files: workflow, reviews: [coderabbit], roles: { mikepitre: 'admin' } }, 'mikepitre');
assert.equal(pass, true);
});
});
83 changes: 83 additions & 0 deletions .github/scripts/team-review-request.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';

import { codeOwnerApproval } from './code-owner-approval.mjs';
import { client, paged } from './github.mjs';

const TEAM_FILE = new URL('../team-mobile', import.meta.url);
const CODERABBIT = 'coderabbitai[bot]';
const HIGH_RISK = new Set(['high', 'critical']);

function parseTeam(text) {
return text
.split('\n')
.map(line => line.replace(/#.*$/, '').trim())
.filter(line => line !== '');
}

// CodeRabbit's summary comment carries its merge risk for the commit it last reviewed, and rewrites it after each review.
function ratedHighRisk(comments, head) {
return comments.some(comment => {
if (comment.user?.login !== CODERABBIT) {
return false;
}
const body = comment.body ?? '';
const covered = /<!-- final_review_risk_coverage:(\{.*?\}) -->/.exec(body)?.[1];
const level = /<!-- final_review_risk_label\s*\*\*Merge Risk:\*\*\s*\*\*[^A-Za-z]*([A-Za-z]+)\*\*/.exec(body)?.[1];
return covered !== undefined && level !== undefined && JSON.parse(covered).coveredCommitId === head && HIGH_RISK.has(level.toLowerCase());
});
}

async function teamReviewRequest({ fetch, api, token, repo, number, team }) {
const get = client({ fetch, api, token, repo });
const pull = await get(`/pulls/${number}`);
if (pull.state !== 'open' || pull.draft) {
return 'The pull request is closed or a draft.';
}
const author = pull.user.login;
const check = await codeOwnerApproval({ fetch, api, token, repo, number, author, baseRef: pull.base.ref });
if (check.pass) {
return `No review request needed. ${check.message}`;
}
if (check.reason === 'coderabbit' && !ratedHighRisk(await paged(get, `/issues/${number}/comments`), pull.head.sha)) {
return 'Waiting on CodeRabbit, which hasn\'t rated this commit high or critical risk.';
}
const asked = new Set([author, ...pull.requested_reviewers.map(user => user.login)]);
for (const review of await paged(get, `/pulls/${number}/reviews`)) {
asked.add(review.user?.login);
}
const reviewers = team.filter(login => !asked.has(login));
if (reviewers.length === 0) {
return 'Every Team Mobile member is already requested or has reviewed.';
}
await get(`/pulls/${number}/requested_reviewers`, { method: 'POST', body: { reviewers } });
const why = check.reason === 'coderabbit' ? 'CodeRabbit rated this commit high or critical risk.' : check.message;
return `Requested ${reviewers.join(', ')}. ${why}`;
}

async function main(env, { fetch = globalThis.fetch, out = process.stdout, teamText = fs.readFileSync(TEAM_FILE, 'utf8') } = {}) {
const missing = ['GITHUB_REPOSITORY', 'GITHUB_TOKEN', 'PR_NUMBER'].filter(name => !env[name]);
if (missing.length > 0) {
throw new Error(`missing ${missing.join(', ')}`);
}
const message = await teamReviewRequest({
fetch,
api: env.GITHUB_API_URL || 'https://api.github.com',
token: env.GITHUB_TOKEN,
repo: env.GITHUB_REPOSITORY,
number: env.PR_NUMBER,
team: parseTeam(teamText),
});
out.write(`${message}\n`);
}

export { main, parseTeam, ratedHighRisk };

if (process.argv[1] !== undefined && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
await main(process.env);
} catch (error) {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
}
}
121 changes: 121 additions & 0 deletions .github/scripts/team-review-request.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { describe, it } from 'node:test';

import { main, parseTeam, ratedHighRisk } from './team-review-request.mjs';

const REPO = 'clerk/clerk-ios';
const API = 'https://api.github.test';
const HEAD = 'c14cacc2763d2dfc271d9c286b09376a90c5b9ff';
const TEAM = '# Team Mobile\nmikepitre\nseanperez29\nswolfand\nwobsoriano\n';
const ROLES = { mikepitre: 'admin', seanperez29: 'admin', swolfand: 'maintain', wobsoriano: 'maintain', 'outside-dev': 'write' };

// The shape CodeRabbit writes into its summary comment.
const riskComment = (level, commit = HEAD) => ({
user: { login: 'coderabbitai[bot]' },
body: `<!-- final_review_risk_start -->\n<!-- final_review_risk_label\n**Merge Risk:** **🔴 ${level}** · up to \`${commit.slice(0, 5)}\`\n-->\n<!-- final_review_risk_coverage:{"sourceCommitId":"${commit}","coveredCommitId":"${commit}","kind":"reviewed"} -->\n<!-- final_review_risk_end -->`,
});
const review = (login, state) => ({ user: { login }, state });

function fakeGitHub({ author = 'outside-dev', draft = false, state = 'open', files = [{ filename: 'Sources/ClerkKit/Clerk.swift' }], pending = [], reviews = [], comments = [] }) {
const requested = [];
const reply = body => ({ ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) });
const notFound = { ok: false, status: 404, json: async () => ({}), text: async () => 'Not Found' };
const fetch = async (url, { method = 'GET', body }) => {
const { pathname, searchParams } = new URL(url);
const path = pathname.replace(`/repos/${REPO}`, '');
if (method === 'POST' && path === '/pulls/7/requested_reviewers') {
requested.push(...JSON.parse(body).reviewers);
return reply({});
}
if (path === '/pulls/7') {
return reply({ state, draft, user: { login: author }, head: { sha: HEAD }, base: { ref: 'main' }, changed_files: files.length, requested_reviewers: pending.map(login => ({ login })) });
}
if (path === '/contents/.github/protected-paths') {
return reply({ content: Buffer.from('/.github/workflows/\n').toString('base64') });
}
const lists = { '/pulls/7/files': files, '/pulls/7/reviews': reviews, '/issues/7/comments': comments };
if (path in lists) {
const page = Number(searchParams.get('page'));
return reply(lists[path].slice((page - 1) * 100, page * 100));
}
const match = /^\/collaborators\/([^/]+)\/permission$/.exec(path);
if (match) {
return match[1] in ROLES ? reply({ role_name: ROLES[match[1]] }) : notFound;
}
throw new Error(`unexpected ${method} ${path}`);
};
return { fetch, requested };
}

async function run(github) {
const { fetch, requested } = fakeGitHub(github);
const lines = [];
await main(
{ GITHUB_API_URL: API, GITHUB_REPOSITORY: REPO, GITHUB_TOKEN: 'test-token', PR_NUMBER: '7' },
{ fetch, out: { write: line => lines.push(line) }, teamText: TEAM },
);
return { requested, output: lines.join('') };
}

describe('ratedHighRisk', () => {
it('reads a high or critical rating for the head commit', () => {
assert.equal(ratedHighRisk([riskComment('High')], HEAD), true);
assert.equal(ratedHighRisk([riskComment('Critical')], HEAD), true);
});

it('ignores a lower rating, a rating of an older commit, and the same text from someone else', () => {
assert.equal(ratedHighRisk([riskComment('Moderate')], HEAD), false);
assert.equal(ratedHighRisk([riskComment('High', 'e225ce56d448093f7910a36cf47bcc9a664e04d0')], HEAD), false);
assert.equal(ratedHighRisk([{ ...riskComment('High'), user: { login: 'outside-dev' } }], HEAD), false);
});
});

describe('team review request', () => {
it('lists GitHub logins in the committed team file', () => {
const team = parseTeam(fs.readFileSync(new URL('../team-mobile', import.meta.url), 'utf8'));
assert.ok(team.length > 0);
assert.ok(team.every(login => /^[A-Za-z0-9-]+$/.test(login)));
});

it('requests nobody for a Team Mobile author on ordinary files while CodeRabbit reviews', async () => {
const { requested, output } = await run({ author: 'swolfand' });
assert.deepEqual(requested, []);
assert.match(output, /Waiting on CodeRabbit/);
});

it('requests the other members when CodeRabbit rates a Team Mobile author\'s commit high risk', async () => {
const { requested, output } = await run({ author: 'swolfand', comments: [riskComment('High')] });
assert.deepEqual(requested, ['mikepitre', 'seanperez29', 'wobsoriano']);
assert.match(output, /CodeRabbit rated this commit high or critical risk/);
});

it('requests the other members when a Team Mobile author changes a protected file', async () => {
const { requested } = await run({ author: 'mikepitre', files: [{ filename: '.github/workflows/pr-ci.yml' }] });
assert.deepEqual(requested, ['seanperez29', 'swolfand', 'wobsoriano']);
});

it('requests Team Mobile for an author with write access, a fork contributor and a bot', async () => {
for (const author of ['outside-dev', 'fork-contributor', 'dependabot[bot]']) {
const { requested } = await run({ author });
assert.deepEqual(requested, ['mikepitre', 'seanperez29', 'swolfand', 'wobsoriano']);
}
});

it('skips members who are already requested or have reviewed', async () => {
const { requested } = await run({ pending: ['mikepitre'], reviews: [review('swolfand', 'COMMENTED')] });
assert.deepEqual(requested, ['seanperez29', 'wobsoriano']);
});

it('requests nobody once Team Mobile approved', async () => {
const { requested } = await run({ reviews: [review('wobsoriano', 'APPROVED')] });
assert.deepEqual(requested, []);
});

it('skips drafts and closed pull requests', async () => {
for (const github of [{ draft: true }, { state: 'closed' }]) {
const { requested } = await run(github);
assert.deepEqual(requested, []);
}
});
});
6 changes: 6 additions & 0 deletions .github/team-mobile
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# GitHub logins that Team review request asks to review a pull request from someone without maintain or admin access.
# Keep this in step with the @clerk/team-mobile team.
mikepitre
seanperez29
swolfand
wobsoriano
Loading
Loading