Add Chromium-only Blender WebEngine parity work

This commit is contained in:
mes123456
2026-08-12 04:47:48 -04:00
commit 9fd26010f6
18225 changed files with 11622124 additions and 0 deletions

View File

@@ -0,0 +1,286 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
r"""
### What this script does
This script looks for Fix #NUMBER commits in the current branch between
a start and end date. It then iterates through each commit figuring
out which issue it fixed and what module that issue belonged too.
Finally it prints the list of modules and the corresponding fix numbers
to terminal.
The steps to use it as as follows:
- Change the terminal to the Blender repository and make sure it's on
the branch you're interested in (E.g. main) and up to date.
- Run the script with `python bug_fixes_per_module.py -s YYYY-MM-DD -e YYYY-MM-DD`
- `-s` and `-e` are the start and end dates you want to checkout.
- Wait for the script to finish.
Limitation:
Because the script is only looking at commits that contain `Fix #NUMBER`
in them, this will not gather a full list of fix commits. It will just
gather a list of commits that fixed reported issues.
Related to this, if the commit message contains the wrong issue number in
`Fix #NUMBER`, then the commit will be sorted improperly.
"""
__all__ = (
"main",
)
import re
import sys
import argparse
import subprocess
import multiprocessing
from typing import Any
from time import time
from gitea_utils import url_json_get, BASE_API_URL
# -----------------------------------------------------------------------------
# Constant used throughout the script
UNKNOWN_MODULE = "UNKNOWN_MODULE"
# -----------------------------------------------------------------------------
# Commit Info Class
class CommitInfo():
def __init__(self, commit_line: str) -> None:
split_message = commit_line.split()
# Commit line is in the format:
# COMMIT_HASH Title of commit
self.hash = split_message[0]
self.fixed_reports: list[str] = []
self.check_full_commit_message_for_fixed_reports()
self.module = UNKNOWN_MODULE
def check_full_commit_message_for_fixed_reports(self) -> None:
command = ['git', 'show', '-s', '--format=%B', self.hash]
command_output = subprocess.run(command, capture_output=True, check=True).stdout.decode('utf-8')
if "revert" in command_output.lower():
# If "revert" is the commit message, then it's probably a revert commit and didn't fix a issue.
return
# Find every instance of #NUMBER. These are the report that the commit claims to fix.
issue_match = re.findall(r'#(\d+)\b', command_output)
if issue_match:
self.fixed_reports = issue_match
def get_module(self, labels: list[dict[Any, Any]]) -> str:
# Figures out what module the report that was fixed belongs too.
for label in labels:
if "module" in label['name'].lower():
# Module labels are typically in the format Module/NAME.
return " ".join(label['name'].split("/")[1:])
return UNKNOWN_MODULE
def classify(self) -> bool:
commit_was_sorted = False
for report_number in self.fixed_reports:
report_information = url_json_get(f"{BASE_API_URL}/repos/blender/blender/issues/{report_number}")
if report_information is None:
# It might be `None` if bug report has been deleted.
continue
if isinstance(report_information, list):
# List type is the wrong format.
continue
if "pull" in report_information['html_url']:
# Pull requests aren't bug reports. So skip processing it.
continue
# The commit didn't exit early due to the criteria above, so it was correctly sorted.
commit_was_sorted = True
module = self.get_module(report_information['labels'])
if module != UNKNOWN_MODULE:
self.module = module
break
return commit_was_sorted
# -----------------------------------------------------------------------------
# Argument Parsing
def argparse_create() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawTextHelpFormatter)
parser.add_argument(
"-s",
"--start",
required=True,
help=(
"Date to start checking commits from. Must be in the format YYYY-MM-DD."
),
)
parser.add_argument(
"-e",
"--end",
required=True,
help=(
"Date to stop checking commits. Must be in the format YYYY-MM-DD."
),
)
parser.add_argument(
"-j",
"--jobs",
type=int,
default=0,
help=(
"Number of threads to use when processing commit messages "
"(Only really useful for debugging)."
),
)
return parser
def validate_arguments(args: argparse.Namespace) -> bool:
def valid_date(date_string: str) -> bool:
if len(date_string) == 0:
print("Date is missing")
print(date_string)
return False
split_date = date_string.split("-")
if len(split_date) != 3:
print("Date has too many or too few sections. It should be in the format YYYY-MM-DD")
print(date_string)
return False
return True
if not (valid_date(args.start) and valid_date(args.end)):
return False
return True
# -----------------------------------------------------------------------------
def setup_commit_info(commit: str) -> CommitInfo | None:
commit_information = CommitInfo(commit)
if len(commit_information.fixed_reports) > 0:
return commit_information
return None
def get_fix_commits(start_date: str, end_date: str, jobs: int) -> list[CommitInfo]:
command = [
'git',
'--no-pager',
'log',
'--oneline',
'--no-abbrev-commit',
f'--since={start_date}',
f'--until={end_date}',
'-i',
'-P',
'--grep',
r'Fix.*#+\d+',
]
git_log_command_output = subprocess.run(command, capture_output=True, check=True).stdout.decode('utf-8')
git_log_output = git_log_command_output.splitlines()
# Gathering a list of commits is not compute intensive, it is time consuming due to hundreds of git log calls.
# Multiprocessing can significantly reduce the time taken (E.g. 19s -> 4s on a 32 thread CPU).
with multiprocessing.Pool(processes=jobs) as pool:
list_of_commits = pool.map(setup_commit_info, git_log_output)
return [commit for commit in list_of_commits if commit is not None]
# -----------------------------------------------------------------------------
def classify_commits(list_of_commits: list[CommitInfo]) -> list[CommitInfo]:
number_of_commits = len(list_of_commits)
print("Identifying which module the fix should be assigned too.")
print("This requires querying information from Gitea which may take a while.\n")
start_time = time()
new_list_of_commits: list[CommitInfo] = []
for i, commit in enumerate(list_of_commits, 1):
# Progress bar.
print(
f"{i}/{number_of_commits} - Estimated time remaining:",
f"{(((time() - start_time) / i) * (number_of_commits - i)) / 60:.1f} minutes",
end="\r",
flush=True
)
if commit.classify():
# Only add commit to list if it was sorted.
# If it wasn't sorted, then it probably means the commit "fixed" a pull request.
new_list_of_commits.append(commit)
# Print so we're away from the progress bar.
print("\n\n\n")
return new_list_of_commits
# -----------------------------------------------------------------------------
def print_info(list_of_commits: list[CommitInfo], start_date: str, end_date: str) -> None:
print(f"Between {start_date} and {end_date}, there were a total of {len(list_of_commits)} Fix #NUMBER commits.")
print("These are the numbers per module:\n")
dict_of_modules_and_commits: dict[str, list[CommitInfo]] = {}
for commit in list_of_commits:
dict_of_modules_and_commits.setdefault(commit.module, []).append(commit)
dict_of_modules_and_commits = dict(sorted(dict_of_modules_and_commits.items()))
for module in dict_of_modules_and_commits:
if module == UNKNOWN_MODULE:
continue
print(f"{module}: {len(dict_of_modules_and_commits[module])}")
if UNKNOWN_MODULE in dict_of_modules_and_commits:
unknown_commits = dict_of_modules_and_commits[UNKNOWN_MODULE]
print(f"\nUnknown: {len(unknown_commits)}")
print("Here is a list of the commits with unknown modules.")
print("Go through each of the commit messages, find the bug reports they fixed, then update the module label.")
for commit in unknown_commits:
print(f"https://projects.blender.org/blender/blender/commit/{commit.hash}")
# -----------------------------------------------------------------------------
def main() -> int:
args = argparse_create().parse_args()
if not validate_arguments(args):
return 0
jobs = multiprocessing.cpu_count() if args.jobs < 1 else args.jobs
list_of_commits = get_fix_commits(args.start, args.end, jobs)
list_of_commits = classify_commits(list_of_commits)
print_info(list_of_commits, args.start, args.end)
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
# Simple module for inspecting GITEA users, pulls and issues.
__all__ = (
"git_username_detect",
"gitea_json_activities_get",
"gitea_json_pull_request_by_base_and_head_get",
"gitea_json_issue_events_filter",
"gitea_json_issue_get",
"gitea_json_issues_search",
"gitea_user_get",
)
import os
import datetime
import json
import urllib.error
import urllib.parse
import urllib.request
from typing import (
Any,
)
API_TOKEN_ENV = 'GITEA_API_TOKEN'
BASE_API_URL = "https://projects.blender.org/api/v1"
def url_json_get(url: str, quiet: bool = False) -> dict[str, Any] | list[dict[str, Any]] | None:
request = urllib.request.Request(url)
# If the token environment variable is set, add the `Authorization` header to the request
token = os.environ.get(API_TOKEN_ENV)
if token:
request.add_header('Authorization', "token " + token)
try:
# Make the HTTP request and store the response in a 'response' object
response = urllib.request.urlopen(request)
except urllib.error.URLError as ex:
if not quiet:
print(url)
print("Error making HTTP request:", ex)
return None
# Convert the response content to a JSON object containing the user information.
result = json.loads(response.read())
assert result is None or isinstance(result, (dict, list))
return result
def url_json_get_all_pages(
url: str,
verbose: bool = False,
) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
page = 1
while True:
if verbose:
print(f"Requesting page {page}", end="\r", flush=True)
if page == 1:
# XXX: In some cases, a bug prevents using the `page` and `limit` parameters if the page is 1
result_page = url_json_get(url)
else:
separator = '&' if urllib.parse.urlparse(url).query else '?'
result_page = url_json_get(f"{url}{separator}page={page}")
if not result_page:
break
assert isinstance(result_page, list)
result.extend(result_page)
if len(result_page) == 0:
break
page += 1
return result
def gitea_user_get(username: str) -> dict[str, Any]:
"""
Get the user data as JSON from the user name. https://docs.gitea.com/api/next/#tag/user/operation/userGet
"""
url = f"{BASE_API_URL}/users/{username}"
result = url_json_get(url)
assert isinstance(result, dict)
return result
def gitea_json_issue_get(issue_fullname: str) -> dict[str, Any]:
"""
Get issue/pull JSON data.
:param issue_fullname: string in the format "{owner}/{repo}/issues/{number}"
"""
url = f"{BASE_API_URL}/repos/{issue_fullname}"
result = url_json_get(url)
assert isinstance(result, dict)
return result
def gitea_json_activities_get(username: str, date: str) -> list[dict[str, Any]]:
"""
List a user's activity feeds.
:param username: username of user.
:param date: the date of the activities to be found.
"""
activity_url = f"{BASE_API_URL}/users/{username}/activities/feeds?only-performed-by=true&date={date}"
result = url_json_get_all_pages(activity_url)
assert isinstance(result, list)
return result
def gitea_json_pull_request_by_base_and_head_get(repo_name: str, base: str, head: str) -> dict[str, Any] | None:
"""
Get a pull request by base and head
:param repo_name: Full name of the repository, e.g. "blender/blender".
:param base: Target branch of the PR (branch it wants to merge into), e.g. "main".
:param head: Full identifier of the branch the PR is made from, e.g. "MyRepository:temp-feature-branch"
"""
url = f"{BASE_API_URL}/repos/{repo_name}/pulls/{base}/{head}"
result = url_json_get(url, quiet=True)
assert result is None or isinstance(result, dict)
return result
def gitea_json_issues_search(
type: str | None = None,
since: str | None = None,
before: str | None = None,
state: str = 'all',
labels: str | None = None,
created: bool = False,
reviewed: bool = False,
access_token: str | None = None,
verbose: bool = True,
) -> list[dict[str, Any]]:
"""
Search for issues across the repositories that the user has access to.
:param type: filter by type (issues / pulls) if set.
:param since: Only show notifications updated after the given time. This is a timestamp in RFC 3339 format.
:param before: Only show notifications updated before the given time. This is a timestamp in RFC 3339 format.
:param state: whether issue is open or closed.
:param labels: comma separated list of labels.
Fetch only issues that have any of this labels. Non existent labels are discarded.
:param created: filter (issues / pulls) created by you, default is false.
:param reviewed: filter pulls reviewed by you, default is false.
:param access_token: token generated by the GITEA API.
:return: List of issues or pulls.
"""
query_params = {k: v for k, v in locals().items() if v and k not in {"verbose"}}
for k, v in query_params.items():
if v is True:
query_params[k] = "true"
elif v is False:
query_params[k] = "false"
if verbose:
print("# Searching for {} #".format(
query_params["type"] if "type" in query_params else "issues and pulls"))
print("Query params:", {
k: v for k, v in query_params.items() if k not in {"type", "access_token"}})
base_url = f"{BASE_API_URL}/repos/issues/search"
encoded_query_params = urllib.parse.urlencode(query_params)
issues_url = f"{base_url}?{encoded_query_params}"
issues = url_json_get_all_pages(issues_url, verbose=verbose)
if verbose:
print(f"Total: {len(issues)} ", end="\n\n", flush=True)
return issues
def gitea_json_issue_events_filter(
issue_fullname: str,
date_start: datetime.datetime | None = None,
date_end: datetime.datetime | None = None,
username: str | None = None,
labels: set[str] | None = None,
event_type: set[str] | None = None,
) -> list[dict[str, Any]]:
"""
Filter all comments and events on the issue list. If both labels and event_type are provided,
an event is included if either the label or event type matches.
:param issue_fullname: string in the format "{owner}/{repo}/issues/{number}"
:param date_start: if provided, only comments updated since the specified time are returned.
:param date_end: if provided, only comments updated before the provided time are returned.
:param labels: list of labels. Fetch only events that have any of these labels (plus, events
passing the event_type check if set)
:param event_type: set of types of events in {"close", "commit_ref"...}.
:return: List of comments or events.
"""
issue_events_url = f"{BASE_API_URL}/repos/{issue_fullname}/timeline"
if date_start or date_end:
query_params = {}
# Assume that if no timezone is provided, that it's UTC.
# Without this, dates passed in that *do* have a timezone aren't handled properly.
if date_start:
if date_start.tzinfo is None:
date_start = date_start.replace(tzinfo=datetime.timezone.utc)
query_params["since"] = date_start.isoformat()
if date_end:
if date_end.tzinfo is None:
date_end = date_end.replace(tzinfo=datetime.timezone.utc)
query_params["before"] = date_end.isoformat()
encoded_query_params = urllib.parse.urlencode(query_params)
issue_events_url = f"{issue_events_url}?{encoded_query_params}"
result = []
for event in url_json_get_all_pages(issue_events_url):
if not event:
continue
if username and (not event["user"] or event["user"]["username"] != username):
continue
if labels and event["type"] == "label" and event["label"]["name"] in labels:
pass
elif event_type and event["type"] in event_type:
pass
elif labels or event_type:
continue
result.append(event)
return result
# WORKAROUND: This function doesn't involve GITEA, and the obtained username may not match the username used in GITEA.
# However, it provides an option to fetch the configured username from the local Git,
# in case the user does not explicitly supply the username.
def git_username_detect() -> str | None:
import subprocess
# Get the repository directory
repo_dir = os.path.abspath(os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")))
# Attempt to get the configured username from the local Git
try:
result = subprocess.run(["git", "config", "user.username"], stdout=subprocess.PIPE, cwd=repo_dir)
result.check_returncode() # Check if the command was executed successfully
username = result.stdout.decode().rstrip()
return username
except subprocess.CalledProcessError as ex:
# Handle errors if the git config command fails
print(f"Error fetching Git username: {ex}")
return None

View File

@@ -0,0 +1,175 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2024 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
# This script prints the numbers of open issues per module.
Example usage:
python ./issues_module_listing.py --severity High
"""
__all__ = (
"main",
)
import argparse
import dataclasses
import sys
from datetime import date
from gitea_utils import gitea_json_issues_search
IS_ATTY = sys.stdout.isatty()
@dataclasses.dataclass
class ModuleInfo:
name: str
labelid: str
buglist: list[str] = dataclasses.field(default_factory=list)
buglist_full: list[str] = dataclasses.field(default_factory=list)
# Label names and IDs are taken from https://projects.blender.org/blender/blender/labels.
modules = {
"Module/Animation & Rigging": ModuleInfo(name="Animation & Rigging", labelid="268"),
"Module/Asset System": ModuleInfo(name="Asset System", labelid="1708"),
"Module/Core": ModuleInfo(name="Core", labelid="269"),
"Module/Development Management": ModuleInfo(name="Development Management", labelid="270"),
"Module/Grease Pencil": ModuleInfo(name="Grease Pencil", labelid="273"),
"Module/Modeling": ModuleInfo(name="Modeling", labelid="274"),
"Module/Nodes & Physics": ModuleInfo(name="Nodes & Physics", labelid="275"),
"Module/Pipeline & IO": ModuleInfo(name="Pipeline & I/O", labelid="276"),
"Module/Platforms & Builds": ModuleInfo(name="Platforms & Builds", labelid="278"),
"Module/Python API": ModuleInfo(name="Python API", labelid="279"),
"Module/Render & Cycles": ModuleInfo(name="Render & Cycles", labelid="280"),
"Module/Sculpt, Paint & Texture": ModuleInfo(name="Sculpt, Paint & Texture", labelid="281"),
"Module/Triaging": ModuleInfo(name="Triaging", labelid="282"),
"Module/User Interface": ModuleInfo(name="User Interface", labelid="283"),
"Module/VFX & Video": ModuleInfo(name="VFX & Video", labelid="284"),
"Module/Viewport & EEVEE": ModuleInfo(name="Viewport & EEVEE", labelid="272"),
}
base_url = (
"https://projects.blender.org/blender/blender/"
"issues?q=&type=all&sort=&state=open&labels="
)
total_url = (
"https://projects.blender.org/blender/blender/"
"issues?q=&type=all&sort=&state=open&labels=285%2c-297%2c-298%2c-299%2c-301"
)
severity_labelid = {
"Low": "286",
"Normal": "287",
"High": "285",
"Unbreak Now!": "288"
}
def compile_list(severity: str) -> None:
label = f"Severity/{severity}"
issues_json = gitea_json_issues_search(
type="issues",
state="open",
labels=label,
verbose=True,
)
# Create a dictionary of format {module_id: module_name}
module_label_ids = {}
for module_name in modules:
module_label_ids[modules[module_name].labelid] = module_name
uncategorized_reports = []
issues_json_sorted = sorted(issues_json, key=lambda x: x["number"])
for issue in issues_json_sorted:
html_url = issue["html_url"]
number = issue["number"]
created_at = issue["created_at"].rsplit('T', 1)[0]
title = issue["title"]
# Check reports module assignment and fill in data.
for label_iter in issue["labels"]:
label_id = str(label_iter["id"])
if label_id not in module_label_ids:
continue
current_module_name = module_label_ids[label_id]
if current_module_name != label_iter["name"]:
new_label_name = label_iter["name"]
print(f"ALERT: The name of label of '{current_module_name}' changed.")
print(f"The new name is '{new_label_name}'.")
if IS_ATTY:
input("Press enter to continue: \n")
modules[current_module_name].buglist.append(f"[#{number}]({html_url})")
modules[current_module_name].buglist_full.append(f"* [{title}]({html_url}) - {created_at}\n")
break
else:
uncategorized_reports.append(f"[#{number}]({html_url})")
# Print statistics
print(f"Open {severity} Severity bugs as of {date.today()}:\n")
# Module overview with numbers
total = 0
modules_with_no_bugs = []
for module in modules.values():
buglist_str = (", ".join(module.buglist))
buglist_len = len(module.buglist)
full_url = base_url + severity_labelid[severity] + "%2c" + module.labelid
if buglist_len > 0:
total += buglist_len
if not module.buglist or severity != "High":
print(f"- [{module.name}]({full_url}): *{buglist_len}*")
else:
print(f"- [{module.name}]({full_url}): *{buglist_len}* _{buglist_str}_")
else:
modules_with_no_bugs.append(f"[{module.name}]({full_url})")
print(f"- {', '.join(modules_with_no_bugs)}: *0*")
print()
print(f"[Total]({total_url}): {total}")
print()
print("Uncategorized:", ", ".join(uncategorized_reports))
print()
# Module overview with titles and creation date
for module in modules.values():
buglist_full_str = ("".join(module.buglist_full))
buglist_full_len = len(module.buglist_full)
if buglist_full_len != 0:
print(f"{module.name}:")
print(f"{buglist_full_str}")
def main() -> None:
parser = argparse.ArgumentParser(
description="Print statistics on open bug reports per module",
epilog="This script is used to help module teams",
)
parser.add_argument(
"--severity",
dest="severity",
default="High",
type=str,
required=False,
choices=severity_labelid.keys(),
help="Severity level of reports",
)
args = parser.parse_args()
compile_list(args.severity)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,83 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2023 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
# This script prints the URLs of all opened issues labeled
# "Status/Needs Information from User" by the specified user
# and last updated more than 7 days ago.
Example usage:
python ./issues_needing_info.py --username mano-wii
"""
__all__ = (
"main",
)
import argparse
import datetime
from gitea_utils import (
git_username_detect,
gitea_json_issue_events_filter,
gitea_json_issues_search,
)
def print_needing_info_urls(username: str, before: str) -> None:
print(f"Needs information from user before {before}:")
label = "Status/Needs Information from User"
issues_json = gitea_json_issues_search(
type="issues",
state="open",
before=before,
labels=label,
verbose=True,
)
for issue in issues_json:
fullname = issue["repository"]["full_name"]
number = issue["number"]
issue_events = gitea_json_issue_events_filter(
f"{fullname}/issues/{number}",
username=username,
labels={label})
if issue_events:
print(issue["html_url"])
print("concluded")
def main() -> None:
parser = argparse.ArgumentParser(
description="Print URL of Issues Needing Info",
epilog="This script is typically used to help triaging")
parser.add_argument(
"--username",
dest="username",
type=str,
required=False,
help="Username registered in Gitea")
args = parser.parse_args()
username = args.username
if not username:
username = git_username_detect()
if not username:
return
before_date = datetime.datetime.now() - datetime.timedelta(7)
print_needing_info_urls(username, f"{before_date.isoformat()}Z")
if __name__ == "__main__":
main()
# wait for input to close window
input()

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