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
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
package.json @DataDog/corpweb @DataDog/documentation
go.mod @DataDog/corpweb @DataDog/documentation
go.sum @DataDog/corpweb @DataDog/documentation
.github/CODEOWNERS @DataDog/WebOps-Platform @DataDog/documentation


# SDK versions
.github/workflows/bump_versions.yml @DataDog/api-clients @DataDog/documentation
Expand Down
220 changes: 220 additions & 0 deletions .github/workflows/codeowner_review_status.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
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 }}
Loading