diff --git a/CODEOWNERS b/.github/next-code-owners similarity index 82% rename from CODEOWNERS rename to .github/next-code-owners index 269d761491bd..bc6e995fb3fd 100644 --- a/CODEOWNERS +++ b/.github/next-code-owners @@ -1,3 +1,19 @@ +# Code owners, tagged in a comment only on pull requests whose merge reaches +# `next`: those based on `next`, and those into a merge train that targets `next` +# (every `merge-train/*` except the `-v` ones, which target a release line). +# The comment names the path each owner came from, so the author can see why. +# +# Deliberately NOT named CODEOWNERS. GitHub reads CODEOWNERS from a pull request's +# base branch with no way to scope it, so a native file also requests owners on +# pull requests stacked onto feature branches, and again on every rebase of the +# stack. .github/workflows/codeowner-notify-next.yml applies this file instead, +# with CODEOWNERS semantics (last match wins, per changed path). Edit it exactly +# as you would CODEOWNERS. +# +# Given up by not using the native name: owners are not added as requested +# reviewers (no Reviewers-sidebar entry or review-queue item), and the +# require_code_owner_review ruleset option is unavailable. + /.devcontainer/ @charlielye @ludamad /.github/ @charlielye @ludamad /build-images/ @charlielye @ludamad diff --git a/.github/scripts/next_code_owners.py b/.github/scripts/next_code_owners.py new file mode 100644 index 000000000000..eaca94012d50 --- /dev/null +++ b/.github/scripts/next_code_owners.py @@ -0,0 +1,114 @@ +"""Apply .github/next-code-owners to a pull request, with CODEOWNERS semantics. + +Usage: next_code_owners.py OWNERS_FILE CHANGED_PATHS_FILE BASE_REF + +Prints a Markdown comment that @-mentions the owners of the changed paths, one line +per owning rule so the author can see which paths brought in whom. Prints nothing +when nobody should be told, and logs the reason to stderr. + +Matching follows GitHub's CODEOWNERS: for each changed path the *last* matching +pattern wins, and every rule that wins for some path gets a line. Patterns are +gitignore-style — leading `/` anchors at the root, a trailing `/` means the whole +directory, `*` stays within a path segment, `**` crosses segments. +""" + +import re +import sys + +# Where a merge train's own pull request lands. A train targets `next` unless its +# name is suffixed -v, in which case it targets that release line. This mirrors +# the base_branch rule in .github/workflows/merge-train-create-pr.yml; keep them +# in step. +TRAIN_TO_RELEASE_LINE = re.compile(r"^merge-train/.*-v[0-9]+$") + + +def reaches_next(base_ref): + if base_ref == "next": + return True + return base_ref.startswith("merge-train/") and not TRAIN_TO_RELEASE_LINE.match(base_ref) + + +def pattern_to_regex(pattern): + anchored = pattern.startswith("/") + directory_only = pattern.endswith("/") + body = pattern.strip("/") + + parts = [] + for token in re.split(r"(\*\*/|\*\*|\*|\?)", body): + if token == "**/": + parts.append("(?:.*/)?") + elif token == "**": + parts.append(".*") + elif token == "*": + parts.append("[^/]*") + elif token == "?": + parts.append("[^/]") + else: + parts.append(re.escape(token)) + + # As in gitignore: a pattern with no slash (`*.js`) matches at any depth; one + # with a slash is relative to the root. + prefix = "^" if anchored or "/" in body else "(?:^|.*/)" + # A directory pattern matches everything beneath it. A file pattern also + # matches a directory of that name, which is how CODEOWNERS treats `/docs`. + suffix = "/.*" if directory_only else "(?:/.*)?$" + return re.compile(prefix + "".join(parts) + suffix) + + +def load_rules(owners_file): + """Each rule is (regex, pattern text, owners), in file order.""" + rules = [] + with open(owners_file, encoding="utf-8") as handle: + for line in handle: + line = line.split("#", 1)[0].strip() + if line: + pattern, *owners = line.split() + rules.append((pattern_to_regex(pattern), pattern, owners)) + return rules + + +def winning_rule(path, rules): + """Index of the last rule whose pattern matches `path`, or None.""" + winner = None + for i, (regex, _, _) in enumerate(rules): + if regex.match(path): + winner = i + return winner + + +def main(): + owners_file, changed_file, base_ref = sys.argv[1:4] + + if not reaches_next(base_ref): + print(f"base {base_ref} does not reach next", file=sys.stderr) + return 0 + try: + rules = load_rules(owners_file) + except FileNotFoundError: + print(f"{owners_file} is absent on {base_ref}; nothing to apply", file=sys.stderr) + return 0 + with open(changed_file, encoding="utf-8") as handle: + changed = [line.strip() for line in handle if line.strip()] + + # One line per winning rule, in file order, so the author sees which of their + # paths brought in which owners. A rule that names nobody owns nothing. + winners = {winning_rule(path, rules) for path in changed} - {None} + winners = [i for i in sorted(winners) if rules[i][2]] + if not winners: + print("no changed path has an owner", file=sys.stderr) + return 0 + + lines = [ + "This pull request touches code with owners listed in " + "`.github/next-code-owners`. Tagging them for review:", + "", + ] + for i in winners: + _, pattern, owners = rules[i] + lines.append(f"- {' '.join(owners)} — `{pattern}`") + print("\n".join(lines)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/codeowner-notify-next.yml b/.github/workflows/codeowner-notify-next.yml new file mode 100644 index 000000000000..146bb71087bf --- /dev/null +++ b/.github/workflows/codeowner-notify-next.yml @@ -0,0 +1,59 @@ +name: Code-owner notify (next) + +# Comments on pull requests whose merge reaches `next` — `next` itself, and the +# merge trains that target it — tagging the owners of the changed paths from +# .github/next-code-owners, with the path each owner came from. This replaces a +# native CODEOWNERS, which GitHub reads from the base branch with no way to scope +# it, so it also fires on pull requests stacked onto feature branches and again +# on every rebase of the stack. +# +# Three moments matter: a pull request is opened non-draft, a draft is marked +# ready, or a stacked pull request is retargeted onto `next` once the one below +# it has merged. `edited` covers the last; the job guard limits it to edits that +# changed the base, so a title or body edit does not tag anyone again. +on: + pull_request_target: + types: [opened, ready_for_review, edited] + branches: + - next + - 'merge-train/**' + +permissions: + contents: read # checkout + pull-requests: write # gh pr comment + +jobs: + tag-owners: + if: >- + ${{ !github.event.pull_request.draft + && (github.event.action != 'edited' || github.event.changes.base.ref.from) }} + runs-on: ubuntu-latest + steps: + # pull_request_target checks out the base branch, never the pull request's + # head, so the elevated token is not exposed to code from the pull request. + # Sparse: the repository is large and only two files are needed. + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + sparse-checkout: | + .github/next-code-owners + .github/scripts/next_code_owners.py + sparse-checkout-cone-mode: false + + - name: Tag the owners of the changed paths + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + gh api --paginate "/repos/$REPO/pulls/$PR_NUMBER/files" \ + --jq '.[].filename' > changed-files.txt + python3 .github/scripts/next_code_owners.py \ + .github/next-code-owners changed-files.txt "$BASE_REF" > comment.md + if [ ! -s comment.md ]; then + echo "Nobody to tag for base $BASE_REF." + exit 0 + fi + cat comment.md + gh pr comment "$PR_NUMBER" --repo "$REPO" --body-file comment.md