mirror of https://github.com/zcash/developers.git
278 lines
10 KiB
Python
Executable File
278 lines
10 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# ZenHub issue dependency graph generator for the ECC core team.
|
|
# Author: jack@electriccoin.co
|
|
# Last updated: 2021-05-07
|
|
|
|
import networkx as nx
|
|
|
|
from str2bool import str2bool as strtobool
|
|
import os
|
|
from textwrap import wrap
|
|
from urllib.parse import urlparse
|
|
|
|
from helpers import github, zenhub
|
|
|
|
GITHUB_TOKEN = os.environ.get('GITHUB_TOKEN')
|
|
ZENHUB_TOKEN = os.environ.get('ZENHUB_TOKEN')
|
|
|
|
DAG_VIEW = os.environ.get('DAG_VIEW', 'core')
|
|
|
|
REPOS = github.REPO_SETS[DAG_VIEW]
|
|
|
|
WORKSPACES = {
|
|
workspace_id: repos
|
|
for (workspace_id, repos) in {
|
|
workspace_id: [repo for repo in repos if repo in REPOS]
|
|
for (workspace_id, repos) in zenhub.WORKSPACE_SETS.items()
|
|
}.items()
|
|
if len(repos) > 0
|
|
}
|
|
|
|
SUPPORTED_CATEGORIES = set(['releases', 'targets'])
|
|
def cats(s):
|
|
return set([x.strip() for x in s.split(',')]) - set([''])
|
|
|
|
# If set, removes all issues and PRs that are not ancestors of the given issues.
|
|
# This can be used to render a sub-graph focused on one area.
|
|
#
|
|
# Format is ORG/REPO#ISSUE[,ORG/REPO#ISSUE[, ..]]
|
|
TERMINATE_AT = cats(os.environ.get('TERMINATE_AT', ''))
|
|
|
|
# Whether to remove issues and PRs that are not target or release issues.
|
|
ONLY_INCLUDE = cats(os.environ.get('ONLY_INCLUDE', ''))
|
|
|
|
# Whether to include subgraphs where all issues and PRs are closed.
|
|
INCLUDE_FINISHED = strtobool(os.environ.get('INCLUDE_FINISHED', 'false'))
|
|
|
|
# Whether to remove closed issues and PRs that are not downstream of open ones.
|
|
# When set to 'targets' or 'releases', only issues upstream of a closed target
|
|
# or release issue will be removed.
|
|
PRUNE_FINISHED = os.environ.get('PRUNE_FINISHED', 'true')
|
|
|
|
# Whether to group issues and PRs by milestone.
|
|
SHOW_MILESTONES = strtobool(os.environ.get('SHOW_MILESTONES', 'false'))
|
|
|
|
# Whether to group issues and PRs by ZenHub epics.
|
|
SHOW_EPICS = strtobool(os.environ.get('SHOW_EPICS', 'false'))
|
|
|
|
|
|
def main():
|
|
gapi = github.api(GITHUB_TOKEN)
|
|
zapi = zenhub.api(ZENHUB_TOKEN)
|
|
|
|
if len(WORKSPACES) == 0:
|
|
print('Error: DAG_VIEW="{}" has no matching ZenHub workspaces'.format(DAG_VIEW))
|
|
return
|
|
|
|
# Build the full dependency graph from ZenHub's per-workspace API.
|
|
print('Fetching graph')
|
|
dg = nx.compose_all([
|
|
zenhub.get_dependency_graph(zapi, workspace_id, repos)
|
|
for (workspace_id, repos) in WORKSPACES.items()
|
|
])
|
|
|
|
print('Rendering DAG')
|
|
|
|
if SHOW_EPICS:
|
|
epics_issues = []
|
|
for (workspace_id, repos) in WORKSPACES.items():
|
|
epics_issues += zenhub.get_epics(zapi, workspace_id, repos)
|
|
epics_issues = set(epics_issues)
|
|
|
|
epics_mapping = github.download_issues(gapi, [gh_ref for (_, gh_ref) in epics_issues], REPOS)
|
|
epics_mapping = {k: v for (k, v) in epics_mapping.items() if v.state != 'closed'}
|
|
issues_by_epic = {}
|
|
for (i, ((repo, epic_id), epic)) in enumerate(epics_mapping.items()):
|
|
workspace_id = [
|
|
workspace_id
|
|
for (workspace_id, repos) in WORKSPACES.items()
|
|
if repo in repos
|
|
][0]
|
|
epic_id = [
|
|
id for (id, gh_ref) in epics_issues
|
|
if gh_ref == (repo, epic_id)
|
|
][0]
|
|
issues = set(zenhub.get_epic_issues(zapi, workspace_id, epic_id))
|
|
issues_by_epic[epic] = issues
|
|
for i in issues:
|
|
# zapi.dependencies only returns nodes that have some connection,
|
|
# but we'd like to show all issues from epics even if they are
|
|
# disconnected.
|
|
dg.add_node(i)
|
|
|
|
if len(TERMINATE_AT) > 0:
|
|
# Look up the repo IDs for the given terminating issues.
|
|
reverse_repos = {repo.name: repo for repo in REPOS}
|
|
terminate_at = [x.split('#') for x in TERMINATE_AT]
|
|
terminate_at = set([(reverse_repos[tuple(r.split('/', 1))], int(i)) for (r, i) in terminate_at])
|
|
|
|
# Replace the graph with the subgraph that only includes the terminating
|
|
# issues and their ancestors.
|
|
ancestors = [nx.ancestors(dg, n) for n in terminate_at]
|
|
dg = nx.subgraph(dg, terminate_at.union(*ancestors))
|
|
|
|
# Fetch the issues within the graph.
|
|
mapping = github.download_issues(gapi, dg.nodes, REPOS)
|
|
|
|
# Relabel the graph
|
|
dg = nx.relabel_nodes(dg, mapping)
|
|
|
|
# Filter out unknown issues
|
|
unknown = [n for n in dg if n.repo not in REPOS]
|
|
if len(unknown) > 0:
|
|
dg.remove_nodes_from(unknown)
|
|
|
|
# Apply property annotations
|
|
for (source, sink) in dg.edges:
|
|
attrs = dg.edges[source, sink]
|
|
attrs['is_open'] = 0 if source.state == 'closed' else 1
|
|
|
|
if len(ONLY_INCLUDE) > 0 and ONLY_INCLUDE.issubset(SUPPORTED_CATEGORIES):
|
|
# Insert direct edges for all transitive paths in the graph. This creates edges
|
|
# between target issues that were not previously directly connected, but were
|
|
# "reachable".
|
|
tc = nx.transitive_closure_dag(dg)
|
|
|
|
# Remove non-target issues. This also removes their involved edges, leaving behind
|
|
# the transitive closure of the target issues.
|
|
tc.remove_nodes_from([n for n in dg.nodes if not n.any_cat(ONLY_INCLUDE)])
|
|
|
|
# Reduce to the minimum number of edges representing the same transitive paths.
|
|
# This is unique for a DAG.
|
|
dg = nx.transitive_reduction(tc)
|
|
|
|
if not INCLUDE_FINISHED:
|
|
# Identify the disconnected subgraphs.
|
|
subgraphs = [dg.subgraph(c) for c in nx.connected_components(dg.to_undirected())]
|
|
|
|
# Identify subgraphs comprised entirely of closed issues.
|
|
ignore = [g for g in subgraphs if all([n.state == 'closed' for n in g])]
|
|
|
|
# Remove fully-closed subgraphs.
|
|
if len(ignore) > 0:
|
|
dg.remove_nodes_from(nx.compose_all(ignore))
|
|
|
|
# Prune nodes that are not downstream of any open issues.
|
|
if cats(PRUNE_FINISHED).issubset(SUPPORTED_CATEGORIES):
|
|
closed_targets = [n for n in dg.nodes if n.any_cat(cats(PRUNE_FINISHED)) and n.state == 'closed']
|
|
for target in closed_targets:
|
|
# Check that the target (and by extension its ancestors) wasn't already
|
|
# removed for being the ancestor of another closed target.
|
|
if target in dg:
|
|
ancestors = nx.ancestors(dg, target)
|
|
if all(n.state == 'closed' for n in ancestors):
|
|
# Only prune ancestors, not the closed target node, so that
|
|
# we see the most recently-closed target nodes in the DAG.
|
|
dg.remove_nodes_from(ancestors)
|
|
|
|
elif PRUNE_FINISHED in ['true', 'all']:
|
|
# - It would be nice to keep the most recently-closed issues on the DAG, but
|
|
# dg.out_degree seems to be broken...
|
|
to_prune = [n for (n, degree) in dg.in_degree() if degree == 0 and n.state == 'closed']
|
|
while len(to_prune) > 0:
|
|
dg.remove_nodes_from(to_prune)
|
|
to_prune = [n for (n, degree) in dg.in_degree() if degree == 0 and n.state == 'closed']
|
|
|
|
do_next = [n for (n, degree) in dg.in_degree(weight='is_open') if degree == 0 and n.state != 'closed']
|
|
|
|
# Apply style annotations.
|
|
for n in dg:
|
|
attrs = dg.nodes[n]
|
|
if n.title:
|
|
attrs['label'] = '\n'.join(['%s' % n] + wrap(n.title, 25))
|
|
if n.state == 'closed':
|
|
attrs['class'] = 'closed'
|
|
attrs['fillcolor'] = '#fad8c7'
|
|
elif n.waiting_on_review:
|
|
attrs['class'] = 'needs-review'
|
|
attrs['fillcolor'] = '#dfc150'
|
|
elif n.is_committed or n.is_in_progress:
|
|
attrs['class'] = 'committed'
|
|
attrs['fillcolor'] = '#a6cfff'
|
|
else:
|
|
attrs['class'] = 'open'
|
|
attrs['fillcolor'] = '#c2e0c6'
|
|
attrs['penwidth'] = 2 if n in do_next else 1
|
|
if n.is_target:
|
|
attrs['shape'] = 'folder'
|
|
elif n.is_pr:
|
|
attrs['shape'] = 'component'
|
|
else:
|
|
attrs['shape'] = 'box'
|
|
attrs['style'] = 'filled'
|
|
if n.url:
|
|
attrs['URL'] = n.url
|
|
attrs['target'] = '_blank'
|
|
|
|
ag = nx.nx_agraph.to_agraph(dg)
|
|
|
|
clusters = 0
|
|
if SHOW_MILESTONES:
|
|
# Identify milestone nbunches
|
|
milestones = {n.milestone: [] for n in dg}
|
|
for m in milestones:
|
|
milestones[m] = [n for n in dg if n.milestone == m]
|
|
if None in milestones:
|
|
del milestones[None]
|
|
for (milestone, nodes) in milestones.items():
|
|
ag.add_subgraph(nodes, 'cluster_%d' % clusters, label=milestone, color='blue')
|
|
clusters += 1
|
|
|
|
if SHOW_EPICS:
|
|
for (epic, issues) in issues_by_epic.items():
|
|
issues = [n for n in dg if (n.repo, n.issue_number) in issues]
|
|
if issues:
|
|
ag.add_subgraph(issues, 'cluster_%d' % clusters, label=epic.title, color='blue')
|
|
clusters += 1
|
|
|
|
# Draw the result!
|
|
ag.graph_attr['rankdir'] = 'LR'
|
|
ag.graph_attr['stylesheet'] = 'zcash-dag.css'
|
|
ag.layout(prog='dot')
|
|
os.makedirs('public', exist_ok=True)
|
|
ag.draw('public/zcash-%s-dag.svg' % DAG_VIEW)
|
|
|
|
# Render the HTML version!
|
|
with open('public/zcash-%s-dag.svg' % DAG_VIEW) as f:
|
|
svg_data = f.read()
|
|
svg_start = svg_data.find('<svg')
|
|
html_data = '''<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<title>Zcash %s DAG</title>
|
|
|
|
<!-- Pan/zoom SVGs -->
|
|
<script src="https://bumbu.me/svg-pan-zoom/dist/svg-pan-zoom.min.js"></script>
|
|
|
|
<link rel="stylesheet" href="zcash-dag.css">
|
|
<style>
|
|
@media (prefers-color-scheme: dark) {
|
|
body {
|
|
/* Material dark theme surface colour */
|
|
background-color: #121212;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="dag">%s</div>
|
|
|
|
<script>
|
|
svgPanZoom('#dag > svg', {
|
|
zoomScaleSensitivity: 0.4
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|
|
''' % (DAG_VIEW, svg_data[svg_start:])
|
|
with open('public/zcash-%s-dag.html' % DAG_VIEW, 'w') as f:
|
|
f.write(html_data)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
if GITHUB_TOKEN and ZENHUB_TOKEN:
|
|
main()
|
|
else:
|
|
print('Please set the GITHUB_TOKEN and ZENHUB_TOKEN environment variables.')
|