mirror of
https://github.com/aphrody-labs/mullvadvpn-app.git
synced 2026-10-13 22:10:39 +00:00
186 lines
7.8 KiB
YAML
186 lines
7.8 KiB
YAML
---
|
|
name: Code Owner Approval
|
|
description: Ensure that someone from each team that owns code changed in the PR has approved the PR
|
|
on:
|
|
# Trigger on both changes to the PR content and new/changed reviews. Both of these can affect
|
|
# whether the PR has the required approvals.
|
|
pull_request:
|
|
pull_request_review:
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
|
cancel-in-progress: true
|
|
|
|
permissions: {}
|
|
|
|
jobs:
|
|
check-code-owner-approvals:
|
|
runs-on: ubuntu-latest
|
|
permissions:
|
|
contents: read
|
|
pull-requests: read
|
|
# Required to write a custom commit status with createCommitStatus
|
|
statuses: write
|
|
steps:
|
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
|
|
|
- name: Check code owner approvals
|
|
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
|
with:
|
|
# Requires a token with read access to the "Members" scope under organization,
|
|
# and read access to the "Pull request" scope under the repository.
|
|
# and write access to "Commit statuses" under the repository.
|
|
github-token: ${{ secrets.CODE_OWNERSHIP_CI_TOKEN }}
|
|
script: |
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
// Helper function to set a custom commit status. This is not the status
|
|
// of this workflow run, but rather a custom status.
|
|
// This is required since the `pull_request` and `pull_request_review` events
|
|
// trigger independently and create separate statuses, that can be incompatible.
|
|
// Instead we report both of these into a custom status, that is set as required
|
|
// in the branch protection rules.
|
|
async function setCommitStatus(state, description) {
|
|
await github.rest.repos.createCommitStatus({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
sha: context.payload.pull_request.head.sha,
|
|
state: state,
|
|
context: 'Code Owner Approval',
|
|
description: description
|
|
});
|
|
}
|
|
|
|
// Returns an array of file paths changed in the PR
|
|
async function getChangedFiles() {
|
|
const changedFiles = await github.paginate(github.rest.pulls.listFiles, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: context.issue.number
|
|
});
|
|
|
|
return changedFiles.map(file => file.filename);
|
|
}
|
|
|
|
// Returns a list of usernames who approved the PR (based on their latest review)
|
|
async function getApprovers() {
|
|
const reviews = await github.paginate(github.rest.pulls.listReviews, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: context.issue.number
|
|
});
|
|
|
|
const latestReviews = new Map();
|
|
|
|
for (const review of reviews) {
|
|
const currentLatest = latestReviews.get(review.user.id);
|
|
|
|
// Keep the most recent review (higher ID = more recent)
|
|
if (!currentLatest || review.id > currentLatest.id) {
|
|
latestReviews.set(review.user.id, review);
|
|
}
|
|
}
|
|
|
|
// Filter to only approved reviews
|
|
const approvers = [];
|
|
for (const [userId, review] of latestReviews) {
|
|
if (review.state === 'APPROVED') {
|
|
approvers.push(review.user.login);
|
|
}
|
|
}
|
|
|
|
return approvers;
|
|
}
|
|
|
|
// Put all logic in a try-catch, so we can set the commit status
|
|
// to "error" if something fails.
|
|
try {
|
|
// Set status to pending at the start, clearing any previous state
|
|
await setCommitStatus('pending', 'Checking code owner approvals...');
|
|
|
|
const changedFiles = await getChangedFiles();
|
|
console.log('[DEBUG] Files changed in this PR:', changedFiles);
|
|
|
|
// Load team ownership mapping
|
|
const codeOwnerships = JSON.parse(fs.readFileSync('code-owners.json'));
|
|
|
|
// The set of teams owning code changed in this PR
|
|
const affectedTeams = new Set();
|
|
|
|
for (const [team, patterns] of Object.entries(codeOwnerships)) {
|
|
console.log(`[DEBUG] Team: ${team}, Ownership patterns:`, [...patterns]);
|
|
|
|
// List all files in the repository matching this owner's patterns
|
|
const globber = await glob.create(patterns.join('\n'));
|
|
const matches = await globber.glob();
|
|
|
|
// Convert absolute paths to relative paths
|
|
const ownedFiles = matches.map(match =>
|
|
path.relative(process.env.GITHUB_WORKSPACE, match)
|
|
);
|
|
|
|
for (const changedFile of changedFiles) {
|
|
if (ownedFiles.includes(changedFile)) {
|
|
affectedTeams.add(team);
|
|
console.log(`[DEBUG] File ${changedFile} is owned by ${team}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (affectedTeams.size === 0) {
|
|
console.log('✅ No code owner for any changed file');
|
|
await setCommitStatus('success', 'No changes require code owner approval');
|
|
return;
|
|
}
|
|
|
|
console.log(`👥 This PR needs approval from: ${[...affectedTeams].join(', ')}`);
|
|
|
|
// Set of teams that have approved this PR
|
|
const approvedTeams = new Set();
|
|
|
|
// Get array of github usernames that have approved the PR
|
|
const approvers = await getApprovers();
|
|
|
|
// PR author automatically counts as an approver. A code owner changing
|
|
// their own code does not need extra code owner approval. They still
|
|
// need the code reviewed, but that's not part of code ownership approval.
|
|
const prAuthor = context.payload.pull_request.user.login;
|
|
if (!approvers.includes(prAuthor)) {
|
|
approvers.push(prAuthor);
|
|
}
|
|
console.log(`👍 PR approved by the following accounts: ${approvers.join(', ')}`);
|
|
|
|
for (const approver of approvers) {
|
|
for (const team of affectedTeams) {
|
|
try {
|
|
await github.rest.teams.getMembershipForUserInOrg({
|
|
org: context.repo.owner,
|
|
team_slug: team,
|
|
username: approver
|
|
});
|
|
approvedTeams.add(team);
|
|
console.log(`[DEBUG] ${approver} is member of team '${team}' - approval counted`);
|
|
} catch (e) {
|
|
console.log(`[DEBUG] ${approver} is not member of team '${team} (${e})`);
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log('👍 Teams that have approved this PR:', [...approvedTeams].join(', '));
|
|
|
|
const missingApprovals = [...affectedTeams].filter(t => !approvedTeams.has(t));
|
|
|
|
if (missingApprovals.length > 0) {
|
|
console.log(`❌ Missing approvals from: ${missingApprovals.join(', ')}`);
|
|
await setCommitStatus('failure', `Missing approvals from: ${missingApprovals.join(', ')}`);
|
|
} else {
|
|
console.log('✅ All code owners approved this change!');
|
|
await setCommitStatus('success', `All code owners have approved: ${[...affectedTeams].join(', ')}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking code owner approvals:', error);
|
|
await setCommitStatus('error', 'Error checking approvals');
|
|
throw error;
|
|
}
|