Files
carbon-lang/scripts/gh_review_stats.py
T
Dana Jansens 8928268a95 Add script that pulls review stats from Github (#7444)
The script grabs all PRs and dumps them into CSV format
2026-06-30 23:11:46 +00:00

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()