Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add codeowner workflow check #24496

Merged
merged 7 commits into from
Oct 21, 2024
Merged
166 changes: 166 additions & 0 deletions .github/workflows/codeowner_review_status.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
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.');
}
Loading