Skip to content

test: cover weekly issue update helpers #280

test: cover weekly issue update helpers

test: cover weekly issue update helpers #280

Workflow file for this run

name: PR Review Ping

Check warning on line 1 in .github/workflows/pr-review-ping.yml

View workflow run for this annotation

GitHub Actions / PR Review Ping

Workflow execution policy warning (evaluate mode)

On December 2, 2026, GitHub will restrict `pull_request_target` on public repositories by default. To continue allowing the event trigger, configure an Actions policy. Learn more: https://gh.io/securely-using-pull_request_target#default-policy-for-pull_request_target
# Comments on every new PR, tagging only the CODEOWNERS whose owned paths the
# PR actually touches - the same routing CODEOWNERS itself does, surfaced as
# a notification for when GitHub's own auto-request doesn't fire (e.g. a
# code owner hasn't accepted their collaborator invite yet). This is a
# notification only: it does not request review, assign anyone, or gate
# merging - see .github/CODEOWNERS and branch protection for that.
#
# Reviewers are computed from .github/CODEOWNERS at runtime instead of a
# hardcoded list, so they can't drift out of sync with it the way issue #273
# found they had; and each owner is only pinged for PRs touching a path they
# actually own (last-matching CODEOWNERS pattern wins per file, mirroring
# GitHub's own CODEOWNERS matching), not every PR in the repo.
on:
pull_request_target:
types: [opened]
permissions:
pull-requests: write
jobs:
ping:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout base ref
uses: actions/checkout@v7
- name: Mention owners of the touched paths
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
function patternMatches(pattern, filePath) {
if (pattern.startsWith('**/')) {
return filePath.split('/').pop() === pattern.slice(3);
}
const anchored = pattern.replace(/^\//, '');
if (pattern.endsWith('/')) {
const prefix = anchored.endsWith('/') ? anchored : anchored + '/';
return filePath.startsWith(prefix);
}
return filePath === anchored;
}
// GitHub's CODEOWNERS syntax escapes a literal space in a path
// with '\ ' (e.g. /AI\ Fair\ Recruitment/). A naive whitespace
// split breaks such a pattern into multiple tokens instead of
// one, silently losing its owner(s) - reassemble it the same
// way tests/test_codeowners.py's _split_codeowners_line() does.
function splitCodeownersLine(line) {
const tokens = line.split(/\s+/);
let pattern = tokens[0];
let i = 1;
while (pattern.endsWith('\\') && i < tokens.length) {
pattern = pattern.slice(0, -1) + ' ' + tokens[i];
i += 1;
}
return { pattern, owners: tokens.slice(i) };
}
const entries = fs.readFileSync('.github/CODEOWNERS', 'utf8')
.split('\n')
.map((line) => line.trim())
.filter((line) => line && !line.startsWith('#'))
.map((line) => {
const { pattern, owners } = splitCodeownersLine(line);
return { pattern, owners: owners.map((o) => o.replace(/^@/, '')) };
});
function ownersFor(filePath) {
// Last matching pattern wins - mirrors real CODEOWNERS semantics.
let owners = [];
for (const entry of entries) {
if (patternMatches(entry.pattern, filePath)) owners = entry.owners;
}
return owners;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
const author = context.payload.pull_request.user.login;
const toMention = new Set();
for (const file of files) {
for (const login of ownersFor(file.filename)) {
if (login !== author) toMention.add(login);
}
}
if (toMention.size === 0) return;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body: [...toMention].map((login) => `@${login}`).join(' ') + ' - new PR touching a path you own, please review.'
});