Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions .github/workflows/test-jira-fixtures.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
name: Test Jira Fixtures

on:
workflow_call:
pull_request:
paths:
- 'test-fixtures/jira/**'
- '.github/workflows/test-jira-fixtures.yml'
push:
branches:
- branch-*
paths:
- 'test-fixtures/jira/**'
- '.github/workflows/test-jira-fixtures.yml'
workflow_dispatch:

jobs:
unit-tests:
name: Run Unit Tests
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.10'

- name: Install dependencies
run: |
cd test-fixtures/jira
pip install -r requirements.txt
pip install pytest pytest-cov

- name: Run unit tests
run: |
cd test-fixtures/jira
python -m pytest test_jira_client.py test_setup.py test_cleanup.py -v --cov=jira_client --cov=setup --cov=cleanup --cov-report=term-missing

integration-test:
name: Integration Test (Jira Sandbox)
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write

steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

- name: Get Jira Credentials from Vault
uses: SonarSource/vault-action-wrapper@v3
id: secrets
with:
secrets: |
development/kv/data/jira user | JIRA_USER;
development/kv/data/jira token | JIRA_TOKEN;

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.10'

- name: Install dependencies
run: pip install -r test-fixtures/jira/requirements.txt

- name: Create test fixtures
id: setup
env:
JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }}
JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }}
run: |
set -euo pipefail
STATE=$(python test-fixtures/jira/setup.py \
--project-key SONARIAC \
--run-id "${{ github.run_id }}" \
--jira-url "https://sonarsource-sandbox-608.atlassian.net/")
echo "$STATE" > /tmp/jira-fixtures.json
echo "Fixture state:"
cat /tmp/jira-fixtures.json | jq .

echo "version_name=$(echo "$STATE" | jq -r .version_name)" >> "$GITHUB_OUTPUT"
echo "version_id=$(echo "$STATE" | jq -r .version_id)" >> "$GITHUB_OUTPUT"
echo "issue_keys=$(echo "$STATE" | jq -r '.issue_keys | join(",")')" >> "$GITHUB_OUTPUT"

- name: Verify version was created
run: |
echo "Version name: ${{ steps.setup.outputs.version_name }}"
echo "Version ID: ${{ steps.setup.outputs.version_id }}"
if [[ -z "${{ steps.setup.outputs.version_id }}" ]]; then
echo "❌ Version ID is empty"
exit 1
fi
echo "✅ Version created successfully"

- name: Verify issues were created
run: |
IFS=',' read -ra KEYS <<< "${{ steps.setup.outputs.issue_keys }}"
if [[ ${#KEYS[@]} -ne 3 ]]; then
echo "❌ Expected 3 issues, got ${#KEYS[@]}"
exit 1
fi
echo "✅ Created 3 issues: ${KEYS[*]}"

- name: Clean up test fixtures
if: always()
env:
JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }}
JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }}
run: |
python test-fixtures/jira/cleanup.py \
--jira-url "https://sonarsource-sandbox-608.atlassian.net/" \
--state-file /tmp/jira-fixtures.json
Comment on lines +113 to +115
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cleanup unconditionally passes --state-file /tmp/jira-fixtures.json. When setup.py fails before writing that file, cleanup crashes with FileNotFoundError (see companion comment in cleanup.py). Guard the invocation:

          if [ -f /tmp/jira-fixtures.json ]; then
            python test-fixtures/jira/cleanup.py \
              --jira-url "[link removed: sonarsource-sandbox-608.atlassian.net]" \
              --state-file /tmp/jira-fixtures.json
          else
            echo "State file not found — setup failed before writing fixtures, nothing to clean up."
          fi

This is the minimal patch; fixing cleanup.py to handle the missing file is still the right defence-in-depth change.

  • Mark as noise


- name: Verify cleanup succeeded
if: always()
run: echo "✅ Cleanup step completed"
104 changes: 104 additions & 0 deletions test-fixtures/jira/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
# Jira Test Fixtures

Reusable Python scripts for setting up and tearing down Jira sandbox state for integration tests.

## Scripts

### `setup.py`

Creates a test version and sample issues in a Jira sandbox project.

```bash
python setup.py \
--project-key SONARIAC \
--run-id "$GITHUB_RUN_ID" \
--jira-url "https://sonarsource-sandbox-608.atlassian.net/"
```

**Output** (JSON to stdout):
```json
{
"version_id": "12345",
"version_name": "99.12345678",
"issue_keys": ["SONARIAC-100", "SONARIAC-101", "SONARIAC-102"]
}
```

Creates:
- 1 version named `99.<run_id>`
- 3 issues (Bug, Feature, Maintenance) linked to the version via `fixVersion`

### `cleanup.py`

Deletes test fixtures. Idempotent — succeeds even if resources are already deleted.

```bash
# From inline arguments:
python cleanup.py \
--jira-url "https://sonarsource-sandbox-608.atlassian.net/" \
--version-id 12345 \
--issue-keys "SONARIAC-100,SONARIAC-101,SONARIAC-102"

# From a state file (output of setup.py):
python cleanup.py \
--jira-url "https://sonarsource-sandbox-608.atlassian.net/" \
--state-file /tmp/setup-state.json
```

## Usage in GitHub Actions Workflows

```yaml
- name: Get Jira Credentials from Vault
uses: SonarSource/vault-action-wrapper@v3
id: secrets
with:
secrets: |
development/kv/data/jira user | JIRA_USER;
development/kv/data/jira token | JIRA_TOKEN;

- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: '3.10'

- name: Install fixture dependencies
run: pip install -r test-fixtures/jira/requirements.txt

- name: Create Jira test fixtures
id: fixtures
env:
JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }}
JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }}
run: |
STATE=$(python test-fixtures/jira/setup.py \
--project-key SONARIAC \
--run-id "${{ github.run_id }}" \
--jira-url "https://sonarsource-sandbox-608.atlassian.net/")
echo "$STATE" > /tmp/jira-fixtures.json
echo "version_name=$(echo "$STATE" | jq -r .version_name)" >> "$GITHUB_OUTPUT"

# ... run your integration tests here ...

- name: Clean up Jira test fixtures
if: always()
env:
JIRA_USER: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_USER }}
JIRA_TOKEN: ${{ fromJSON(steps.secrets.outputs.vault).JIRA_TOKEN }}
run: |
python test-fixtures/jira/cleanup.py \
--jira-url "https://sonarsource-sandbox-608.atlassian.net/" \
--state-file /tmp/jira-fixtures.json
```

## Running Tests Locally

```bash
cd test-fixtures/jira
pip install -r requirements.txt
pip install pytest pytest-cov
python -m pytest test_*.py -v --cov --cov-report=term-missing
```

## Authentication

Scripts use `JIRA_USER` and `JIRA_TOKEN` environment variables. In CI, these are fetched from Vault at `development/kv/data/jira`.
75 changes: 75 additions & 0 deletions test-fixtures/jira/cleanup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""
Cleans up Jira test fixtures created by setup.py.

Deletes issues and versions by ID. Idempotent: succeeds even if
resources have already been deleted or never existed.

Usage:
python cleanup.py --jira-url https://sandbox.atlassian.net/ --version-id 12345 --issue-keys SONARIAC-100,SONARIAC-101
python cleanup.py --jira-url https://sandbox.atlassian.net/ --state-file /tmp/setup-state.json
"""

import argparse
import json
import sys

from jira_client import get_jira_instance, eprint
from jira.exceptions import JIRAError


def delete_issues(jira, issue_keys):
"""Deletes issues by key. Ignores errors (idempotent)."""
for key in issue_keys:
try:
issue = jira.issue(key)
issue.delete()
eprint(f"Deleted issue: {key}")
except JIRAError as e:
eprint(f"Warning: Could not delete issue {key} (status={e.status_code}). Skipping.")
except Exception as e:
eprint(f"Warning: Unexpected error deleting issue {key}: {e}. Skipping.")


def delete_version(jira, version_id):
"""Deletes a version by ID. Ignores errors (idempotent)."""
try:
version = jira.version(version_id)
version.delete()
eprint(f"Deleted version: {version_id}")
except JIRAError as e:
eprint(f"Warning: Could not delete version {version_id} (status={e.status_code}). Skipping.")
except Exception as e:
eprint(f"Warning: Unexpected error deleting version {version_id}: {e}. Skipping.")


def main():
parser = argparse.ArgumentParser(description="Clean up Jira test fixtures.")
parser.add_argument("--jira-url", required=True, help="URL of the Jira instance.")
parser.add_argument("--version-id", help="ID of the version to delete.")
parser.add_argument("--issue-keys", default="", help="Comma-separated issue keys to delete.")
parser.add_argument("--state-file", help="Path to JSON state file from setup.py.")
args = parser.parse_args()

jira = get_jira_instance(args.jira_url)

version_id = args.version_id
issue_keys = [k for k in args.issue_keys.split(',') if k] if args.issue_keys else []

if args.state_file:
with open(args.state_file) as f:
state = json.load(f)
version_id = state.get('version_id', version_id)
issue_keys = state.get('issue_keys', issue_keys)
Comment on lines +59 to +63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unhandled FileNotFoundError breaks cleanup when setup.py fails mid-run.

If setup.py creates the Jira version but then fails during issue creation (API error, rate limit, etc.), set -euo pipefail in the workflow aborts the step before echo "$STATE" > /tmp/jira-fixtures.json executes. The state file is never written.

The cleanup step still runs (if: always()) and unconditionally passes --state-file /tmp/jira-fixtures.json. Line 60's open() raises FileNotFoundError — unhandled — so cleanup itself exits non-zero. The orphaned version and any partially-created issues are never cleaned up.

Fix here: catch FileNotFoundError and log a warning instead of crashing:

    if args.state_file:
        try:
            with open(args.state_file) as f:
                state = json.load(f)
            version_id = state.get('version_id', version_id)
            issue_keys = state.get('issue_keys', issue_keys)
        except FileNotFoundError:
            eprint(f"Warning: State file {args.state_file} not found. Nothing to clean up from state.")

Note that this alone doesn't recover the orphaned version — setup.py would need to write partial state to a file as resources are created (rather than only emitting JSON to stdout at success) for a complete fix.

  • Mark as noise


if issue_keys:
delete_issues(jira, issue_keys)

if version_id:
delete_version(jira, version_id)

eprint("Cleanup complete.")


if __name__ == "__main__":
main()
7 changes: 7 additions & 0 deletions test-fixtures/jira/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
"""Configuration constants for Jira test fixtures."""

SANDBOX_URL = "https://sonarsource-sandbox-608.atlassian.net/"
PROD_URL = "https://sonarsource.atlassian.net/"
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PROD_URL is never imported anywhere — confirmed by grep. Neither is SANDBOX_URL; the actual sandbox URL is hardcoded in the workflow and README instead. Remove both unused URL constants. Leaving PROD_URL in a test-fixture config file creates risk: a future contributor copying a usage pattern could accidentally point cleanup at production Jira.

  • Mark as noise

TEST_PROJECT_KEY = "SONARIAC"
VERSION_PREFIX = "99"
ISSUE_TYPES = ["Bug", "Feature", "Maintenance"]
40 changes: 40 additions & 0 deletions test-fixtures/jira/jira_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Shared Jira connection helper for test fixtures."""

import os
import sys

from jira import JIRA
from jira.exceptions import JIRAError


def eprint(*args, **kwargs):
"""Prints messages to stderr for logging."""
print(*args, file=sys.stderr, **kwargs)


def get_jira_instance(jira_url):
"""
Initializes and returns a JIRA client instance.
Authentication is handled via JIRA_USER and JIRA_TOKEN environment variables.
"""
jira_user = os.environ.get('JIRA_USER')
jira_token = os.environ.get('JIRA_TOKEN')

if not jira_user or not jira_token:
eprint("Error: JIRA_USER and JIRA_TOKEN environment variables must be set.")
sys.exit(1)

eprint(f"Connecting to JIRA server at: {jira_url}")
try:
jira_client = JIRA(jira_url, basic_auth=(jira_user, jira_token))
jira_client.server_info()
eprint("JIRA authentication successful.")
return jira_client
except JIRAError as e:
eprint(f"Error: JIRA authentication failed. Status: {e.status_code}")
eprint(f"Response text: {e.text}")
sys.exit(1)
except Exception as e:
eprint(f"An unexpected error occurred during JIRA connection: {e}")
sys.exit(1)
1 change: 1 addition & 0 deletions test-fixtures/jira/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
jira==3.10.5
Loading
Loading