Add Chromium-only Blender WebEngine parity work
This commit is contained in:
510
blender-5.2.0/tools/triage/weekly_report.py
Executable file
510
blender-5.2.0/tools/triage/weekly_report.py
Executable file
@@ -0,0 +1,510 @@
|
||||
#!/usr/bin/env python3
|
||||
# SPDX-FileCopyrightText: 2023 Blender Authors
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
"""
|
||||
# Generates the weekly report containing information on:
|
||||
# - Pull Requests created,
|
||||
# - Pull Requests revised,
|
||||
# - Issues closed,
|
||||
# - Issues confirmed,
|
||||
# - Commits,
|
||||
|
||||
Example usage:
|
||||
|
||||
python ./weekly_report.py --username mano-wii
|
||||
"""
|
||||
__all__ = (
|
||||
"main",
|
||||
)
|
||||
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from gitea_utils import (
|
||||
gitea_json_activities_get,
|
||||
gitea_json_pull_request_by_base_and_head_get,
|
||||
gitea_json_issue_events_filter,
|
||||
gitea_json_issue_get,
|
||||
gitea_user_get, git_username_detect,
|
||||
)
|
||||
|
||||
from typing import (
|
||||
Any,
|
||||
)
|
||||
from collections.abc import (
|
||||
Iterable,
|
||||
)
|
||||
|
||||
# Support piping the output to a file or process.
|
||||
IS_ATTY = sys.stdout.isatty()
|
||||
|
||||
|
||||
if IS_ATTY:
|
||||
def print_progress(text: str) -> None:
|
||||
# The trailing space clears the previous output.
|
||||
term_width = shutil.get_terminal_size(fallback=(80, 20))[0]
|
||||
|
||||
if (space := term_width - len(text)) > 0:
|
||||
text = text + (" " * space)
|
||||
print(text, end="\r", flush=True)
|
||||
else:
|
||||
def print_progress(text: str) -> None:
|
||||
del text
|
||||
|
||||
|
||||
def argparse_create() -> argparse.ArgumentParser:
|
||||
|
||||
def str_as_isodate(value: str) -> datetime.datetime:
|
||||
try:
|
||||
value_as_date = datetime.datetime.fromisoformat(value)
|
||||
except Exception as ex:
|
||||
raise argparse.ArgumentTypeError("Must be a valid ISO date (YYYY-MM-DD), failed: {!s}".format(ex))
|
||||
return value_as_date
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate Weekly Report",
|
||||
epilog="This script is typically used to help write weekly reports",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--username",
|
||||
dest="username",
|
||||
metavar='USERNAME',
|
||||
type=str,
|
||||
required=False,
|
||||
help="",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--weeks-ago",
|
||||
dest="weeks_ago",
|
||||
type=int,
|
||||
default=1,
|
||||
help=(
|
||||
"Determine which week the report should be generated for. 0 means the current week. "
|
||||
"The default is 1, to create a report for the previous week."
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--date",
|
||||
dest="date",
|
||||
type=str_as_isodate,
|
||||
default=None,
|
||||
help="Show only for this day (YYYY-MM-DD), and not for an entire week."
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--hash-length",
|
||||
dest="hash_length",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Number of characters to abbreviate the hash to (0 to disable).",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-v",
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="increase output verbosity",
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def report_personal_weekly_get(
|
||||
username: str,
|
||||
start: datetime.datetime,
|
||||
num_days: int,
|
||||
*,
|
||||
hash_length: int,
|
||||
verbose: bool = True,
|
||||
) -> None:
|
||||
|
||||
data_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def gitea_json_issue_get_cached(issue_fullname: str) -> dict[str, Any]:
|
||||
if issue_fullname not in data_cache:
|
||||
issue = gitea_json_issue_get(issue_fullname)
|
||||
data_cache[issue_fullname] = issue
|
||||
|
||||
return data_cache[issue_fullname]
|
||||
|
||||
pulls_closed: set[str] = set()
|
||||
pulls_commented: set[str] = set()
|
||||
pulls_created: set[str] = set()
|
||||
|
||||
issues_closed: set[str] = set()
|
||||
issues_commented: set[str] = set()
|
||||
issues_created: set[str] = set()
|
||||
|
||||
pulls_reviewed: set[str] = set()
|
||||
|
||||
issues_confirmed: list[str] = []
|
||||
issues_needing_user_info: list[str] = []
|
||||
issues_needing_developer_info: list[str] = []
|
||||
issues_fixed: list[str] = []
|
||||
issues_duplicated: list[str] = []
|
||||
issues_archived: list[str] = []
|
||||
|
||||
@dataclass
|
||||
class Branch:
|
||||
# Name of the repository owning the branch (which can differ from the repository targeted by this branch!)
|
||||
repository_full_name: str
|
||||
commits: list[str]
|
||||
|
||||
@dataclass
|
||||
class PullRequest:
|
||||
title_str: str
|
||||
|
||||
@dataclass
|
||||
class Repository:
|
||||
name: str
|
||||
# Branches targeting this repository. Branch name is key.
|
||||
branches: dict[str, Branch] = field(default_factory=dict)
|
||||
# Pull requests targeting this repository. Key is repository of the branch and the branch name.
|
||||
prs: dict[tuple[str, str], PullRequest] = field(default_factory=dict)
|
||||
|
||||
# Repositories containing any commit activity, identified by full name (e.g. "blender/blender").
|
||||
repositories: dict[str, Repository] = {}
|
||||
|
||||
user_data: dict[str, Any] = gitea_user_get(username)
|
||||
|
||||
for i in range(num_days):
|
||||
date_curr = start + datetime.timedelta(days=i)
|
||||
date_curr_str = date_curr.strftime("%Y-%m-%d")
|
||||
print_progress(f"Requesting activity of {date_curr_str}")
|
||||
for activity in gitea_json_activities_get(username, date_curr_str):
|
||||
op_type = activity["op_type"]
|
||||
if op_type == "close_issue":
|
||||
fullname = activity["repo"]["full_name"] + "/issues/" + activity["content"].split('|')[0]
|
||||
issues_closed.add(fullname)
|
||||
elif op_type == "comment_issue":
|
||||
fullname = activity["repo"]["full_name"] + "/issues/" + activity["content"].split('|')[0]
|
||||
issues_commented.add(fullname)
|
||||
elif op_type == "create_issue":
|
||||
fullname = activity["repo"]["full_name"] + "/issues/" + activity["content"].split('|')[0]
|
||||
issues_created.add(fullname)
|
||||
elif op_type == "merge_pull_request":
|
||||
fullname = activity["repo"]["full_name"] + "/pulls/" + activity["content"].split('|')[0]
|
||||
pulls_closed.add(fullname)
|
||||
elif op_type == "comment_pull":
|
||||
fullname = activity["repo"]["full_name"] + "/pulls/" + activity["content"].split('|')[0]
|
||||
pulls_commented.add(fullname)
|
||||
elif op_type == "create_pull_request":
|
||||
fullname = activity["repo"]["full_name"] + "/pulls/" + activity["content"].split('|')[0]
|
||||
pulls_created.add(fullname)
|
||||
elif op_type in {"approve_pull_request", "reject_pull_request"}:
|
||||
fullname = activity["repo"]["full_name"] + "/pulls/" + activity["content"].split('|')[0]
|
||||
pulls_reviewed.add(fullname)
|
||||
elif op_type == "commit_repo":
|
||||
if (
|
||||
activity["content"] and
|
||||
activity["repo"]["name"] != ".profile"
|
||||
):
|
||||
content_json = json.loads(activity["content"])
|
||||
assert isinstance(content_json, dict)
|
||||
repo = activity["repo"]
|
||||
repo_fullname = repo["full_name"]
|
||||
content_json_commits: list[dict[str, Any]] = content_json["Commits"]
|
||||
for commit_json in content_json_commits:
|
||||
# Skip commits that were not made by this user. Using email doesn't seem to
|
||||
# be possible unfortunately.
|
||||
if commit_json["AuthorName"] != user_data["full_name"]:
|
||||
continue
|
||||
|
||||
title = commit_json["Message"].split('\n', 1)[0]
|
||||
|
||||
if title.startswith("Merge branch "):
|
||||
continue
|
||||
|
||||
hash_value = commit_json["Sha1"]
|
||||
if hash_length > 0:
|
||||
hash_value = hash_value[:hash_length]
|
||||
|
||||
branch_name = activity["ref_name"].removeprefix("refs/heads/")
|
||||
is_release_branch = re.match(r"^blender-v(?:\d+\.\d+)(?:\.\d+)?-release$", branch_name)
|
||||
|
||||
pr = None
|
||||
|
||||
# The PR workflow means branches and PRs are owned by a user's repository instead of the
|
||||
# repository they are made for. For weekly reports it makes more sense to keep all branches and
|
||||
# PRs related to a single repository together, regardless of who happens to own them.
|
||||
#
|
||||
# So the following adds branches and PRs to a "target" repository, not the owning one.
|
||||
target_repo_json = repo.get("parent", repo)
|
||||
target_repo_fullname = target_repo_json["full_name"] if target_repo_json else repo_fullname
|
||||
|
||||
# Substitute occurrences of "#\d+" with "repo#\d+"
|
||||
title = re.sub(r"#(\d+)", rf"{target_repo_fullname}#\1", title)
|
||||
|
||||
if target_repo_fullname not in repositories:
|
||||
repositories[target_repo_fullname] = Repository(target_repo_fullname)
|
||||
target_repo = repositories[target_repo_fullname]
|
||||
|
||||
if branch_name not in target_repo.branches:
|
||||
target_repo.branches[branch_name] = Branch(repo_fullname, [])
|
||||
# If we see this branch for the first time, try to find a PR for it. Only catches PRs made
|
||||
# against the default branch of the target repository.
|
||||
if not is_release_branch and target_repo_json:
|
||||
pr = gitea_json_pull_request_by_base_and_head_get(
|
||||
target_repo_fullname,
|
||||
target_repo_json["default_branch"],
|
||||
f"{repo_fullname}:{branch_name}",
|
||||
)
|
||||
branch = target_repo.branches[branch_name]
|
||||
|
||||
if pr:
|
||||
pr_title = pr["title"]
|
||||
pr_id = pr["number"]
|
||||
target_repo.prs[(repo_fullname, branch_name)
|
||||
] = PullRequest(f"{pr_title} ({target_repo_fullname}!{pr_id})")
|
||||
|
||||
branch.commits.append(f"{title} ({repo_fullname}@{hash_value})")
|
||||
|
||||
date_end = date_curr
|
||||
len_total = len(issues_closed) + len(issues_commented) + len(pulls_commented)
|
||||
process = 0
|
||||
for issue in issues_commented:
|
||||
print_progress("[{:d}%] Checking issue {:s}".format(int(100 * (process / len_total)), issue))
|
||||
process += 1
|
||||
|
||||
issue_events = gitea_json_issue_events_filter(
|
||||
issue,
|
||||
date_start=start,
|
||||
date_end=date_end,
|
||||
username=username,
|
||||
labels={
|
||||
"Status/Confirmed",
|
||||
"Status/Needs Information from User",
|
||||
"Status/Needs Info from Developers"
|
||||
}
|
||||
)
|
||||
|
||||
for event in issue_events:
|
||||
label_name = event["label"]["name"]
|
||||
if label_name == "Status/Confirmed":
|
||||
issues_confirmed.append(issue)
|
||||
elif label_name == "Status/Needs Information from User":
|
||||
issues_needing_user_info.append(issue)
|
||||
elif label_name == "Status/Needs Info from Developers":
|
||||
issues_needing_developer_info.append(issue)
|
||||
|
||||
for issue in issues_closed:
|
||||
print_progress("[{:d}%] Checking issue {:s}".format(int(100 * (process / len_total)), issue))
|
||||
process += 1
|
||||
|
||||
issue_events = gitea_json_issue_events_filter(
|
||||
issue,
|
||||
date_start=start,
|
||||
date_end=date_end,
|
||||
username=username,
|
||||
event_type={"close", "commit_ref"},
|
||||
labels={"Status/Duplicate"},
|
||||
)
|
||||
|
||||
for event in issue_events:
|
||||
event_type = event["type"]
|
||||
if event_type == "commit_ref":
|
||||
issues_fixed.append(issue)
|
||||
elif event_type == "label":
|
||||
issues_duplicated.append(issue)
|
||||
else:
|
||||
issues_archived.append(issue)
|
||||
|
||||
for pull in pulls_commented:
|
||||
print_progress("[{:d}%] Checking pull {:s}".format(int(100 * (process / len_total)), pull))
|
||||
process += 1
|
||||
|
||||
pull_events = gitea_json_issue_events_filter(
|
||||
pull.replace("pulls", "issues"),
|
||||
date_start=start,
|
||||
date_end=date_end,
|
||||
username=username,
|
||||
event_type={"comment"},
|
||||
)
|
||||
|
||||
if pull_events:
|
||||
pull_data = gitea_json_issue_get_cached(pull)
|
||||
if pull_data["user"]["login"] != username:
|
||||
pulls_reviewed.add(pull)
|
||||
|
||||
# Print triaging stats
|
||||
|
||||
issues_involved = issues_closed | issues_commented | issues_created
|
||||
|
||||
# Clear any progress.
|
||||
print_progress("")
|
||||
|
||||
print("**Involved in {:d} reports:**".format(len(issues_involved)))
|
||||
print("* Confirmed: {:d}".format(len(issues_confirmed)))
|
||||
print("* Closed as Resolved: {:d}".format(len(issues_fixed)))
|
||||
print("* Closed as Archived: {:d}".format(len(issues_archived)))
|
||||
print("* Closed as Duplicate: {:d}".format(len(issues_duplicated)))
|
||||
print("* Needs Info from User: {:d}".format(len(issues_needing_user_info)))
|
||||
print("* Needs Info from Developers: {:d}".format(len(issues_needing_developer_info)))
|
||||
print("* Actions total: {:d}".format(len(issues_closed) + len(issues_commented) + len(issues_created)))
|
||||
print()
|
||||
|
||||
# Print review stats
|
||||
def print_pulls(pulls: Iterable[str]) -> None:
|
||||
display_list = []
|
||||
|
||||
for pull in pulls:
|
||||
pull_data = gitea_json_issue_get_cached(pull)
|
||||
owner, repo, _, number = pull.split('/')
|
||||
|
||||
display_list.append({
|
||||
"title": pull_data["title"],
|
||||
"formatted_ref": f"{owner}/{repo}!{number}"
|
||||
})
|
||||
|
||||
# Sort the list by the "title" key. Use .lower() to ensure case insensitiveness.
|
||||
display_list.sort(key=lambda x: x["title"].lower())
|
||||
|
||||
for item in display_list:
|
||||
print(f"* {item['title']} ({item['formatted_ref']})")
|
||||
|
||||
print("**Review: {:d}**".format(len(pulls_reviewed)))
|
||||
print_pulls(pulls_reviewed)
|
||||
print()
|
||||
|
||||
# Print created diffs
|
||||
print("**Created Pull Requests: {:d}**".format(len(pulls_created)))
|
||||
print_pulls(pulls_created)
|
||||
print()
|
||||
|
||||
nice_repo_names = {
|
||||
"blender/blender-developer-docs": "Developer Documentation",
|
||||
"blender/blender-manual": "Blender Manual",
|
||||
}
|
||||
|
||||
def print_repo(repo: Repository, indent_level: int = 0) -> None:
|
||||
# Print main branch commits immediately, no need to add extra section.
|
||||
main_branch = repo.branches.get("main")
|
||||
if main_branch:
|
||||
for commit in main_branch.commits:
|
||||
print("{:s}* {:s}".format(" " * indent_level, commit))
|
||||
|
||||
for branch_name, branch in repo.branches.items():
|
||||
# Main branch already printed above.
|
||||
if branch_name == "main":
|
||||
continue
|
||||
|
||||
pr = repo.prs.get((branch.repository_full_name, branch_name))
|
||||
if pr:
|
||||
print("{:s}* {:s}".format(" " * indent_level, pr.title_str))
|
||||
else:
|
||||
print("{:s}* {:s}:{:s}".format(" " * indent_level, branch.repository_full_name, branch_name))
|
||||
|
||||
for commit in branch.commits:
|
||||
print(" {:s}* {:s}".format(" " * indent_level, commit))
|
||||
|
||||
# Print commits
|
||||
print("**Commits:**")
|
||||
# Print main branch commits from blender/blender first.
|
||||
blender_repo = repositories.get("blender/blender")
|
||||
if blender_repo:
|
||||
print_repo(blender_repo)
|
||||
|
||||
for repo in repositories.values():
|
||||
# Blender repo already handled above.
|
||||
if repo.name == "blender/blender":
|
||||
continue
|
||||
|
||||
# For some repositories we know a nicer name to display (e.g. "blender/blender-manual" -> "Blender Manual")
|
||||
nice_repo_name = nice_repo_names.get(repo.name, repo.name)
|
||||
print(f"* {nice_repo_name}:")
|
||||
print_repo(repo, indent_level=1)
|
||||
|
||||
if verbose:
|
||||
# Debug
|
||||
|
||||
def print_links(issues: Iterable[str]) -> None:
|
||||
for fullname in issues:
|
||||
print(f"https://projects.blender.org/{fullname}")
|
||||
|
||||
print("Debug:")
|
||||
print(f"Activities from {start.isoformat()} to {date_end.isoformat()}:")
|
||||
print()
|
||||
print("Pull Requests Created:")
|
||||
print_links(pulls_created)
|
||||
print("Pull Requests Reviewed:")
|
||||
print_links(pulls_reviewed)
|
||||
print("Issues Confirmed:")
|
||||
print_links(issues_confirmed)
|
||||
print("Issues Closed as Resolved:")
|
||||
print_links(issues_fixed)
|
||||
print("Issues Closed as Archived:")
|
||||
print_links(issues_closed)
|
||||
print("Issues Closed as Duplicate:")
|
||||
print_links(issues_duplicated)
|
||||
print("Issues Needing Info from User:")
|
||||
print_links(issues_needing_user_info)
|
||||
print("Issues Needing Info from Developers:")
|
||||
print_links(issues_needing_developer_info)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
# ----------
|
||||
# Parse Args
|
||||
args = argparse_create().parse_args()
|
||||
username = args.username
|
||||
if not username:
|
||||
username = git_username_detect()
|
||||
if not username:
|
||||
return
|
||||
|
||||
if args.date:
|
||||
num_days = 1 # Show only one day.
|
||||
start_date = args.date
|
||||
start_date_str = start_date.strftime('%B ') + str(start_date.day)
|
||||
|
||||
print(f"## {start_date_str}\n")
|
||||
else:
|
||||
num_days = 7 # Show an entire week.
|
||||
# end_date = datetime.datetime(2020, 3, 14)
|
||||
end_date = datetime.datetime.now() - datetime.timedelta(weeks=(args.weeks_ago - 1))
|
||||
weekday = end_date.weekday()
|
||||
|
||||
# Assuming I am lazy and making this at last moment or even later in worst case
|
||||
if weekday < 2:
|
||||
time_delta = 7 + weekday
|
||||
start_date = end_date - datetime.timedelta(days=time_delta, hours=end_date.hour)
|
||||
end_date -= datetime.timedelta(days=weekday, hours=end_date.hour)
|
||||
else:
|
||||
time_delta = weekday
|
||||
start_date = end_date - datetime.timedelta(days=time_delta, hours=end_date.hour)
|
||||
|
||||
sunday = start_date + datetime.timedelta(days=6)
|
||||
|
||||
# week = start_date.isocalendar()[1]
|
||||
start_date_str = start_date.strftime('%B ') + str(start_date.day)
|
||||
end_date_str = str(sunday.day) if start_date.month == sunday.month else sunday.strftime('%B ') + str(sunday.day)
|
||||
|
||||
print(f"## {start_date_str} - {end_date_str}\n")
|
||||
|
||||
report_personal_weekly_get(
|
||||
username,
|
||||
start_date,
|
||||
num_days,
|
||||
hash_length=args.hash_length,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
# Wait for input to close window.
|
||||
if IS_ATTY:
|
||||
input()
|
||||
Reference in New Issue
Block a user