Skip to content

Add codeowner workflow check #28

Add codeowner workflow check

Add codeowner workflow check #28

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
env:
CODEOWNER_WORKFLOW_TOKEN: ${{secrets.CODEOWNER_WORKFLOW_TOKEN}}
with:
github-token: ${{secrets.GITHUB_TOKEN}}
script: |
const { owner, repo, number } = context.issue;
const gh = require('@actions/github');
// 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(/^.*\//, ''));
console.log('Team slugs:', teamSlugs);
// Check if a user is a member of a team
async function checkTeamMembership(teamSlug) {
try {
const octokit = gh.getOctokit(process.env.CODEOWNER_WORKFLOW_TOKEN);
const { data: teamMembers } = await octokit.rest.teams.listMembersInOrg({
org: context.repo.owner,
team_slug: teamSlug,
});
return teamMembers.some(member => member.login === context.payload.pull_request.user.login);
} catch (error) {
console.error(`Error checking membership for team ${teamSlug}: ${error}`);
return false;
}
}
for (const teamSlug of teamSlugs) {
if (await checkTeamMembership(teamSlug)) {
console.log(`${reviewer} is a member of the required team ${teamSlug}.`);
return;
}
}
// 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;
}
// Get reviews
const { data: reviews } = await github.rest.pulls.listReviews({ owner, repo, pull_number: number });
console.log(reviews)
const approvals = new Set(
reviews
.filter(review => review.state === 'APPROVED')
.map(review => `@DataDog/${review.user.login}`)
);
const codeOwnerStatus = Array.from(relevantOwners).map(owner => ({
owner,
approved: approvals.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 }}