Skip to content

Code Owners Approval Check #395

Code Owners Approval Check

Code Owners Approval Check #395

name: Code Owners Approval Check
on:
pull_request_review:
types: [submitted, dismissed]
permissions: {}
# Stop the current running job if a new push is made to the PR
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref }}
cancel-in-progress: true
jobs:
check-code-owners-approval:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- name: Check Code Owners Approval
id: check_approvals
uses: actions/github-script@v7
with:
github-token: ${{secrets.CODEOWNER_WORKFLOW_TOKEN}}
script: |
const { owner, repo, number } = context.issue;
// Get pull request details and files
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
const { data: files } = await github.rest.pulls.listFiles({ owner, repo, pull_number: number });
// Check if the base branch is master
if (pr.base.ref !== 'master') {
console.log(`Base branch is ${pr.base.ref}. Skipping check.`);
return;
}
// Get CODEOWNERS file content
let codeowners;
try {
const { data } = await github.rest.repos.getContent({
owner,
repo,
path: '.github/CODEOWNERS',
});
codeowners = Buffer.from(data.content, 'base64').toString('utf8');
} catch (error) {
console.log('CODEOWNERS file not found in .github directory. Skipping check.');
return;
}
// Parse CODEOWNERS file
const codeownersRules = codeowners.split('\n')
.filter(line => line.trim() && !line.startsWith('#'))
.map(line => {
const [pattern, ...owners] = line.split(/\s+/);
return { pattern, owners: owners.map(o => o.trim()) };
});
// Extract unique team slugs
const teamSlugs = [...new Set(codeownersRules.flatMap(rule => rule.owners))]
.map(owner => owner.replace(/^.*\//, ''));
// Get reviews
const { data: reviews } = await github.rest.pulls.listReviews({ owner, repo, pull_number: number });
const approvals = new Set(
reviews
.filter(review => review.state === 'APPROVED')
.map(review => review.user.login)
);
// Function to check if a file matches a pattern
const matchesPattern = (file, pattern) => {
// Handle directory patterns
if (pattern.endsWith('/')) {
return file.startsWith(pattern);
}
// Handle glob patterns
let regexPattern = pattern
.replace(/\./g, '\\.')
.replace(/\*/g, '[^/]*')
.replace(/\?/g, '[^/]')
.replace(/\//g, '\\/')
.replace(/\*\*/g, '.*');
// For double-star patterns, match any depth
if (pattern.includes('**')) {
return new RegExp(`^${regexPattern}`).test(file);
}
// For patterns without wildcards, check if the file is in the directory or is the file itself
if (!pattern.includes('*') && !pattern.includes('?')) {
return file === pattern || file.startsWith(pattern + '/');
}
// For single-star patterns, don't match across directory boundaries
return new RegExp(`^${regexPattern}$`).test(file);
};
// Get relevant code owners for the changed files
const relevantOwners = new Set();
const defaultOwner = codeownersRules.find(rule => rule.pattern === '*')?.owners[0];
files.forEach(file => {
let fileOwners = new Set();
let hasSpecificOwner = false;
codeownersRules.forEach(rule => {
if (matchesPattern(file.filename, rule.pattern)) {
rule.owners.forEach(owner => {
fileOwners.add(owner);
if (rule.pattern !== '*') {
hasSpecificOwner = true;
}
});
}
});
// If no specific owners found or only the default owner was found, use the default owner
if (!hasSpecificOwner && defaultOwner) {
fileOwners.add(defaultOwner);
}
fileOwners.forEach(owner => relevantOwners.add(owner));
});
if (relevantOwners.size === 0) {
console.log('No relevant code owners found for the changed files. Skipping check.');
return;
}
// Check if a user is a member of a team
async function checkTeamMembership(teamSlug) {
try {
const { data: teamMembers } = await github.rest.teams.listMembersInOrg({
org: context.repo.owner,
team_slug: teamSlug,
});
return teamMembers.map(teamMember => {
const user = teamMember.login;
if (approvals.has(user)) {
return teamSlug;
}
});
} catch (error) {
console.error(`Error checking membership for team ${teamSlug}: ${error}`);
return false;
}
}
let approvingTeams = new Set()
for (const teamSlug of relevantOwners) {
const strippedTeamSlug = teamSlug.replace('@DataDog/', '');
const teamApproval = await checkTeamMembership(strippedTeamSlug);
if (teamApproval.includes(strippedTeamSlug)) {
approvingTeams.add(teamSlug);
}
}
const codeOwnerStatus = Array.from(relevantOwners).map(owner => ({
owner,
approved: approvingTeams.has(owner),
}));
const missingApprovals = codeOwnerStatus.filter(status => !status.approved);
if (missingApprovals.length > 0) {
core.setFailed(`Missing approvals from code owners: ${missingApprovals.map(status => status.owner).join(', ')}`);
} else {
console.log('All relevant code owners have approved the pull request.');
}