mirror of
https://github.com/zed-industries/zed.git
synced 2026-08-23 16:04:45 +00:00
- Notify the author when their PR has been in draft state with no new commits for three weeks - Close the draft PRs that didn't get updated after one more week - Re-try the cla-bot check on PRs that are still unsigned after a week - Close the PRs that are still unsigned after the re-try ## Testing It works on my machine™ ``` python script/github-pr-cleanup.py --dry-run Checking 713 open pull requests Dry-run mode: no comments or closures will be made PR #60885: asking the CLA bot to check again Would comment on PR #60885: @cla-bot check PR #61625: asking the CLA bot to check again Would comment on PR #61625: @cla-bot check PR #57239: warning about a stale draft Would comment on PR #57239: <!-- zed-community-automation:stale-draft-warning --> This pull request has remained in draft without new commits for three weeks. If it remains a draft without new commits for another week, it will be closed automatically. PR #57241: warning about a stale draft Would comment on PR #57241: <!-- zed-community-automation:stale-draft-warning --> This pull request has remained in draft without new commits for three weeks. If it remains a draft without new commits for another week, it will be closed automatically. PR #61461: warning about a stale draft Would comment on PR #61461: <!-- zed-community-automation:stale-draft-warning --> This pull request has remained in draft without new commits for three weeks. If it remains a draft without new commits for another week, it will be closed automatically. PR #61722: warning about a stale draft Would comment on PR #61722: <!-- zed-community-automation:stale-draft-warning --> This pull request has remained in draft without new commits for three weeks. If it remains a draft without new commits for another week, it will be closed automatically. PR #61808: warning about a stale draft Would comment on PR #61808: <!-- zed-community-automation:stale-draft-warning --> This pull request has remained in draft without new commits for three weeks. If it remains a draft without new commits for another week, it will be closed automatically. PR #61809: warning about a stale draft Would comment on PR #61809: <!-- zed-community-automation:stale-draft-warning --> This pull request has remained in draft without new commits for three weeks. If it remains a draft without new commits for another week, it will be closed automatically. Cleanup complete: 0 unsigned PR closures, 6 draft warnings, 0 draft closures ``` Release Notes: - N/A
406 lines
13 KiB
Python
406 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Clean up the community pull request queue:
|
|
- retry CLA check and close PRs if that didn't help
|
|
- close stale draft PRs
|
|
|
|
Requires: requests
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
from functools import cache
|
|
|
|
import requests
|
|
|
|
GITHUB_API_URL = "https://api.github.com"
|
|
REPO_OWNER = "zed-industries"
|
|
REPO_NAME = "zed"
|
|
CLA_SIGNED_LABEL = "cla-signed"
|
|
|
|
DRAFT_WARNING_AFTER = timedelta(days=21)
|
|
DRAFT_CLOSE_AFTER_WARNING = timedelta(days=7)
|
|
CLA_RETRY_AFTER = timedelta(days=7)
|
|
CLA_CLOSE_AFTER_RETRY = timedelta(days=1)
|
|
|
|
DRAFT_WARNING_MARKER = "<!-- zed-community-automation:stale-draft-warning -->"
|
|
DRAFT_CLOSE_MARKER = "<!-- zed-community-automation:close-reason=stale_draft -->"
|
|
CLA_CLOSE_MARKER = "<!-- zed-community-automation:close-reason=CLA-not-signed -->"
|
|
COMMUNITY_BOT_LOGIN = "zed-community-bot[bot]"
|
|
|
|
DRAFT_WARNING_COMMENT = f"""{DRAFT_WARNING_MARKER}
|
|
|
|
This pull request has remained in draft without new commits for three weeks.
|
|
If it remains a draft without new commits for another week, it will be closed
|
|
automatically."""
|
|
|
|
DRAFT_CLOSE_COMMENT = f"""{DRAFT_CLOSE_MARKER}
|
|
|
|
Closing this pull request because it remained in draft without new commits for
|
|
another week after the ping above.
|
|
|
|
We close long-running drafts to keep the pull request queue focused on actionable work
|
|
and to avoid accumulating conflicts with `main`. If you want to continue this work,
|
|
please open a new pull request when it is ready for review."""
|
|
|
|
CLA_RETRY_COMMENT = "@cla-bot check"
|
|
|
|
CLA_CLOSE_COMMENT = f"""{CLA_CLOSE_MARKER}
|
|
|
|
Closing this pull request because Zed's contribution requirements have not been
|
|
met: the Contributor License Agreement is unsigned after seven days and a
|
|
final automated re-check.
|
|
|
|
We need a signed CLA before we can review or merge a contribution. If you would like
|
|
to continue, please sign the CLA at https://zed.dev/cla and open a new pull request.
|
|
If the CLA bot does not recognize your completed signature, mention that in the
|
|
new pull request so a maintainer can help."""
|
|
|
|
RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
MAX_RETRIES = 3
|
|
RETRY_DELAY_SECONDS = 5
|
|
|
|
GITHUB_HEADERS = {}
|
|
|
|
|
|
def parse_datetime(value):
|
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
|
|
|
|
def github_graphql(query, variables):
|
|
for attempt in range(MAX_RETRIES + 1):
|
|
response = requests.post(
|
|
f"{GITHUB_API_URL}/graphql",
|
|
headers=GITHUB_HEADERS,
|
|
json={"query": query, "variables": variables},
|
|
timeout=30,
|
|
)
|
|
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
time.sleep(RETRY_DELAY_SECONDS)
|
|
continue
|
|
response.raise_for_status()
|
|
result = response.json()
|
|
if "errors" in result:
|
|
raise RuntimeError(f"GraphQL error: {result['errors']}")
|
|
return result["data"]
|
|
raise RuntimeError("github_graphql: retry loop exited without return")
|
|
|
|
|
|
def github_rest_request(method, path, body=None):
|
|
for attempt in range(MAX_RETRIES + 1):
|
|
response = requests.request(
|
|
method, f"{GITHUB_API_URL}/{path}", headers=GITHUB_HEADERS, json=body, timeout=30)
|
|
if response.status_code in RETRYABLE_STATUS_CODES and attempt < MAX_RETRIES:
|
|
time.sleep(RETRY_DELAY_SECONDS)
|
|
continue
|
|
response.raise_for_status()
|
|
if response.status_code == 204 or not response.content:
|
|
return None
|
|
return response.json()
|
|
raise RuntimeError("github_rest_request: retry loop exited without return")
|
|
|
|
|
|
def github_rest_get_paginated(path):
|
|
results = []
|
|
page = 1
|
|
while True:
|
|
separator = "&" if "?" in path else "?"
|
|
batch = github_rest_request("GET", f"{path}{separator}per_page=100&page={page}")
|
|
if not batch:
|
|
return results
|
|
results.extend(batch)
|
|
if len(batch) < 100:
|
|
return results
|
|
page += 1
|
|
|
|
|
|
def fetch_open_pull_requests():
|
|
pull_requests = []
|
|
cursor = None
|
|
while True:
|
|
data = github_graphql(
|
|
"""
|
|
query($owner: String!, $repo: String!, $cursor: String) {
|
|
repository(owner: $owner, name: $repo) {
|
|
pullRequests(states: OPEN, first: 100, after: $cursor) {
|
|
pageInfo { hasNextPage endCursor }
|
|
nodes {
|
|
number
|
|
state
|
|
isDraft
|
|
createdAt
|
|
author { __typename login }
|
|
labels(first: 100) { nodes { name } }
|
|
commits(last: 1) {
|
|
nodes {
|
|
commit { pushedDate committedDate }
|
|
}
|
|
}
|
|
timelineItems(
|
|
last: 20,
|
|
itemTypes: [CONVERT_TO_DRAFT_EVENT, READY_FOR_REVIEW_EVENT]
|
|
) {
|
|
nodes {
|
|
__typename
|
|
... on ConvertToDraftEvent { createdAt }
|
|
... on ReadyForReviewEvent { createdAt }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
""",
|
|
{"owner": REPO_OWNER, "repo": REPO_NAME, "cursor": cursor},
|
|
)
|
|
page = data["repository"]["pullRequests"]
|
|
pull_requests.extend(page["nodes"])
|
|
if not page["pageInfo"]["hasNextPage"]:
|
|
return pull_requests
|
|
cursor = page["pageInfo"]["endCursor"]
|
|
|
|
|
|
@cache
|
|
def fetch_comments(number):
|
|
return github_rest_get_paginated(
|
|
f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{number}/comments"
|
|
)
|
|
|
|
|
|
def post_comment(number, body, dry_run):
|
|
if dry_run:
|
|
print(f" Would comment on PR #{number}:\n{body}\n")
|
|
else:
|
|
github_rest_request(
|
|
"POST",
|
|
f"repos/{REPO_OWNER}/{REPO_NAME}/issues/{number}/comments",
|
|
{"body": body},
|
|
)
|
|
|
|
|
|
def close_pull_request(number, dry_run):
|
|
if dry_run:
|
|
print(f" Would close PR #{number}")
|
|
else:
|
|
github_rest_request(
|
|
"PATCH",
|
|
f"repos/{REPO_OWNER}/{REPO_NAME}/pulls/{number}",
|
|
{"state": "closed"},
|
|
)
|
|
|
|
|
|
def label_names(pull_request):
|
|
return {
|
|
label["name"].lower()
|
|
for label in (pull_request.get("labels") or {}).get("nodes", [])
|
|
}
|
|
|
|
|
|
def latest_commit_at(pull_request):
|
|
commits = (pull_request.get("commits") or {}).get("nodes", [])
|
|
if not commits:
|
|
return None
|
|
commit = commits[-1]["commit"]
|
|
timestamp = commit.get("pushedDate") or commit.get("committedDate")
|
|
return parse_datetime(timestamp) if timestamp else None
|
|
|
|
|
|
def current_draft_started_at(pull_request):
|
|
if pull_request.get("state") != "OPEN" or not pull_request.get("isDraft"):
|
|
return None
|
|
|
|
transitions = (pull_request.get("timelineItems") or {}).get("nodes", [])
|
|
if not transitions:
|
|
return parse_datetime(pull_request["createdAt"])
|
|
|
|
latest_transition = max(
|
|
transitions, key=lambda transition: parse_datetime(transition["createdAt"])
|
|
)
|
|
if latest_transition["__typename"] != "ConvertToDraftEvent":
|
|
return None
|
|
return parse_datetime(latest_transition["createdAt"])
|
|
|
|
|
|
def latest_draft_activity_at(pull_request):
|
|
draft_started_at = current_draft_started_at(pull_request)
|
|
if draft_started_at is None:
|
|
return None
|
|
commit_at = latest_commit_at(pull_request)
|
|
if commit_at is None:
|
|
return draft_started_at
|
|
return max(draft_started_at, commit_at)
|
|
|
|
|
|
def latest_marker_comment_at(comments, marker, not_before=None):
|
|
latest_timestamp = None
|
|
for comment in comments:
|
|
if marker not in (comment.get("body") or ""):
|
|
continue
|
|
timestamp = parse_datetime(comment["created_at"])
|
|
if not_before is not None and timestamp < not_before:
|
|
continue
|
|
if latest_timestamp is None or timestamp > latest_timestamp:
|
|
latest_timestamp = timestamp
|
|
return latest_timestamp
|
|
|
|
|
|
def find_stale_drafts(pull_requests, now):
|
|
stale_drafts = []
|
|
for pull_request in pull_requests:
|
|
activity_at = latest_draft_activity_at(pull_request)
|
|
if activity_at is not None and now - activity_at >= DRAFT_WARNING_AFTER:
|
|
stale_drafts.append((pull_request, activity_at))
|
|
return stale_drafts
|
|
|
|
|
|
def draft_action(activity_at, comments, now):
|
|
# A previous run may have posted the explanation but failed to close the PR.
|
|
if latest_marker_comment_at(comments, DRAFT_CLOSE_MARKER, activity_at):
|
|
return None, True
|
|
|
|
warning_at = latest_marker_comment_at(
|
|
comments, DRAFT_WARNING_MARKER, activity_at
|
|
)
|
|
if warning_at is not None:
|
|
if now - warning_at >= DRAFT_CLOSE_AFTER_WARNING:
|
|
return DRAFT_CLOSE_COMMENT, True
|
|
return None
|
|
return DRAFT_WARNING_COMMENT, False
|
|
|
|
|
|
def is_bare_cla_check(comment_body):
|
|
return " ".join((comment_body or "").lower().split()) == "@cla-bot check"
|
|
|
|
|
|
def author_has_comment_other_than_cla_check(comments, author):
|
|
return any(
|
|
(comment.get("user") or {}).get("login", "").lower() == author.lower()
|
|
and not is_bare_cla_check(comment.get("body"))
|
|
for comment in comments
|
|
)
|
|
|
|
|
|
def latest_cla_retry_at(comments):
|
|
return max(
|
|
(
|
|
parse_datetime(comment["created_at"])
|
|
for comment in comments
|
|
if (comment.get("user") or {}).get("login", "").lower()
|
|
== COMMUNITY_BOT_LOGIN
|
|
and is_bare_cla_check(comment.get("body"))
|
|
),
|
|
default=None,
|
|
)
|
|
|
|
|
|
def cla_action(author, comments, now):
|
|
if author_has_comment_other_than_cla_check(comments, author):
|
|
return None
|
|
|
|
retry_at = latest_cla_retry_at(comments)
|
|
if retry_at is None:
|
|
return CLA_RETRY_COMMENT, False
|
|
if now - retry_at < CLA_CLOSE_AFTER_RETRY:
|
|
return None
|
|
# A previous run may have posted the explanation but failed to close the PR.
|
|
if latest_marker_comment_at(comments, CLA_CLOSE_MARKER) is not None:
|
|
return None, True
|
|
return CLA_CLOSE_COMMENT, True
|
|
|
|
|
|
def needs_cla_follow_up(pull_request, now):
|
|
author = pull_request.get("author") or {}
|
|
return (
|
|
now - parse_datetime(pull_request["createdAt"]) >= CLA_RETRY_AFTER
|
|
and author.get("__typename") == "User"
|
|
and bool(author.get("login"))
|
|
and CLA_SIGNED_LABEL not in label_names(pull_request)
|
|
)
|
|
|
|
|
|
def retry_or_close_unsigned_pull_requests(pull_requests, now, dry_run):
|
|
closed = 0
|
|
for pull_request in pull_requests:
|
|
number = pull_request["number"]
|
|
comments = fetch_comments(number)
|
|
action = cla_action(pull_request["author"]["login"], comments, now)
|
|
if action is None:
|
|
continue
|
|
comment, should_close = action
|
|
if should_close:
|
|
print(f"PR #{number}: closing because the CLA is unsigned")
|
|
if comment is not None:
|
|
post_comment(number, comment, dry_run)
|
|
close_pull_request(number, dry_run)
|
|
closed += 1
|
|
else:
|
|
print(f"PR #{number}: asking the CLA bot to check again")
|
|
post_comment(number, comment, dry_run)
|
|
return closed
|
|
|
|
|
|
def warn_or_close_stale_draft_pull_requests(stale_drafts, now, dry_run):
|
|
warned = closed = 0
|
|
for pull_request, activity_at in stale_drafts:
|
|
number = pull_request["number"]
|
|
comments = fetch_comments(number)
|
|
action = draft_action(activity_at, comments, now)
|
|
if action is None:
|
|
continue
|
|
comment, should_close = action
|
|
if should_close:
|
|
print(f"PR #{number}: closing a stale draft")
|
|
if comment is not None:
|
|
post_comment(number, comment, dry_run)
|
|
close_pull_request(number, dry_run)
|
|
closed += 1
|
|
else:
|
|
print(f"PR #{number}: warning about a stale draft")
|
|
post_comment(number, comment, dry_run)
|
|
warned += 1
|
|
return warned, closed
|
|
|
|
|
|
def run(now, dry_run):
|
|
pull_requests = fetch_open_pull_requests()
|
|
print(f"Checking {len(pull_requests)} open pull requests")
|
|
if dry_run:
|
|
print("Dry-run mode: no comments or closures will be made")
|
|
|
|
unsigned_pull_requests_closed = retry_or_close_unsigned_pull_requests(
|
|
[
|
|
pull_request
|
|
for pull_request in pull_requests
|
|
if needs_cla_follow_up(pull_request, now)
|
|
],
|
|
now,
|
|
dry_run,
|
|
)
|
|
draft_warned, draft_closed = warn_or_close_stale_draft_pull_requests(
|
|
find_stale_drafts(pull_requests, now), now, dry_run
|
|
)
|
|
print(
|
|
"Cleanup complete: "
|
|
f"{unsigned_pull_requests_closed} unsigned PR closures, "
|
|
f"{draft_warned} draft warnings, "
|
|
f"{draft_closed} draft closures"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
argument_parser = argparse.ArgumentParser()
|
|
argument_parser.add_argument(
|
|
"--dry-run",
|
|
action="store_true",
|
|
help="Report actions without posting comments or closing pull requests",
|
|
)
|
|
arguments = argument_parser.parse_args()
|
|
|
|
GITHUB_HEADERS = {
|
|
"Authorization": f"Bearer {os.environ['GITHUB_TOKEN']}",
|
|
"Accept": "application/vnd.github+json",
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
}
|
|
run(datetime.now(timezone.utc), arguments.dry_run)
|