-
Notifications
You must be signed in to change notification settings - Fork 1
GHA-257 Jira fixture infrastructure — reusable setup/teardown scripts #136
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
| - name: Verify cleanup succeeded | ||
| if: always() | ||
| run: echo "✅ Cleanup step completed" | ||
| 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`. |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unhandled If The cleanup step still runs ( Fix here: catch 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 —
|
||
|
|
||
| if issue_keys: | ||
| delete_issues(jira, issue_keys) | ||
|
|
||
| if version_id: | ||
| delete_version(jira, version_id) | ||
|
|
||
| eprint("Cleanup complete.") | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| main() | ||
| 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/" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| TEST_PROJECT_KEY = "SONARIAC" | ||
| VERSION_PREFIX = "99" | ||
| ISSUE_TYPES = ["Bug", "Feature", "Maintenance"] | ||
| 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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| jira==3.10.5 |
There was a problem hiding this comment.
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. Whensetup.pyfails before writing that file, cleanup crashes withFileNotFoundError(see companion comment incleanup.py). Guard the invocation:This is the minimal patch; fixing
cleanup.pyto handle the missing file is still the right defence-in-depth change.