[kde-linux/kde-linux] /: Download JUnit XML from openQA pipeline for test reports

Thomas Duckworth <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit ab8a95f098008b66ec6bc27cbdc295ba0e7951e1 by Thomas Duckworth.
Committed on 27/07/2026 at 08:53.
Pushed by tduck into branch 'master'.

Download JUnit XML from openQA pipeline for test reports

Downloads the JUnit XML artifacts from the downstream openQA jobs,
then names them according to their test flow, collecting them as a
test report so we can have a nice summary in merge requests.

M  +22   -0    .gitlab-ci.yml
A  +93   -0    .gitlab-ci/scripts/download-openqa-results.py

https://invent.kde.org/kde-linux/kde-linux/-/commit/ab8a95f098008b66ec6bc27cbdc295ba0e7951e1

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index c1ff729b..585c03ab 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -85,6 +85,28 @@ trigger-openqa:
     SYSUPDATE_PUBKEY_B64: $SYSUPDATE_PUBKEY_B64
     OPENQA_BRANCH: master
 
+# Collect the JUnit files produced by the triggered openQA pipeline.
+openqa-report:
+  stage: test
+  image: python:3.13-alpine
+  rules:
+    - if: $CI_PROJECT_PATH != 'kde-linux/kde-linux'
+      when: never
+    - when: always
+  needs:
+    - job: trigger-openqa
+      artifacts: false
+  script:
+    - python3 .gitlab-ci/scripts/download-openqa-results.py
+  artifacts:
+    when: always
+    expire_in: 4 weeks
+    reports:
+      junit:
+        - openqa-results/**/gitlab-artifacts/*-results.xml
+    paths:
+      - openqa-results/
+
 # Publish the staged image in storage.
 publish:
   stage: publish
diff --git a/.gitlab-ci/scripts/download-openqa-results.py b/.gitlab-ci/scripts/download-openqa-results.py
new file mode 100644
index 00000000..9f6e17de
--- /dev/null
+++ b/.gitlab-ci/scripts/download-openqa-results.py
@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
+# SPDX-FileCopyrightText: 2026 Thomas Duckworth <[email protected]>
+import io
+import json
+import os
+import urllib.parse
+import urllib.request
+import zipfile
+import xml.etree.ElementTree as ET
+from pathlib import Path
+from typing import Any
+
+
+def get(url: str, token: str, json_response: bool = True) -> Any:
+    request = urllib.request.Request(url, headers={"JOB-TOKEN": token})
+    with urllib.request.urlopen(request) as response:
+        return json.load(response) if json_response else response.read()
+
+
+def label(xml: bytes, job: str) -> bytes:
+    root = ET.fromstring(xml)
+    for testcase in root.iter("testcase"):
+        testcase.set("classname", f"{job}.{testcase.get('classname')}")
+    return ET.tostring(root, encoding="utf-8", xml_declaration=True)
+
+
+def main() -> None:
+    # Fetches JUnit results from the downstream openQA pipeline.
+    env = os.environ
+    api = env["CI_API_V4_URL"]
+    token = env["CI_JOB_TOKEN"]
+    project = urllib.parse.quote("kde-linux/os-autoinst-distri-kdelinux", safe="")
+
+    bridges = get(
+        f"{api}/projects/{env['CI_PROJECT_ID']}/pipelines/"
+        f"{env['CI_PIPELINE_ID']}/bridges",
+        token,
+    )
+    bridge = next(
+        (
+            bridge
+            for bridge in bridges
+            if bridge["name"] == "trigger-openqa"
+        ),
+        None,
+    )
+    if bridge is None:
+        return
+
+    downstream = next(
+        (
+            bridge["downstream_pipeline"]["id"]
+            for bridge in [bridge]
+            if bridge["downstream_pipeline"] is not None
+        ),
+        None,
+    )
+    if downstream is None:
+        return
+    jobs = get(f"{api}/projects/{project}/pipelines/{downstream}/jobs", token)
+    output = Path("openqa-results")
+
+    for job in jobs:
+        if not job.get("artifacts_file"):
+            continue
+        archive: bytes = get(
+            f"{api}/projects/{project}/jobs/{job['id']}/artifacts",
+            token,
+            False,
+        )
+        with zipfile.ZipFile(io.BytesIO(archive)) as zipped:
+            for name in zipped.namelist():
+                if name.startswith("gitlab-artifacts/") and name.endswith("-results.xml"):
+                    xml = zipped.read(name)
+
+                    if not xml.strip():
+                        print(f"skipping empty XML file {name!r} from job {job['name']!r}")
+                        continue
+
+                    try:
+                        labelled_xml = label(xml, str(job["name"]))
+                    except ET.ParseError as error:
+                        print(f"skipping {name!r}: {error}")
+                        continue
+
+                    result = output / str(job["id"]) / name
+                    result.parent.mkdir(parents=True, exist_ok=True)
+                    result.write_bytes(labelled_xml)
+
+
+if __name__ == "__main__":
+    main()
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.