mirror of
https://github.com/carbon-language/carbon-lang.git
synced 2026-09-24 21:10:12 +01:00
258 lines
7.4 KiB
Python
Executable File
258 lines
7.4 KiB
Python
Executable File
#!/usr/bin/env -S uv run --script
|
|
|
|
# /// script
|
|
# requires-python = ">=3.12"
|
|
# dependencies = [
|
|
# "gql>=2.0.0,<3.0.0",
|
|
# ]
|
|
# ///
|
|
|
|
|
|
"""Gather Carbon review stats, prints to CSV."""
|
|
|
|
__copyright__ = """
|
|
Part of the Carbon Language project, under the Apache License v2.0 with LLVM
|
|
Exceptions. See /LICENSE for license information.
|
|
SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
|
|
"""
|
|
|
|
import argparse
|
|
import datetime
|
|
import os
|
|
import sys
|
|
from typing import Any, Optional
|
|
|
|
# https://pypi.org/project/gql/
|
|
import gql
|
|
from gql.transport.requests import RequestsHTTPTransport
|
|
|
|
# The main query. We request all pullRequests in the repository, along with a
|
|
# number of different events on each pullRequest. The request is paginated, and
|
|
# we use the pageInfo to determine what the `cursor` should be for the next
|
|
# request.
|
|
_QUERY = """
|
|
{
|
|
repository(owner:"carbon-language", name:"carbon-lang") {
|
|
pullRequests(
|
|
first:100,
|
|
states: MERGED,
|
|
labels:"toolchain"
|
|
%(cursor)s
|
|
) {
|
|
pageInfo {
|
|
hasNextPage
|
|
endCursor
|
|
}
|
|
edges {
|
|
node {
|
|
... on PullRequest {
|
|
number
|
|
author {
|
|
login
|
|
}
|
|
createdAt
|
|
mergedAt
|
|
timelineItems(first: 250) {
|
|
nodes {
|
|
__typename
|
|
... on ReadyForReviewEvent {
|
|
actor {
|
|
login
|
|
}
|
|
createdAt
|
|
}
|
|
... on ReviewRequestedEvent {
|
|
actor {
|
|
login
|
|
}
|
|
createdAt
|
|
requestedReviewer {
|
|
... on User {
|
|
login
|
|
}
|
|
}
|
|
}
|
|
... on IssueComment {
|
|
author {
|
|
login
|
|
}
|
|
createdAt
|
|
}
|
|
... on PullRequestReview {
|
|
author {
|
|
login
|
|
}
|
|
createdAt
|
|
state
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}"""
|
|
|
|
|
|
_ENV_TOKEN = "GITHUB_ACCESS_TOKEN"
|
|
|
|
|
|
class PRInfo:
|
|
number: int
|
|
author: str
|
|
created_at: datetime.datetime
|
|
ready_for_review_at: datetime.datetime
|
|
merged_at: datetime.datetime
|
|
autoassignee: Optional[str] = None
|
|
first_comment_user: Optional[str] = None
|
|
first_comment_at: Optional[datetime.datetime] = None
|
|
first_approval_user: Optional[str] = None
|
|
first_approval_at: Optional[datetime.datetime] = None
|
|
|
|
def __init__(
|
|
self,
|
|
number: int,
|
|
author: str,
|
|
created_at: datetime.datetime,
|
|
merged_at: datetime.datetime,
|
|
):
|
|
self.number = number
|
|
self.author = author
|
|
self.created_at = created_at
|
|
self.ready_for_review_at = created_at
|
|
self.merged_at = merged_at
|
|
|
|
|
|
def _parse_args(args: Optional[list[str]] = None) -> argparse.Namespace:
|
|
"""Parses command-line arguments and flags."""
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
access_token = os.environ.get(_ENV_TOKEN, default=None)
|
|
parser.add_argument(
|
|
"--access-token",
|
|
metavar="ACCESS_TOKEN",
|
|
default=access_token,
|
|
required=not access_token,
|
|
help="The access token for use with GitHub. May also be specified in "
|
|
f"the environment as {_ENV_TOKEN}.",
|
|
)
|
|
return parser.parse_args(args=args)
|
|
|
|
|
|
def _time(timestamp: str) -> datetime.datetime:
|
|
return datetime.datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def main() -> None:
|
|
parsed_args = _parse_args()
|
|
|
|
# TODO: This is a gql.transport.aiohttp.AIOHTTPTransport in gql>=3
|
|
transport = RequestsHTTPTransport(
|
|
url="https://api.github.com/graphql",
|
|
headers={"Authorization": "bearer %s" % parsed_args.access_token},
|
|
# TODO: `ssl=True` in gql>=3
|
|
)
|
|
client = gql.Client(
|
|
transport=transport,
|
|
# TODO: `execute_timeout=60` in gql>=3
|
|
)
|
|
|
|
cursor = ""
|
|
|
|
prs = []
|
|
|
|
print("querying PRs...", file=sys.stderr)
|
|
while True:
|
|
retries = 3
|
|
while True:
|
|
try:
|
|
raw_result = client.execute(
|
|
gql.gql(request_string=_QUERY % {"cursor": cursor})
|
|
)
|
|
break
|
|
# TODO: This is a gql.transport.TransportServerError in gql>=3
|
|
except Exception:
|
|
if retries == 0:
|
|
raise
|
|
retries -= 1
|
|
print(f"retrying ({retries} more)...", file=sys.stderr)
|
|
result = raw_result["repository"]["pullRequests"]
|
|
|
|
last = None
|
|
for pr_edge in result["edges"]:
|
|
pr = pr_edge["node"]
|
|
last = pr["number"]
|
|
|
|
pr_info = PRInfo(
|
|
number=int(pr["number"]),
|
|
author=pr["author"]["login"],
|
|
created_at=_time(pr["createdAt"]),
|
|
merged_at=_time(pr["mergedAt"]),
|
|
)
|
|
|
|
for event in pr["timelineItems"]["nodes"]:
|
|
if (
|
|
not pr_info.first_comment_user
|
|
and event["__typename"]
|
|
in ("IssueComment", "PullRequestReview")
|
|
and event["author"]
|
|
and event["author"]["login"]
|
|
not in (
|
|
pr_info.author,
|
|
"CarbonInfraBot",
|
|
"github-actions",
|
|
"gemini-code-assist",
|
|
)
|
|
):
|
|
pr_info.first_comment_user = event["author"]["login"]
|
|
pr_info.first_comment_at = _time(event["createdAt"])
|
|
|
|
if event["__typename"] == "ReadyForReviewEvent":
|
|
pr_info.ready_for_review_at = _time(event["createdAt"])
|
|
|
|
if (
|
|
not pr_info.first_approval_user
|
|
and event["__typename"] == "PullRequestReview"
|
|
and event["state"] == "APPROVED"
|
|
):
|
|
pr_info.first_approval_user = event["author"]["login"]
|
|
pr_info.first_approval_at = _time(event["createdAt"])
|
|
|
|
if (
|
|
not pr_info.autoassignee
|
|
and event["__typename"] == "ReviewRequestedEvent"
|
|
and event["actor"]["login"] == "github-actions"
|
|
):
|
|
pr_info.autoassignee = event["requestedReviewer"]["login"]
|
|
continue
|
|
|
|
prs.append(pr_info)
|
|
|
|
page_info = result["pageInfo"]
|
|
if page_info["hasNextPage"]:
|
|
cursor = f'after: "{page_info["endCursor"]}"'
|
|
print(f"continuing from {last}...", file=sys.stderr)
|
|
else:
|
|
break
|
|
|
|
def for_csv(x: Any) -> str:
|
|
return str(x) if x else ""
|
|
|
|
print(
|
|
"PR#,Author,Created At,Ready For Review At,Merged At,Autoassignee,"
|
|
"First Comment User,First Comment At,First Approval User,"
|
|
"First Approval At"
|
|
)
|
|
for pr in prs:
|
|
print(
|
|
f"{pr.number},{pr.author},{pr.created_at},"
|
|
f"{pr.ready_for_review_at},{pr.merged_at},"
|
|
f"{for_csv(pr.autoassignee)},{for_csv(pr.first_comment_user)},"
|
|
f"{for_csv(pr.first_comment_at)},{for_csv(pr.first_approval_user)},"
|
|
f"{for_csv(pr.first_approval_at)}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|