From 6c9c6f9f809c9e1495293a328274172ad0a71079 Mon Sep 17 00:00:00 2001 From: Giles Hutton Date: Thu, 13 Aug 2026 15:58:24 +0100 Subject: [PATCH] feat(test): add standalone junit2jira action Add a self-contained composite action that converts JUnit test failures into Jira tickets and uploads test metrics to GCS for BigQuery. Unlike the stackrox/stackrox junit2jira action, this bundles its own helper script (via GITHUB_ACTION_PATH) so calling repositories do not need to provide scripts/ci helpers. This lets multiple repos (e.g. collector) reuse it. - Optional gcp-account input: authenticate gcloud in-action, or reuse an existing session from the caller. - Configurable jira-url, gcs-bucket and gcs-subdir (previously hard-coded). - gcp-metrics toggle to skip the metrics upload independently of Jira. --- README.md | 1 + test/junit2jira/README.md | 148 +++++++++++++++++++++++++++ test/junit2jira/action.yml | 138 +++++++++++++++++++++++++ test/junit2jira/junit2jira.sh | 184 ++++++++++++++++++++++++++++++++++ 4 files changed, 471 insertions(+) create mode 100644 test/junit2jira/README.md create mode 100644 test/junit2jira/action.yml create mode 100755 test/junit2jira/junit2jira.sh diff --git a/README.md b/README.md index efc159db..5eb28f53 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ * [Release / Tag](release/tag/README.md) * [Roxie / Install CLI](roxie/install-cli/README.md) * [Test](test/README.md) +* [Test / junit2jira](test/junit2jira/README.md) ## Workflows diff --git a/test/junit2jira/README.md b/test/junit2jira/README.md new file mode 100644 index 00000000..88103c32 --- /dev/null +++ b/test/junit2jira/README.md @@ -0,0 +1,148 @@ +# Convert JUnit test failures into Jira tickets + +Scans a directory of JUnit XML reports and creates (or deduplicates) Jira issues +for test failures using [`junit2jira`](https://github.com/stackrox/junit2jira). +Optionally uploads a CSV of test metrics to GCS for BigQuery ingestion. + +If the GitHub job failed but produced no test-level `` records, the +action synthesises a JUnit failure so infrastructure/setup failures are still +reported. + +The action is self-contained: it bundles its own helper scripts and does not +require the calling repository to provide any `scripts/ci` helpers. + +## Recommended permissions + +The action doesn't require any specific permission. + +```yaml +permissions: {} +``` + +## All options + +| Input | Description | Default | +| ------------------------------ | --------------------------------------------------------------------------------------- | ------------------------------ | +| [create-jiras](#create-jiras) | Whether to actually create Jira issues (otherwise runs `--dry-run`) | `true` | +| [jira-user](#jira-user) | User used to authenticate with Jira | | +| [jira-token](#jira-token) | Token used to authenticate with Jira | | +| [jira-url](#jira-url) | Base URL of the Jira instance | `https://redhat.atlassian.net/`| +| [directory](#directory) | Directory containing the JUnit XML files to scan | | +| [threshold](#threshold) | Minimal number of failures that results in a single cumulative Jira issue | `5` | +| [gcp-account](#gcp-account) | Optional GCP service account JSON. When set, the action authenticates gcloud itself | unset | +| [gcp-metrics](#gcp-metrics) | Whether to upload test metrics to GCS for BigQuery | `true` | +| [gcs-bucket](#gcs-bucket) | GCS bucket root used to store test metrics | `gs://stackrox-ci-artifacts` | +| [gcs-subdir](#gcs-subdir) | Subdirectory (relative to the bucket root) used to store test metrics | `test-metrics/upload` | +| [version](#version) | `junit2jira` release version to download | `v0.0.27` | + +## Outputs + +| Output | Description | +| ----------- | -------------------------------------------------- | +| `new-jiras` | `"true"`/`"false"` — whether new issues were created | + +### Detailed options + +#### create-jiras + +Whether to actually create Jira issues. When `false`, `junit2jira` runs with +`--dry-run` and no issues are created. Commonly wired to only create issues on +pushes: `${{ github.event_name == 'push' }}`. + +Default value: `true` + +#### jira-user + +User used to authenticate with Jira. Pass via a secret, e.g. +`${{ secrets.JIRA_USER }}`. + +#### jira-token + +Token used to authenticate with Jira. Pass via a secret, e.g. +`${{ secrets.JIRA_TOKEN }}`. If empty, the reporting step is skipped so the +action no-ops gracefully on forks/PRs without secrets. + +#### jira-url + +Base URL of the Jira instance. + +Default value: `https://redhat.atlassian.net/` + +#### directory + +Directory containing the JUnit XML files to scan. `junit2jira` scans it +recursively for `*.xml` files. + +#### threshold + +Minimal number of failed tests that results in a single cumulative Jira issue +instead of one issue per failure. + +Default value: `5` + +#### gcp-account + +Optional GCP service account JSON. When provided, the action authenticates with +gcloud itself (via `google-github-actions/auth`). When omitted, the action +assumes the caller has already authenticated gcloud. + +Default value: unset + +#### gcp-metrics + +Whether to upload the test metrics CSV to GCS for BigQuery ingestion. Requires +an authenticated gcloud session (see `gcp-account`). + +Default value: `true` + +#### gcs-bucket + +GCS bucket root used to store test metrics. + +Default value: `gs://stackrox-ci-artifacts` + +#### gcs-subdir + +Subdirectory (relative to the bucket root) used to store test metrics. + +Default value: `test-metrics/upload` + +#### version + +`junit2jira` release version to download. + +Default value: `v0.0.27` + +## Usage + +The action assumes gcloud is already authenticated (e.g. via +`google-github-actions/auth`) unless `gcp-account` is provided. + +```yaml +jobs: + test: + runs-on: ubuntu-latest + steps: + # ... run tests, producing JUnit XML under junit-reports/ ... + + - name: Report test failures to Jira + if: (!cancelled()) + id: junit2jira + uses: stackrox/actions/test/junit2jira@main + with: + create-jiras: ${{ github.event_name == 'push' }} + jira-user: ${{ secrets.JIRA_USER }} + jira-token: ${{ secrets.JIRA_TOKEN }} + directory: junit-reports +``` + +To have the action authenticate to GCP itself, pass a service account: + +```yaml + - uses: stackrox/actions/test/junit2jira@main + with: + jira-user: ${{ secrets.JIRA_USER }} + jira-token: ${{ secrets.JIRA_TOKEN }} + directory: junit-reports + gcp-account: ${{ secrets.GCP_SERVICE_ACCOUNT }} +``` diff --git a/test/junit2jira/action.yml b/test/junit2jira/action.yml new file mode 100644 index 00000000..0e773cbd --- /dev/null +++ b/test/junit2jira/action.yml @@ -0,0 +1,138 @@ +name: junit2jira +description: Convert JUnit test failures into Jira tickets and upload test metrics + +inputs: + create-jiras: + description: Whether to actually create Jira issues. When false, junit2jira runs with --dry-run. + required: false + default: "true" + jira-user: + description: User used to authenticate with Jira. + required: true + jira-token: + description: Token used to authenticate with Jira. + required: true + jira-url: + description: Base URL of the Jira instance. + required: false + default: https://redhat.atlassian.net/ + directory: + description: Directory containing the JUnit XML files to scan. + required: true + threshold: + description: Minimal number of failed tests that will result in a single cumulative Jira issue. + required: false + default: "5" + gcp-account: + description: | + Optional GCP service account JSON. When provided, the action authenticates + with gcloud itself. When omitted, the action assumes the caller has already + authenticated gcloud (e.g. via google-github-actions/auth). + required: false + default: "" + gcp-metrics: + description: Whether to upload test metrics to GCS for BigQuery ingestion. + required: false + default: "true" + gcs-bucket: + description: GCS bucket root used to store test metrics. + required: false + default: gs://stackrox-ci-artifacts + gcs-subdir: + description: Subdirectory (relative to the bucket root) used to store test metrics. + required: false + default: test-metrics/upload + version: + description: junit2jira release version to download. + required: false + default: v0.0.27 + +outputs: + new-jiras: + description: Bool indicating if new Jira issues were created. + value: ${{ steps.run.outputs.NEW_JIRAS }} + +runs: + using: composite + steps: + - name: Download junit2jira + shell: bash + env: + VERSION: ${{ inputs.version }} + run: | + set -u + LOCATION="https://github.com/stackrox/junit2jira/releases/download/$VERSION/junit2jira" + # Skip downloading release if downloaded already, e.g. when the action is used multiple times. + if [[ ! -x junit2jira ]]; then + curl --retry 5 --retry-connrefused --silent --show-error --fail --location --output junit2jira "$LOCATION" + chmod +x junit2jira + fi + + - name: Capture job failure as JUnit if no test failures exist + shell: bash + if: always() + env: + STEPS_JSON: ${{ toJSON(steps) }} + ARTIFACT_DIR: ${{ inputs.directory }} + run: | + set -uo pipefail + "${GITHUB_ACTION_PATH}/../../common/common.sh" \ + "${GITHUB_ACTION_PATH}/junit2jira.sh" \ + capture_job_failure_as_junit \ + "${{ inputs.directory }}" \ + "${{ github.job }}" \ + "${{ job.status }}" \ + "$STEPS_JSON" \ + "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" + + - name: Authenticate with GCP + if: inputs.gcp-account != '' + uses: google-github-actions/auth@v2 + with: + credentials_json: ${{ inputs.gcp-account }} + + - name: Set up Cloud SDK + if: inputs.gcp-account != '' + uses: google-github-actions/setup-gcloud@v2 + + - name: Report failures to Jira and upload metrics + id: run + shell: bash + env: + JIRA_USER: ${{ inputs.jira-user }} + JIRA_TOKEN: ${{ inputs.jira-token }} + if: ${{ env.JIRA_TOKEN != '' }} + run: | + set -uo pipefail + extra_args=() + if [[ "${{ inputs.create-jiras }}" == "false" ]]; then + extra_args=(--dry-run) + else + echo "Will create Jira issues for JUnit failures found in ${{ inputs.directory }}" + fi + csv_output="$(mktemp --suffix=.csv)" + summary_file="$(mktemp --suffix=.json)" + ./junit2jira \ + -base-link "${{ github.server_url }}/${{ github.repository }}/commit/${{ github.sha }}" \ + -build-id "${{ github.run_id }}" \ + -build-link "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + -build-tag "${{ github.ref_name }}@${{ github.sha }}" \ + -csv-output "${csv_output}" \ + -jira-url "${{ inputs.jira-url }}" \ + -job-name "${{ github.job }}" \ + -junit-reports-dir "${{ inputs.directory }}" \ + -orchestrator "${{ runner.name }} ${{ runner.os }}-${{ runner.arch }}" \ + -threshold "${{ inputs.threshold }}" \ + -summary-output "${summary_file}" \ + "${extra_args[@]}" + + echo "NEW_JIRAS=$(jq -r '.newJIRAs > 0' "${summary_file}")" >> "$GITHUB_OUTPUT" + + if [[ "${{ inputs.gcp-metrics }}" == "true" ]]; then + "${GITHUB_ACTION_PATH}/../../common/common.sh" \ + "${GITHUB_ACTION_PATH}/junit2jira.sh" \ + save_test_metrics \ + "${csv_output}" \ + "${{ inputs.gcs-bucket }}" \ + "${{ inputs.gcs-subdir }}" + fi diff --git a/test/junit2jira/junit2jira.sh b/test/junit2jira/junit2jira.sh new file mode 100755 index 00000000..33bec464 --- /dev/null +++ b/test/junit2jira/junit2jira.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# +# Helper functions for the junit2jira composite action. +# +# This script is self-contained: it relies only on common/common.sh (sourced by +# the action wrapper) and standard CLI tools. It is dispatched by subcommand, +# e.g.: +# +# junit2jira.sh capture_job_failure_as_junit ... +# junit2jira.sh save_test_metrics +# +# Local run: +# +# test/local-env.sh test/junit2jira/junit2jira.sh save_test_metrics ... +# +set -euo pipefail + +_JUNIT_RESULT_FAILURE="FAILURE" + +get_junit_misc_dir() { + echo "${ARTIFACT_DIR}/junit-misc" +} + +# Returns 0 if any *.xml file under the directory contains a JUnit . +junit_contains_failure() { + local dir="$1" + if [[ ! -d $dir ]]; then + return 1 + fi + local f + while IFS= read -r -d '' f; do + # Match both and formats. + if grep -q ']' "$f"; then + return 0 + fi + done < <(find "$dir" -type f -iname '*.xml' -print0) + return 1 +} + +# Writes a synthetic JUnit failure record into ${ARTIFACT_DIR}/junit-misc. +save_junit_failure() { + if [[ "$#" -ne 3 ]]; then + gh_log error "missing args. usage: save_junit_failure
" + exit 1 + fi + _save_junit_record "${_JUNIT_RESULT_FAILURE}" "$@" +} + +_save_junit_record() { + local disposition="$1" + local class="$2" + local description="$3" + local details="${4:-}" + + if [[ -z "${ARTIFACT_DIR:-}" ]]; then + gh_log warning "_save_junit_record requires the ARTIFACT_DIR variable to be set" + return + fi + + local junit_dir + junit_dir="$(get_junit_misc_dir)" + mkdir -p "${junit_dir}" + + # XML escape description. + description="${description//&/\&}" + description="${description//\"/\"}" + description="${description//\'/\'}" + description="${description///\>}" + + local failures=0 + if [[ "${disposition}" == "${_JUNIT_RESULT_FAILURE}" ]]; then + failures=1 + fi + + local junit_file="${junit_dir}/junit-${class}.xml" + { + echo "" + echo " " + if [[ "${disposition}" == "${_JUNIT_RESULT_FAILURE}" ]]; then + echo " " + fi + echo " " + echo "" + } > "${junit_file}" +} + +# If the GitHub job failed but produced no JUnit records, synthesise a +# JUnit failure so infrastructure/setup failures still get reported to Jira. +capture_job_failure_as_junit() { + if [[ "$#" -ne 5 ]]; then + gh_log error "missing args. usage: capture_job_failure_as_junit " + exit 1 + fi + + local directory="$1" + local job_name="$2" + local job_status="$3" + local steps_json="$4" + local workflow_run_url="$5" + + export ARTIFACT_DIR="${directory}" + + # Only process failures. + if [[ "$job_status" != "failure" ]]; then + gh_log debug "Job status: ${job_status} - no failure record needed" + return 0 + fi + + # Check if JUnit test failures already exist. + if junit_contains_failure "$directory"; then + gh_log debug "JUnit test failures already exist - skipping failure record" + return 0 + fi + + gh_log debug "Job failed but no JUnit test failures found - looking for failed step" + + # Try to find a specific failed step from the steps context (only includes steps with id). + local failed_step + failed_step=$(echo "$steps_json" | jq -r 'to_entries[] | select(.value.outcome == "failure") | .key' | head -1) + + if [[ -n "$failed_step" ]]; then + local step_outcome step_conclusion + step_outcome=$(echo "$steps_json" | jq -r ".[\"$failed_step\"].outcome") + step_conclusion=$(echo "$steps_json" | jq -r ".[\"$failed_step\"].conclusion") + + local failure_details + failure_details=$(cat < " + exit 1 + fi + local csv="$1" + local bucket="$2" + local subdir="$3" + local to="${bucket}/${subdir}" + gh_log debug "Saving Big Query test records from ${csv} to ${to}" + gcloud storage cp "${csv}" "${to}/" +} + +main() { + local subcommand="${1:-}" + check_not_empty subcommand + shift + case "$subcommand" in + capture_job_failure_as_junit | save_test_metrics) + "$subcommand" "$@" + ;; + *) + gh_log error "unknown subcommand: ${subcommand}" + exit 1 + ;; + esac +} + +main "$@"