blob: 5238b7f7a456bdc6d9cec336330983608f7a0185 [file] [log] [blame]
from __future__ import print_function
import argparse
import getpass
import os
import re
import requests
import sys
import time
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from enum import Enum, IntEnum
from functools import partial
from operator import attrgetter
from typing import List
from jinja2 import Template
from pygerrit2 import Anonymous, GerritRestAPI, HTTPBasicAuth, HTTPBasicAuthFromNetrc
from tqdm import tqdm
BRANCHES = ["master"] + [
f"stable-{version}" for version in ["3.10", "3.9", "3.8"]
]
CI = "https://gerrit-ci.gerritforge.com"
GERRIT = "https://gerrit-review.googlesource.com"
GITILES = "https://gerrit.googlesource.com"
CORE_MAINTAINERS_ID = "google:AI2Pq9rwJtXWrKQ9Q62CcHSid7ngIF2hCfJ4bSpVquX_P2z5kFM6v9s"
CORE_MAINTAINERS_NAME = "Core maintainers"
BRANCH_MARK = "⌥"
GREEN_CHECK_MARK = "✅"
LOCK = "🔒"
RED_CROSS = "❌"
SQUARE = "⃞"
class BuildResult(Enum):
"""Build result for a plugin"""
UNAVAILABLE = "unavailable"
SUCCESSFUL = "successful"
FAILED = "failed"
def render(self):
if self == BuildResult.SUCCESSFUL:
return GREEN_CHECK_MARK
elif self == BuildResult.FAILED:
return RED_CROSS
else:
return SQUARE
class PluginState(IntEnum):
ACTIVE = 1
READ_ONLY = 2
def render(self):
if self == PluginState.ACTIVE:
return GREEN_CHECK_MARK
else:
return LOCK
class Branch:
"""Branch of a plugin repository"""
name: str
build: BuildResult
present: bool
def __init__(self, name, build, present=True):
self.name = name
self.build = build
self.present = present
@classmethod
def missing(cls, name):
return cls(name, BuildResult.UNAVAILABLE, False)
def render(self):
return BRANCH_MARK if self.present else SQUARE
@dataclass
class Plugin:
"""Gerrit plugin"""
name: str
parent: str
state: PluginState
owner_group_ids: List[str]
owner_names: List[str]
empty: bool
description: str
all_changes_count: int
recent_changes_count: int
branches: List[Branch]
def render_empty(self):
return SQUARE if self.empty else BRANCH_MARK
@dataclass(frozen=True)
class Account:
"""Gerrit account"""
id: str
name: str
email: str
class Plugins:
"""Class to retrieve data about Gerrit plugins and render the plugins page"""
@staticmethod
def _parse_options():
parser = argparse.ArgumentParser(
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
"-n",
"--netrc",
dest="netrc",
action="store_true",
help="use credentials from .netrc",
)
group.add_argument(
"-a",
"--anonymous",
dest="anonymous",
action="store_true",
help="use anonymous access, i.e. no credentials",
)
parser.add_argument(
"-t",
"--threads",
dest="threads",
default=1,
type=int,
help="number of threads to fetch data from Gerrit concurrently",
)
return parser.parse_args()
@staticmethod
def _render_header():
header = "|Name|State|Repo|Changes (last 3 months/all)|Description|Maintainers"
dashes = "|----|-----|----|---------------------------|-----------|---"
spacer = "| | | | | | "
links = "\n"
for b in BRANCHES:
header += "|Branch|CI"
dashes += "|-----:|--"
spacer += f"|[{b}]|"
links += f"[{b}]: {CI}/view/Plugins-{b}\n"
return (header, dashes, spacer, links)
@staticmethod
def _render_template():
data = {
"permalink": "plugins",
"updated": time.strftime("%A %d %B at %H:%M:%S %Z", time.gmtime()),
}
template_path = os.path.join(
os.path.dirname(os.path.realpath(__file__)), "plugins.md.template"
)
template = Template(open(template_path).read())
return template.render(data=data)
@staticmethod
def _get_matrix_header(state, is_empty):
if state == PluginState.ACTIVE:
if is_empty:
return "Not Started"
else:
return "Active"
else:
if is_empty:
return "Deprecated, not started"
else:
return "Deprecated"
def __init__(self):
self.options = self._parse_options()
auth = self._authenticate()
self.api = GerritRestAPI(url=GERRIT, auth=auth)
self.plugins = list()
self.maintainers = defaultdict(list)
self._create_plugins()
self.plugins = sorted(self.plugins, key=attrgetter("state", "empty"))
def __iter__(self):
return iter(self.plugins)
def _create_plugin(self, plugin_list: dict, builds, p):
"""Create a plugin by fetching its data from Gerrit"""
name = p[len("plugins/") :]
plugin = plugin_list[p]
if plugin["state"] == "ACTIVE":
state = PluginState.ACTIVE
changes = self._get_recent_changes_count(p)
branches = self._get_branch_results(plugin["id"], name, builds)
else:
state = PluginState.READ_ONLY
changes = 0
branches = [Branch.missing(branch) for branch in BRANCHES]
description = (
plugin["description"].split("\n")[0].rstrip(r"\.")
if "description" in plugin
else ""
)
parent, owner_group_ids = self._get_meta_data(name)
maintainers, maintainers_csv = self._get_owner_names(
parent, name, owner_group_ids
)
plugin = Plugin(
name=name,
parent=parent,
state=state,
owner_group_ids=owner_group_ids,
owner_names=maintainers_csv,
empty=self._is_project_empty(p),
description=description,
all_changes_count=self._get_all_changes_count(p),
recent_changes_count=changes,
branches=branches,
)
return plugin, maintainers
def _create_plugins(self):
"""Create plugins by fetching plugin data from Gerrit"""
# Set an explicit limit to get more results than the index default limit
# which is applied if no limit is set and which is 100 for gerrit-review
# TODO: Instead of setting a high limit paginate over the results (see
# https://issues.gerritcodereview.com/issues/296837507)
plugin_list = self.api.get("/projects/?p=plugins%2f&d&limit=500")
builds = requests.get(
f"{CI}/api/json?pretty=true&tree=jobs[name,lastBuild[result]]"
).json()
creator = partial(self._create_plugin, plugin_list, builds)
with ThreadPoolExecutor(max_workers=self.options.threads) as executor:
results = list(
tqdm(executor.map(creator, plugin_list), total=len(plugin_list))
)
for (plugin, maintainers) in results:
self.plugins.append(plugin)
for m in maintainers:
self.maintainers[m.name].append((m, plugin.name))
def _authenticate(self):
if self.options.netrc:
return HTTPBasicAuthFromNetrc(url=GERRIT)
elif self.options.anonymous:
return Anonymous()
else:
return self._authenticate_interactive()
def _authenticate_interactive(self):
username = os.environ.get("username")
password = os.environ.get("password")
while not username:
username = input("user: ")
while not password:
password = getpass.getpass("password: ")
auth = HTTPBasicAuth(username, password)
return auth
def _get_branches(self, pluginId):
branchList = self.api.get(f"/projects/{pluginId}/branches/")
pluginBranches = []
for b in branchList:
if b["ref"].startswith("refs/heads/"):
ref = b["ref"]
pluginBranches += [ref[len("refs/heads/") :]]
return pluginBranches
def _get_branch_results(self, pluginId, pluginName, builds):
pluginBranches = self._get_branches(pluginId)
branches = list()
for branch in BRANCHES:
string = fr"^plugin-{pluginName}-[a-z|-]*{branch}$"
pattern = re.compile(string)
result = BuildResult.UNAVAILABLE
for job in builds["jobs"]:
if pattern.match(job["name"]):
result = (
BuildResult.SUCCESSFUL
if job["lastBuild"] and job["lastBuild"]["result"] == "SUCCESS"
else BuildResult.FAILED
)
branches.append(Branch(branch, result, branch in pluginBranches))
break
if result == BuildResult.UNAVAILABLE:
branches.append(Branch.missing(branch))
return branches
def _get_all_changes_count(self, pluginName):
changes = self.api.get(f"/changes/?q=project:{pluginName}")
return len(changes)
def _get_recent_changes_count(self, pluginName):
changes = self.api.get(f"/changes/?q=project:{pluginName}+-age:3months")
return len(changes)
def _get_meta_data(self, pluginName):
path = requests.utils.quote(f"plugins/{pluginName}", safe="")
permissions = self.api.get(f"projects/{path}/access")
parent = permissions["inherits_from"]["name"]
try:
owner_group_ids = permissions["local"]["refs/*"]["permissions"]["owner"][
"rules"
].keys()
except KeyError:
# no owner group defined
owner_group_ids = list()
return parent, owner_group_ids
def _get_owner_names(self, parent, name, owner_group_ids):
accounts = set()
external_groups = set()
all_owner_group_ids = set(owner_group_ids)
# add subgroups if any
for id in owner_group_ids:
if id == CORE_MAINTAINERS_ID:
continue
else:
try:
subgroups = self.api.get(f"/groups/{id}/groups")
for s in subgroups:
all_owner_group_ids.add(s.get("id"))
except requests.HTTPError:
print(
f"Failed to read subgroup {id} of owner group of plugin {name}",
file=sys.stderr,
)
for id in all_owner_group_ids:
try:
if id == CORE_MAINTAINERS_ID:
external_groups.add(CORE_MAINTAINERS_NAME)
else:
owners = self.api.get(f"/groups/{id}/members/")
a = {
Account(o.get("_account_id"), o.get("name"), o.get("email"))
for o in owners
}
accounts.update(a)
except requests.HTTPError:
print(
f"Failed to read owner group {id} of plugin {name}", file=sys.stderr
)
csv = ", ".join(sorted({a.name for a in accounts} | external_groups))
return accounts, csv
def _is_project_empty(self, pluginName):
gitiles_uri = f"{GITILES}/{pluginName}"
try:
response = requests.get(gitiles_uri)
if response.status_code == 200:
if response.text.find("Empty Repository") > -1:
return True
except requests.HTTPError as e:
print(f"Failed to browse {pluginName} in gitiles:\n{e}", file=sys.stderr)
return False
def _render_maintainers(self, output):
header = "|Maintainer|Plugins|"
dashes = "|----------|-------|"
output.write("\n\n## Plugin Maintainers")
output.write(f"\n\n{header}|\n{dashes}|\n")
for m in sorted(self.maintainers):
plugins = set()
for (_, p) in self.maintainers.get(m):
plugins.add(p)
output.write(f"|{m}|{', '.join(sorted(plugins))}|\n")
def render_maintainers_email(self, output):
output.write(f"\nAll {len(self.maintainers)} plugin maintainers:\n")
for m in sorted(self.maintainers):
if m == CORE_MAINTAINERS_NAME:
continue
emails = set()
output.write(f"{m}: ")
for (accounts, _) in self.maintainers.get(m):
if accounts.email:
emails.add(accounts.email)
output.write(f"{', '.join(sorted(emails))}\n")
def render_page(self, output):
output.writelines(self._render_template())
(header, dashes, spacer, links) = self._render_header()
flags = (None, None)
for p in self.plugins:
if flags != (p.state, p.empty):
output.write(f"\n\n### {self._get_matrix_header(p.state, p.empty)}")
output.write(f"\n\n{header}|\n{dashes}|\n{spacer}|\n")
branches = "|".join(
[f"{b.render()}|{b.build.render()}" for b in p.branches]
)
output.write(
f"|[{p.name}]"
+ f"|{p.state.render()}|{p.render_empty()}"
+ f"|{p.recent_changes_count}"
+ f"/[{p.all_changes_count}]({GERRIT}/q/project:plugins/{p.name})"
+ f"|{p.description}"
+ f"|{p.owner_names}"
+ f"|{branches}"
+ "|\n"
)
flags = (p.state, p.empty)
links += f"[{p.name}]: {GITILES}/plugins/{p.name}\n"
output.write(links)
self._render_maintainers(output)
def main():
plugins = Plugins()
with open("pages/site/plugins/plugins.md", "w") as output:
plugins.render_page(output)
plugins.render_maintainers_email(sys.stdout)
if __name__ == "__main__":
main()