From ab8a95f098008b66ec6bc27cbdc295ba0e7951e1 Mon Sep 17 00:00:00 2001 From: Thomas Duckworth Date: Sun, 26 Jul 2026 16:12:12 +1000 Subject: [PATCH] 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. --- .gitlab-ci.yml | 22 +++++ .gitlab-ci/scripts/download-openqa-results.py | 93 +++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 .gitlab-ci/scripts/download-openqa-results.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index c1ff729..585c03a 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 0000000..9f6e17d --- /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 +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()