chore(rmv2): resumable backfills to support concurrent (HA) replication managers #3075
Workflow file for this run
This file contains hidden or 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: CODEOWNERS Approval | |
| on: | |
| pull_request: | |
| types: [opened, synchronize, reopened, ready_for_review] | |
| pull_request_review: | |
| types: [submitted, dismissed, edited] | |
| permissions: {} | |
| concurrency: | |
| group: '${{ github.workflow }} @ ${{ github.event.pull_request.number }}' | |
| cancel-in-progress: true | |
| jobs: | |
| evaluate: | |
| # Always exits green; the real verdict is the `codeowners/approval` | |
| # check this job creates via the Checks API. Require that check in | |
| # branch protection, not this job. | |
| name: Evaluate Code Owner Approval | |
| runs-on: ubuntu-latest | |
| permissions: | |
| contents: read # Required to check out repository contents. | |
| issues: read # Required to read PR timeline dismissal events. | |
| pull-requests: read # Required to read PR metadata, files, and reviews. | |
| checks: write # Required to upsert the `codeowners/approval` check run. | |
| steps: | |
| - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 | |
| with: | |
| persist-credentials: false | |
| ref: ${{ github.event.pull_request.base.sha }} | |
| - uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 | |
| with: | |
| github-token: ${{ github.token }} | |
| script: | | |
| const fs = require('fs'); | |
| const pr = context.payload.pull_request; | |
| if (!pr) { | |
| core.setFailed('No pull_request payload.'); | |
| return; | |
| } | |
| const CHECK_NAME = 'codeowners/approval'; | |
| const isChecksApiPermissionError = (error) => | |
| error?.status === 403 || | |
| /Resource not accessible by integration/i.test( | |
| String(error?.message ?? ''), | |
| ); | |
| const writeCheck = async ({ conclusion, title, summary }) => { | |
| try { | |
| const existing = await github.rest.checks.listForRef({ | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| ref: pr.head.sha, | |
| check_name: CHECK_NAME, | |
| }); | |
| const base = { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| status: 'completed', | |
| conclusion, | |
| output: { title, summary }, | |
| }; | |
| if (existing.data.check_runs.length) { | |
| await github.rest.checks.update({ | |
| ...base, | |
| check_run_id: existing.data.check_runs[0].id, | |
| }); | |
| } else { | |
| await github.rest.checks.create({ | |
| ...base, | |
| name: CHECK_NAME, | |
| head_sha: pr.head.sha, | |
| }); | |
| } | |
| } catch (error) { | |
| if (isChecksApiPermissionError(error)) { | |
| core.warning( | |
| `Skipping ${CHECK_NAME} check run because this workflow token cannot access the Checks API: ${error.message}`, | |
| ); | |
| core.info(`${title}: ${summary}`); | |
| return; | |
| } | |
| throw error; | |
| } | |
| }; | |
| const candidates = ['CODEOWNERS', '.github/CODEOWNERS', 'docs/CODEOWNERS']; | |
| let codeownersText = null; | |
| for (const p of candidates) { | |
| if (fs.existsSync(p)) { | |
| codeownersText = fs.readFileSync(p, 'utf8'); | |
| core.info(`Using ${p}`); | |
| break; | |
| } | |
| } | |
| if (!codeownersText) { | |
| await writeCheck({ | |
| conclusion: 'success', | |
| title: 'No CODEOWNERS file', | |
| summary: 'No CODEOWNERS file found; nothing to enforce.', | |
| }); | |
| return; | |
| } | |
| const rules = []; | |
| for (const raw of codeownersText.split('\n')) { | |
| const line = raw.replace(/#.*/, '').trim(); | |
| if (!line) continue; | |
| const parts = line.split(/\s+/); | |
| rules.push({ pattern: parts[0], owners: parts.slice(1) }); | |
| } | |
| // Convert a CODEOWNERS pattern to a regex matching repo-relative paths. | |
| // CODEOWNERS uses gitignore-style globs; this covers the common cases: | |
| // leading '/' anchors to repo root, trailing '/' matches a directory, | |
| // '*' matches within a path segment, '**' matches across segments. | |
| const patternToRegex = (raw) => { | |
| let pat = raw; | |
| const anchored = pat.startsWith('/'); | |
| if (anchored) pat = pat.slice(1); | |
| const dirOnly = pat.endsWith('/'); | |
| if (dirOnly) pat = pat.slice(0, -1); | |
| let re = ''; | |
| for (let i = 0; i < pat.length; i++) { | |
| const c = pat[i]; | |
| if (c === '*' && pat[i + 1] === '*') { | |
| re += '.*'; | |
| i++; | |
| if (pat[i + 1] === '/') i++; | |
| } else if (c === '*') { | |
| re += '[^/]*'; | |
| } else if (c === '?') { | |
| re += '[^/]'; | |
| } else if ('.+^$()[]{}|\\'.includes(c)) { | |
| re += '\\' + c; | |
| } else { | |
| re += c; | |
| } | |
| } | |
| const prefix = anchored || pat.includes('/') ? '^' : '^(?:.*/)?'; | |
| const suffix = dirOnly ? '/.*$' : '(?:/.*)?$'; | |
| return new RegExp(prefix + re + suffix); | |
| }; | |
| const compiled = rules.map(r => ({ ...r, re: patternToRegex(r.pattern) })); | |
| // Last matching rule wins (CODEOWNERS semantics). | |
| const ownersFor = (file) => { | |
| for (let i = compiled.length - 1; i >= 0; i--) { | |
| if (compiled[i].re.test(file)) return compiled[i].owners; | |
| } | |
| return null; | |
| }; | |
| const files = await github.paginate(github.rest.pulls.listFiles, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| per_page: 100, | |
| }); | |
| const isSupportedOwner = (owner) => owner.startsWith('@') && !owner.includes('/'); | |
| const required = []; | |
| for (const f of files) { | |
| const owners = ownersFor(f.filename); | |
| const supportedOwners = owners ? owners.filter(isSupportedOwner) : []; | |
| if (supportedOwners.length) required.push({ file: f.filename, owners: supportedOwners }); | |
| } | |
| if (required.length === 0) { | |
| await writeCheck({ | |
| conclusion: 'success', | |
| title: 'No owned files changed', | |
| summary: 'No changed files match a CODEOWNERS pattern.', | |
| }); | |
| return; | |
| } | |
| const reviews = await github.paginate(github.rest.pulls.listReviews, { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| pull_number: pr.number, | |
| per_page: 100, | |
| }); | |
| const timelineEvents = await github.paginate( | |
| github.rest.issues.listEventsForTimeline, | |
| { | |
| owner: context.repo.owner, | |
| repo: context.repo.repo, | |
| issue_number: pr.number, | |
| per_page: 100, | |
| }, | |
| ); | |
| const dismissedReviewIDs = new Set( | |
| timelineEvents | |
| .filter(e => e.event === 'review_dismissed') | |
| .map(e => e.dismissed_review?.review_id) | |
| .filter(id => typeof id === 'number'), | |
| ); | |
| // Take the latest non-comment review per user. | |
| const latestByUser = new Map(); | |
| for (const r of reviews) { | |
| const login = r.user?.login; | |
| if (!login) continue; | |
| if (!['APPROVED', 'CHANGES_REQUESTED'].includes(r.state)) continue; | |
| if (dismissedReviewIDs.has(r.id)) continue; | |
| const prev = latestByUser.get(login); | |
| if (!prev || new Date(r.submitted_at) > new Date(prev.submitted_at)) { | |
| latestByUser.set(login, r); | |
| } | |
| } | |
| const author = pr.user.login.toLowerCase(); | |
| const approvers = [...latestByUser.values()] | |
| .filter(r => r.state === 'APPROVED') | |
| .map(r => r.user.login.toLowerCase()) | |
| .filter(login => login !== author); | |
| core.info(`Eligible approvers: ${approvers.join(', ') || '(none)'}`); | |
| // Only @user owners are supported; team and email owners are ignored. | |
| const ownerApproved = (owner) => { | |
| if (!owner.startsWith('@') || owner.includes('/')) return false; | |
| return approvers.includes(owner.slice(1).toLowerCase()); | |
| }; | |
| const failing = []; | |
| for (const r of required) { | |
| if (!r.owners.some(ownerApproved)) failing.push(r); | |
| } | |
| if (failing.length) { | |
| const list = failing | |
| .map(f => `- \`${f.file}\` (owners: ${f.owners.join(', ')})`) | |
| .join('\n'); | |
| await writeCheck({ | |
| conclusion: 'failure', | |
| title: `Awaiting code owner approval for ${failing.length} file(s)`, | |
| summary: `Missing CODEOWNERS approval for:\n\n${list}`, | |
| }); | |
| } else { | |
| await writeCheck({ | |
| conclusion: 'success', | |
| title: `Approved by code owner`, | |
| summary: `${required.length} owned file(s) covered by an approving review.`, | |
| }); | |
| } |