11"""Automatic bugs filer"""
22
33import contextlib
4+ import dataclasses
45import os
56import shlex
67import subprocess
1112from collections import defaultdict
1213from datetime import UTC , datetime
1314from functools import partial
14- from itertools import chain
15+ from itertools import chain , count
1516from os .path import join as pjoin
1617from typing import Literal
1718
@@ -158,6 +159,17 @@ def __call__(self, parser, namespace, values, option_string=None):
158159 "--dot" ,
159160 help = "path file where to save the graph in dot format" ,
160161)
162+ bugs .add_argument (
163+ "--dry-run" ,
164+ action = "store_true" ,
165+ help = "print the bugs instead of filing them" ,
166+ docs = """
167+ Do everything up to filing, including searching for open bugs, but
168+ print the bugs which would be filed and the updates to existing bugs,
169+ instead of sending them to Bugzilla. Bugs not yet filed are numbered
170+ ``new-1``, ``new-2``, ... in the output. No API key is required.
171+ """ ,
172+ )
161173bugs .add_argument (
162174 "--edit-graph" ,
163175 action = "store_true" ,
@@ -304,6 +316,69 @@ def _naming(what: str):
304316 raise BugzillaError (f"{ what } : { exc } " ) from exc
305317
306318
319+ class DryRunBugzilla :
320+ """Bugzilla client which searches as usual, but prints any change instead."""
321+
322+ def __init__ (self , bugzilla : Bugzilla , out : Formatter ):
323+ self .bugzilla = bugzilla
324+ self .out = out
325+ self ._new_ids = (f"new-{ i } " for i in count (1 ))
326+
327+ def search (self , query : BugQuery ):
328+ return self .bugzilla .search (query )
329+
330+ def _field (self , name : str , value ):
331+ if value :
332+ self .out .write (f" { name + ':' :<12} { value } " )
333+
334+ def create (self , bug : NewBug ) -> str :
335+ bugno = next (self ._new_ids )
336+ out = self .out
337+ out .write (out .fg ("green" ), f"Would file { bugno } : " , out .reset , bug .summary )
338+ self ._field ("component" , f"{ bug .product } / { bug .component } " )
339+ self ._field ("assignee" , bug .assigned_to )
340+ self ._field ("CC" , ", " .join (bug .cc ))
341+ self ._field ("keywords" , ", " .join (bug .keywords ))
342+ self ._field ("depends on" , ", " .join (sorted (map (str , bug .depends_on ))))
343+ self ._field ("blocks" , ", " .join (sorted (map (str , bug .blocks ))))
344+ out .write (" packages:" )
345+ for line in str (bug .package_list ).splitlines ():
346+ out .write (f" { line } " )
347+ out .write (" description:" )
348+ for line in bug .description .splitlines ():
349+ out .write (f" { line } " .rstrip ())
350+ out .write ()
351+ out .flush ()
352+ return bugno
353+
354+ def update (self , bugs , update : BugUpdate ):
355+ ids = [bugs ] if isinstance (bugs , int ) else list (bugs )
356+ out = self .out
357+ out .write (
358+ out .fg ("yellow" ),
359+ "Would update bugs " if len (ids ) > 1 else "Would update bug " ,
360+ out .reset ,
361+ ", " .join (map (str , ids )),
362+ )
363+ for field in dataclasses .fields (update ):
364+ value = getattr (update , field .name )
365+ if isinstance (value , ListChange ):
366+ value = " " .join (
367+ chain (
368+ (f"+{ x } " for x in value .add ),
369+ (f"-{ x } " for x in value .remove ),
370+ (f"={ x } " for x in value .replace or ()),
371+ )
372+ )
373+ elif isinstance (value , tuple ):
374+ value = ", " .join (map (str , value ))
375+ elif value is not None :
376+ value = str (value )
377+ self ._field (field .name .replace ("_" , " " ), value )
378+ out .write ()
379+ out .flush ()
380+
381+
307382class GraphNode :
308383 __slots__ = ("bugno" , "category" , "cc_arches" , "edges" , "obsoletes" , "pkgs" , "summary" )
309384
@@ -1184,8 +1259,9 @@ def observe(node: GraphNode):
11841259 )
11851260 self .out .flush ()
11861261
1262+ observer = None if isinstance (bugzilla , DryRunBugzilla ) else observe
11871263 for node in self .starting_nodes :
1188- node .file_bug (bugzilla , auto_cc_arches , block_bugs , self .modified_repo , observe )
1264+ node .file_bug (bugzilla , auto_cc_arches , block_bugs , self .modified_repo , observer )
11891265 self .obsolete_bugs (bugzilla )
11901266
11911267 def obsolete_bugs (self , bugzilla : Bugzilla ):
@@ -1210,7 +1286,9 @@ def _load_from_stdin(out: Formatter):
12101286
12111287@bugs .bind_main_func
12121288def main (options , out : Formatter , err : Formatter ):
1213- if options .api_key is None :
1289+ if options .dry_run :
1290+ options .bugzilla = DryRunBugzilla (options .bugzilla , out )
1291+ elif options .api_key is None :
12141292 err .write (out .fg ("red" ), "No API key provided, exiting" , out .reset )
12151293 return 1
12161294
@@ -1286,11 +1364,14 @@ def main(options, out: Formatter, err: Formatter):
12861364 pending = [node for node in d .nodes if node .bugno is None ]
12871365 if not pending :
12881366 # no bugs to file, but obsoletions of matched bugs may still be pending
1289- if (obsoletes := {bugno for node in d .nodes for bugno in node .obsoletes }) and userquery (
1290- f"No bugs to file, still obsolete { len (obsoletes )} bugs?" ,
1291- out ,
1292- err ,
1293- default_answer = False ,
1367+ if (obsoletes := {bugno for node in d .nodes for bugno in node .obsoletes }) and (
1368+ options .dry_run
1369+ or userquery (
1370+ f"No bugs to file, still obsolete { len (obsoletes )} bugs?" ,
1371+ out ,
1372+ err ,
1373+ default_answer = False ,
1374+ )
12941375 ):
12951376 d .obsolete_bugs (options .bugzilla )
12961377 return 0
@@ -1302,7 +1383,9 @@ def main(options, out: Formatter, err: Formatter):
13021383 }
13031384 summary = ", " .join (f"{ count } { suffix } " for suffix , count in counts .items () if count )
13041385
1305- if not userquery (
1386+ if options .dry_run :
1387+ out .write (f"Dry run, would create { len (pending )} bugs ({ summary } ):\n " )
1388+ elif not userquery (
13061389 f"Continue and create { len (pending )} bugs ({ summary } )?" , out , err , default_answer = False
13071390 ):
13081391 return 1
0 commit comments