Skip to content

Commit 26e9d1b

Browse files
authored
Merge pull request #581 from okurz/fix/style_trigger_bisect
fix(openqa-trigger-bisect-jobs): refactor for lint compliance
2 parents edae897 + 9780719 commit 26e9d1b

2 files changed

Lines changed: 130 additions & 88 deletions

File tree

openqa-trigger-bisect-jobs

Lines changed: 129 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
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
76
Determine 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
109
http://open.qa/docs/#_enable_custom_hook_scripts_on_job_done_based_on_result.
1110
"""
1211

12+
from __future__ import annotations
13+
1314
import argparse
1415
import hashlib
1516
import json
@@ -38,12 +39,16 @@ class CustomFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescri
3839

3940
@total_ordering
4041
class 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

7488
def 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

115131
def 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

124141
def 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

129147
def 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

148168
def 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

165186
def 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

tests/test_trigger_bisect_jobs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def test_problems() -> None:
246246
args.url = "http://openqa.opensuse.org/tests/101"
247247
openqa.log.info = MagicMock()
248248
openqa.main(args)
249-
openqa.log.info.assert_called_with("Job 101 (foo) is passed, skipping bisection")
249+
openqa.log.info.assert_called_with("Job %d (%s) is passed, skipping bisection", 101, "foo")
250250
assert (
251251
call(
252252
"http://openqa.opensuse.org/tests/101/investigation_ajax",

0 commit comments

Comments
 (0)