new-contributors.py (198 lines of code) (raw):
#!/usr/bin/env python3
import argparse
import json
import sys
import urllib.error as url_error
import urllib.parse as url_parse
import urllib.request as url_request
from pathlib import Path
from typing import Any
# Generates a list of new contributors to Desktop Firefox for a given version.
PRODUCTS = [
"Core",
"Developer Infrastructure",
"DevTools",
"Firefox",
"Firefox Build System",
"NSPR",
"NSS",
"Remote Protocol",
"Testing",
"Toolkit",
"Web Compatibility",
"WebExtensions",
]
class Error(Exception):
"""throwing this won't generate a stack trace"""
def plural(count: int, item: str, *, suffix: str = "s") -> str:
return f"{count:,d} {item}{'' if count == 1 else suffix}"
def bmo_request(
end_point: str,
query: dict[str, Any],
*,
api_key: str | None = None,
) -> Any:
# dict to query-string
query_args = []
for name, value in query.items():
if isinstance(value, list):
query_args.extend((name, v) for v in value)
else:
query_args.append((name, value))
query_encoded = url_parse.urlencode(query_args)
# build request
req = url_request.Request(
f"https://bugzilla.mozilla.org/rest/{end_point}?{query_encoded}",
headers={
"User-Agent": "new-contributors",
"X-BUGZILLA-API-KEY": api_key if api_key else "",
},
)
# return json response
try:
with url_request.urlopen(req) as r:
res = json.load(r)
return res
except url_error.HTTPError as e:
try:
res = json.load(e.fp)
raise Error(res["message"])
except (OSError, ValueError, KeyError):
raise Error(e)
def main() -> None:
# parse command line arguments
parser = argparse.ArgumentParser()
parser.add_argument(
"version",
type=int,
help="Firefox version",
)
parser.add_argument(
"--api-key",
"--apikey",
required=True,
help="Bugzilla API-Key",
)
args = parser.parse_args()
if args.version < 0:
raise Error(f"Invalid version: {args.version}")
# load cache
# store a list of users against each version that were determined to have patches
# landed _prior_ to the specified version
cache_file = Path(__file__).parent / "new-contributors.cache"
try:
with cache_file.open() as f:
cache = json.load(f)
except (FileNotFoundError, ValueError):
cache = []
current_cache = None
for cache_item in cache:
if cache_item["version"] == args.version:
current_cache = cache_item
break
if not current_cache:
current_cache = {"version": args.version, "skip": []}
cache.append(current_cache)
# find bugs fixed in specified version
print(f"looking for bugs fixed in Firefox {args.version}", file=sys.stderr)
bugs = bmo_request(
"bug",
{
"target_milestone": f"{args.version} Branch",
"status": "RESOLVED",
"product": PRODUCTS,
"include_fields": "id,assigned_to,cf_last_resolved",
"order": "cf_last_resolved",
},
api_key=args.api_key,
)["bugs"]
print(f"found {plural(len(bugs), 'bug')}", file=sys.stderr)
if not bugs:
return
# find new assignees
new = {}
for bug in bugs:
assignee = bug["assigned_to"]
# skip users that are clearly employees or contractors
if assignee.endswith(
(
"@getpocket.com",
"@mozilla.com",
"@mozilla.org",
"@mozillafoundation.org",
"@softvision.com",
"@softvision.ro",
"@softvisioninc.eu",
)
):
continue
# skip users we already know are not new
should_skip = False
for cache_item in cache:
if cache_item["version"] <= args.version and assignee in cache_item["skip"]:
should_skip = True
break
if should_skip:
continue
# handle users that we know are new and fixed more than one bug
if assignee in new:
new[assignee]["bugs"].append(bug["id"])
continue
print(f"checking {assignee}", file=sys.stderr, end="")
# always exclude employees; this is quicker than a bug search, and not
# all bugs have correct metadata
users = bmo_request(
"user",
{"names": assignee},
api_key=args.api_key,
)["users"]
is_employee = False
if users:
for group in users[0]["groups"]:
if group["name"] == "mozilla-employee-confidential":
is_employee = True
break
if is_employee:
print(" employee", file=sys.stderr)
current_cache["skip"].append(assignee)
continue
print(" contributor", file=sys.stderr, end="")
# query for bugs fixed by this user before this one
prior_bugs = bmo_request(
"bug",
{
# resolved bugs in our products
"product": PRODUCTS,
"status": "RESOLVED",
# assigned to our user
"emailassigned_to1": "1",
"emailtype1": "exact",
"email1": assignee,
# where the last resolved is older than this bug's
"f1": "cf_last_resolved",
"o1": "lessthan",
"v1": bug["cf_last_resolved"].replace("T", " ").replace("Z", ""),
# and a target_milestone is set (filter our duplicates, etc)
"f2": "target_milestone",
"o2": "notequals",
"v2": "---",
# don't need the full list or count, just need to know if there's any
"limit": 1,
},
api_key=args.api_key,
)["bugs"]
if prior_bugs:
print(" existing", file=sys.stderr)
current_cache["skip"].append(assignee)
continue
# collate in `new` dict
print(" new", file=sys.stderr)
new.setdefault(
assignee,
{
"name": bug["assigned_to_detail"]["real_name"]
or bug["assigned_to_detail"]["nick"],
"bugs": [],
},
)
new[assignee]["bugs"].append(bug["id"])
print(f"found {plural(len(new), 'new contributor')}", file=sys.stderr)
# update cache
with cache_file.open("w") as f:
json.dump(cache, f, indent=2, sort_keys=True)
# generate nucleus output
print(
f"With the release of Firefox {args.version}, we are pleased to welcome "
"the developers who contributed their first code change to Firefox in "
f"this release, {len(new)} of whom were brand new volunteers! Please "
"join us in thanking each of these diligent and enthusiastic "
"individuals, and take a look at their contributions:\n"
)
for user in sorted(new.values(), key=lambda u: u["name"].lower()):
bug_links = ", ".join(
f'<a href="https://bugzilla.mozilla.org/{b}">{b}</a>'
for b in sorted(user["bugs"])
)
print(f"* {user['name']}: {bug_links}")
if __name__ == "__main__":
try:
main()
except Error as error:
print(error, file=sys.stderr)
sys.exit(1)
except KeyboardInterrupt:
sys.exit(2)