22
33import contextlib
44import json
5+ import os
6+ import shlex
7+ import subprocess
58import sys
9+ import tempfile
610import urllib .request as urllib
711from collections import defaultdict
812from datetime import datetime
1317from pkgcheck import const as pkgcheck_const
1418from pkgcheck .addons import ArchesAddon , init_addon
1519from pkgcheck .addons .profiles import ProfileAddon
16- from pkgcheck .addons .git import GitAddon , GitModifiedRepo
20+ from pkgcheck .addons .git import GitAddon , GitAddedRepo , GitModifiedRepo
1721from pkgcheck .checks import visibility , stablereq
1822from pkgcheck .scripts import argparse_actions
1923from pkgcore .ebuild .atom import atom
3438from ..cli import ArgumentParser
3539from .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+
3746bugs = ArgumentParser (
3847 prog = "pkgdev bugs" ,
3948 description = __doc__ ,
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+ )
5777bugs .add_argument (
5878 "--auto-cc-arches" ,
5979 action = arghparse .CommaSeparatedNegationsAppend ,
@@ -192,12 +212,14 @@ def parse_atom(pkg: str):
192212
193213
194214class 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 ("\t rankdir=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
620725def _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