blob: f625f8f3a774d8907d7903e5c6b3d1139b5f51a3 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# /// script
# requires-python = ">=3.10"
# dependencies = [
# "rich>=13.6.0",
# ]
# ///
"""
Close the open PRs of contributors who are over the open pull request limit.
One-time step of introducing the limit, described in
``contributing-docs/32_open_pull_request_limit.rst``. For every author without write access
who has more open PRs than the limit, all their open PRs are closed with a comment asking
them to choose which ones to reopen - except PRs where a maintainer has already engaged
(commented or reviewed). Engagement by the maintainers doing the triage (by default the
user running the script) does not count.
Uses the ``gh`` CLI, so it runs with the credentials of the authenticated maintainer.
Dry-run by default - nothing is changed on GitHub unless ``--execute`` is passed.
Usage::
uv run dev/close_prs_over_open_pr_limit.py
uv run dev/close_prs_over_open_pr_limit.py --author some-login --execute
uv run dev/close_prs_over_open_pr_limit.py --execute
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import time
from collections import defaultdict
from dataclasses import dataclass, field
from rich.console import Console
from rich.table import Table
console = Console(width=200)
DOC_URL = "https://github.com/apache/airflow/blob/main/contributing-docs/32_open_pull_request_limit.rst"
DEFAULT_LABEL = "closed because of open PR limit"
LABEL_COLOR = "d4c5f9"
PRS_QUERY = """
query($owner: String!, $name: String!, $cursor: String) {
repository(owner: $owner, name: $name) {
pullRequests(states: OPEN, first: 50, after: $cursor, orderBy: {field: CREATED_AT, direction: ASC}) {
pageInfo { hasNextPage endCursor }
nodes {
number
title
url
isDraft
author { login }
comments(first: 100) { nodes { author { login } } }
reviews(first: 100) { nodes { author { login } } }
reviewThreads(first: 50) { nodes { comments(first: 20) { nodes { author { login } } } } }
}
}
}
}
"""
COMMENT_TEMPLATE = """\
Hello @{author} - thank you for your contributions to Apache Airflow!
The Airflow community has introduced a limit of **{limit} open pull requests at a time** for \
contributors without write access to the repository. You currently have {total} open pull \
requests, so - as a one-time step of introducing the limit - we closed the ones where \
maintainers have not engaged yet:
{closed_list}
{kept_section}
This is **not** a judgement of you or of your changes. We never told contributors before that \
opening many pull requests at once was a problem, so there is nothing to feel bad about - and \
nothing is lost: your branches, commits and the review history stay where they are.
What we ask you to do is to make your **first prioritization decision**: choose which of the pull \
requests above matter most to you, and reopen them (up to {limit} open at a time, including the \
ones still open) with the "Reopen pull request" button or \
`gh pr reopen <PR_NUMBER> --repo apache/airflow`. Reopen the ones you are ready to follow \
through - keep them rebased, respond to review comments and fix failing checks.
While your pull requests are waiting for review, the most valuable thing you can do is help in \
other ways - reviewing other contributors' pull requests, helping with issues, and taking part \
in the discussions on the devlist and Slack.
Why we introduced the limit, what it means for you and how to reopen or restore a pull request \
is explained in {doc_url}.
---
Drafted-by: Claude Code (Opus 5); reviewed by @potiuk before posting
"""
@dataclass
class PullRequest:
number: int
title: str
url: str
is_draft: bool
author: str
participants: set[str] = field(default_factory=set)
def run_gh(args: list[str], stdin: str | None = None) -> str:
result = subprocess.run(["gh", *args], input=stdin, capture_output=True, text=True, check=False)
if result.returncode != 0:
console.print(f"[red]gh {' '.join(args)} failed:[/]\n{result.stderr}")
sys.exit(1)
return result.stdout
def get_authenticated_login() -> str:
return run_gh(["api", "user", "--jq", ".login"]).strip()
def get_logins_with_write_access(repo: str) -> set[str]:
"""Return logins with push (write) access - these users are not subject to the limit."""
output = run_gh(
[
"api",
"--paginate",
f"repos/{repo}/collaborators?affiliation=all&permission=push&per_page=100",
"--jq",
".[].login",
]
)
return {login.lower() for login in output.split()}
def extract_logins(connection: dict) -> set[str]:
return {node["author"]["login"].lower() for node in connection["nodes"] if node.get("author")}
def get_open_pull_requests(repo: str) -> list[PullRequest]:
owner, name = repo.split("/")
pull_requests: list[PullRequest] = []
cursor: str | None = None
while True:
args = ["api", "graphql", "-f", f"query={PRS_QUERY}", "-F", f"owner={owner}", "-F", f"name={name}"]
if cursor:
args.extend(["-F", f"cursor={cursor}"])
page = json.loads(run_gh(args))["data"]["repository"]["pullRequests"]
for node in page["nodes"]:
if not node.get("author"):
continue
participants = extract_logins(node["comments"]) | extract_logins(node["reviews"])
for thread in node["reviewThreads"]["nodes"]:
participants |= extract_logins(thread["comments"])
pull_requests.append(
PullRequest(
number=node["number"],
title=node["title"],
url=node["url"],
is_draft=node["isDraft"],
author=node["author"]["login"],
participants=participants,
)
)
console.print(f"Fetched {len(pull_requests)} open PRs...", end="\r")
if not page["pageInfo"]["hasNextPage"]:
break
cursor = page["pageInfo"]["endCursor"]
console.print()
return pull_requests
def is_bot(login: str) -> bool:
return login.endswith("[bot]") or login in {"dependabot", "github-actions"}
def is_maintainer_engaged(pr: PullRequest, maintainers: set[str], triagers: set[str]) -> bool:
engaged = pr.participants & maintainers
engaged.discard(pr.author.lower())
return bool(engaged - triagers)
@dataclass
class AuthorPlan:
author: str
total: int
to_close: list[PullRequest]
kept: list[PullRequest]
def build_plans(
pull_requests: list[PullRequest],
*,
maintainers: set[str],
triagers: set[str],
limit: int,
count_drafts: bool,
only_author: str | None,
) -> list[AuthorPlan]:
by_author: dict[str, list[PullRequest]] = defaultdict(list)
for pr in pull_requests:
login = pr.author.lower()
if login in maintainers or is_bot(login):
continue
if only_author and login != only_author.lower():
continue
by_author[pr.author].append(pr)
plans = []
for author, prs in sorted(by_author.items(), key=lambda item: -len(item[1])):
counted = [pr for pr in prs if count_drafts or not pr.is_draft]
if len(counted) <= limit:
continue
kept = [pr for pr in prs if is_maintainer_engaged(pr, maintainers, triagers)]
kept_numbers = {pr.number for pr in kept}
to_close = [pr for pr in prs if pr.number not in kept_numbers]
if to_close:
plans.append(AuthorPlan(author=author, total=len(prs), to_close=to_close, kept=kept))
return plans
def build_comment(plan: AuthorPlan, limit: int) -> str:
closed_list = "\n".join(f"* #{pr.number} - {pr.title}" for pr in plan.to_close)
kept_section = ""
if plan.kept:
kept_list = "\n".join(f"* #{pr.number} - {pr.title}" for pr in plan.kept)
kept_section = (
"\nThese pull requests stay open because maintainers are already engaged in them - "
f"they count towards your limit:\n\n{kept_list}\n"
)
return COMMENT_TEMPLATE.format(
author=plan.author,
limit=limit,
total=plan.total,
closed_list=closed_list,
kept_section=kept_section,
doc_url=DOC_URL,
)
def print_plans(plans: list[AuthorPlan]) -> None:
table = Table(title="Authors over the open PR limit")
table.add_column("Author")
table.add_column("Open", justify="right")
table.add_column("To close", justify="right")
table.add_column("Kept (maintainer engaged)")
for plan in plans:
table.add_row(
plan.author,
str(plan.total),
str(len(plan.to_close)),
", ".join(f"#{pr.number}" for pr in plan.kept) or "-",
)
console.print(table)
console.print(
f"[bold]{len(plans)}[/] authors, [bold]{sum(len(p.to_close) for p in plans)}[/] PRs to close, "
f"[bold]{sum(len(p.kept) for p in plans)}[/] kept open."
)
def close_pull_requests(repo: str, plans: list[AuthorPlan], *, limit: int, label: str, delay: float) -> None:
run_gh(
[
"label",
"create",
label,
"--repo",
repo,
"--color",
LABEL_COLOR,
"--force",
"--description",
"Closed as a one-time step of introducing the open pull request limit",
]
)
for plan in plans:
comment = build_comment(plan, limit)
for pr in plan.to_close:
run_gh(["pr", "edit", str(pr.number), "--repo", repo, "--add-label", label])
run_gh(["pr", "close", str(pr.number), "--repo", repo, "--comment", comment])
console.print(f"Closed {pr.url}")
# GitHub's secondary rate limits throttle bursts of content-creating requests.
time.sleep(delay)
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("--repo", default="apache/airflow")
parser.add_argument("--limit", type=int, default=5)
parser.add_argument(
"--count-drafts",
action="store_true",
help="Count draft PRs towards the limit (GitHub does not count them yet).",
)
parser.add_argument(
"--triager",
action="append",
default=[],
help="Maintainer whose engagement does not count, e.g. triage comments. "
"Can be repeated. Defaults to the authenticated gh user.",
)
parser.add_argument("--author", help="Only handle this author - useful to try the closure on one person.")
parser.add_argument("--label", default=DEFAULT_LABEL)
parser.add_argument("--delay", type=float, default=2.0, help="Seconds to wait between closures.")
parser.add_argument("--show-comment", action="store_true", help="Print the comment for each author.")
parser.add_argument("--execute", action="store_true", help="Actually close PRs (default is dry-run).")
args = parser.parse_args()
triagers = {login.lower() for login in (args.triager or [get_authenticated_login()])}
maintainers = get_logins_with_write_access(args.repo)
console.print(
f"{len(maintainers)} users with write access; triage engagement ignored for: {sorted(triagers)}"
)
plans = build_plans(
get_open_pull_requests(args.repo),
maintainers=maintainers,
triagers=triagers,
limit=args.limit,
count_drafts=args.count_drafts,
only_author=args.author,
)
print_plans(plans)
if args.show_comment:
for plan in plans:
console.rule(plan.author)
console.print(build_comment(plan, args.limit), markup=False)
if not args.execute:
console.print("[yellow]Dry run - nothing was changed. Pass --execute to close the PRs.[/]")
return
close_pull_requests(args.repo, plans, limit=args.limit, label=args.label, delay=args.delay)
if __name__ == "__main__":
main()