summaryrefslogtreecommitdiff
path: root/scripts/pr_limit/check_pr.py
blob: 51289d3dc1b2cf35e842f8f95db1960374780a06 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
#!/usr/bin/env python3
"""
Limit open pull requests from new Django contributors.

Close a PR if its author has fewer than five commits on main, has another
open PR created within the past 180 days, and does not have triage, write,
maintain, or admin permissions on the repository.

Required environment variables:
    GITHUB_TOKEN  GitHub API token
    PR_NUMBER     Pull request number
    PR_REPO       Repository in "owner/repo" format

Optional environment variables:
    AUTOCLOSE     Set to "true" to comment and close (default: false)
"""

import logging
import os
import sys
import urllib.error
from datetime import datetime, timedelta, timezone
from http import HTTPStatus

from github import (
    get_comment_ids_containing_marker,
    get_recent_commit_count,
    github_request,
    github_results,
)

GITHUB_PER_PAGE = 100
MIN_COMMITS = 5
LOOKBACK_DAYS = 180  # Approximately six months.
EXEMPT_ROLES = {"triage", "write", "maintain", "admin"}
COMMENT_MARKER = "<!-- django-pr-limit -->"

logger = logging.getLogger(__name__)


def has_exempt_permissions(pr_author, repo, token):
    """Return whether the author has an exempt repository role."""
    try:
        permissions = github_request(
            "GET", f"/collaborators/{pr_author}/permission", token, repo
        )
    except urllib.error.HTTPError as exc:
        code = exc.code
        exc.close()
        if code == HTTPStatus.NOT_FOUND:
            # GitHub may return 404 for a user who is not a collaborator.
            return False
        raise
    return permissions["role_name"] in EXEMPT_ROLES


def get_other_open_prs(pr_number, pr_author, repo, token, cutoff):
    """Return the author's other open PRs created on or after cutoff."""
    matches = []
    for pr in github_results(
        "/issues",
        repo,
        token,
        params={
            "creator": pr_author,
            "state": "open",
            "sort": "created",
            "direction": "desc",
        },
    ):
        if datetime.fromisoformat(pr["created_at"]) < cutoff:
            break
        if pr["number"] != pr_number:
            matches.append(pr["number"])
    return matches


def comment_exists(pr_number, repo, token):
    """Return whether this workflow has already explained the PR limit."""
    return bool(
        get_comment_ids_containing_marker(pr_number, repo, token, marker=COMMENT_MARKER)
    )


def get_closing_message(other_pr_numbers):
    """Build the explanation, including links to the author's existing PRs."""
    other_prs = ", ".join(f"#{number}" for number in sorted(other_pr_numbers))
    return (
        f"{COMMENT_MARKER}\n\n"
        "Thank you for contributing to Django!\n\n"
        f"Since you have fewer than {MIN_COMMITS} commits on Django's `main` "
        "branch and already have an open pull request created within the past "
        f"{LOOKBACK_DAYS} days ({other_prs}), please finish or close that "
        "pull request before opening another. We are closing this pull request "
        "because you have reached this limit.\n\n"
        "Please focus on your existing pull request and any outstanding review "
        "feedback. If you would prefer to work on this issue, you can close "
        "your existing pull request and unassign yourself from its associated "
        "ticket so someone else can take it on and leave a comment here asking "
        "for this pull request to be reopened."
    )


def main(repo, token, pr_number, autoclose=False, now=None):
    pr_number = int(pr_number)
    now = now if now is not None else datetime.now(timezone.utc)
    pr = github_request("GET", f"/pulls/{pr_number}", token, repo)
    if pr["state"] != "open":
        logger.info("PR #%s is already closed -- skipping.", pr_number)
        return 0

    author = pr["user"]["login"]
    if has_exempt_permissions(author, repo, token):
        logger.info("PR #%s author has exempt repository permissions.", pr_number)
        return 0

    commit_count = get_recent_commit_count(
        author, repo, token, since_days=None, max_count=MIN_COMMITS
    )
    if commit_count >= MIN_COMMITS:
        logger.info("PR #%s author meets the commit threshold.", pr_number)
        return 0

    cutoff = now - timedelta(days=LOOKBACK_DAYS)
    others = get_other_open_prs(pr_number, author, repo, token, cutoff)
    if not others:
        logger.info("PR #%s author has no other recent open PRs.", pr_number)
        return 0

    logger.info(
        "PR #%s exceeds the limit; other recent open PRs: %s.",
        pr_number,
        ", ".join(f"#{number}" for number in others),
    )
    if not autoclose:
        logger.info(
            "AUTOCLOSE is disabled -- would comment and close PR #%s.",
            pr_number,
        )
        return 0

    # Do not comment if the target PR was closed while checks were running.
    pr = github_request("GET", f"/pulls/{pr_number}", token, repo)
    if pr["state"] != "open":
        return 0

    # Post the explanation first. If commenting fails, leave the PR open.
    if not comment_exists(pr_number, repo, token):
        github_request(
            "POST",
            f"/issues/{pr_number}/comments",
            token,
            repo,
            {"body": get_closing_message(others)},
        )
    github_request("PATCH", f"/pulls/{pr_number}", token, repo, {"state": "closed"})
    logger.info("Closed PR #%s because its author has reached the limit.", pr_number)
    return 0


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
    sys.exit(
        main(
            repo=os.environ["PR_REPO"],
            token=os.environ["GITHUB_TOKEN"],
            pr_number=os.environ["PR_NUMBER"],
            autoclose=os.environ.get("AUTOCLOSE", "").lower() == "true",
        )
    )