Skip to content

Commit 8ef6cce

Browse files
committed
bugs: implement edit resulting graph before filing
Relates: #169 Signed-off-by: Arthur Zamarin <arthurzam@gentoo.org>
1 parent 650b295 commit 8ef6cce

1 file changed

Lines changed: 146 additions & 20 deletions

File tree

src/pkgdev/scripts/pkgdev_bugs.py

Lines changed: 146 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
import contextlib
44
import json
5+
import os
6+
import shlex
7+
import subprocess
58
import sys
9+
import tempfile
610
import urllib.request as urllib
711
from collections import defaultdict
812
from datetime import datetime
@@ -13,7 +17,7 @@
1317
from pkgcheck import const as pkgcheck_const
1418
from pkgcheck.addons import ArchesAddon, init_addon
1519
from pkgcheck.addons.profiles import ProfileAddon
16-
from pkgcheck.addons.git import GitAddon, GitModifiedRepo
20+
from pkgcheck.addons.git import GitAddon, GitAddedRepo, GitModifiedRepo
1721
from pkgcheck.checks import visibility, stablereq
1822
from pkgcheck.scripts import argparse_actions
1923
from pkgcore.ebuild.atom import atom
@@ -34,6 +38,11 @@
3438
from ..cli import ArgumentParser
3539
from .argparsers import _determine_cwd_repo, cwd_repo_argparser, BugzillaApiKey
3640

41+
if sys.version_info >= (3, 11):
42+
import tomllib
43+
else:
44+
import tomli as tomllib
45+
3746
bugs = ArgumentParser(
3847
prog="pkgdev bugs",
3948
description=__doc__,
@@ -54,6 +63,17 @@
5463
"--dot",
5564
help="path file where to save the graph in dot format",
5665
)
66+
bugs.add_argument(
67+
"--edit-graph",
68+
action="store_true",
69+
help="open editor to modify the graph before filing bugs",
70+
docs="""
71+
When this argument is passed, pkgdev will open the graph in the editor
72+
(either ``$VISUAL`` or ``$EDITOR``) before filing bugs. The graph is
73+
represented in TOML format. After saving and exiting the editor, the
74+
tool would use the graph from the file to file bugs.
75+
""",
76+
)
5777
bugs.add_argument(
5878
"--auto-cc-arches",
5979
action=arghparse.CommaSeparatedNegationsAppend,
@@ -192,12 +212,14 @@ def parse_atom(pkg: str):
192212

193213

194214
class GraphNode:
195-
__slots__ = ("pkgs", "edges", "bugno")
215+
__slots__ = ("pkgs", "edges", "bugno", "summary", "cc_arches")
196216

197217
def __init__(self, pkgs: tuple[tuple[package, set[str]], ...], bugno=None):
198218
self.pkgs = pkgs
199219
self.edges: set[GraphNode] = set()
200220
self.bugno = bugno
221+
self.summary = ""
222+
self.cc_arches = None
201223

202224
def __eq__(self, __o: object):
203225
return self is __o
@@ -217,6 +239,8 @@ def lines(self):
217239

218240
@property
219241
def dot_edge(self):
242+
if self.bugno is not None:
243+
return f"bug_{self.bugno}"
220244
return f'"{self.pkgs[0][0].versioned_atom}"'
221245

222246
def cleanup_keywords(self, repo):
@@ -234,6 +258,29 @@ def cleanup_keywords(self, repo):
234258
keywords.clear()
235259
keywords.add("*")
236260

261+
@property
262+
def bug_summary(self):
263+
if self.summary:
264+
return self.summary
265+
summary = f"{', '.join(pkg.versioned_atom.cpvstr for pkg, _ in self.pkgs)}: stablereq"
266+
if len(summary) > 90 and len(self.pkgs) > 1:
267+
return f"{self.pkgs[0][0].versioned_atom.cpvstr} and friends: stablereq"
268+
return summary
269+
270+
@property
271+
def node_maintainers(self):
272+
return dict.fromkeys(
273+
maintainer.email for pkg, _ in self.pkgs for maintainer in pkg.maintainers
274+
)
275+
276+
def should_cc_arches(self, auto_cc_arches: frozenset[str]):
277+
if self.cc_arches is not None:
278+
return self.cc_arches
279+
maintainers = self.node_maintainers
280+
return bool(
281+
not maintainers or "*" in auto_cc_arches or auto_cc_arches.intersection(maintainers)
282+
)
283+
237284
def file_bug(
238285
self,
239286
api_key: str,
@@ -247,28 +294,22 @@ def file_bug(
247294
for dep in self.edges:
248295
if dep.bugno is None:
249296
dep.file_bug(api_key, auto_cc_arches, (), modified_repo, observer)
250-
maintainers = dict.fromkeys(
251-
maintainer.email for pkg, _ in self.pkgs for maintainer in pkg.maintainers
252-
)
253-
if not maintainers or "*" in auto_cc_arches or auto_cc_arches.intersection(maintainers):
297+
maintainers = self.node_maintainers
298+
if self.should_cc_arches(auto_cc_arches):
254299
keywords = ["CC-ARCHES"]
255300
else:
256301
keywords = []
257302
maintainers = tuple(maintainers) or ("maintainer-needed@gentoo.org",)
258303

259-
summary = f"{', '.join(pkg.versioned_atom.cpvstr for pkg, _ in self.pkgs)}: stablereq"
260-
if len(summary) > 90 and len(self.pkgs) > 1:
261-
summary = f"{self.pkgs[0][0].versioned_atom.cpvstr} and friends: stablereq"
262-
263304
description = ["Please stabilize", ""]
264305
if modified_repo is not None:
265306
for pkg, _ in self.pkgs:
266307
with contextlib.suppress(StopIteration):
267308
match = next(modified_repo.itermatch(pkg.versioned_atom))
268-
added = datetime.fromtimestamp(match.time)
269-
days_old = (datetime.today() - added).days
309+
modified = datetime.fromtimestamp(match.time)
310+
days_old = (datetime.today() - modified).days
270311
description.append(
271-
f" {pkg.versioned_atom.cpvstr}: no change for {days_old} days, since {added:%Y-%m-%d}"
312+
f" {pkg.versioned_atom.cpvstr}: no change for {days_old} days, since {modified:%Y-%m-%d}"
272313
)
273314

274315
request_data = dict(
@@ -277,7 +318,7 @@ def file_bug(
277318
component="Stabilization",
278319
severity="enhancement",
279320
version="unspecified",
280-
summary=summary,
321+
summary=self.bug_summary,
281322
description="\n".join(description).strip(),
282323
keywords=keywords,
283324
cf_stabilisation_atoms="\n".join(self.lines()),
@@ -308,13 +349,17 @@ def __init__(self, out: Formatter, err: Formatter, options):
308349
self.out = out
309350
self.err = err
310351
self.options = options
352+
disabled, enabled = options.auto_cc_arches
353+
self.auto_cc_arches = frozenset(enabled).difference(disabled)
311354
self.profile_addon: ProfileAddon = init_addon(ProfileAddon, options)
312355

313356
self.nodes: set[GraphNode] = set()
314357
self.starting_nodes: set[GraphNode] = set()
315358
self.targets: tuple[package] = ()
316359

317360
git_addon = init_addon(GitAddon, options)
361+
self.added_repo = git_addon.cached_repo(GitAddedRepo)
362+
self.modified_repo = git_addon.cached_repo(GitModifiedRepo)
318363
self.stablereq_check = stablereq.StableRequestCheck(self.options, git_addon=git_addon)
319364

320365
def mk_fake_pkg(self, pkg: package, keywords: set[str]):
@@ -467,7 +512,7 @@ def build_full_graph(self):
467512
vertices[starting_node] for starting_node in self.targets if starting_node in vertices
468513
}
469514

470-
def output_dot(self, dot_file):
515+
def output_dot(self, dot_file: str):
471516
with open(dot_file, "w") as dot:
472517
dot.write("digraph {\n")
473518
dot.write("\trankdir=LR;\n")
@@ -481,6 +526,67 @@ def output_dot(self, dot_file):
481526
dot.write("}\n")
482527
dot.close()
483528

529+
def output_graph_toml(self):
530+
self.auto_cc_arches
531+
bugs = dict(enumerate(self.nodes, start=1))
532+
reverse_bugs = {node: bugno for bugno, node in bugs.items()}
533+
534+
toml = tempfile.NamedTemporaryFile(mode="w", suffix=".toml")
535+
for bugno, node in bugs.items():
536+
if node.bugno is not None:
537+
continue # already filed
538+
toml.write(f"[bug-{bugno}]\n")
539+
toml.write(f'summary = "{node.bug_summary}"\n')
540+
toml.write(f"cc_arches = {str(node.should_cc_arches(self.auto_cc_arches)).lower()}\n")
541+
if node_depends := ", ".join(
542+
(f'"bug-{reverse_bugs[dep]}"' if dep.bugno is None else str(dep.bugno))
543+
for dep in node.edges
544+
):
545+
toml.write(f"depends = [{node_depends}]\n")
546+
if node_blocks := ", ".join(
547+
f'"bug-{i}"' for i, src in bugs.items() if node in src.edges
548+
):
549+
toml.write(f"blocks = [{node_blocks}]\n")
550+
for pkg, arches in node.pkgs:
551+
match = next(self.modified_repo.itermatch(pkg.versioned_atom))
552+
modified = datetime.fromtimestamp(match.time)
553+
match = next(self.added_repo.itermatch(pkg.versioned_atom))
554+
added = datetime.fromtimestamp(match.time)
555+
toml.write(
556+
f"# added on {added:%Y-%m-%d} (age {(datetime.today() - added).days} days), last modified on {modified:%Y-%m-%d} (age {(datetime.today() - modified).days} days)\n"
557+
)
558+
keywords = ", ".join(f'"{x}"' for x in sort_keywords(arches))
559+
toml.write(f'"{pkg.versioned_atom}" = [{keywords}]\n')
560+
toml.write("\n\n")
561+
toml.flush()
562+
return toml
563+
564+
def load_graph_toml(self, toml_file: str):
565+
repo = self.options.search_repo
566+
with open(toml_file, "rb") as f:
567+
data = tomllib.load(f)
568+
569+
new_bugs: dict[int | str, GraphNode] = {}
570+
for node_name, data_node in data.items():
571+
pkgs = tuple(
572+
(next(repo.itermatch(atom(pkg))), set(keywords))
573+
for pkg, keywords in data_node.items()
574+
if pkg.startswith("=")
575+
)
576+
new_bugs[node_name] = GraphNode(pkgs)
577+
for node_name, data_node in data.items():
578+
new_bugs[node_name].summary = data_node.get("summary", "")
579+
new_bugs[node_name].cc_arches = data_node.get("cc_arches", None)
580+
for dep in data_node.get("depends", ()):
581+
if isinstance(dep, int):
582+
new_bugs[node_name].edges.add(new_bugs.setdefault(dep, GraphNode((), dep)))
583+
elif new_bugs.get(dep) is not None:
584+
new_bugs[node_name].edges.add(new_bugs[dep])
585+
else:
586+
bugs.error(f"[{node_name}]['depends']: unknown dependency {dep!r}")
587+
self.nodes = set(new_bugs.values())
588+
self.starting_nodes = {node for node in self.nodes if not node.edges}
589+
484590
def merge_nodes(self, nodes: tuple[GraphNode, ...]) -> GraphNode:
485591
self.nodes.difference_update(nodes)
486592
is_start = bool(self.starting_nodes.intersection(nodes))
@@ -612,9 +718,8 @@ def observe(node: GraphNode):
612718
)
613719
self.out.flush()
614720

615-
modified_repo = init_addon(GitAddon, self.options).cached_repo(GitModifiedRepo)
616721
for node in self.starting_nodes:
617-
node.file_bug(api_key, auto_cc_arches, block_bugs, modified_repo, observe)
722+
node.file_bug(api_key, auto_cc_arches, block_bugs, self.modified_repo, observe)
618723

619724

620725
def _load_from_stdin(out: Formatter):
@@ -644,19 +749,40 @@ def main(options, out: Formatter, err: Formatter):
644749
d.merge_cycles()
645750
d.merge_new_keywords_children()
646751

647-
for node in d.nodes:
648-
node.cleanup_keywords(search_repo)
649-
650752
if not d.nodes:
651753
out.write(out.fg("red"), "Nothing to do, exiting", out.reset)
652754
return 1
653755

654756
if userquery("Check for open bugs matching current graph?", out, err, default_answer=False):
655757
d.scan_existing_bugs(options.api_key)
656758

759+
if options.edit_graph:
760+
toml = d.output_graph_toml()
761+
762+
for node in d.nodes:
763+
node.cleanup_keywords(search_repo)
764+
657765
if options.dot is not None:
658766
d.output_dot(options.dot)
659767
out.write(out.fg("green"), f"Dot file written to {options.dot}", out.reset)
768+
out.flush()
769+
770+
if options.edit_graph:
771+
editor = shlex.split(os.environ.get("VISUAL", os.environ.get("EDITOR", "nano")))
772+
try:
773+
subprocess.run(editor + [toml.name], check=True)
774+
except subprocess.CalledProcessError:
775+
bugs.error("failed writing mask comment")
776+
except FileNotFoundError:
777+
bugs.error(f"nonexistent editor: {editor[0]!r}")
778+
d.load_graph_toml(toml.name)
779+
for node in d.nodes:
780+
node.cleanup_keywords(search_repo)
781+
782+
if options.dot is not None:
783+
d.output_dot(options.dot)
784+
out.write(out.fg("green"), f"Dot file written to {options.dot}", out.reset)
785+
out.flush()
660786

661787
bugs_count = len(tuple(node for node in d.nodes if node.bugno is None))
662788
if bugs_count == 0:

0 commit comments

Comments
 (0)