11#!/usr/bin/env python3
22# Copyright SUSE LLC
3- # ruff: noqa: ANN001, ANN201, B005, C901, G001, G002, G003, G004, PLR0911, PLR0916, PLW1510, PYI063, S113, S324, S404, S603, TRY401, FBT002, SIM112, UP031
4-
3+ # ruff: noqa: ANN001, ANN201, B005, PLW1510, PYI063, S113, S324, S404, S603, TRY401, FBT002, SIM112
54"""Trigger openQA tests bisecting maintenance updates.
65
76Determine which pending operating system maintenance update, called "incident",
@@ -10,6 +9,8 @@ Can be called manually and is also called by openQA hook scripts, see
109http://open.qa/docs/#_enable_custom_hook_scripts_on_job_done_based_on_result.
1110"""
1211
12+ from __future__ import annotations
13+
1314import argparse
1415import hashlib
1516import json
@@ -38,12 +39,16 @@ class CustomFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescri
3839
3940@total_ordering
4041class Incident :
42+ """Maintenance incident representation."""
43+
4144 def __init__ (self , inc : str ) -> None :
45+ """Initialize incident from string."""
4246 self .incident = inc
4347 self ._incident_id = None
4448
4549 @property
46- def incident_id (self ):
50+ def incident_id (self ) -> str :
51+ """Return the incident ID."""
4752 if self ._incident_id :
4853 return self ._incident_id
4954
@@ -56,22 +61,32 @@ class Incident:
5661 return self ._incident_id
5762
5863 def __str__ (self ) -> str :
64+ """Return the incident string."""
5965 return self .incident
6066
61- def __eq__ (self , __o ) -> bool :
67+ def __eq__ (self , __o : object ) -> bool :
68+ """Return whether two incidents are equal."""
69+ if not isinstance (__o , Incident ):
70+ return NotImplemented
6271 return self .incident_id == __o .incident_id
6372
64- def __gt__ (self , __o ) -> bool :
73+ def __gt__ (self , __o : object ) -> bool :
74+ """Return whether this incident ID is greater than the other."""
75+ if not isinstance (__o , Incident ):
76+ return NotImplemented
6577 return int (self .incident_id ) > int (__o .incident_id )
6678
6779 def __hash__ (self ) -> int :
80+ """Return the hash of the incident."""
6881 return int (hashlib .md5 (self .incident .encode ()).hexdigest (), base = 16 )
6982
7083 def __repr__ (self ) -> str :
84+ """Return the representation of the incident."""
7185 return f"<Incident -> { self .incident } "
7286
7387
7488def parse_args ():
89+ """Parse command line arguments."""
7590 parser = argparse .ArgumentParser (description = __doc__ , formatter_class = CustomFormatter )
7691 parser .add_argument (
7792 "-v" ,
@@ -99,7 +114,8 @@ def parse_args():
99114 3 : logging .INFO ,
100115 4 : logging .DEBUG ,
101116 }
102- logging_level = logging .DEBUG if args .verbose > 4 else verbose_to_log [args .verbose ]
117+ max_verbose = 4
118+ logging_level = logging .DEBUG if args .verbose > max_verbose else verbose_to_log [args .verbose ]
103119 log .setLevel (logging_level )
104120 return args
105121
@@ -113,20 +129,23 @@ client_args = [
113129
114130
115131def call (cmds , dry_run = False ):
116- log .debug ("call: {}" .format (cmds ))
132+ """Call a command and return stdout."""
133+ log .debug ("call: %s" , cmds )
117134 res = subprocess .run ((["echo" , "Simulating: " ] if dry_run else []) + cmds , capture_output = True )
118135 if len (res .stderr ):
119- log .warning (f "call() { cmds [0 ]} stderr: { res .stderr } " )
136+ log .warning ("call() %s stderr: %s" , cmds [0 ], res .stderr )
120137 res .check_returncode ()
121138 return res .stdout .decode ("utf-8" )
122139
123140
124141def openqa_comment (job , host , comment , dry_run ):
142+ """Post a comment to an openQA job."""
125143 args = [* client_args , "--host" , host , "-X" , "POST" , "jobs/" + str (job ) + "/comments" , "text=" + comment ]
126144 return call (args , dry_run )
127145
128146
129147def openqa_set_job_prio (job_id , host , prio , dry_run ):
148+ """Set the priority of an openQA job."""
130149 prio_json = json .dumps ({"priority" : prio })
131150 args = [* client_args , "--host" , host , "--json" , "--data" , prio_json , "-X" , "PUT" , "jobs/" + str (job_id )]
132151 return call (args , dry_run )
@@ -138,6 +157,7 @@ def openqa_clone(
138157 default_opts = None ,
139158 default_cmds = None ,
140159):
160+ """Clone an openQA job."""
141161 if default_cmds is None :
142162 default_cmds = ["_GROUP=0" ]
143163 if default_opts is None :
@@ -146,23 +166,25 @@ def openqa_clone(
146166
147167
148168def fetch_url (url , request_type = "text" ):
169+ """Fetch content from URL."""
149170 try :
150171 content = requests .get (url , headers = {"User-Agent" : USER_AGENT })
151172 content .raise_for_status ()
152173 except requests .exceptions .RequestException as e :
153- log .exception ("Error while fetching {}: {}" . format ( url , str ( e )) )
174+ log .exception ("Error while fetching %s: %s" , url , e )
154175 raise
155176 raw = content .content
156177 if request_type == "json" :
157178 try :
158179 content = content .json ()
159180 except json .decoder .JSONDecodeError as e :
160- log .exception ("Error while decoding JSON from {} -> >>{} <<: {}" . format ( url , raw , str ( e )) )
181+ log .exception ("Error while decoding JSON from %s -> >>%s <<: %s" , url , raw , e )
161182 raise
162183 return content
163184
164185
165186def find_changed_issues (investigation ):
187+ """Find changed issues in investigation text."""
166188 changes = {}
167189 pattern = re .compile (r"(?P<diff>[+-])\s+\"(?P<key>[A-Z]+_TEST_(?:ISSUES|REPOS))\"\s*:\s*\"(?P<var>[^\"]*)\"," )
168190
@@ -178,6 +200,11 @@ def find_changed_issues(investigation):
178200 if not changes [key ].get (BAD ) or not changes [key ].get (GOOD ):
179201 del changes [key ]
180202 continue
203+ removed_key , added_key = (
204+ list (changes [key ][GOOD ] - changes [key ][BAD ]),
205+ list (changes [key ][BAD ] - changes [key ][GOOD ]),
206+ )
207+ log .debug ("[%s] removed: %s, added: %s" , key , removed_key , added_key )
181208 if len (changes [key ][BAD ]) <= 1 :
182209 # no value in triggering single-incident bisections
183210 del changes [key ]
@@ -188,119 +215,134 @@ def find_changed_issues(investigation):
188215 return changes_repos or changes
189216
190217
191- def main (args ) -> None :
192- parsed_url = urlparse (args .url )
193- base_url = urlunparse ((parsed_url .scheme , parsed_url .netloc , "" , "" , "" , "" ))
194- job_id = parsed_url .path .lstrip ("/tests/" )
195- test_url = f"{ base_url } /api/v1/jobs/{ job_id } "
196- log .debug ("Retrieving job data from {}" .format (test_url ))
197- test_data = fetch_url (test_url , request_type = "json" )
198- job = test_data ["job" ]
218+ def _check_skip_bisection (job : dict ) -> bool :
219+ """Return whether bisection should be skipped for this job."""
199220 if job ["result" ] == "passed" :
200- log .info ("Job %d (%s) is passed, skipping bisection" % (job ["id" ], job ["test" ]))
201- return
202- search = re .search (r":investigate:" , job ["test" ])
203- if search :
204- log .info ("Job %d (%s) is already an investigation, skipping bisection" % (job ["id" ], job ["test" ]))
205- return
221+ log .info ("Job %d (%s) is passed, skipping bisection" , job ["id" ], job ["test" ])
222+ return True
223+ if r":investigate:" in job ["test" ]:
224+ log .info ("Job %d (%s) is already an investigation, skipping bisection" , job ["id" ], job ["test" ])
225+ return True
206226 if job .get ("clone_id" ) is not None :
207- log .info ("Job %d already has a clone, skipping bisection" % job ["id" ])
208- return
227+ log .info ("Job %d already has a clone, skipping bisection" , job ["id" ])
228+ return True
209229
210- children = job .get ("children" , [] )
211- parents = job .get ("parents" , [] )
212- if (
230+ children = job .get ("children" , {} )
231+ parents = job .get ("parents" , {} )
232+ return bool (
213233 ("Parallel" in children and len (children ["Parallel" ]))
214234 or ("Directly chained" in children and len (children ["Directly chained" ]))
215235 or ("Parallel" in parents and len (parents ["Parallel" ]))
216236 or ("Directly chained" in parents and len (parents ["Directly chained" ]))
217- ):
218- return
237+ )
219238
220- investigation_url = f"{ base_url } /tests/{ job_id } /investigation_ajax"
221- log .debug ("Retrieving investigation info from {}" .format (investigation_url ))
222- investigation = fetch_url (investigation_url , request_type = "json" )
223- log .debug ("Received investigation info: {}" .format (investigation ))
224- if "diff_to_last_good" not in investigation :
225- return
226- all_changes = find_changed_issues (investigation ["diff_to_last_good" ])
227-
228- if not all_changes :
229- return
230239
240+ def _check_exclude_regex (job : dict ) -> bool :
241+ """Return whether the job matches exclude regex from environment."""
231242 exclude_group_regex = os .environ .get ("exclude_group_regex" , "" )
232243 if len (exclude_group_regex ) > 0 :
233244 full_group = job .get ("group" , "" )
234245 if "parent_group" in job :
235246 full_group = "{} / {}" .format (job ["parent_group" ], full_group )
236247 if re .search (exclude_group_regex , full_group ):
237- log .debug ("job group '{} ' matches 'exclude_group_regex', skipping" . format ( full_group ) )
238- return
248+ log .debug ("job group '%s ' matches 'exclude_group_regex', skipping" , full_group )
249+ return True
239250
240251 exclude_name_regex = os .environ .get ("exclude_name_regex" , "" )
241252 if len (exclude_name_regex ) > 0 and re .search (exclude_name_regex , job ["test" ]):
242- log .debug ("job name '{}' matches 'exclude_name_regex', skipping" .format (job ["test" ]))
243- return
253+ log .debug ("job name '%s' matches 'exclude_name_regex', skipping" , job ["test" ])
254+ return True
255+ return False
256+
257+
258+ def _trigger_bisect_job (args , base_url , job_id , test_name , line ) -> str :
259+ """Trigger a single bisection job."""
260+ params = (
261+ [args .url ]
262+ + [f"{ k } ={ v } " for k , v in line .items ()]
263+ + [
264+ f"TEST={ test_name } " ,
265+ f"OPENQA_INVESTIGATE_ORIGIN={ args .url } " ,
266+ "MAINT_TEST_REPO=" ,
267+ ]
268+ )
244269
245- log .debug ("Received job data: {}" .format (test_data ))
246- test = job ["settings" ]["TEST" ]
247- prio = int (job ["priority" ]) + args .priority_add
248- log .debug ("Found test name '{}'" .format (test ))
270+ try :
271+ out = openqa_clone (params , args .dry_run )
272+ except subprocess .SubprocessError as err :
273+ if "the repositories for the below updates are unavailable" in str (err .stderr ):
274+ extra_comment = "Not triggering any bisect jobs because: "
275+ openqa_comment (job_id , base_url , f"{ extra_comment } { err .stderr } " , args .dry_run )
276+ sys .exit (0 )
277+ raise
278+ return out
249279
250- created = ""
280+
281+ def _process_bisections (args , base_url , job_id , all_changes , test , prio ) -> str : # noqa: PLR0913, PLR0917
282+ """Process all potential bisections."""
251283 added = []
252284 for key in all_changes :
253- changes = all_changes [key ]
254- removed_key , added_key = list (changes [GOOD ] - changes [BAD ]), list (changes [BAD ] - changes [GOOD ])
255- log .debug ("[{}] removed: {}, added: {}" .format (key , removed_key , added_key ))
256- added += added_key
285+ added += list (all_changes [key ][BAD ] - all_changes [key ][GOOD ])
257286
287+ created = ""
258288 # whole sort is to simplify testability of code
259289 for issue in sorted ({i .incident_id for i in added }, key = int ):
260290 line = {}
261- log .info ("Triggering one bisection job without issue '{}'" . format ( issue ) )
291+ log .info ("Triggering one bisection job without issue '%s'" , issue )
262292 for key in all_changes :
263293 # use only VARS where is incident present in BAD
264294 if [i for i in all_changes [key ][BAD ] if i .incident_id == issue ]:
265295 line [key ] = "," .join (str (i ) for i in sorted (all_changes [key ][BAD ]) if i .incident_id != issue )
266- log .debug ("New set of {}='{}'" .format (key , line [key ]))
267-
268- test_name = test + ":investigate:bisect_without_{}" .format (issue )
269- params = (
270- [args .url ]
271- + [k + "=" + v for k , v in line .items ()]
272- + [
273- "TEST=" + test_name ,
274- "OPENQA_INVESTIGATE_ORIGIN=" + args .url ,
275- "MAINT_TEST_REPO=" ,
276- ]
277- )
296+ log .debug ("New set of %s='%s'" , key , line [key ])
278297
279- try :
280- out = openqa_clone (
281- params ,
282- args .dry_run ,
283- )
284- except subprocess .SubprocessError as err :
285- if "the repositories for the below updates are unavailable" in str (err .stderr ):
286- extra_comment = "Not triggering any bisect jobs because: "
287- openqa_comment (job_id , base_url , f"{ extra_comment } { err .stderr } " , args .dry_run )
288- sys .exit (0 )
289- else :
290- raise
298+ test_name = f"{ test } :investigate:bisect_without_{ issue } "
299+ out = _trigger_bisect_job (args , base_url , job_id , test_name , line )
291300
292301 created_job_ids = []
293302 try :
294303 created_job_ids = json .loads (out ).values ()
295304 except Exception :
296- log .exception ("openqa-clone-job returned non-JSON output: " + out )
297- for job_id in sorted (created_job_ids ):
298- log .info ("Created %s" , job_id )
299- created += f"* **{ test_name } **: { base_url } /t{ job_id } \n "
300- openqa_set_job_prio (job_id , args .url , prio , args .dry_run )
301-
302- if len (created ):
303- comment = "Automatic bisect jobs:\n \n " + created
305+ log .exception ("openqa-clone-job returned non-JSON output: %s" , out )
306+ for created_id in sorted (created_job_ids ):
307+ log .info ("Created %s" , created_id )
308+ created += f"* **{ test_name } **: { base_url } /t{ created_id } \n "
309+ openqa_set_job_prio (created_id , args .url , prio , args .dry_run )
310+ return created
311+
312+
313+ def main (args ) -> None :
314+ """Execute main function."""
315+ parsed_url = urlparse (args .url )
316+ base_url = urlunparse ((parsed_url .scheme , parsed_url .netloc , "" , "" , "" , "" ))
317+ job_id = parsed_url .path .lstrip ("/tests/" )
318+ test_url = f"{ base_url } /api/v1/jobs/{ job_id } "
319+ log .debug ("Retrieving job data from %s" , test_url )
320+ test_data = fetch_url (test_url , request_type = "json" )
321+ job = test_data ["job" ]
322+
323+ if _check_skip_bisection (job ) or _check_exclude_regex (job ):
324+ return
325+
326+ investigation_url = f"{ base_url } /tests/{ job_id } /investigation_ajax"
327+ log .debug ("Retrieving investigation info from %s" , investigation_url )
328+ investigation = fetch_url (investigation_url , request_type = "json" )
329+ log .debug ("Received investigation info: %s" , investigation )
330+ if "diff_to_last_good" not in investigation :
331+ return
332+ all_changes = find_changed_issues (investigation ["diff_to_last_good" ])
333+
334+ if not all_changes :
335+ return
336+
337+ log .debug ("Received job data: %s" , test_data )
338+ test = job ["settings" ]["TEST" ]
339+ prio = int (job ["priority" ]) + args .priority_add
340+ log .debug ("Found test name '%s'" , test )
341+
342+ created = _process_bisections (args , base_url , job_id , all_changes , test , prio )
343+
344+ if created :
345+ comment = f"Automatic bisect jobs:\n \n { created } "
304346 openqa_comment (job ["id" ], base_url , comment , args .dry_run )
305347
306348
0 commit comments