diff --git a/.github/workflows/codeowner_review_status.yml b/.github/workflows/codeowner_review_status.yml new file mode 100644 index 0000000000000..978d2d33bf498 --- /dev/null +++ b/.github/workflows/codeowner_review_status.yml @@ -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.'); + } \ No newline at end of file