Skip to content

CODEOWNERS: add @glennsong09 to src and test #2

CODEOWNERS: add @glennsong09 to src and test

CODEOWNERS: add @glennsong09 to src and test #2

name: Review Checklist

Check failure on line 1 in .github/workflows/review-checklist.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/review-checklist.yml

Invalid workflow file

You have an error in your yaml syntax
# Posts a per-area sign-off checklist on every PR and auto-checks each item
# when one of that area's designated owners submits an approval.
#
# Reviewer lists are derived entirely from .github/CODEOWNERS — no duplication.
# To add an area or change owners, edit only CODEOWNERS.
on:
pull_request:
types: [opened, synchronize, reopened]
pull_request_review:
types: [submitted]
permissions:
pull-requests: write
contents: read
jobs:
checklist:
runs-on: ubuntu-latest
# For review events only run on approvals; all PR events always run
if: |
github.event_name == 'pull_request' ||
(github.event_name == 'pull_request_review' &&
github.event.review.state == 'approved')
steps:
- uses: actions/github-script@v7
with:
script: |
const MARKER = '<!-- hdf5-review-checklist-v1 -->';
const { owner, repo } = context.repo;
const pr_number = context.payload.pull_request.number;
// ----------------------------------------------------------------
// Configuration
//
// LINE_THRESHOLD: lines changed (additions + deletions) within a
// single area below which a reviewer is auto-assigned using the
// fewest-open-PRs algorithm. Above this threshold the assignment
// is left to the maintainers — complex changes deserve a
// deliberate choice of who reviews them.
// ----------------------------------------------------------------
const LINE_THRESHOLD = 50;
// ----------------------------------------------------------------
// 1. Parse CODEOWNERS into a list of { pattern, label, owners }
//
// Rules:
// - Skip blank lines and lines starting with #
// - Skip the global wildcard (*) — it covers everything and
// would make every PR require every reviewer
// - Owners are the @-prefixed tokens after the pattern
// - Label is derived from the pattern path for display
// ----------------------------------------------------------------
const { data: coData } = await github.rest.repos.getContent({
owner, repo, path: '.github/CODEOWNERS',
});
const coText = Buffer.from(coData.content, 'base64').toString('utf-8');
function labelFromPattern(pattern) {
// /fortran/ → "fortran", /.github/.well-known → ".github/.well-known"
return pattern.replace(/^\//, '').replace(/\/$/, '') || pattern;
}
// Returns true if `file` (repo-relative, no leading slash) matches
// a CODEOWNERS-style gitignore pattern.
function matchesPattern(file, pattern) {
let p = pattern;
// Strip leading slash — CODEOWNERS anchors to root with it,
// but GitHub API paths have no leading slash
if (p.startsWith('/')) p = p.slice(1);
// Directory pattern: /fortran/ → matches fortran/<anything>
if (p.endsWith('/')) return file.startsWith(p);
// Glob pattern: convert * and ** to regex equivalents
if (p.includes('*')) {
const re = new RegExp(
'^' +
p.replace(/\./g, '\\.').replace(/\*\*/g, '').replace(/\*/g, '[^/]*').replace(//g, '.*') +
'($|/)'
);
return re.test(file);
}
// Plain path: exact match or directory prefix
return file === p || file.startsWith(p + '/');
}
const areas = [];
for (const rawLine of coText.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) continue;
const tokens = line.split(/\s+/);
const pattern = tokens[0];
const owners = tokens.slice(1)
.filter(t => t.startsWith('@'))
.map(t => t.slice(1)); // strip @
// Skip the global catch-all — it would fire on every file
if (pattern === '*') continue;
if (owners.length === 0) continue;
areas.push({
pattern,
label: labelFromPattern(pattern),
owners,
});
}
if (areas.length === 0) {
core.info('No path-specific rules found in CODEOWNERS — skipping checklist.');
return;
}
// ----------------------------------------------------------------
// 2. Collect all changed files with line counts.
// Keeps the full file objects so per-area line totals can be
// used to decide whether auto-assignment is warranted.
// ----------------------------------------------------------------
const changedFileData = [];
for (let page = 1; ; page++) {
const { data } = await github.rest.pulls.listFiles({
owner, repo, pull_number: pr_number, per_page: 100, page,
});
changedFileData.push(...data);
if (data.length < 100) break;
}
// ----------------------------------------------------------------
// 3. Find which CODEOWNERS areas are touched by this PR, and
// total the lines changed within each area.
// ----------------------------------------------------------------
const touchedAreas = areas
.map(area => {
const areaFiles = changedFileData.filter(f =>
matchesPattern(f.filename, area.pattern)
);
return {
...area,
linesChanged: areaFiles.reduce((n, f) => n + f.changes, 0),
};
})
.filter(area => area.linesChanged > 0);
if (touchedAreas.length === 0) {
core.info('No CODEOWNERS-tracked areas changed — skipping checklist.');
return;
}
// ----------------------------------------------------------------
// 4. Determine current approvals.
// Track latest review state per user so a subsequent
// "request changes" cancels an earlier approval.
// ----------------------------------------------------------------
const allReviews = [];
for (let page = 1; ; page++) {
const { data } = await github.rest.pulls.listReviews({
owner, repo, pull_number: pr_number, per_page: 100, page,
});
allReviews.push(...data);
if (data.length < 100) break;
}
const latestStateByUser = {};
for (const review of allReviews) {
latestStateByUser[review.user.login] = review.state;
}
const approvedUsers = new Set(
Object.entries(latestStateByUser)
.filter(([, state]) => state === 'APPROVED')
.map(([login]) => login)
);
// ----------------------------------------------------------------
// 5. Fetch current PR state (requested reviewers).
// Done outside the PR-only block so the checklist can also
// show who is assigned when triggered by a review event.
// ----------------------------------------------------------------
const { data: prData } = await github.rest.pulls.get({
owner, repo, pull_number: pr_number,
});
const requestedReviewers = new Set(
prData.requested_reviewers.map(r => r.login)
);
// ----------------------------------------------------------------
// 6. For each touched area pick the ONE owner with the fewest
// open review requests in this repo right now, then request
// only that person. This load-balances across owners without
// needing persistent state.
//
// Skipped entirely if an area owner is already requested
// (manual assignment or a previous run) — don't override.
// Only runs on pull_request events, not review submissions.
// ----------------------------------------------------------------
if (context.eventName !== 'pull_request_review') {
const prAuthor = context.payload.pull_request.user.login;
async function pendingReviewCount(username) {
const { data } = await github.rest.search.issuesAndPullRequests({
q: `is:pr is:open review-requested:${username} repo:${owner}/${repo}`,
per_page: 1,
});
return data.total_count;
}
async function selectReviewer(owners) {
const candidates = owners.filter(u => u !== prAuthor);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const counts = await Promise.all(
candidates.map(async u => ({ u, n: await pendingReviewCount(u) }))
);
counts.sort((a, b) => a.n - b.n || candidates.indexOf(a.u) - candidates.indexOf(b.u));
core.info(`Review load for [${candidates.join(', ')}]: ${counts.map(c => `${c.u}=${c.n}`).join(', ')} → assigning ${counts[0].u}`);
return counts[0].u;
}
const selected = new Set();
for (const area of touchedAreas) {
if (area.owners.some(o => requestedReviewers.has(o))) {
core.info(`Area "${area.label}" already has an owner assigned — skipping.`);
continue;
}
let pick;
if (area.linesChanged < LINE_THRESHOLD) {
// Small change: assign whoever has the lightest review load.
// Anyone on the list is qualified for a routine review.
pick = await selectReviewer(area.owners);
core.info(`Area "${area.label}": ${area.linesChanged} lines (< ${LINE_THRESHOLD}) — load-balanced pick: ${pick}`);
} else {
// Large change: always assign the first owner listed in
// CODEOWNERS — they are the designated lead for complex work,
// regardless of how many PRs they currently have open.
pick = area.owners.find(u => u !== prAuthor) ?? null;
core.info(`Area "${area.label}": ${area.linesChanged} lines (≥ ${LINE_THRESHOLD}) — primary owner: ${pick}`);
}
if (pick) {
selected.add(pick);
requestedReviewers.add(pick);
}
}
if (selected.size > 0) {
try {
await github.rest.pulls.requestReviewers({
owner, repo, pull_number: pr_number,
reviewers: [...selected],
});
} catch (e) {
core.warning(`Could not request reviewers: ${e.message}`);
}
}
}
// ----------------------------------------------------------------
// 7. Build the checklist body.
// Each row shows the specific owner assigned for that area:
// - pending: the owner currently in requested_reviewers
// - signed off: the owner who approved
// This avoids listing all owners (bystander effect) while
// still making clear who is responsible for each area.
// ----------------------------------------------------------------
const rows = touchedAreas.map(area => {
const approver = area.owners.find(o => approvedUsers.has(o));
const assigned = approver ?? area.owners.find(o => requestedReviewers.has(o));
const signedOff = !!approver;
const box = signedOff ? 'x' : ' ';
const tick = signedOff ? ' ✅' : '';
const mention = assigned ? ` — @${assigned}` : '';
return `- [${box}] **${area.label}**${tick}${mention}`;
});
const allDone = rows.every(r => r.includes('[x]'));
const summary = allDone
? '> ✅ All areas have been signed off.'
: '> ⏳ Waiting for sign-off on all areas listed above.';
const body = [
MARKER,
'## Review Checklist',
'',
'This PR touches the following areas. Each needs at least one',
'sign-off from its listed owners before merging — an approval',
'covering only one area does **not** satisfy the others.',
'',
...rows,
'',
summary,
].join('\n');
// ----------------------------------------------------------------
// 8. Create or update the checklist comment (idempotent via marker)
// ----------------------------------------------------------------
const { data: comments } = await github.rest.issues.listComments({
owner, repo, issue_number: pr_number, per_page: 100,
});
const existing = comments.find(c => c.body.includes(MARKER));
if (existing) {
await github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body,
});
core.info(`Updated checklist comment #${existing.id}`);
} else {
await github.rest.issues.createComment({
owner, repo, issue_number: pr_number, body,
});
core.info('Created checklist comment');
}