Code Owners Approval Check #395
Workflow file for this run
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.'); | |
} |