Code Owners Approval Check #50
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: | |
branches: | |
- master | |
types: [opened, synchronize, reopened, ready_for_review] | |
pull_request_review: | |
types: [submitted, dismissed] | |
permissions: {} | |
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 }); | |
// 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.'); | |
} | |
core.setOutput('codeOwnerStatus', JSON.stringify(codeOwnerStatus)); | |
- name: Update PR status | |
if: always() | |
uses: actions/github-script@v7 | |
with: | |
github-token: ${{secrets.GITHUB_TOKEN}} | |
script: | | |
const { owner, repo, number } = context.issue; | |
const codeOwnerStatus = JSON.parse(process.env.CODE_OWNER_STATUS); | |
const statusList = codeOwnerStatus.map(status => { | |
const emoji = status.approved ? '✅' : '❌'; | |
return `${emoji} ${status.owner}`; | |
}).join('\n'); | |
const commentBody = `## Code Owners Approval Status | |
${statusList} | |
${codeOwnerStatus.every(status => status.approved) | |
? '✅ All required code owners have approved this pull request.' | |
: '❌ This pull request is still missing approvals from one or more code owners.'}`; | |
// Find existing bot comment | |
const { data: comments } = await github.rest.issues.listComments({ | |
owner, | |
repo, | |
issue_number: number, | |
}); | |
const botComment = comments.find(comment => | |
comment.user.type === 'Bot' && comment.body.includes('Code Owners Approval Status') | |
); | |
if (botComment) { | |
// Update existing comment | |
await github.rest.issues.updateComment({ | |
owner, | |
repo, | |
comment_id: botComment.id, | |
body: commentBody, | |
}); | |
} else { | |
// Create new comment | |
await github.rest.issues.createComment({ | |
owner, | |
repo, | |
issue_number: number, | |
body: commentBody, | |
}); | |
} | |
env: | |
CODE_OWNER_STATUS: ${{ steps.check_approvals.outputs.codeOwnerStatus }} |