blob: 04f59bc65f927c2310a7addde023582d9a162224 [file]
#!/usr/bin/env python3
# Copyright (c) 2022 Intel Corp.
# SPDX-License-Identifier: Apache-2.0
"""Assign reviewers, maintainers, and labels to new PRs based on MAINTAINERS.yml.
Overview
--------
This script automates three housekeeping tasks on newly opened GitHub pull
requests (and issues):
1. Apply area labels derived from MAINTAINERS.yml.
2. Request reviews from the relevant maintainers and collaborators.
3. Assign a primary maintainer as the PR assignee.
It reads MAINTAINERS.yml (or a custom file via -M) to map file paths to named
areas. Each area carries a list of maintainers, collaborators, labels, and
file/path patterns. The script uses the PyGithub library to interact with the
GitHub API and requires a personal access token in the GITHUB_TOKEN environment
variable.
Labeling strategy
-----------------
For every file changed in the PR, MAINTAINERS.yml is consulted to find the
matching areas. The union of all area labels across all matched areas is
collected and applied to the PR, subject to a cap of MAX_LABELS (10). If more
than MAX_LABELS distinct labels would be applied, they are ranked by how much of
the PR the areas carrying them cover (their file weight, falling back to their
plain file count for areas weighted 0 -- see Area weighting below) and only the
MAX_LABELS highest-ranked ones are applied. That keeps a PR touching very
broad cross-cutting areas from being papered with labels, while still labelling
it by whatever it predominantly changes. Size labels are managed separately:
they neither count towards the cap nor get dropped by it.
The cap governs what a run applies; labels already on the PR are never removed,
so it cannot pull an over-labelled PR back under the cap (use --reset for that).
A special lightweight "size: XS" label is managed by default:
- Added when: the PR has exactly one commit AND at most one line added AND
at most one line deleted AND does not touch any manifest file
(west.yml, submanifests/optional.yaml).
- Removed when: the PR already carries "size: XS" but no longer qualifies
(e.g. after a rebase that added more commits or lines).
When --size-labels is given, the full size label suite is managed instead
of only "size: XS". The suite maps total changed lines (additions +
deletions) to one of five mutually exclusive labels:
size: XS -- 1 commit, ≤1 line changed, no manifest file touched
size: S -- ≤9 lines changed
size: M -- ≤49 lines changed
size: L -- ≤499 lines changed
size: XL -- >499 lines changed
Any existing size label that does not match the computed bucket is removed
before the new label is applied, keeping exactly one size label on the PR
at all times.
Area weighting
--------------
Each changed file contributes a weight of 1 to the areas it belongs to, with
two exceptions that contribute 0:
- CMakeLists.txt files: build-system boilerplate present in nearly every
directory, not a reliable signal for area ownership.
- Meta-areas, i.e. areas carrying ``meta: true`` in MAINTAINERS.yml
(Documentation, Samples, Tests, ...): used only for assignee selection
when they are the *sole* area touched (see below); not weighted for
mixed PRs.
Platform (driver/board) areas receive a weight of 1 for the first file that
maps to them. Subsequent files that also map to the *same* Platform area
score 0 to avoid double-counting. This is controlled by the is_instance flag:
once a Platform area has been seen for a given file's sorted_areas list, all
further areas for that file score 0.
The resulting per-area weights drive both reviewer selection (ordered by
weight) and assignee selection.
Reviewer selection
------------------
The ordered reviewer candidate list is built in two tiers, maintainers before
collaborators, so that a PR touching many areas does not spend its limited
review slots on the collaborators of the highest-weight areas while dropping
the maintainers of every other touched area:
1. Maintainer tier:
a. For each area in descending weight order: the area's maintainers.
b. Any extra reviewers identified from the manifest or MAINTAINERS.yml
diff (see Manifest / MAINTAINERS.yml change handling below).
c. Maintainers of deferred file-group areas (see Deferred file-groups).
2. Collaborator tier:
a. For each area in descending weight order: the area's collaborators.
b. Any path-specific collaborators returned by
get_collaborators_for_path() for each matched file.
3. Deduplicate while preserving insertion order.
4. Heuristic tier, used for changed files MAINTAINERS.yml cannot staff:
- files in an under-covered area, i.e. one that names no maintainers or
at most THIN_AREA_COLLABORATORS (2) collaborators, and so cannot
supply enough reviewers on its own. Meta-areas are excluded: they
cover large parts of the tree by design, and the people they name are
the right reviewers for them, and
- orphaned files, which match no area at all. A PR consisting only of
these would otherwise get no reviewer whatsoever, since every tier
above is derived from matched areas.
This tier is skipped entirely once the tiers above have already produced
MIN_TOTAL_REVIEWERS (3) candidates other than the author: the PR then has
reviewers who own it by declaration, and lower-confidence names added on
top would only dilute the request (and cost API calls).
Two heuristics are tried in order, and the result is appended after the
tiers above (the author and anyone already listed are excluded). These
are lower-confidence picks, so they fill only leftover review slots.
a. GitHub's own ``suggestedReviewers`` for the PR, which GitHub derives
from commit history *and* past review comments. This is the better
signal and costs one query, but it is only available over GraphQL and
is empty for most PRs, so it cannot be relied on alone.
b. When GitHub suggests nobody, the recent contributors to the unstaffed
files, read from the commit history. Unlike (a) this is scoped to the
files that are actually short of reviewers, rather than to the PR as a
whole. Only the last HISTORY_MAX_AGE_DAYS (365) days are looked at, so
that people who stopped working on the file long ago are not asked.
Further bounded by HISTORY_COMMITS_PER_FILE, MAX_HISTORY_FILES, and
MAX_HISTORY_REVIEWERS.
Candidates are then filtered:
- The PR author is skipped.
- Users who already submitted a review or are already on the review-request
list are skipped.
- Users whose login no longer resolves (deleted accounts) are skipped.
- Users who previously self-removed themselves from the review request list
are skipped. This is checked before the collaborator test below, so that
someone who opted out is never routed to the mention fallback instead.
- Users who are not repository collaborators are set aside: GitHub requires
collaborator status to receive a review request, so they are mentioned in a
comment instead (see below) rather than dropped.
Every candidate that survives those filters is then requested: no cap is
applied. GitHub documents no limit on how many reviewers a pull request may
carry, and PRs holding more than 15 are observable in the wild, so imposing one
here only meant dropping people who were legitimately responsible for the
change. What keeps the request meaningful is the tier ordering above, not a
count: if a PR is broad enough to need thirty reviewers, all thirty own part of
it.
Should GitHub refuse the full set anyway (some undocumented limit, or a
candidate who lost collaborator status between the check and the request), the
call is retried once with the first REVIEWER_RETRY_BATCH (15) candidates.
Because the list is ordered highest-signal first, that retry keeps the area
maintainers and drops only the tail.
Mention fallback
----------------
A formal review request is not always possible: non-collaborators cannot
receive one at all, and a request may be refused outright. Rather than let
those people fall off the PR silently, they are @mentioned in a comment asking
for their review, which notifies them regardless of their permissions.
The comment carries a hidden MENTION_MARKER so that repeated runs over the same
PR find their own previous comment and edit it in place, instead of posting a
duplicate every time the PR is updated. Users who removed themselves from the
review request are never mentioned, since a mention would route around an
explicit opt-out.
Assignee selection
------------------
Assignees are selected from area_counter (areas sorted by descending file
weight) using the following priority rules:
1. Areas with a weight of 0 or no maintainers are skipped.
2. If the PR author is the area's only maintainer, the area is skipped
(to avoid self-assignment). If the author is one of several maintainers,
they are excluded from the candidate list and the remaining maintainers
are used.
3. Non-platform areas (no "Platform" in the area name) take highest
priority: the first such area's maintainers are inserted at the front
of the ranked list, and the search stops.
4. Platform areas (drivers, boards) are appended as lower-priority
fallbacks.
5. Meta-areas (``meta: true`` in MAINTAINERS.yml) are skipped in mixed PRs.
After iterating all areas, the first entry in the ranked list is chosen.
If the ranking process yielded nothing (e.g. all areas had the author as sole
maintainer), the maintainer with the highest cumulative file-weight score is
used as a last resort.
Reset mode
----------
With --reset, the PR is staffed from scratch instead of being topped up. The
new staffing is worked out first, and every decision an earlier run made is then
undone, just before the new one is applied:
- review requests are withdrawn, except from people who reviewed or commented
on the PR (they are involved by their own action) and people this run is
about to request again (withdrawing only to re-send would notify them
twice). Team review requests are left alone: the script only ever requests
individuals, so a team was requested by hand;
- all assignees are removed;
- all area labels defined in MAINTAINERS.yml are removed. Size labels are
left to update_size_labels, which recomputes them on every run, and labels
the script does not own (``bug``, backport labels, ...) are never touched.
Labels, reviewers and the assignee are chosen exactly as they would be for a
newly opened PR -- including the rule that people who removed themselves from
the review request are never re-added, which --reset cannot route around.
A PR the run skips (draft, closed, more than MAX_FILES changed files) is not
reset either: the skip is decided before the reset runs, so such a PR keeps the
staffing it has rather than being stripped and left bare.
Use it after MAINTAINERS.yml changed, or when a previous run staffed a PR
badly.
Deferred file-groups
--------------------
A file-group in MAINTAINERS.yml may carry ``defer-to-other-areas: true``.
When a changed file is matched by both a deferred file-group area **and** a
non-deferred area, the deferred area's maintainers are added to the reviewer
request instead of being considered for the assignee role. Only the
non-deferred area's maintainers are candidates for assignment.
If a file is matched *only* by deferred file-groups and no other area also
claims it, the deferral has no effect: the area is used normally for
both assignment and review.
Typical use-case: the Clock Control subsystem marks platform-specific files
(e.g. ``drivers/clock_control/nrf_*.c``) in a deferred file-group. A Nordic
platform area also lists those files without the defer flag. When a PR
touches those files the platform maintainer is assigned and the clock-control
maintainer is added as a reviewer.
Manifest / MAINTAINERS.yml change handling
-------------------------------------------
west.yml and submanifests/optional.yaml:
If --updated-manifest is provided, it (the PR's manifest) is compared against
--base-manifest to find added, removed, and updated west projects. Each
changed project is looked up in MAINTAINERS.yml under "West project: <name>"
and the corresponding collaborators are added to the reviewer candidate list.
--base-manifest must be the manifest the PR forked from (the merge ref's
first parent); comparing against the moving base-branch tip instead would
also pick up every project that advanced on the base branch after the PR was
created and add all their maintainers as reviewers (see #110422). When
--base-manifest is omitted it defaults to the checked-out west.yml.
MAINTAINERS.yml:
If --updated-maintainer-file is provided, it (the PR's version) is diffed
field-by-field against --base-maintainer-file (maintainers, collaborators,
labels, files, files-regex, status). Maintainers of every area that changed
are appended to the reviewer candidate list so that the people responsible
for each changed area are automatically notified. As with the manifest, the
base file must be the version the PR forked from, not the base-branch tip;
it defaults to the checked-out MAINTAINERS.yml when omitted.
Issue assignment
----------------
When run with -I/--issue, the script matches the issue's GitHub labels against
the area labels in MAINTAINERS.yml and assigns the maintainers of the matching
area. A single unambiguous label is sufficient for a match; multi-label areas
require all their labels to be present. If no match is found, or the issue
already has assignees, the script exits without making changes.
Module PR assignment
--------------------
When run with -m/--modules, the script reads the active west manifest,
locates every active non-manifest project that has a "West project: <name>"
entry in MAINTAINERS.yml with at least one maintainer, and searches GitHub for
open, non-draft, unassigned PRs across all those repos. Each PR is assigned
to the project maintainers and review requests are created for both maintainers
and collaborators.
"""
import argparse
import datetime
import logging
import os
import sys
import time
from collections import defaultdict
from pathlib import Path
import yaml
from github import Auth, Github, GithubException
from github.GithubException import UnknownObjectException
from west.manifest import Manifest, ManifestProject
TOP_DIR = os.path.join(os.path.dirname(__file__))
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from get_maintainer import Maintainers # noqa: E402
ZEPHYR_BASE = os.environ.get('ZEPHYR_BASE')
if ZEPHYR_BASE:
ZEPHYR_BASE = Path(ZEPHYR_BASE)
else:
ZEPHYR_BASE = Path(__file__).resolve().parents[2]
# Propagate this decision to child processes.
os.environ['ZEPHYR_BASE'] = str(ZEPHYR_BASE)
logger = logging.getLogger(__name__)
# Maximum number of changed files to process; larger PRs are skipped.
MAX_FILES = 500
# GitHub does not document a cap on how many reviewers a pull request may have,
# and PRs carrying more than 15 are observable in the wild, so no cap is imposed
# here: every eligible candidate is requested. Should GitHub reject the full
# set anyway, the request is retried with this many of the highest-ranked
# candidates so that a rejection cannot cost the review request entirely.
REVIEWER_RETRY_BATCH = 15
# Hidden marker identifying the comment used to mention reviewers who could not
# be added to the review request. It lets a re-run find and update its own
# previous comment instead of posting a duplicate.
MENTION_MARKER = "<!-- set_assignees: reviewer-mention -->"
# Maximum number of labels to apply; beyond this the labels are noise from
# over-broad matches, so only the ones contributed by the heaviest areas are
# kept and the PR is still labelled by what it mostly changes.
MAX_LABELS = 10
# An area is considered under-covered when it names no maintainers, or names at
# most this many collaborators. Such areas do not supply enough reviewers on
# their own, so recent Git contributors to the changed files are added as
# heuristic reviewer candidates (see _history_reviewers).
THIN_AREA_COLLABORATORS = 2
# Bounds on the Git-history heuristic, to keep its GitHub API cost predictable:
# - sample at most this many recent commits per changed file,
# - inspect at most this many under-covered files in total,
# - add at most this many heuristic reviewers to the candidate list,
# - ignore commits older than this, so that people who last touched the file
# years ago and have long since moved on are not asked to review it.
HISTORY_COMMITS_PER_FILE = 20
MAX_HISTORY_FILES = 40
MAX_HISTORY_REVIEWERS = 3
HISTORY_MAX_AGE_DAYS = 365
# The heuristic reviewer tiers (GitHub suggestions, Git history) exist to keep a
# PR from going unreviewed when MAINTAINERS.yml cannot staff it. They are not
# consulted once all the touched areas together have yielded this many
# candidates: at that point the PR has reviewers responsible for it by
# declaration, and adding lower-confidence names on top only dilutes the
# request.
MIN_TOTAL_REVIEWERS = 3
# GitHub's own reviewer suggestions ("based on commit history and past review
# comments") are exposed only through the GraphQL API, as a plain unpaginated
# list on the PullRequest object.
SUGGESTED_REVIEWERS_QUERY = """
query($owner: String!, $name: String!, $number: Int!) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
suggestedReviewers {
isAuthor
isCommenter
reviewer {
login
}
}
}
}
}
"""
# Courtesy sleep between consecutive GitHub API calls to avoid secondary rate limits.
API_SLEEP_SECONDS = 1
def parse_args():
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
allow_abbrev=False,
)
parser.add_argument(
"-M",
"--maintainer-file",
required=False,
default="MAINTAINERS.yml",
help="Maintainer file to be used.",
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"-P",
"--pull_request",
required=False,
default=None,
type=int,
help="Operate on one pull-request only.",
)
group.add_argument(
"-I", "--issue", required=False, default=None, type=int, help="Operate on one issue only."
)
group.add_argument("-s", "--since", required=False, help="Process pull-requests since date.")
group.add_argument(
"-m", "--modules", action="store_true", help="Process pull-requests from modules."
)
parser.add_argument("-y", "--dry-run", action="store_true", default=False, help="Dry run only.")
parser.add_argument("-o", "--org", default="zephyrproject-rtos", help="Github organisation")
parser.add_argument("-r", "--repo", default="zephyr", help="Github repository")
parser.add_argument(
"--updated-manifest",
default=None,
help="Manifest file as it appears in the pull request.",
)
parser.add_argument(
"--base-manifest",
default=None,
help=(
"Manifest file to compare --updated-manifest against. Should be the "
"version the PR forked from (the merge ref's first parent), not the "
"base-branch tip. Defaults to the checked-out west.yml when omitted."
),
)
parser.add_argument(
"--updated-maintainer-file",
default=None,
help="Maintainer file as it appears in the pull request.",
)
parser.add_argument(
"--base-maintainer-file",
default=None,
help=(
"Maintainer file to compare --updated-maintainer-file against. Should "
"be the version the PR forked from (the merge ref's first parent), not "
"the base-branch tip. Defaults to the checked-out MAINTAINERS.yml when "
"omitted."
),
)
parser.add_argument(
"-v",
"--verbose",
action="count",
default=0,
help="Verbose output. Use -v for INFO, -vv for DEBUG.",
)
parser.add_argument(
"--reset",
action="store_true",
default=False,
help=(
"Re-staff the pull request from scratch: drop the review requests of "
"everyone who has not reviewed or commented, remove the assignees and "
"the MAINTAINERS.yml area labels, then apply labels, reviewers and an "
"assignee as if the pull request had just been opened. Reviewers who "
"removed themselves are not re-added."
),
)
parser.add_argument(
"--size-labels",
action="store_true",
default=False,
help=(
"Manage the full size label suite (size: XS/S/M/L/XL) instead of "
"only the default size: XS label."
),
)
return parser.parse_args()
def setup_logging(verbose: int):
"""Configure logging verbosity.
-v -> INFO level (operational progress)
-vv -> DEBUG level (per-file and per-area detail)
"""
if verbose >= 2:
level = logging.DEBUG
elif verbose == 1:
level = logging.INFO
else:
level = logging.WARNING
logging.basicConfig(
format="%(levelname)s: %(message)s",
level=level,
stream=sys.stdout,
)
def load_areas(filename: str) -> dict:
"""Load MAINTAINERS YAML and return only areas that define file-matching patterns."""
with open(filename) as f:
doc = yaml.safe_load(f)
return {
k: v for k, v in doc.items() if isinstance(v, dict) and ("files" in v or "files-regex" in v)
}
def process_manifest(pr_manifest_file: str, base_manifest_file: str = "west.yml") -> list:
"""Return area name strings for projects that changed between two west.yml files.
*pr_manifest_file* is the manifest as it appears in the pull request, and
*base_manifest_file* is the manifest it should be compared against. The
base must be the version the PR actually forked from (the merge ref's first
parent), not the moving base-branch tip; otherwise projects that advanced on
the base branch after the PR was created are wrongly reported as changed and
their maintainers get spuriously added as reviewers (see #110422).
"""
logger.info("Processing manifest changes")
if not os.path.isfile(base_manifest_file):
logger.warning(
"Base manifest '%s' not found; skipping manifest processing", base_manifest_file
)
return []
if not os.path.isfile(pr_manifest_file):
logger.warning("PR manifest '%s' not found; skipping manifest processing", pr_manifest_file)
return []
old_manifest = Manifest.from_file(base_manifest_file)
new_manifest = Manifest.from_file(pr_manifest_file)
old_projs = {(p.name, p.revision) for p in old_manifest.projects}
new_projs = {(p.name, p.revision) for p in new_manifest.projects}
old_names = {name for name, _ in old_projs}
new_names = {name for name, _ in new_projs}
# Projects whose name no longer appears in the new manifest.
removed = {(n, r) for n, r in old_projs if n not in new_names}
# Projects that exist in both manifests but with a different revision.
updated = {(n, r) for n, r in new_projs if n in old_names and (n, r) not in old_projs}
# Entirely new projects.
added = {(n, r) for n, r in new_projs if n not in old_names}
changed_names = sorted({n for n, _ in removed | updated | added})
logger.info("Modified west projects: %s", changed_names)
areas = [f"West project: {name}" for name in changed_names]
logger.debug("Manifest areas: %s", areas)
return areas
def set_or_empty(d: dict, key: str) -> set:
return set(d.get(key, []) or [])
def _diff_area_entry(old_entry: dict, new_entry: dict) -> list:
"""Return human-readable change strings between two MAINTAINERS area entries."""
changes = []
fields = [
("maintainers", "Maintainers"),
("collaborators", "Collaborators"),
("labels", "Labels"),
("files", "Files"),
("files-regex", "files-regex"),
]
for key, label in fields:
added = set_or_empty(new_entry, key) - set_or_empty(old_entry, key)
removed = set_or_empty(old_entry, key) - set_or_empty(new_entry, key)
if added:
changes.append(f"{label} added: {', '.join(sorted(added))}")
if removed:
changes.append(f"{label} removed: {', '.join(sorted(removed))}")
old_status = old_entry.get("status")
new_status = new_entry.get("status")
if old_status != new_status:
changes.append(f"Status changed: {old_status} -> {new_status}")
return changes
def compare_areas(old: dict, new: dict) -> set:
"""Compare two MAINTAINERS area dicts; return the set of added, removed, or changed names."""
old_areas = set(old.keys())
new_areas = set(new.keys())
added_areas = new_areas - old_areas
removed_areas = old_areas - new_areas
common_areas = old_areas & new_areas
if added_areas:
logger.info("Areas added (%d):", len(added_areas))
for area in sorted(added_areas):
logger.info(" + %s", area)
if removed_areas:
logger.info("Areas removed (%d):", len(removed_areas))
for area in sorted(removed_areas):
logger.info(" - %s", area)
changed_areas = set()
for area in sorted(common_areas):
changes = _diff_area_entry(old[area], new[area])
if changes:
changed_areas.add(area)
logger.info("Area changed: %s", area)
for change in changes:
logger.debug(" %s", change)
return added_areas | removed_areas | changed_areas
def _pick_assignees(pr, area_counter: dict, all_maintainers: dict, num_files: int):
"""Select the best assignee list for *pr* from area and file-coverage data.
Iterates areas in descending file-change count. Non-platform areas take
priority; platform (driver/board) areas are only used as a fallback.
Returns a list of login strings, or None if no suitable assignee is found.
"""
ranked_assignees = []
assignees = None
for area, count in area_counter.items():
if count == 0 or not area.maintainers:
continue
if pr.user.login in area.maintainers:
# Author is a maintainer; try to assign someone else from the same area.
if len(area.maintainers) > 1:
candidates = area.maintainers.copy()
candidates.remove(pr.user.login)
assignees = candidates
else:
# Author is the sole maintainer; skip this area.
continue
else:
assignees = area.maintainers
# FIXME: identify platform areas and other areas using some flag in the
# MAINTAINERS.yml data instead of relying on the name containing "Platform".
if 'platform' in area.name.lower():
logger.debug(
"Platform area '%s' with maintainers %s added as fallback for assignment",
area.name,
assignees,
)
ranked_assignees.append(assignees)
elif not area.meta:
logger.debug(
"Non-platform area '%s' with maintainers %s takes priority for assignment",
area.name,
assignees,
)
# Non-platform area: highest priority — insert at front and stop.
ranked_assignees.insert(0, assignees)
break
if ranked_assignees:
assignees = ranked_assignees[0]
if assignees:
coverage = (all_maintainers.get(assignees[0], 0) / num_files) * 100 if num_files else 0
logger.info("Picked assignees: %s (%.2f%% file coverage)", assignees, coverage)
elif all_maintainers:
# No area-based pick succeeded; fall back to the maintainer with most file changes.
assignees = [next(iter(all_maintainers))]
logger.info("Fallback assignee (highest file count): %s", assignees)
return assignees
def _thin_areas(maintainer_file, area_counter: dict) -> set:
"""Return the names of areas in *area_counter* that are under-covered.
An area is under-covered when MAINTAINERS.yml names no maintainers for it,
or names at most THIN_AREA_COLLABORATORS collaborators. These areas cannot
supply enough reviewers on their own, so the caller supplements them with
recent Git contributors (see _history_reviewers).
Meta-areas (``meta: true`` in MAINTAINERS.yml) are never reported as
under-covered. Their file patterns span large parts of the tree, so the
small number of people they name is deliberate rather than a gap, and
walking the history of every documentation or sample file they match would
add reviewers on top of the maintainers who already own the area.
"""
thin = set()
for area in area_counter:
entry = maintainer_file.areas[area.name]
if entry.meta:
continue
if not entry.maintainers or len(entry.collaborators) <= THIN_AREA_COLLABORATORS:
thin.add(area.name)
return thin
def _suggested_reviewers(gh, args, pr, exclude: set) -> list:
"""Return GitHub's own reviewer suggestions for *pr* as ordered logins.
GitHub computes these from commit history and past review comments, so
they are a strictly better-informed version of the _history_reviewers
heuristic when available. They are only exposed through GraphQL, hence
the raw query; PyGithub's requester reuses the same token and session, so
this adds no dependency.
Suggestions where GitHub flagged the person as an author of the changed
code rank ahead of comment-only suggestions, and ties break by login for
determinism. Logins in *exclude* are dropped.
The list is frequently empty (GitHub's heuristic is sparse, and it is not
documented when it declines to suggest), and the field is best-effort, so
every failure path degrades to an empty list and lets the caller fall back
to _history_reviewers.
"""
variables = {"owner": args.org, "name": args.repo, "number": pr.number}
try:
_headers, response = gh.requester.graphql_query(SUGGESTED_REVIEWERS_QUERY, variables)
except GithubException as exc:
logger.debug("suggestedReviewers query failed for PR #%d: %s", pr.number, exc)
return []
if response.get("errors"):
logger.debug(
"suggestedReviewers query returned errors for PR #%d: %s",
pr.number,
response["errors"],
)
try:
suggestions = response["data"]["repository"]["pullRequest"]["suggestedReviewers"]
except (KeyError, TypeError):
logger.debug("Unexpected suggestedReviewers response for PR #%d", pr.number)
return []
ranked = []
for suggestion in suggestions or []:
login = (suggestion.get("reviewer") or {}).get("login")
if not login or login in exclude:
continue
# Authorship of the changed code is a stronger signal than having
# commented on it, so sort authors first.
ranked.append((0 if suggestion.get("isAuthor") else 1, login))
return [login for _rank, login in sorted(ranked)]
def _history_reviewers(gh_repo, filenames, exclude: set) -> list:
"""Return recent contributors to *filenames* as ordered GitHub logins.
Walks each file's commit history (newest first) and tallies the commit
authors GitHub resolved to a real account, ranking them by how many of the
sampled commits they authored (ties broken by login for determinism).
Commit history is read through the GitHub API rather than a local
``git log``/``git blame`` because the API yields actual GitHub logins
(commit-author emails cannot be mapped to logins reliably) and does not
depend on the CI checkout having the full history.
Only commits from the last HISTORY_MAX_AGE_DAYS days are considered: a
contributor who has not touched the file within a year is unlikely to still
be the right person to ask, and on old files the unbounded history would
otherwise fill every heuristic slot with people who have moved on.
*exclude* holds logins already covered (PR author, existing candidates) so
they are not re-proposed. Sampling is bounded by HISTORY_COMMITS_PER_FILE,
MAX_HISTORY_FILES, and MAX_HISTORY_REVIEWERS.
"""
since = datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=HISTORY_MAX_AGE_DAYS)
sampled_files = sorted(filenames)
if len(sampled_files) > MAX_HISTORY_FILES:
logger.info(
"Sampling Git history for %d of %d under-covered files (limit %d)",
MAX_HISTORY_FILES,
len(sampled_files),
MAX_HISTORY_FILES,
)
sampled_files = sampled_files[:MAX_HISTORY_FILES]
counts = defaultdict(int)
for filename in sampled_files:
try:
commits = gh_repo.get_commits(path=filename, since=since)
except GithubException as exc:
logger.debug("No commit history for '%s': %s", filename, exc)
continue
sampled = 0
for commit in commits:
if sampled >= HISTORY_COMMITS_PER_FILE:
break
sampled += 1
author = commit.author
login = getattr(author, "login", None) if author else None
if not login or login in exclude:
continue
counts[login] += 1
ranked = sorted(counts, key=lambda login: (-counts[login], login))
return ranked[:MAX_HISTORY_REVIEWERS]
def _build_reviewer_candidates(
maintainer_file,
area_counter: dict,
collab_per_path: set,
additional_reviews: set,
deferred_reviewers: set,
) -> list:
"""Build the ordered reviewer candidate list for a PR.
Maintainers of every touched area come first (in descending area-weight
order), then collaborators. The caller truncates the filtered result to
the reviewer cap, so ordering maintainers ahead of collaborators ensures
the highest-signal reviewers are never dropped in favour of lower-signal
ones. Lower-confidence Git-history reviewers (for under-covered areas) are
appended after this list by the caller so they fill only leftover slots.
"""
maintainers = []
collaborators = []
for area in area_counter:
maintainers += maintainer_file.areas[area.name].maintainers
collaborators += maintainer_file.areas[area.name].collaborators
candidates = (
maintainers
+ sorted(additional_reviews)
+ sorted(deferred_reviewers)
+ collaborators
+ sorted(collab_per_path)
)
# Deduplicate while preserving insertion order.
return list(dict.fromkeys(candidates))
def _add_reviewers(gh, gh_repo, pr, args, collab: list):
"""Request reviews from eligible collaborators on *pr*.
Skips the PR author, existing reviewers, non-collaborators, and users
who previously removed themselves from the review request.
Every remaining candidate is requested; no cap is applied. *collab* is
ordered highest-signal first (area maintainers, then collaborators, then
heuristic picks -- see _build_reviewer_candidates), so the ordering, rather
than an arbitrary limit, is what protects the review request from being
dominated by low-signal reviewers.
"""
existing_reviewers = set()
for review in pr.get_reviews():
existing_reviewers.add(review.user)
# get_review_requests() returns (PaginatedList[NamedUser], PaginatedList[Team]).
review_users, _review_teams = pr.get_review_requests()
existing_reviewers.update(review_users)
logger.debug(
"Existing reviewers: %s", [u.login for u in existing_reviewers if hasattr(u, 'login')]
)
# Track users who removed themselves; do not re-add them.
self_removed = set()
for event in pr.get_issue_events():
if event.event == 'review_request_removed' and event.actor == event.requested_reviewer:
self_removed.add(event.actor)
reviewers = []
non_collaborators = []
for collaborator in collab:
try:
gh_user = gh.get_user(collaborator)
except UnknownObjectException:
# The login does not resolve, so there is nobody to mention either.
logger.warning("User '%s' not found; account may have been deleted", collaborator)
continue
if pr.user == gh_user:
logger.debug("Skipping PR author '%s'", collaborator)
continue
if gh_user in existing_reviewers:
logger.debug("Skipping existing reviewer '%s'", collaborator)
continue
if gh_user in self_removed:
logger.info("Skipping '%s': previously self-removed from reviewers", collaborator)
continue
if not gh_repo.has_in_collaborators(gh_user):
# GitHub refuses a review request for non-collaborators, so ask
# them by mention instead of dropping them.
logger.info("'%s' is not a repository collaborator; will mention", collaborator)
non_collaborators.append(collaborator)
continue
reviewers.append(collaborator)
if reviewers:
logger.info("Requesting reviews from %d user(s): %s", len(reviewers), reviewers)
unrequested = _request_reviews(pr, args, reviewers)
else:
unrequested = []
# Anyone the review request could not accommodate still gets asked, by
# name, in a comment. Users who removed themselves are deliberately not
# mentioned: they opted out, and a mention would route around that.
_mention_reviewers(pr, args, non_collaborators + unrequested)
def _request_reviews(pr, args, reviewers: list) -> list:
"""Request reviews from *reviewers*; return those that could not be added.
A rejected bulk request is retried once with the highest-ranked
REVIEWER_RETRY_BATCH candidates, so that hitting an undocumented per-PR
reviewer limit cannot leave the PR with no reviewers at all. Whoever is
left out is returned for the caller to mention instead.
"""
if args.dry_run:
return []
try:
pr.create_review_request(reviewers=reviewers)
return []
except GithubException as exc:
logger.error("Failed to add reviewers %s: %s", reviewers, exc)
if len(reviewers) > REVIEWER_RETRY_BATCH:
retry = reviewers[:REVIEWER_RETRY_BATCH]
logger.info("Retrying with the first %d: %s", len(retry), retry)
try:
pr.create_review_request(reviewers=retry)
return reviewers[REVIEWER_RETRY_BATCH:]
except GithubException as retry_exc:
logger.error("Retry failed for reviewers %s: %s", retry, retry_exc)
return list(reviewers)
def _mention_reviewers(pr, args, logins: list):
"""Ask *logins* for a review in a PR comment.
Used for people who cannot receive a formal review request: those who are
not repository collaborators (GitHub rejects a review request for them),
and those the request itself could not accommodate. An @mention notifies
them regardless of their permissions, so the change still reaches the
people responsible for it.
The comment is maintained in place: one is created the first time and
edited afterwards, keyed on MENTION_MARKER, so that re-running over the
same PR does not spam it with duplicates.
"""
logins = list(dict.fromkeys(logins))
if not logins:
return
mentions = " ".join(f"@{login}" for login in logins)
body = (
f"{MENTION_MARKER}\n"
f"{mentions}\n\n"
"You have been identified as a likely reviewer for the code this pull "
"request changes, but could not be added to its review request "
"automatically. Please review it if you are able to."
)
logger.info("Mentioning %d user(s) unable to be requested: %s", len(logins), logins)
if args.dry_run:
return
try:
for comment in pr.get_issue_comments():
if MENTION_MARKER in comment.body:
# Already asked; refresh only when the set of people changed.
if comment.body.strip() != body.strip():
comment.edit(body)
logger.info("Updated existing reviewer-mention comment")
else:
logger.debug("Reviewer-mention comment already up to date")
return
pr.create_issue_comment(body)
except GithubException as exc:
logger.error("Failed to mention reviewers %s: %s", logins, exc)
def _assign_maintainers(gh, pr, args, assignees: list):
"""Add *assignees* (login strings) to *pr*, logging any unknown-user errors."""
users = []
for login in assignees:
try:
users.append(gh.get_user(login))
except GithubException as exc:
logger.error("Unknown user '%s': %s", login, exc)
for user in users:
logger.info("Adding assignee: %s", user.login)
if not args.dry_run:
pr.add_to_assignees(user)
# Manifest files are never considered trivial regardless of line count.
_MANIFEST_FILES = frozenset({'west.yml', 'submanifests/optional.yaml'})
# All size labels managed by this script; used to remove stale buckets.
_SIZE_LABELS = frozenset({'size: XS', 'size: S', 'size: M', 'size: L', 'size: XL'})
# (max total lines changed inclusive, label name) in ascending order.
# XS is handled separately by update_size_xs_label / update_size_labels.
_SIZE_THRESHOLDS = (
(9, 'size: S'),
(49, 'size: M'),
(499, 'size: L'),
)
def update_size_xs_label(pr, args, changed_files: list, labels: set):
"""Add or remove the 'size: XS' label based on PR size and changed files.
A PR qualifies for 'size: XS' when all of the following are true:
- Exactly one commit.
- At most one line added and one line deleted.
- No manifest file (west.yml, submanifests/optional.yaml) is changed.
"""
touches_manifest = any(f.filename in _MANIFEST_FILES for f in changed_files)
current_label_names = {label.name for label in pr.labels}
qualifies_for_xs = (
pr.commits == 1 and pr.additions <= 1 and pr.deletions <= 1 and not touches_manifest
)
if qualifies_for_xs:
labels.add('size: XS')
elif 'size: XS' in current_label_names:
logger.info(
"Removing 'size: XS' label from PR #%d (commits=%d, +%d/-%d)",
pr.number,
pr.commits,
pr.additions,
pr.deletions,
)
if not args.dry_run:
pr.remove_from_labels('size: XS')
def update_size_labels(pr, args, changed_files: list, labels: set):
"""Manage the full size label suite (XS/S/M/L/XL) on *pr*.
Computes the correct size bucket, removes any existing size labels that
belong to a different bucket, and queues the correct label for addition.
The XS bucket inherits the same stricter qualification rules as
update_size_xs_label (single commit, ≤1 line, no manifest files).
"""
total_lines = pr.additions + pr.deletions
touches_manifest = any(f.filename in _MANIFEST_FILES for f in changed_files)
qualifies_for_xs = (
pr.commits == 1 and pr.additions <= 1 and pr.deletions <= 1 and not touches_manifest
)
if qualifies_for_xs:
correct_label = 'size: XS'
else:
correct_label = 'size: XL'
for max_lines, label in _SIZE_THRESHOLDS:
if total_lines <= max_lines:
correct_label = label
break
current_label_names = {lbl.name for lbl in pr.labels}
stale = _SIZE_LABELS & current_label_names - {correct_label}
for label in sorted(stale):
logger.info(
"Removing stale size label '%s' from PR #%d",
label,
pr.number,
)
if not args.dry_run:
pr.remove_from_labels(label)
labels.add(correct_label)
logger.info(
"PR #%d size: %s (+%d/-%d, %d total line(s), %d commit(s))",
pr.number,
correct_label,
pr.additions,
pr.deletions,
total_lines,
pr.commits,
)
def _rank_labels(labels: set, area_counter: dict, area_files: dict) -> list:
"""Return *labels* ordered by how much of the PR the areas carrying them cover.
A label's rank is the sum of the file weights of every touched area that
carries it, so a label backed by the bulk of the changed files outranks one
coming from a single incidentally matched area. Ties break by label name to
keep the choice deterministic.
Areas weighing 0 fall back to the number of files they matched. Those
weights are zeroed for assignee selection -- meta-areas, files that only
matched CMakeLists.txt, repeated matches of one Platform area -- but the
areas still describe what the PR changes, and a label is about exactly that.
Without the fallback a documentation-only PR would rank "area: Documentation"
last and drop it first. Labels with no contributing area at all (labels of
deferred file-groups) still score 0 and sort last.
"""
weights = defaultdict(int)
for area, weight in area_counter.items():
weight = weight or len(area_files.get(area.name, ()))
for label in area.labels:
if label in labels:
weights[label] += weight
return sorted(labels, key=lambda label: (-weights[label], label))
def _select_labels(pr, labels: set, area_counter: dict, area_files: dict) -> set:
"""Return the labels to apply to *pr*, truncated to MAX_LABELS if needed.
Up to MAX_LABELS area labels everything is applied. Beyond that the PR
touches so many areas that labelling it with all of them says nothing, so
only the MAX_LABELS highest-ranked ones are applied (see _rank_labels).
Only the area labels count towards the limit, and only they can be dropped.
Size labels are computed for this PR alone and update_size_labels has
already removed the stale ones, so dropping the new one would leave the PR
with no size label at all -- and counting it would make MAX_LABELS area
labels plus a size label look like an overflow.
This caps what the run *applies*; it never takes a label off the PR. A
label already there stays, whether a human added it or an earlier run did
(--reset is the way to clear those).
"""
area_labels = labels - _SIZE_LABELS
if len(area_labels) <= MAX_LABELS:
return labels
ranked = _rank_labels(area_labels, area_counter, area_files)
kept = set(ranked[:MAX_LABELS])
logger.warning(
"PR #%d matched %d area labels (limit %d); applying only the %d highest-weight: "
"%s (not applying: %s)",
pr.number,
len(area_labels),
MAX_LABELS,
len(kept),
sorted(kept),
sorted(area_labels - kept),
)
return kept | (labels & _SIZE_LABELS)
def _interacted_logins(pr) -> set:
"""Return the logins of everyone who has engaged with *pr*.
That is anyone who submitted a review, or commented on the PR itself or on
one of its review threads. Such people are involved in the change by their
own action, so a reset must not drop them.
"""
logins = set()
def _add(user):
login = getattr(user, 'login', None)
if login:
logins.add(login)
for review in pr.get_reviews():
_add(review.user)
for comment in pr.get_issue_comments():
_add(comment.user)
for comment in pr.get_review_comments():
_add(comment.user)
return logins
def _reset_pr(pr, args, maintainer_file, keep=()) -> set:
"""Undo this script's earlier decisions on *pr* so it can be staffed afresh.
Removes the review request from everyone who has not engaged with the PR,
drops every assignee, and strips the area labels that MAINTAINERS.yml
defines. The caller then applies the staffing it has just computed, so the
PR ends up as if it had been opened now.
Called late in process_pr, once that staffing is known, for two reasons:
every early return (draft, closed, MAX_FILES) has already had its say, so a
reset cannot strip a PR and then bail out without restaffing it; and *keep*
can hold the logins about to be requested again. Their request is left in
place, since withdrawing it only to re-send it would notify them twice.
Four things are deliberately left alone:
- Reviewers who reviewed or commented (see _interacted_logins). They are
already part of the conversation, and dropping their request would
either lose that or notify them again for nothing.
- Team review requests. This script only ever requests individuals, so a
team request was added by hand and a reset has no basis to withdraw it.
- Size labels: update_size_labels recomputes them on every run and
removes the stale ones itself.
- Labels the script does not own (``bug``, ``RFC``, backport labels, ...).
They are not derived from MAINTAINERS.yml, so a reset has no basis for
second-guessing whoever added them.
People who removed themselves from the review request are not handled here:
_add_reviewers already refuses to re-request them, so the reset cannot
route around that opt-out.
Returns the names of the labels removed, since pr.labels is a snapshot
taken before the reset.
"""
protected = _interacted_logins(pr) | set(keep)
review_users, _review_teams = pr.get_review_requests()
requested = {user.login for user in review_users}
kept = sorted(requested & protected)
if kept:
logger.info(
"Reset: keeping review request(s) for users who engaged or are still responsible: %s",
kept,
)
stale = sorted(requested - protected)
if stale:
logger.info("Reset: removing %d review request(s): %s", len(stale), stale)
if not args.dry_run:
pr.delete_review_request(reviewers=stale)
assignees = sorted({assignee.login for assignee in pr.assignees})
if assignees:
logger.info("Reset: removing assignee(s): %s", assignees)
if not args.dry_run:
pr.remove_from_assignees(*assignees)
area_labels = {label for area in maintainer_file.areas.values() for label in area.labels}
removed_labels = {label.name for label in pr.labels} & area_labels
if removed_labels:
logger.info("Reset: removing label(s): %s", sorted(removed_labels))
for name in sorted(removed_labels):
if not args.dry_run:
pr.remove_from_labels(name)
return removed_labels
def process_pr(gh, args, maintainer_file, number: int):
gh_repo = gh.get_repo(f"{args.org}/{args.repo}")
pr = gh_repo.get_pull(number)
pr_url = f"https://github.com/{args.org}/{args.repo}/pull/{pr.number}"
logger.info("Processing PR #%d: %s (%s)", pr.number, pr.title, pr_url)
if pr.draft:
logger.info("PR #%d is a draft; skipping", pr.number)
return
if pr.state != 'open':
logger.info("PR #%d is %s; skipping", pr.number, pr.state)
return
labels = set()
area_counter = defaultdict(int)
found_maintainers = defaultdict(int)
collab_per_path = set()
additional_reviews = set()
deferred_reviewers = set()
# Files that mapped to each area, used to sample Git history for areas that
# MAINTAINERS.yml under-covers (see _thin_areas / _history_reviewers).
area_files = defaultdict(set)
# Changed files that mapped to no area at all. MAINTAINERS.yml offers no
# reviewer for these, so they feed the same heuristics as thin areas.
orphan_files = set()
changed_files = list(pr.get_files())
num_files = len(changed_files)
if num_files > MAX_FILES:
logger.warning(
"PR #%d has %d changed files (limit %d); skipping",
pr.number,
num_files,
MAX_FILES,
)
return
if args.size_labels:
update_size_labels(pr, args, changed_files, labels)
else:
update_size_xs_label(pr, args, changed_files, labels)
for changed_file in changed_files:
filename = changed_file.filename
logger.debug("Processing file: %s", filename)
areas = []
if filename in ('west.yml', 'submanifests/optional.yaml'):
if not args.updated_manifest:
logger.debug(
"No --updated-manifest provided; skipping manifest diff for %s", filename
)
continue
parsed_areas = process_manifest(
pr_manifest_file=args.updated_manifest,
base_manifest_file=args.base_manifest or "west.yml",
)
for area_name in parsed_areas:
area_matches = maintainer_file.name2areas(area_name)
if area_matches:
collab_per_path.update(area_matches[0].get_collaborators_for_path(filename))
areas.extend(area_matches)
elif filename == 'MAINTAINERS.yml':
areas = maintainer_file.path2areas(filename)
if args.updated_maintainer_file:
# Compare the PR's MAINTAINERS.yml against the version it forked
# from (the merge ref's first parent), not the base-branch tip,
# so areas changed on the base branch after the PR was created
# are not wrongly attributed to this PR (see #110422).
old_areas = load_areas(args.base_maintainer_file or 'MAINTAINERS.yml')
new_areas = load_areas(args.updated_maintainer_file)
changed_area_names = compare_areas(old_areas, new_areas)
for area_name in changed_area_names:
area_matches = maintainer_file.name2areas(area_name)
if area_matches:
additional_reviews.update(maintainer_file.areas[area_name].maintainers)
logger.info(
"MAINTAINERS.yml changed; adding extra reviewers: %s",
sorted(additional_reviews),
)
else:
areas = maintainer_file.path2areas(filename)
for area in areas:
collab_per_path.update(area.get_collaborators_for_path(filename))
logger.debug(" areas for %s: %s", filename, [a.name for a in areas])
if not areas:
# Orphaned: no area claims this file, so MAINTAINERS.yml yields no
# reviewer for it. Remember it for the heuristic pass below.
logger.debug(" '%s' matches no area", filename)
orphan_files.add(filename)
continue
# Handle deferred file-groups: when a file is matched by both a
# deferred area (file-group with defer-to-other-areas: true) and a
# non-deferred area, the deferred area's maintainers become reviewers
# only and do not count toward assignee weighting for this file.
_deferred = [a for a in areas if a.is_deferred_for_path(filename)]
_non_deferred = [a for a in areas if not a.is_deferred_for_path(filename)]
if _deferred and _non_deferred:
for _area in _deferred:
deferred_reviewers.update(_area.maintainers)
labels.update(_area.labels)
logger.debug(
" area '%s' defers to other areas for '%s'; "
"maintainers %s added as reviewers only",
_area.name,
filename,
_area.maintainers,
)
areas = _non_deferred
# Sort so that Platform (driver/board) areas are processed first. This
# sets is_instance=True early, preventing the same file from being
# counted again for the corresponding subsystem area.
sorted_areas = sorted(areas, key=lambda x: 'Platform' in x.name, reverse=True)
is_instance = False
for area in sorted_areas:
# CMakeLists.txt changes and meta-area files do not count toward
# the area weight used for assignee selection.
if 'CMakeLists.txt' in filename or area.meta:
count = 0
else:
# Once an instance (Platform) area has been seen, subsequent
# areas for this file score 0 to avoid double-counting.
count = 1 if not is_instance else 0
area_counter[area] += count
area_files[area.name].add(filename)
logger.debug(" area weight update: %s += %d", area.name, count)
labels.update(area.labels)
# NOTE: a file appearing in multiple areas with the same maintainer
# will over-count that maintainer's file coverage score.
for maintainer in area.maintainers:
found_maintainers[maintainer] += count
if 'Platform' in area.name:
is_instance = True
# Collect path-specific collaborators for all areas that matched this file.
for area in sorted_areas:
collab_per_path.update(area.get_collaborators_for_path(filename))
area_counter = dict(sorted(area_counter.items(), key=lambda item: item[1], reverse=True))
logger.info("Area weights: %s", {a.name: c for a, c in area_counter.items()})
logger.debug("Collected labels: %s", labels)
# Build the ordered collaborator/reviewer list: maintainers of all touched
# areas by area priority first, then collaborators.
collab = _build_reviewer_candidates(
maintainer_file, area_counter, collab_per_path, additional_reviews, deferred_reviewers
)
# Supplement files MAINTAINERS.yml cannot staff with heuristic reviewers:
# those in under-covered areas (no maintainers, or few collaborators) and
# those in no area at all. Exclude the author and everyone already listed
# so these last-resort slots are not wasted on reviewers we already have.
thin = _thin_areas(maintainer_file, area_counter)
uncovered_files = set(orphan_files)
for name in thin:
uncovered_files.update(area_files.get(name, ()))
# The heuristics below are a stopgap for PRs the areas cannot staff, so they
# only run while the area tiers are short of reviewers. The author does not
# count: they cannot review their own PR.
total_reviewers = set(collab) - {pr.user.login}
if uncovered_files and len(total_reviewers) >= MIN_TOTAL_REVIEWERS:
logger.info(
"Skipping heuristic reviewers: %d reviewer(s) already found from the "
"touched areas (threshold %d)",
len(total_reviewers),
MIN_TOTAL_REVIEWERS,
)
elif uncovered_files:
if orphan_files:
logger.info(
"%d changed file(s) match no area: %s",
len(orphan_files),
sorted(orphan_files),
)
exclude = set(collab) | {pr.user.login}
# Prefer GitHub's own suggestions: they draw on both commit history and
# past review comments, and cost a single query. They are often empty,
# in which case fall back to walking the history of the files that
# MAINTAINERS.yml left unstaffed.
heuristic = _suggested_reviewers(gh, args, pr, exclude)[:MAX_HISTORY_REVIEWERS]
source = "GitHub-suggested"
if not heuristic:
heuristic = _history_reviewers(gh_repo, uncovered_files, exclude)
source = "Git-history"
if heuristic:
logger.info(
"Under-covered areas %s / %d orphaned file(s); adding %s reviewers: %s",
sorted(thin),
len(orphan_files),
source,
heuristic,
)
collab += heuristic
else:
logger.info("No heuristic reviewers found for under-covered files")
logger.debug("Reviewer candidates: %s", collab)
all_maintainers = dict(
sorted(found_maintainers.items(), key=lambda item: item[1], reverse=True)
)
logger.info("PR submitted by: %s", pr.user.login)
logger.info("Maintainer file-coverage scores: %s", all_maintainers)
assignees = _pick_assignees(pr, area_counter, all_maintainers, num_files)
# With --reset, wipe what an earlier run decided before applying the above,
# so the PR ends up staffed as if it were new. This happens here, not
# earlier: every skip condition above has had its say, so a reset cannot
# strip a PR the run then declines to restaff, and the reviewers about to be
# requested again keep the request they already have. What the reset
# removed is tracked because pr.labels and pr.assignee still describe the
# PR as it was before.
removed_labels = _reset_pr(pr, args, maintainer_file, keep=collab) if args.reset else set()
# Apply labels — skip any that are already present, then add the rest in
# a single API call to avoid per-label timeline noise.
if labels:
labels = _select_labels(pr, labels, area_counter, area_files)
current_label_names = {lbl.name for lbl in pr.labels} - removed_labels
new_labels = sorted(labels - current_label_names)
if new_labels:
logger.info("Adding labels: %s", new_labels)
if not args.dry_run:
pr.add_to_labels(*new_labels)
else:
logger.info("All labels already present on PR #%d; skipping", pr.number)
# Request reviews.
# additional_reviews is already folded into collab by
# _build_reviewer_candidates, so collab alone decides whether to ask.
if collab:
_add_reviewers(gh, gh_repo, pr, args, collab)
# Set assignees (only when none are set yet, unless doing a dry run).
# --reset just cleared them, so pr.assignee describes a state that is gone.
has_assignee = pr.assignee and not args.reset
if assignees and (not has_assignee or args.dry_run):
_assign_maintainers(gh, pr, args, assignees)
else:
reason = "already has assignee" if has_assignee else "no assignees found"
logger.info("Not setting assignee for PR #%d: %s", pr.number, reason)
time.sleep(API_SLEEP_SECONDS)
def process_issue(gh, args, maintainer_file, number: int):
gh_repo = gh.get_repo(f"{args.org}/{args.repo}")
issue = gh_repo.get_issue(number)
logger.info("Processing issue #%d: %s (%s)", issue.number, issue.title, issue.html_url)
if issue.assignees:
logger.warning(
"Issue #%d already has assignees (%s); skipping",
issue.number,
[a.login for a in issue.assignees],
)
return
# Build a mapping from sorted label-name tuples to maintainer sets.
label_to_maintainer = defaultdict(set)
for _, area in maintainer_file.areas.items():
if not area.labels:
continue
label_tuple = tuple(sorted(label.lower() for label in area.labels))
for maintainer in area.maintainers:
label_to_maintainer[label_tuple].add(maintainer)
# Also allow matching on a single label when it is unambiguous enough.
for label_tuple, maintainers in dict(label_to_maintainer).items():
for label in label_tuple:
single = (label,)
if single not in label_to_maintainer:
label_to_maintainer[single] = maintainers
valid_labels = []
for label in issue.labels:
label_name = label.name.lower()
if (label_name,) not in label_to_maintainer:
logger.debug("Ignoring label '%s': no area match", label.name)
else:
valid_labels.append(label_name)
issue_labels = tuple(sorted(valid_labels))
logger.info("Matched labels: %s", issue_labels)
if not issue_labels or issue_labels not in label_to_maintainer:
logger.warning("No matching label set found for issue #%d; not assigning", issue.number)
return
for maintainer in label_to_maintainer[issue_labels]:
logger.info("Assigning '%s' to issue #%d (%s)", maintainer, issue.number, issue.html_url)
if not args.dry_run:
issue.add_to_assignees(maintainer)
def process_modules(gh, args, maintainers_file):
manifest = Manifest.from_file()
repos = {}
for project in manifest.get_projects([]):
if not manifest.is_active(project) or isinstance(project, ManifestProject):
continue
area_name = f"West project: {project.name}"
if area_name not in maintainers_file.areas:
logger.debug("No area defined for project '%s'; skipping", project.name)
continue
area = maintainers_file.areas[area_name]
if not area.maintainers:
logger.info("No maintainers for project '%s'; skipping", project.name)
continue
logger.debug(
"Project '%s': maintainers=%s, collaborators=%s",
project.name,
area.maintainers,
area.collaborators,
)
repos[f"{args.org}/{project.name}"] = area
if not repos:
logger.warning("No active module repos with maintainers found in manifest")
return
query = "is:open is:pr no:assignee " + " ".join(f"repo:{repo}" for repo in repos)
logger.info("Searching for unassigned module PRs with query: %s", query)
for issue in gh.search_issues(query=query):
pull = issue.as_pull_request()
if pull.draft:
logger.debug("Skipping draft PR: %s", pull.html_url)
continue
if pull.assignees:
logger.error(
"PR %s unexpectedly has assignees %s despite no:assignee filter",
pull.html_url,
pull.assignees,
)
continue
area = repos[f"{args.org}/{issue.repository.name}"]
for maintainer in area.maintainers:
logger.info("Assigning '%s' to %s", maintainer, pull.html_url)
if not args.dry_run:
pull.add_to_assignees(maintainer)
pull.create_review_request(maintainer)
for collaborator in area.collaborators:
logger.info("Adding reviewer '%s' to %s", collaborator, pull.html_url)
if not args.dry_run:
pull.create_review_request(collaborator)
def main():
args = parse_args()
setup_logging(args.verbose)
token = os.environ.get('GITHUB_TOKEN')
if not token:
sys.exit('GITHUB_TOKEN environment variable is not set. Please set it and retry.')
gh = Github(auth=Auth.Token(token))
maintainer_file = Maintainers(args.maintainer_file)
if args.pull_request:
process_pr(gh, args, maintainer_file, args.pull_request)
elif args.issue:
process_issue(gh, args, maintainer_file, args.issue)
elif args.modules:
process_modules(gh, args, maintainer_file)
else:
if args.since:
since = args.since
else:
since = datetime.date.today() - datetime.timedelta(days=1)
query = (
f'repo:{args.org}/{args.repo} is:open is:pr base:main '
f'-is:draft no:assignee created:>{since}'
)
logger.info("Searching for unassigned PRs with query: %s", query)
for issue in gh.search_issues(query=query):
process_pr(gh, args, maintainer_file, issue.number)
if __name__ == "__main__":
main()