Skip to content

Commit 1f39e71

Browse files
pluskymimi1vx
authored andcommitted
fix(dashboard): drop obsoleted openQA jobs and stop the false "All jobs passed"
Two correctness bugs in the dashboard-backed auto-workflow openQA provider (DashboardAutoOpenQA), both in how it interprets job results. Obsoleted (superseded) jobs were never filtered. _normalize_job captured the dashboard's `obsolete` flag but nothing used it, so a retriggered job's stale older run stayed in the working set: its old result was folded into the install verdict (_has_passed_install_jobs) and it surfaced as a phantom entry in the failed-jobs listing. A qam-incidentinstall that failed, was retriggered, and now passes was therefore reported as "no successful install". Drop obsoleted runs in _load_jobs (new _is_obsolete helper: the `obsolete` flag or an `obsoleted` result), matching oqa_search.incident_jobs; only the current run per scenario is kept. The section trailer printed "All jobs passed." whenever failed_by_group was empty, even when problem_keys was not. A group whose only problem lives in the `other` bucket (still-running result=none, parallel_failed, skipped, ...) is flagged in the Summary but carries no failed/incomplete/timeout job, so the section contradicted itself. Gate the trailer on problem_keys: emit a "some groups need review" note for that case, keep "All jobs passed." only when there are genuinely no problem groups, and never print an empty Failed jobs block. Both fixes are revert-verified by the added tests. Resolves findings openSUSE#7 and openSUSE#8 from the recent code review.
1 parent 995e744 commit 1f39e71

3 files changed

Lines changed: 190 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,18 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht
349349
logged and abandoned so `close()` — and the http registry's idle-sweep behind
350350
it — always returns. Previously the bound was defeated by the thread pool's
351351
context-manager exit, which re-joined every worker regardless of the timeout.
352+
- The dashboard-backed auto-workflow openQA report no longer counts superseded
353+
(obsoleted) jobs. When a job is retriggered the dashboard keeps the older run;
354+
its stale result was still folded into the install verdict and listed as a
355+
phantom failure. Obsoleted runs (marked by the dashboard's `obsolete` flag or
356+
an `obsoleted` result) are now dropped, matching the openQA-search connector,
357+
so a retriggered-and-now-passing install is reported as passed and only the
358+
current run per scenario is shown.
359+
- The dashboard-backed auto-workflow openQA section no longer prints
360+
"All jobs passed." while its own Summary lists a problem group. A group whose
361+
only problem is a still-running, `parallel_failed`, or otherwise non-failed
362+
job (i.e. it has no failed/incomplete/timeout job) is now reported with a
363+
"some groups need review" note instead of a contradictory all-passed verdict.
352364
- An unscoped multi-template fan-out of a host-phase command (e.g. `lock`,
353365
`run`) no longer fails the whole fan-out — or, for `lock`, pretends to
354366
succeed — when a loaded template has no connected host. A host-less template

mtui/data_sources/qem_dashboard/dashboard_openqa.py

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,17 @@ def _fetch(source: str, setting_id: int) -> list[dict[str, Any]]:
9898
)
9999
future.cancel()
100100
continue
101-
jobs.extend(
102-
self._normalize_job(job, source, setting) for job in setting_jobs
103-
)
101+
for job in setting_jobs:
102+
normalized = self._normalize_job(job, source, setting)
103+
if self._is_obsolete(normalized):
104+
logger.debug(
105+
"dropping obsoleted %s job %s (%s)",
106+
source,
107+
normalized.get("id"),
108+
normalized.get("test"),
109+
)
110+
continue
111+
jobs.append(normalized)
104112

105113
return jobs
106114

@@ -154,6 +162,20 @@ def _normalize_job(
154162
normalized["incidents"] = setting.get("incidents") or []
155163
return normalized
156164

165+
@staticmethod
166+
def _is_obsolete(job: dict[str, Any]) -> bool:
167+
"""True for a superseded run that must not count toward results.
168+
169+
When an openQA job is retriggered the dashboard keeps the older
170+
run but marks it superseded — either with an ``obsolete`` flag or
171+
an ``"obsoleted"`` result. Both must be dropped so a stale failure
172+
does not poison the install verdict (:meth:`_has_passed_install_jobs`)
173+
or surface as a phantom entry in the failed-jobs listing. Mirrors
174+
:func:`mtui.data_sources.oqa_search.search.incident_jobs`, which
175+
filters ``result == "obsoleted"``; only the current run matters.
176+
"""
177+
return bool(job.get("obsolete")) or job.get("result") == "obsoleted"
178+
157179
@staticmethod
158180
def _normalize_result(result: str | None) -> bool:
159181
return result in ("passed", "softfailed")
@@ -429,6 +451,16 @@ def _pretty_print_section(
429451
ret.append(f" {test.ljust(width)}{suffix}\n")
430452
else:
431453
ret.append(f" {test.ljust(width)} [{result}]{suffix}\n")
454+
elif problem_keys:
455+
# Problem groups exist but none carry a failed/incomplete/
456+
# timeout_exceeded job, so `failed_by_group` is empty — their
457+
# problems are entirely in the `other` bucket (still-running,
458+
# parallel_failed, skipped, ...). The Summary already flags
459+
# them; don't claim success (the old bug) and don't print an
460+
# empty `Failed jobs:` block.
461+
ret.append(
462+
" No failed jobs, but some groups need review (see Summary above).\n"
463+
)
432464
else:
433465
ret.append(" All jobs passed.\n")
434466
ret.append("\n")

tests/test_qem_dashboard_connector.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -759,3 +759,146 @@ def test_has_passed_install_jobs_counts_slfo_jobs(mock_config):
759759
assert DashboardAutoOpenQA._has_passed_install_jobs(jobs) is True
760760
failed = [{"test": "qam-incidentinstall-SLFO", "result": "failed"}]
761761
assert DashboardAutoOpenQA._has_passed_install_jobs(failed) is False
762+
763+
764+
# --------------------------------------------------------------------------- #
765+
# Finding #7: a problem group whose only problem is in the `other` bucket #
766+
# (still-running / parallel_failed / ...) must not be reported "All passed". #
767+
# --------------------------------------------------------------------------- #
768+
769+
770+
def test_pretty_print_parallel_failed_not_reported_as_all_passed(mock_config):
771+
"""A `parallel_failed` job flags a problem group; the section must not lie.
772+
773+
`parallel_failed` is not in `FAILED_RESULTS`, so it lands in the `other`
774+
bucket and populates no `failed_by_group` entry. The old trailer gated on
775+
`failed_by_group` and therefore printed "All jobs passed." even though the
776+
Summary listed the group as a problem.
777+
"""
778+
dashboard = _make_dashboard(mock_config)
779+
# A clean group (folds into an all-passed row) plus a distinct group whose
780+
# only job is parallel_failed (a problem living entirely in `other`).
781+
jobs = [_incident_job(4000 + i, f"qam-pass-{i}", "passed") for i in range(3)]
782+
jobs.append(
783+
_incident_job(4100, "qam-parallel", "parallel_failed", flavor="Other-Flavor")
784+
)
785+
out = "".join(dashboard._pretty_print(jobs))
786+
787+
# The parallel_failed job is counted under `other`, so its group is a problem.
788+
assert "other: 1" in out
789+
# It must NOT claim success while the Summary flags a problem group.
790+
assert "All jobs passed." not in out
791+
# No failed/incomplete/timeout job -> no (empty) `Failed jobs:` block either.
792+
assert "Failed jobs:" not in out
793+
# Instead, an explicit needs-review note is emitted.
794+
assert "some groups need review" in out
795+
796+
797+
def test_pretty_print_running_job_not_reported_as_all_passed(mock_config):
798+
"""A still-running job (openQA result ``none``) must not read as all-passed."""
799+
dashboard = _make_dashboard(mock_config)
800+
jobs = [_incident_job(4200 + i, f"qam-pass-{i}", "passed") for i in range(2)]
801+
jobs.append(_incident_job(4300, "qam-running", "none"))
802+
out = "".join(dashboard._pretty_print(jobs))
803+
804+
assert "other: 1" in out
805+
assert "All jobs passed." not in out
806+
assert "some groups need review" in out
807+
808+
809+
# --------------------------------------------------------------------------- #
810+
# Finding #8: obsoleted (superseded) jobs must be dropped from the working set #
811+
# so a stale failure poisons neither the install verdict nor the listing. #
812+
# --------------------------------------------------------------------------- #
813+
814+
815+
def test_is_obsolete_flags_superseded_runs():
816+
"""Both the `obsolete` flag and an `obsoleted` result mark a stale run."""
817+
assert DashboardAutoOpenQA._is_obsolete({"obsolete": True}) is True
818+
assert DashboardAutoOpenQA._is_obsolete({"result": "obsoleted"}) is True
819+
assert (
820+
DashboardAutoOpenQA._is_obsolete({"obsolete": False, "result": "failed"})
821+
is False
822+
)
823+
assert DashboardAutoOpenQA._is_obsolete({}) is False
824+
825+
826+
@responses.activate
827+
def test_load_jobs_drops_obsoleted_runs(mock_config):
828+
"""A retriggered install's stale failed run must not sink the verdict.
829+
830+
The dashboard returns the superseded run alongside the current one. If it
831+
is not filtered, `_has_passed_install_jobs` sees a failed
832+
`qam-incidentinstall` and returns False, so `results` is None and the
833+
failed run shows up as a phantom failure.
834+
"""
835+
rrid = RequestReviewID("SUSE:Maintenance:12358:199773")
836+
responses.add(
837+
responses.GET,
838+
f"{API}/incidents/12358",
839+
json={"number": 12358, "packages": ["bash"], "channels": []},
840+
status=200,
841+
)
842+
responses.add(
843+
responses.GET,
844+
f"{API}/incident_settings/12358",
845+
json=[
846+
{
847+
"id": 7,
848+
"incident": 12358,
849+
"version": "15-SP5",
850+
"flavor": "Server-DVD-Incidents",
851+
"arch": "x86_64",
852+
"settings": {"DISTRI": "sle"},
853+
}
854+
],
855+
status=200,
856+
)
857+
858+
def _install_job(job_id, status, *, obsolete=False):
859+
return {
860+
"job_id": job_id,
861+
"incident_settings": 7,
862+
"update_settings": None,
863+
"name": "qam-incidentinstall",
864+
"job_group": "Maintenance",
865+
"group_id": 1,
866+
"status": status,
867+
"distri": "sle",
868+
"flavor": "Server-DVD-Incidents",
869+
"arch": "x86_64",
870+
"version": "15-SP5",
871+
"build": ":12358:bash",
872+
"obsolete": obsolete,
873+
}
874+
875+
responses.add(
876+
responses.GET,
877+
f"{API}/jobs/incident/7",
878+
json=[
879+
_install_job(1000, "failed", obsolete=True), # superseded (flag)
880+
_install_job(999, "obsoleted"), # superseded (result marker)
881+
_install_job(1001, "passed"), # the current run
882+
],
883+
status=200,
884+
)
885+
responses.add(
886+
responses.GET,
887+
f"{API}/update_settings/12358",
888+
json=[],
889+
status=200,
890+
)
891+
892+
incident = QEMIncident(rrid, API)
893+
dashboard = DashboardAutoOpenQA(mock_config, OPENQA_HOST, incident, rrid).run()
894+
895+
# Only the current (non-superseded) run survives.
896+
assert len(dashboard.jobs) == 1
897+
assert dashboard.jobs[0]["id"] == 1001
898+
# The stale failed run did not poison the install verdict.
899+
assert dashboard.results is not None
900+
assert len(dashboard.results) == 1
901+
# And it is not listed as a phantom failure.
902+
pp = "".join(dashboard.pp)
903+
assert "Failed jobs:" not in pp
904+
assert "obsoleted" not in pp

0 commit comments

Comments
 (0)